feat: add GenesysCX study and fix Streamlit chart key collisions

- Add 202512_GenesysCX TEI study (config, seed data, notebooks, README)
  with NPV $10.8M / ROI 266% including AI-token cost line
- Add explicit `key` parameter to all chart wrappers in app/components
  to prevent StreamlitDuplicateElementId errors when the same figure
  type renders across Summary/Benefits/Costs tabs
- Render benefits bar and cost pie charts on their respective tabs
- Add benefits_vs_costs_by_year chart wrapper
This commit is contained in:
2026-06-10 14:26:49 -04:00
parent ecd164ee6d
commit 64fb83257d
34 changed files with 12902 additions and 39 deletions

View File

@@ -0,0 +1,356 @@
"""
CTM default inputs and the Genesys meter catalogue.
⚠️ Site volumes/AHTs/costs outside NAM are PLACEHOLDERS flagged
ESTIMATED — confirm with CTM data before client use. NAM volumes are
from the CTM discovery pack. Named users across all sites total the
contracted licence count (2,088).
"""
from __future__ import annotations
from .inputs import CostTakeout, FeatureScope, SiteInput
from .meters import Confidence, MeterType, TokenMeter, TokenPricing
from .rollout import RolloutPlan
# ── Platform ─────────────────────────────────────────────────────────
#: Genesys Cloud CX 3 named-user list rate, USD/user/month.
#: Source: Genesys Cloud public pricing (CX 3 tier), planning figure.
PLATFORM_RATE_PER_USER_MONTHLY = 111.28
#: CTM contracted named-user count — UI warns when site totals diverge.
CONTRACTED_NAMED_USERS = 2_088
#: Business-case discount rate (CTM treasury planning assumption).
DEFAULT_DISCOUNT_RATE = 0.08
#: One-off implementation estimate, amortized straight-line over the
#: analysis horizon in the P&L. ESTIMATED — confirm with delivery team.
DEFAULT_IMPLEMENTATION_COST = 0.0
_GENESYS_TOKEN_FAQ = (
"https://help.mypurecloud.com/articles/genesys-cloud-ai-experience-tokens-faqs/"
)
# ── Token meters ─────────────────────────────────────────────────────
# Rates per the published Genesys AI Experience token tables unless
# flagged otherwise. UNKNOWN meters carry working defaults (clearly
# labelled) so the model still produces a range.
DEFAULT_METERS: dict[str, TokenMeter] = {
m.feature: m
for m in [
TokenMeter(
feature="Voice Bot",
meter_type=MeterType.PER_MINUTE,
units_per_token=17.0,
tokens_per_unit=1 / 17, # 0.0588
confidence=Confidence.CONFIRMED,
notes="IVR self-service voice bot minutes; 17 min per token.",
source_url=_GENESYS_TOKEN_FAQ,
),
TokenMeter(
feature="Virtual Agent (legacy)",
meter_type=MeterType.PER_INTERACTION,
units_per_token=2.0,
tokens_per_unit=0.5,
confidence=Confidence.CONFIRMED,
notes="Legacy (non-agentic) virtual agent; 2 interactions per token.",
source_url=_GENESYS_TOKEN_FAQ,
),
TokenMeter(
feature="Agentic Virtual Agent",
meter_type=MeterType.PER_INTERACTION,
units_per_token=0.833,
tokens_per_unit=1.2,
confidence=Confidence.CONFIRMED,
notes="Agentic VA; 1.2 tokens per interaction.",
source_url=_GENESYS_TOKEN_FAQ,
),
TokenMeter(
feature="AI Summary & Insights",
meter_type=MeterType.PER_SUMMARY,
units_per_token=50.0,
tokens_per_unit=0.02,
confidence=Confidence.CONFIRMED,
notes=(
"Supervisor standalone summarization; 50 summaries per token. "
"NOT metered where Agent Copilot is assigned — see cost model."
),
source_url=_GENESYS_TOKEN_FAQ,
),
TokenMeter(
feature="Direct Messaging",
meter_type=MeterType.PER_MESSAGE,
units_per_token=400.0,
tokens_per_unit=0.0025,
confidence=Confidence.CONFIRMED,
notes="FB/IG/WhatsApp messages; 400 messages per token.",
source_url=_GENESYS_TOKEN_FAQ,
),
TokenMeter(
feature="Social Listening",
meter_type=MeterType.PER_MESSAGE,
units_per_token=400.0,
tokens_per_unit=0.0025,
confidence=Confidence.CONFIRMED,
notes="400 messages per token.",
source_url=_GENESYS_TOKEN_FAQ,
),
TokenMeter(
feature="Social Responses",
meter_type=MeterType.PER_MESSAGE,
units_per_token=400.0,
tokens_per_unit=0.0025,
confidence=Confidence.CONFIRMED,
notes="400 messages per token.",
source_url=_GENESYS_TOKEN_FAQ,
),
TokenMeter(
feature="Speech & Text Analytics",
meter_type=MeterType.PER_USER_PER_MONTH,
units_per_token=0.0, # n/a for per-user meters
tokens_per_unit=30.0,
confidence=Confidence.CONFIRMED,
notes="STA: 30 tokens per named user per month.",
source_url=_GENESYS_TOKEN_FAQ,
),
TokenMeter(
feature="Agent Copilot",
meter_type=MeterType.PER_USER_PER_MONTH,
units_per_token=0.0,
tokens_per_unit=40.0,
confidence=Confidence.CONFIRMED,
notes=(
"40 tokens per named user per month. Includes interaction "
"summarization (covers AI Summary & Insights)."
),
source_url=_GENESYS_TOKEN_FAQ,
),
TokenMeter(
feature="Email AI (Auto-Suggest)",
meter_type=MeterType.PER_USER_PER_MONTH,
units_per_token=0.0,
tokens_per_unit=30.0, # TBD — working default
confidence=Confidence.UNKNOWN,
notes="Rate not yet sourced. Working default 30 tokens/user/month.",
),
TokenMeter(
feature="Email AI (Auto-Respond)",
meter_type=MeterType.PER_MESSAGE,
units_per_token=2.0, # TBD
tokens_per_unit=0.5, # TBD — working default
confidence=Confidence.UNKNOWN,
notes="Rate not yet sourced. Working default 0.5 tokens/message.",
),
TokenMeter(
feature="AI Translate",
meter_type=MeterType.PER_USER_PER_MONTH,
units_per_token=0.0,
tokens_per_unit=20.0, # TBD — working default
confidence=Confidence.UNKNOWN,
notes="Rate not yet sourced. Working default 20 tokens/user/month.",
),
]
}
#: Features metered per named user per month.
PER_USER_FEATURES = [
f for f, m in DEFAULT_METERS.items()
if m.meter_type is MeterType.PER_USER_PER_MONTH
]
# ── Token pricing ────────────────────────────────────────────────────
# $1/token US list confirmed; other regions default to the same list
# rate until regional figures are sourced (override in UI).
DEFAULT_PRICING: dict[str, TokenPricing] = {
"US": TokenPricing(region="US", list_rate_per_token=1.0),
"EU": TokenPricing(region="EU", list_rate_per_token=1.0), # TBD — assumed US list
"AU": TokenPricing(region="AU", list_rate_per_token=1.0), # TBD — assumed US list
"APAC": TokenPricing(region="APAC", list_rate_per_token=1.0), # TBD
}
# ── CTM sites ────────────────────────────────────────────────────────
# NAM figures from CTM discovery. ALL OTHER SITES + every AHT/ACW and
# labour-cost figure are ESTIMATED placeholders — confirm with CTM.
# Named users sum to CONTRACTED_NAMED_USERS (2,088).
_COMMON = {
"voice_aht_seconds": 300, # placeholder — flag as estimate
"email_aht_seconds": 600,
"chat_aht_seconds": 480,
"voice_acw_seconds": 60,
}
CTM_DEFAULT_SITES: list[SiteInput] = [
SiteInput(
"NAM", "US", agents=890, supervisors=60, # split TBD
voice_volume_monthly=1_214_358,
email_volume_monthly=275_800,
chat_volume_monthly=110,
sms_volume_monthly=1_040,
fully_loaded_agent_cost_annual=65_000, # placeholder
fully_loaded_supervisor_cost_annual=95_000,
languages=["English", "French", "Spanish"],
**_COMMON,
),
SiteInput(
"EMEA", "EU", agents=320, supervisors=25,
voice_volume_monthly=420_000,
email_volume_monthly=95_000,
chat_volume_monthly=40,
sms_volume_monthly=400,
fully_loaded_agent_cost_annual=60_000,
fully_loaded_supervisor_cost_annual=88_000,
languages=["English", "French", "German", "Italian", "Spanish"],
**_COMMON,
),
SiteInput(
"AUZ", "AU", agents=180, supervisors=15,
voice_volume_monthly=250_000,
email_volume_monthly=56_000,
chat_volume_monthly=25,
sms_volume_monthly=250,
fully_loaded_agent_cost_annual=70_000,
fully_loaded_supervisor_cost_annual=100_000,
languages=["English"],
**_COMMON,
),
SiteInput(
"APAC HK", "APAC", agents=120, supervisors=10,
voice_volume_monthly=160_000,
email_volume_monthly=38_000,
chat_volume_monthly=15,
sms_volume_monthly=150,
fully_loaded_agent_cost_annual=55_000,
fully_loaded_supervisor_cost_annual=80_000,
languages=["English", "Cantonese", "Mandarin"],
**_COMMON,
),
SiteInput(
"APAC SG", "APAC", agents=110, supervisors=10,
voice_volume_monthly=150_000,
email_volume_monthly=34_000,
chat_volume_monthly=15,
sms_volume_monthly=120,
fully_loaded_agent_cost_annual=55_000,
fully_loaded_supervisor_cost_annual=80_000,
languages=["English", "Mandarin", "Malay"],
**_COMMON,
),
SiteInput(
"APAC SH", "APAC", agents=130, supervisors=10,
voice_volume_monthly=175_000,
email_volume_monthly=40_000,
chat_volume_monthly=15,
sms_volume_monthly=130,
fully_loaded_agent_cost_annual=35_000,
fully_loaded_supervisor_cost_annual=55_000,
languages=["Mandarin"],
**_COMMON,
),
SiteInput(
"APAC GZ", "APAC", agents=90, supervisors=8,
voice_volume_monthly=120_000,
email_volume_monthly=28_000,
chat_volume_monthly=10,
sms_volume_monthly=100,
fully_loaded_agent_cost_annual=35_000,
fully_loaded_supervisor_cost_annual=55_000,
languages=["Mandarin", "Cantonese"],
**_COMMON,
),
SiteInput(
"APAC JP", "APAC", agents=60, supervisors=6,
voice_volume_monthly=80_000,
email_volume_monthly=19_000,
chat_volume_monthly=8,
sms_volume_monthly=80,
fully_loaded_agent_cost_annual=60_000,
fully_loaded_supervisor_cost_annual=85_000,
languages=["Japanese"],
**_COMMON,
),
SiteInput(
"APAC TW", "APAC", agents=40, supervisors=4,
voice_volume_monthly=54_000,
email_volume_monthly=12_000,
chat_volume_monthly=5,
sms_volume_monthly=50,
fully_loaded_agent_cost_annual=40_000,
fully_loaded_supervisor_cost_annual=60_000,
languages=["Mandarin"],
**_COMMON,
),
]
ALL_SITE_NAMES = [s.site_name for s in CTM_DEFAULT_SITES]
# ── Cost takeouts ────────────────────────────────────────────────────
CTM_DEFAULT_TAKEOUTS: list[CostTakeout] = [
CostTakeout(
"NICE IEX (NAM)",
annual_cost=1_300_000,
start_year=1,
start_month=7, # can only switch off after NAM go-live (month 6)
confidence=Confidence.ESTIMATED,
notes="Mid-band estimate; needs CTM contract confirmation.",
),
CostTakeout(
"Legacy CC platform",
annual_cost=0,
start_year=2,
confidence=Confidence.UNKNOWN,
notes="Placeholder — populate once retirement scope is confirmed.",
),
]
# ── Default rollout & ramp ───────────────────────────────────────────
# 12-month build. Genesys bills the licence commit from contract start;
# the 6-month ramp gives a 50% first-year credit on the platform commit.
# AI token usage (and benefits) start only when each region goes live.
CTM_DEFAULT_ROLLOUT = RolloutPlan(
contract_start=None, # set when known — "Date Genesys starts billing"
build_months=12,
ramp_months=6,
first_year_platform_discount=0.50,
go_live_month={
"NAM": 6,
"EMEA": 9,
"AUZ": 12,
"APAC HK": 12,
"APAC SG": 12,
"APAC SH": 12,
"APAC GZ": 12,
"APAC JP": 12,
"APAC TW": 12,
},
)
# ── Default feature scoping / phasing ────────────────────────────────
# Phase = model year the feature switches on. Consumption features ramp
# via adoption_curve; per-user licences are paid in full from the phase
# year.
_RAMP = {1: 0.70, 2: 1.0, 3: 1.0}
CTM_DEFAULT_FEATURE_SCOPES: list[FeatureScope] = [
FeatureScope("Voice Bot", ALL_SITE_NAMES, phase=1, adoption_curve=_RAMP),
FeatureScope("Agentic Virtual Agent", ["NAM", "EMEA"], phase=2,
adoption_curve={2: 0.70, 3: 1.0}),
FeatureScope("Speech & Text Analytics", ALL_SITE_NAMES, phase=1),
FeatureScope("Agent Copilot", ALL_SITE_NAMES, phase=1),
FeatureScope("AI Summary & Insights", ALL_SITE_NAMES, phase=1,
adoption_curve=_RAMP),
FeatureScope("Direct Messaging", ALL_SITE_NAMES, phase=1, adoption_curve=_RAMP),
FeatureScope("Email AI (Auto-Suggest)", ["NAM", "EMEA"], phase=2),
FeatureScope("Email AI (Auto-Respond)", ["NAM", "EMEA"], phase=2,
adoption_curve={2: 0.70, 3: 1.0}),
FeatureScope("AI Translate",
["APAC HK", "APAC SG", "APAC SH", "APAC GZ", "APAC JP", "APAC TW"],
phase=3),
]