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,301 @@
"""
Cost calculation engine.
Correctness rules implemented here (see spec §4.1):
1. **Agent Copilot covers Supervisor AI Summary.** Where Agent Copilot
is enabled at a site, AI Summary & Insights consumption at that site
is forced to zero — Copilot's per-user token rate already includes
interaction summarization. Source: Genesys Cloud AI Experience
tokens FAQ,
https://help.mypurecloud.com/articles/genesys-cloud-ai-experience-tokens-faqs/
2. **Token rounding.** Genesys rounds consumption up at billing —
``math.ceil`` is applied to each site's MONTHLY consumption token
total before the rate. Per-user totals (users × tokens/user/month)
are exact and not rounded.
3. **Regional pricing.** Every site resolves its rate through its
``region_pricing`` key — never a hardcoded US rate.
4. **Adoption ramp.** Consumption features ramp (default Y1 = 70%);
per-user licences are paid in full from their phase year.
"""
from __future__ import annotations
import math
import pandas as pd
from .defaults import PLATFORM_RATE_PER_USER_MONTHLY
from .inputs import FeatureScope, SiteInput
from .meters import Confidence, MeterType, TokenMeter, TokenPricing
from .rollout import NO_ROLLOUT, RolloutPlan
from .scenarios import Scenario, get_scenario
MONTHS_PER_YEAR = 12
def _rate(site: SiteInput, pricing: dict[str, TokenPricing],
use_contracted: bool = False) -> float:
"""Resolve the per-token rate for a site's pricing region."""
region = pricing.get(site.region_pricing)
if region is None:
raise KeyError(
f"No TokenPricing for region {site.region_pricing!r} "
f"(site {site.site_name})"
)
return region.effective_rate(use_contracted)
def calculate_platform_license_cost(
sites: list[SiteInput],
per_user_monthly_rate: float = PLATFORM_RATE_PER_USER_MONTHLY,
year: int = 1,
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Genesys Cloud CX 3 named-user platform licences.
The commit bills in full from contract start regardless of site
go-lives; the vendor ramp credit reduces YEAR 1 only (typical
6-month ramp → 50% Y1 discount).
Returns DataFrame: site, agents, supervisors, named_users, annual_cost.
"""
ro = rollout or NO_ROLLOUT
factor = ro.platform_factor(year)
rows = [
{
"site": s.site_name,
"agents": s.agents,
"supervisors": s.supervisors,
"named_users": s.named_users,
"annual_cost": s.named_users
* per_user_monthly_rate
* MONTHS_PER_YEAR
* factor,
}
for s in sites
]
return pd.DataFrame(rows)
def calculate_per_user_ai_cost(
sites: list[SiteInput],
feature_scope: FeatureScope,
meter: TokenMeter,
pricing: dict[str, TokenPricing],
year: int = 1,
use_contracted: bool = False,
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Per-user-per-month AI features (STA, Agent Copilot, AI Translate,
Email Auto-Suggest).
No adoption ramp and no rounding (users × tokens/user/month is
exact) — but token usage only starts at site go-live, so the year
bills for the months the site is live (``rollout``).
Returns DataFrame: site, users_in_scope, tokens_monthly, annual_cost.
"""
if meter.meter_type is not MeterType.PER_USER_PER_MONTH:
raise ValueError(f"{meter.feature} is not a per-user meter")
ro = rollout or NO_ROLLOUT
rows = []
for s in sites:
in_scope = feature_scope.active(s.site_name, year)
users = s.named_users if in_scope else 0
live_months = ro.live_months_in_year(s.site_name, year)
tokens_monthly = users * meter.tokens_per_unit
rows.append(
{
"site": s.site_name,
"users_in_scope": users,
"tokens_monthly": tokens_monthly,
"annual_cost": tokens_monthly
* live_months
* _rate(s, pricing, use_contracted),
}
)
return pd.DataFrame(rows)
def _monthly_units(site: SiteInput, feature: str, scope: FeatureScope,
scenario: Scenario) -> float:
"""Monthly metered units for a consumption feature at one site.
Explicit ``scope.deflection_target`` / ``scope.eligibility_pct``
override the scenario defaults.
"""
if feature == "Voice Bot":
deflection = (
scope.deflection_target
if scope.deflection_target is not None
else scenario.voice_bot_deflection
)
return (
site.voice_volume_monthly * deflection * scenario.voice_bot_avg_minutes
) # minutes
if feature == "Agentic Virtual Agent":
deflection = (
scope.deflection_target
if scope.deflection_target is not None
else scenario.agentic_va_deflection
)
return site.voice_volume_monthly * deflection # interactions
if feature == "Virtual Agent (legacy)":
deflection = scope.deflection_target or 0.0
return site.voice_volume_monthly * deflection
if feature == "AI Summary & Insights":
eligibility = (
scope.eligibility_pct
if scope.eligibility_pct is not None
else scenario.voice_summarization_eligibility
)
return site.voice_volume_monthly * eligibility # summaries
if feature == "Email AI (Auto-Respond)":
rate = (
scope.deflection_target
if scope.deflection_target is not None
else scenario.email_auto_respond_rate
)
return site.email_volume_monthly * rate # messages
if feature in ("Direct Messaging", "Social Listening", "Social Responses"):
eligibility = scope.eligibility_pct if scope.eligibility_pct is not None else 1.0
return (site.chat_volume_monthly + site.sms_volume_monthly) * eligibility
raise KeyError(f"No consumption-volume mapping for feature {feature!r}")
def calculate_consumption_ai_cost(
sites: list[SiteInput],
feature_scope: FeatureScope,
meter: TokenMeter,
scenario: str | Scenario,
pricing: dict[str, TokenPricing],
year: int = 1,
use_contracted: bool = False,
excluded_sites: set[str] | None = None,
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Consumption-metered AI features (Voice Bots, Agentic VA,
Supervisor AI Summary, Email Auto-Respond, messaging meters).
Applies eligibility/deflection from the scenario (or explicit scope
overrides), the adoption ramp, billing-style ``ceil`` rounding on
each site's monthly token total, and — with a ``rollout`` — bills
only the months the site is live (usage starts at go-live).
``excluded_sites`` supports the Copilot-covers-Summary rule.
Returns DataFrame: site, eligible_volume, tokens_monthly, annual_cost.
"""
if meter.meter_type is MeterType.PER_USER_PER_MONTH:
raise ValueError(f"{meter.feature} is a per-user meter, not consumption")
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
excluded = excluded_sites or set()
ro = rollout or NO_ROLLOUT
# Ramp: an explicit adoption curve wins; otherwise the scenario's
# default consumption realization (Y1 = 70%). This models usage
# maturity; rollout live-months model calendar availability — they
# compound (live 6 months × 70% maturity).
ramp = (
feature_scope.adoption(year)
if feature_scope.adoption_curve
else sc.cost_realization(year)
)
rows = []
for s in sites:
active = (
feature_scope.active(s.site_name, year)
and s.site_name not in excluded
)
units = _monthly_units(s, meter.feature, feature_scope, sc) if active else 0.0
units *= ramp
live_months = ro.live_months_in_year(s.site_name, year)
# Rule 2: round each site's monthly token total UP (billing).
tokens_monthly = math.ceil(units * meter.tokens_per_unit) if units > 0 else 0
rows.append(
{
"site": s.site_name,
"eligible_volume": units,
"tokens_monthly": tokens_monthly,
"annual_cost": tokens_monthly
* live_months
* _rate(s, pricing, use_contracted),
}
)
return pd.DataFrame(rows)
def calculate_total_cost(
sites: list[SiteInput],
feature_scopes: list[FeatureScope],
meters: dict[str, TokenMeter],
pricing: dict[str, TokenPricing],
scenario: str | Scenario,
year: int,
platform_rate: float = PLATFORM_RATE_PER_USER_MONTHLY,
use_contracted: bool = False,
include_platform: bool = True,
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""All cost lines for one scenario-year.
Returns DataFrame: cost_line, scope, annual_cost, confidence.
"""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
rows: list[dict] = []
if include_platform:
platform = calculate_platform_license_cost(
sites, platform_rate, year=year, rollout=rollout
)
ramped = rollout is not None and rollout.platform_factor(year) < 1.0
rows.append(
{
"cost_line": "Genesys CX 3 platform licences"
+ (" (ramp credit applied)" if ramped else ""),
"scope": "all sites",
"annual_cost": float(platform["annual_cost"].sum()),
"confidence": Confidence.CONFIRMED.value,
}
)
# Rule 1: Agent Copilot covers Supervisor AI Summary. Sites where
# Copilot is active this year are excluded from AI Summary billing —
# Copilot's 40 tokens/user/month already includes summarization.
# https://help.mypurecloud.com/articles/genesys-cloud-ai-experience-tokens-faqs/
copilot_sites: set[str] = set()
for scope in feature_scopes:
if scope.feature == "Agent Copilot":
copilot_sites |= {
s.site_name for s in sites if scope.active(s.site_name, year)
}
for scope in feature_scopes:
meter = meters.get(scope.feature)
if meter is None:
raise KeyError(f"No meter defined for feature {scope.feature!r}")
if meter.meter_type is MeterType.PER_USER_PER_MONTH:
df = calculate_per_user_ai_cost(
sites, scope, meter, pricing, year=year,
use_contracted=use_contracted, rollout=rollout,
)
in_scope = df[df["users_in_scope"] > 0]["site"].tolist()
else:
excluded = (
copilot_sites if scope.feature == "AI Summary & Insights" else None
)
df = calculate_consumption_ai_cost(
sites, scope, meter, sc, pricing, year=year,
use_contracted=use_contracted, excluded_sites=excluded,
rollout=rollout,
)
in_scope = df[df["annual_cost"] > 0]["site"].tolist()
rows.append(
{
"cost_line": scope.feature,
"scope": ", ".join(in_scope) if in_scope else "",
"annual_cost": float(df["annual_cost"].sum()),
"confidence": meter.confidence.value,
}
)
return pd.DataFrame(rows)