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,77 @@
"""
tokencalc — Genesys AI token cost & business case calculator core.
Pure-Python, UI-agnostic. The JupyterLab notebook and the Streamlit
app are thin presentation layers over these functions.
"""
from .benefit_model import calculate_total_benefit
from .business_case import build_business_case, npv, payback_years
from .cost_model import (
calculate_consumption_ai_cost,
calculate_per_user_ai_cost,
calculate_platform_license_cost,
calculate_total_cost,
)
from .defaults import (
CONTRACTED_NAMED_USERS,
CTM_DEFAULT_FEATURE_SCOPES,
CTM_DEFAULT_ROLLOUT,
CTM_DEFAULT_SITES,
CTM_DEFAULT_TAKEOUTS,
DEFAULT_METERS,
DEFAULT_PRICING,
PLATFORM_RATE_PER_USER_MONTHLY,
)
from .rollout import NO_ROLLOUT, RolloutPlan
from .exports import (
export_csv,
export_excel,
meters_dataframe,
scenario_state_from_json,
scenario_state_to_json,
sites_dataframe,
)
from .inputs import CostTakeout, FeatureScope, SiteInput
from .meters import Confidence, MeterType, TokenMeter, TokenPricing
from .scenarios import BENEFIT_PARAMS, SCENARIOS, Scenario, get_scenario
__version__ = "0.1.0"
__all__ = [
"BENEFIT_PARAMS",
"CONTRACTED_NAMED_USERS",
"CTM_DEFAULT_FEATURE_SCOPES",
"CTM_DEFAULT_ROLLOUT",
"CTM_DEFAULT_SITES",
"CTM_DEFAULT_TAKEOUTS",
"Confidence",
"CostTakeout",
"DEFAULT_METERS",
"DEFAULT_PRICING",
"FeatureScope",
"MeterType",
"NO_ROLLOUT",
"PLATFORM_RATE_PER_USER_MONTHLY",
"RolloutPlan",
"SCENARIOS",
"Scenario",
"SiteInput",
"TokenMeter",
"TokenPricing",
"build_business_case",
"calculate_consumption_ai_cost",
"calculate_per_user_ai_cost",
"calculate_platform_license_cost",
"calculate_total_benefit",
"calculate_total_cost",
"export_csv",
"export_excel",
"get_scenario",
"meters_dataframe",
"npv",
"payback_years",
"scenario_state_from_json",
"scenario_state_to_json",
"sites_dataframe",
]

View File

@@ -0,0 +1,379 @@
"""
Benefit calculation engine.
All benefits convert saved handle-time seconds into dollars via each
site's fully-loaded labour rate per working second. Reduction
percentages come from :data:`tokencalc.scenarios.BENEFIT_PARAMS` —
``realistic`` (pressure-tested) by default; pass ``params="claim"``
to reproduce the Genesys ROI-doc figures for side-by-side comparison.
Every figure scales by the scenario's year realization ramp.
"""
from __future__ import annotations
import pandas as pd
from .inputs import FeatureScope, SiteInput
from .meters import Confidence
from .rollout import NO_ROLLOUT, RolloutPlan
from .scenarios import BENEFIT_PARAMS, Scenario, get_scenario
MONTHS_PER_YEAR = 12
def _param(name: str, params: str) -> float:
return BENEFIT_PARAMS[name][params]
def _scope_for(feature_scopes: list[FeatureScope] | FeatureScope,
feature: str) -> FeatureScope | None:
if isinstance(feature_scopes, FeatureScope):
return feature_scopes if feature_scopes.feature == feature else None
return next((s for s in feature_scopes if s.feature == feature), None)
def _df(rows: list[dict]) -> pd.DataFrame:
return pd.DataFrame(
rows, columns=["benefit_line", "scope", "annual_value", "confidence"]
)
def calculate_voice_handle_time_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""AHT reduction from knowledge surfacing (Agent Copilot).
Benefit = volume × eligibility × AHT × reduction% × labour rate.
"""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
reduction = _param("voice_aht_knowledge_reduction", params)
realization = sc.realization(year)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
eligibility = (
feature_scope.eligibility_pct
if feature_scope.eligibility_pct is not None
else sc.voice_knowledge_eligibility
)
seconds_saved = (
s.voice_volume_monthly * MONTHS_PER_YEAR
* eligibility * s.voice_aht_seconds * reduction * realization
)
rows.append(
{
"benefit_line": "Voice AHT (knowledge surfacing)",
"scope": s.site_name,
"annual_value": seconds_saved * s.agent_cost_per_second
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.ESTIMATED.value,
}
)
return _df(rows)
def calculate_acw_summarization_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""ACW eliminated by auto-summarization (Copilot / AI Summary)."""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
reduction = _param("voice_acw_reduction", params)
realization = sc.realization(year)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
eligibility = (
feature_scope.eligibility_pct
if feature_scope.eligibility_pct is not None
else sc.voice_summarization_eligibility
)
seconds_saved = (
s.voice_volume_monthly * MONTHS_PER_YEAR
* eligibility * s.voice_acw_seconds * reduction * realization
)
rows.append(
{
"benefit_line": "Voice ACW (summarization)",
"scope": s.site_name,
"annual_value": seconds_saved * s.agent_cost_per_second
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.ESTIMATED.value,
}
)
return _df(rows)
def calculate_email_ai_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Email Auto-Respond (full displacement at the respond rate) plus
Auto-Suggest (time saving × acceptance on the remainder)."""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
suggest_saving = _param("email_auto_suggest_time_saving", params)
realization = sc.realization(year)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
respond_rate = (
feature_scope.deflection_target
if feature_scope.deflection_target is not None
else sc.email_auto_respond_rate
)
annual_emails = s.email_volume_monthly * MONTHS_PER_YEAR
respond_seconds = (
annual_emails * respond_rate * s.email_aht_seconds * realization
)
suggest_seconds = (
annual_emails * (1 - respond_rate)
* sc.email_auto_suggest_acceptance * s.email_aht_seconds
* suggest_saving * realization
)
rate = s.agent_cost_per_second
rows.append(
{
"benefit_line": "Email Auto-Respond (displaced handling)",
"scope": s.site_name,
"annual_value": respond_seconds * rate
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.UNKNOWN.value, # meter rate unsourced
}
)
rows.append(
{
"benefit_line": "Email Auto-Suggest (drafting time)",
"scope": s.site_name,
"annual_value": suggest_seconds * rate
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.UNKNOWN.value,
}
)
return _df(rows)
def calculate_sta_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""STA reduces AHT *indirectly* via coaching — small reduction with
a realistic ramp (default 1.5% vs the 4% claim)."""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
reduction = _param("sta_aht_reduction", params)
realization = sc.realization(year)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
seconds_saved = (
s.voice_volume_monthly * MONTHS_PER_YEAR
* s.voice_aht_seconds * reduction * realization
)
rows.append(
{
"benefit_line": "STA coaching (AHT)",
"scope": s.site_name,
"annual_value": seconds_saved * s.agent_cost_per_second
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.ESTIMATED.value,
}
)
return _df(rows)
def calculate_bot_deflection_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Agent labour avoided on calls deflected to Voice Bot / Agentic VA.
Not in the original function list but required for a complete net
case — deflected volume never reaches an agent, so the full AHT is
avoided.
"""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
realization = sc.realization(year)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
if feature_scope.feature == "Voice Bot":
deflection = (
feature_scope.deflection_target
if feature_scope.deflection_target is not None
else sc.voice_bot_deflection
)
else: # Agentic Virtual Agent
deflection = (
feature_scope.deflection_target
if feature_scope.deflection_target is not None
else sc.agentic_va_deflection
)
seconds_saved = (
s.voice_volume_monthly * MONTHS_PER_YEAR
* deflection * s.voice_aht_seconds * realization
)
rows.append(
{
"benefit_line": f"{feature_scope.feature} deflection (labour avoided)",
"scope": s.site_name,
"annual_value": seconds_saved * s.agent_cost_per_second
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.ESTIMATED.value,
}
)
return _df(rows)
def calculate_supervisor_copilot_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Supervisor time reclaimed (summaries, QA triage). ESTIMATED."""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
saving = _param("supervisor_copilot_time_saving", params)
realization = sc.realization(year)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
rows.append(
{
"benefit_line": "Supervisor time (AI summaries/insights)",
"scope": s.site_name,
"annual_value": s.supervisors
* s.fully_loaded_supervisor_cost_annual
* saving * realization
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.ESTIMATED.value,
}
)
return _df(rows)
def calculate_predictive_routing_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Predictive routing AHT effect. ESTIMATED; off unless scoped."""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
reduction = _param("predictive_routing_aht_reduction", params)
realization = sc.realization(year)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
seconds_saved = (
s.voice_volume_monthly * MONTHS_PER_YEAR
* s.voice_aht_seconds * reduction * realization
)
rows.append(
{
"benefit_line": "Predictive routing (AHT)",
"scope": s.site_name,
"annual_value": seconds_saved * s.agent_cost_per_second
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.ESTIMATED.value,
}
)
return _df(rows)
#: Which calculator handles which feature scope.
_BENEFIT_DISPATCH = {
"Agent Copilot": (
calculate_voice_handle_time_benefit,
calculate_acw_summarization_benefit,
),
"AI Summary & Insights": (), # benefit carried by Copilot where present
"Speech & Text Analytics": (calculate_sta_benefit,),
"Voice Bot": (calculate_bot_deflection_benefit,),
"Agentic Virtual Agent": (calculate_bot_deflection_benefit,),
"Email AI (Auto-Respond)": (calculate_email_ai_benefit,),
"Predictive Routing": (calculate_predictive_routing_benefit,),
}
def calculate_total_benefit(
sites: list[SiteInput],
feature_scopes: list[FeatureScope],
scenario: str | Scenario,
year: int,
params: str = "realistic",
include_supervisor_benefit: bool = True,
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""All benefit lines for one scenario-year, aggregated per line.
Returns DataFrame: benefit_line, scope, annual_value, confidence.
"""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
frames: list[pd.DataFrame] = []
copilot_scope = _scope_for(feature_scopes, "Agent Copilot")
for scope in feature_scopes:
for fn in _BENEFIT_DISPATCH.get(scope.feature, ()): # type: ignore[arg-type]
frames.append(fn(sites, scope, sc, year, params=params, rollout=rollout))
if include_supervisor_benefit and copilot_scope is not None:
frames.append(
calculate_supervisor_copilot_benefit(
sites, copilot_scope, sc, year, params=params, rollout=rollout
)
)
frames = [f for f in frames if not f.empty]
if not frames:
return _df([])
detail = pd.concat(frames, ignore_index=True)
grouped = (
detail.groupby("benefit_line", sort=False)
.agg(
scope=("scope", lambda v: ", ".join(sorted(set(v)))),
annual_value=("annual_value", "sum"),
confidence=("confidence", "first"),
)
.reset_index()
)
return grouped[["benefit_line", "scope", "annual_value", "confidence"]]

View File

@@ -0,0 +1,188 @@
"""
Business case — combines costs, benefits, and cost takeouts into a
3-year net view with NPV, payback, and ROI.
Convention: all cashflows are year-end and discounted at
``discount_rate`` (default 8%); there is no undiscounted year-0 column
— implementation is amortized straight-line across the analysis years
(spec §5.6 "Implementation amort." line).
"""
from __future__ import annotations
import pandas as pd
from .benefit_model import calculate_total_benefit
from .cost_model import calculate_total_cost
from .defaults import (
DEFAULT_DISCOUNT_RATE,
DEFAULT_IMPLEMENTATION_COST,
PLATFORM_RATE_PER_USER_MONTHLY,
)
from .inputs import CostTakeout, FeatureScope, SiteInput
from .meters import Confidence, TokenMeter, TokenPricing
from .rollout import RolloutPlan
from .scenarios import Scenario, get_scenario
def npv(cashflows_by_year: list[float], discount_rate: float) -> float:
"""Year-end-discounted NPV of year-1..N cashflows."""
return sum(
cf / (1 + discount_rate) ** year
for year, cf in enumerate(cashflows_by_year, start=1)
)
def payback_years(cashflows_by_year: list[float]) -> float | None:
"""First (fractional) year cumulative net turns >= 0; None if never.
Cashflows are assumed evenly spread within each year.
"""
cumulative = 0.0
for year, cf in enumerate(cashflows_by_year, start=1):
if cumulative + cf >= 0 and cf != 0:
if cumulative >= 0:
return float(year - 1)
return (year - 1) + (-cumulative / cf)
cumulative += cf
return None
def build_business_case(
sites: list[SiteInput],
feature_scopes: list[FeatureScope],
meters: dict[str, TokenMeter],
pricing: dict[str, TokenPricing],
takeouts: list[CostTakeout],
scenario: str | Scenario,
years: int = 3,
discount_rate: float = DEFAULT_DISCOUNT_RATE,
platform_rate: float = PLATFORM_RATE_PER_USER_MONTHLY,
implementation_cost: float = DEFAULT_IMPLEMENTATION_COST,
use_contracted: bool = False,
benefit_params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> dict:
"""Returns the dict described in spec §4.3 (DataFrames + headline
metrics). Every number traces to a cost line, benefit line, or
takeout row in the per-year detail frames.
"""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
year_cols = [f"Y{y}" for y in range(1, years + 1)]
cost_frames, benefit_frames = {}, {}
for y in range(1, years + 1):
cost_frames[y] = calculate_total_cost(
sites, feature_scopes, meters, pricing, sc, y,
platform_rate=platform_rate, use_contracted=use_contracted,
rollout=rollout,
)
benefit_frames[y] = calculate_total_benefit(
sites, feature_scopes, sc, y, params=benefit_params,
rollout=rollout,
)
# ── cost_by_year: one row per cost line, one column per year ────
cost_lines = list(cost_frames[1]["cost_line"])
cost_by_year = pd.DataFrame({"line": cost_lines})
for y in range(1, years + 1):
cost_by_year[f"Y{y}"] = list(cost_frames[y]["annual_cost"])
cost_by_year["confidence"] = list(cost_frames[1]["confidence"])
if implementation_cost:
amort = implementation_cost / years
cost_by_year = pd.concat(
[
cost_by_year,
pd.DataFrame(
[
{
"line": "Implementation (amortized)",
**{c: amort for c in year_cols},
"confidence": Confidence.ESTIMATED.value,
}
]
),
],
ignore_index=True,
)
# ── benefit_by_year ──────────────────────────────────────────────
benefit_lines: list[str] = []
for y in range(1, years + 1):
for line in benefit_frames[y]["benefit_line"]:
if line not in benefit_lines:
benefit_lines.append(line)
benefit_by_year = pd.DataFrame({"line": benefit_lines})
for y in range(1, years + 1):
lookup = dict(
zip(benefit_frames[y]["benefit_line"], benefit_frames[y]["annual_value"])
)
benefit_by_year[f"Y{y}"] = [lookup.get(line, 0.0) for line in benefit_lines]
conf_lookup: dict[str, str] = {}
for y in range(1, years + 1):
conf_lookup.update(
dict(zip(benefit_frames[y]["benefit_line"], benefit_frames[y]["confidence"]))
)
benefit_by_year["confidence"] = [
conf_lookup.get(line, Confidence.ESTIMATED.value) for line in benefit_lines
]
# ── takeouts_by_year ─────────────────────────────────────────────
takeouts_by_year = pd.DataFrame(
[
{
"line": t.name,
**{f"Y{y}": t.value_in_year(y) for y in range(1, years + 1)},
"confidence": t.confidence.value,
}
for t in takeouts
]
)
# ── net + cumulative ─────────────────────────────────────────────
total_costs = [float(cost_by_year[c].sum()) for c in year_cols]
total_benefits = [float(benefit_by_year[c].sum()) for c in year_cols]
total_takeouts = [
float(takeouts_by_year[c].sum()) if not takeouts_by_year.empty else 0.0
for c in year_cols
]
net = [
b + t - c for b, t, c in zip(total_benefits, total_takeouts, total_costs)
]
cumulative = pd.Series(net).cumsum().tolist()
net_by_year = pd.DataFrame(
{
"line": [
"TOTAL COSTS", "TOTAL TAKEOUTS", "TOTAL BENEFITS",
"NET", "Cumulative net",
],
**{
f"Y{y}": [
total_costs[y - 1], total_takeouts[y - 1],
total_benefits[y - 1], net[y - 1], cumulative[y - 1],
]
for y in range(1, years + 1)
},
}
)
cumulative_net = pd.DataFrame(
{"year": list(range(1, years + 1)), "cumulative_net": cumulative}
)
total_cost_sum = sum(total_costs)
total_value_sum = sum(total_benefits) + sum(total_takeouts)
return {
"cost_by_year": cost_by_year,
"benefit_by_year": benefit_by_year,
"takeouts_by_year": takeouts_by_year,
"net_by_year": net_by_year,
"cumulative_net": cumulative_net,
"npv": npv(net, discount_rate),
"payback_period_years": payback_years(net),
"roi_3yr": (
(total_value_sum - total_cost_sum) / total_cost_sum
if total_cost_sum
else None
),
}

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)

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),
]

View File

@@ -0,0 +1,131 @@
"""
Excel / CSV / JSON export.
Excel uses openpyxl via pandas — multi-sheet workbooks readable in
Excel 2019+. JSON round-trips the full input state (sites, takeouts,
feature scopes) so a scenario can be saved and reloaded.
"""
from __future__ import annotations
import dataclasses
import json
from pathlib import Path
import pandas as pd
from .inputs import CostTakeout, FeatureScope, SiteInput
from .meters import Confidence, TokenMeter
from .rollout import RolloutPlan
def meters_dataframe(meters: dict[str, TokenMeter]) -> pd.DataFrame:
"""Meter catalogue as a display/export-ready DataFrame."""
return pd.DataFrame(
[
{
"feature": m.feature,
"meter_type": m.meter_type.value,
"units_per_token": m.units_per_token or None,
"tokens_per_unit": m.tokens_per_unit,
"confidence": f"{m.confidence.icon} {m.confidence.value}",
"notes": m.notes,
"source": m.source_url or "",
}
for m in meters.values()
]
)
def sites_dataframe(sites: list[SiteInput]) -> pd.DataFrame:
rows = []
for s in sites:
d = dataclasses.asdict(s)
d["languages"] = ", ".join(d["languages"])
rows.append(d)
return pd.DataFrame(rows)
def export_excel(
sheets: dict[str, pd.DataFrame],
path: str | Path,
) -> Path:
"""Write a multi-sheet Excel workbook. Sheet names are truncated to
Excel's 31-character limit."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with pd.ExcelWriter(path, engine="openpyxl") as writer:
for name, df in sheets.items():
df.to_excel(writer, sheet_name=name[:31], index=False)
return path
def export_csv(df: pd.DataFrame, path: str | Path) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
df.to_csv(path, index=False)
return path
# ── JSON scenario save / load ────────────────────────────────────────
def scenario_state_to_json(
sites: list[SiteInput],
takeouts: list[CostTakeout],
feature_scopes: list[FeatureScope],
path: str | Path | None = None,
rollout: RolloutPlan | None = None,
) -> str:
"""Serialize the full input state; optionally write to ``path``."""
state = {
"sites": [dataclasses.asdict(s) for s in sites],
"takeouts": [
{**dataclasses.asdict(t), "confidence": t.confidence.value}
for t in takeouts
],
"feature_scopes": [
{
**dataclasses.asdict(f),
"adoption_curve": {str(k): v for k, v in f.adoption_curve.items()},
}
for f in feature_scopes
],
}
if rollout is not None:
state["rollout"] = dataclasses.asdict(rollout)
text = json.dumps(state, indent=2)
if path is not None:
Path(path).write_text(text)
return text
def scenario_state_from_json(
source: str | Path,
) -> tuple[list[SiteInput], list[CostTakeout], list[FeatureScope], RolloutPlan | None]:
"""Inverse of :func:`scenario_state_to_json`. ``source`` is a JSON
string or a file path. The fourth element is None for legacy files
saved without a rollout plan."""
raw = (
Path(source).read_text()
if isinstance(source, Path) or (isinstance(source, str) and source.strip().endswith(".json"))
else str(source)
)
state = json.loads(raw)
sites = [SiteInput(**s) for s in state["sites"]]
takeouts = [
CostTakeout(**{**t, "confidence": Confidence(t["confidence"])})
for t in state["takeouts"]
]
scopes = [
FeatureScope(
**{
**f,
"adoption_curve": {int(k): v for k, v in f["adoption_curve"].items()},
}
)
for f in state["feature_scopes"]
]
rollout = (
RolloutPlan(**state["rollout"]) if "rollout" in state else None
)
return sites, takeouts, scopes, rollout

View File

@@ -0,0 +1,149 @@
"""
Input bundles — validated dataclasses, no untyped dicts.
All volumes are MONTHLY; all AHT/ACW figures are SECONDS; all labour
costs are ANNUAL fully-loaded USD.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .meters import Confidence
#: Sanity bounds for handle times (seconds).
AHT_MIN_SECONDS = 10
AHT_MAX_SECONDS = 3600
#: Working hours per FTE-year used to derive per-second labour rates.
WORKING_HOURS_PER_YEAR = 2_080
WORKING_SECONDS_PER_YEAR = WORKING_HOURS_PER_YEAR * 3600
@dataclass
class SiteInput:
site_name: str # "NAM", "EMEA", "AUZ", "APAC HK", …
region_pricing: str # "US", "AU", "EU", "APAC"
agents: int # excluding supervisors
supervisors: int
voice_volume_monthly: int
email_volume_monthly: int
chat_volume_monthly: int
sms_volume_monthly: int
voice_aht_seconds: int
email_aht_seconds: int
chat_aht_seconds: int
voice_acw_seconds: int
fully_loaded_agent_cost_annual: float
fully_loaded_supervisor_cost_annual: float
languages: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
if self.agents < 0 or self.supervisors < 0:
raise ValueError(f"{self.site_name}: agent/supervisor counts must be >= 0")
for name in (
"voice_volume_monthly",
"email_volume_monthly",
"chat_volume_monthly",
"sms_volume_monthly",
):
if getattr(self, name) < 0:
raise ValueError(f"{self.site_name}: {name} must be >= 0")
for name in ("voice_aht_seconds", "email_aht_seconds", "chat_aht_seconds"):
v = getattr(self, name)
if v and not AHT_MIN_SECONDS <= v <= AHT_MAX_SECONDS:
raise ValueError(
f"{self.site_name}: {name}={v}s outside sensible bounds "
f"({AHT_MIN_SECONDS}-{AHT_MAX_SECONDS}s)"
)
if self.voice_acw_seconds < 0:
raise ValueError(f"{self.site_name}: voice_acw_seconds must be >= 0")
@property
def named_users(self) -> int:
return self.agents + self.supervisors
@property
def agent_cost_per_second(self) -> float:
"""Fully-loaded agent labour rate per working second (DBZ-safe)."""
return self.fully_loaded_agent_cost_annual / WORKING_SECONDS_PER_YEAR
@property
def supervisor_cost_per_second(self) -> float:
return self.fully_loaded_supervisor_cost_annual / WORKING_SECONDS_PER_YEAR
@dataclass
class FeatureScope:
"""Which feature is enabled at which sites, in which phase.
``phase`` is the model year (1-3) the feature switches on;
``adoption_curve`` maps model year -> adoption fraction (0.0-1.0)
applied to consumption-metered features (per-user licenses are paid
in full from the phase year onward).
"""
feature: str
enabled_sites: list[str]
phase: int = 1
adoption_curve: dict[int, float] = field(default_factory=dict)
deflection_target: float | None = None
eligibility_pct: float | None = None
def __post_init__(self) -> None:
if self.phase < 1:
raise ValueError(f"{self.feature}: phase must be >= 1")
for year, pct in self.adoption_curve.items():
if not 0.0 <= pct <= 1.0:
raise ValueError(
f"{self.feature}: adoption_curve[{year}]={pct} outside 0-1"
)
for name in ("deflection_target", "eligibility_pct"):
v = getattr(self, name)
if v is not None and not 0.0 <= v <= 1.0:
raise ValueError(f"{self.feature}: {name}={v} outside 0-1")
def active(self, site_name: str, year: int) -> bool:
return site_name in self.enabled_sites and year >= self.phase
def adoption(self, year: int) -> float:
"""Adoption fraction for ``year`` (1.0 when no curve given)."""
if not self.adoption_curve:
return 1.0
if year in self.adoption_curve:
return self.adoption_curve[year]
# Past the last defined year → hold the last value.
last = max(self.adoption_curve)
return self.adoption_curve[last] if year > last else 0.0
@dataclass
class CostTakeout:
"""A retired platform/licence whose cost the programme reclaims.
``start_month`` (1-12, within ``start_year``) prorates the first
active year — e.g. NICE IEX can only be switched off once NAM is
live, so start_year=1, start_month=7 reclaims 6/12 of Y1.
"""
name: str # "NICE IEX (NAM)", "Legacy CC platform", …
annual_cost: float
start_year: int = 1
confidence: Confidence = Confidence.ESTIMATED
notes: str = ""
start_month: int = 1
def __post_init__(self) -> None:
if self.annual_cost < 0:
raise ValueError(f"{self.name}: annual_cost must be >= 0")
if self.start_year < 1:
raise ValueError(f"{self.name}: start_year must be >= 1")
if not 1 <= self.start_month <= 12:
raise ValueError(f"{self.name}: start_month must be 1-12")
def value_in_year(self, year: int) -> float:
if year < self.start_year:
return 0.0
if year == self.start_year:
return self.annual_cost * (12 - (self.start_month - 1)) / 12
return self.annual_cost

View File

@@ -0,0 +1,87 @@
"""
Genesys AI Experience token meters and pricing.
Every meter carries a :class:`Confidence` flag so the UI can distinguish
published Genesys rates from estimates and unknowns. Rates here are
*planning inputs* — this tool explicitly does not replace contractual
pricing (see README, Non-Goals).
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class MeterType(Enum):
PER_USER_PER_MONTH = "per_user_per_month"
PER_INTERACTION = "per_interaction"
PER_MINUTE = "per_minute"
PER_MESSAGE = "per_message"
PER_SUMMARY = "per_summary"
class Confidence(Enum):
CONFIRMED = "confirmed" # published Genesys rate
ESTIMATED = "estimated" # reasonable industry assumption
UNKNOWN = "unknown" # rate not yet sourced
@property
def icon(self) -> str:
return {"confirmed": "🟢", "estimated": "🟡", "unknown": "🔴"}[self.value]
@dataclass
class TokenMeter:
"""One Genesys AI feature's token meter.
``units_per_token`` and ``tokens_per_unit`` are inverses; both are
stored because the UI shows whichever reads more naturally (e.g.
"17 minutes per token" vs "0.0588 tokens per minute"). For
PER_USER_PER_MONTH meters ``units_per_token`` is 0.0 (n/a) and
``tokens_per_unit`` is the flat tokens/user/month figure.
"""
feature: str
meter_type: MeterType
units_per_token: float
tokens_per_unit: float
confidence: Confidence
notes: str
source_url: str | None = None
def __post_init__(self) -> None:
if self.tokens_per_unit < 0:
raise ValueError(f"{self.feature}: tokens_per_unit must be >= 0")
if (
self.meter_type is not MeterType.PER_USER_PER_MONTH
and self.units_per_token > 0
and self.tokens_per_unit > 0
):
product = self.units_per_token * self.tokens_per_unit
if not 0.95 <= product <= 1.05:
raise ValueError(
f"{self.feature}: units_per_token ({self.units_per_token}) and "
f"tokens_per_unit ({self.tokens_per_unit}) are not inverses"
)
@dataclass
class TokenPricing:
"""Per-region token pricing. Default is US list at $1/token."""
region: str # "US", "AU", "EU", "APAC"
list_rate_per_token: float = 1.0
contracted_rate_per_token: float | None = None
prepay_commit_tokens: int | None = None
overage_rate_per_token: float | None = None
def __post_init__(self) -> None:
if self.list_rate_per_token < 0:
raise ValueError(f"{self.region}: list rate must be >= 0")
def effective_rate(self, use_contracted: bool = False) -> float:
"""Contracted rate when requested and known, else list rate."""
if use_contracted and self.contracted_rate_per_token is not None:
return self.contracted_rate_per_token
return self.list_rate_per_token

View File

@@ -0,0 +1,81 @@
"""
Implementation rollout & ramp model.
Captures the gap between **when Genesys starts billing** (contract
start) and **when each region actually goes live**:
- The platform licence commit bills in full from contract start; the
vendor's *ramp period* compensates with a first-year credit
(typical: 6-month ramp → 50% Y1 discount on the platform commit).
- AI token usage (per-user and consumption meters) starts only when a
site goes live, and bills for the months the site is live in each
model year.
- Benefits likewise accrue only from go-live (the scenario realization
curve then models adoption maturity *within* the live period).
A site with ``go_live_month = m`` is live for ``12*year m`` months of
the first ``year`` years (clamped to 0..12 per year). So NAM at month 6
is live 6 months of Y1; EMEA at month 9 → 3 months; AUZ/APAC at month
12 → 0 months in Y1 and fully live from Y2.
"""
from __future__ import annotations
from dataclasses import dataclass, field
MONTHS_PER_YEAR = 12
@dataclass
class RolloutPlan:
#: ISO date Genesys starts billing the licence commit (informational,
#: surfaced in UI/exports; the model works in months-from-start).
contract_start: str | None = None
#: Total build duration, months (informational).
build_months: int = 12
#: Vendor ramp period, months. Documentation for the Y1 credit below.
ramp_months: int = 6
#: First-year credit on the platform licence commit. Typical
#: 6-month ramp = 50% discount in year 1; years 2+ bill in full.
first_year_platform_discount: float = 0.5
#: site_name -> go-live month (months after contract start).
#: Sites absent from the map are treated as live from day 0.
go_live_month: dict[str, int] = field(default_factory=dict)
def __post_init__(self) -> None:
if not 0.0 <= self.first_year_platform_discount <= 1.0:
raise ValueError("first_year_platform_discount must be within 0-1")
if self.ramp_months < 0 or self.build_months < 0:
raise ValueError("ramp_months/build_months must be >= 0")
for site, m in self.go_live_month.items():
if m < 0:
raise ValueError(f"{site}: go_live_month must be >= 0")
# ── Availability ────────────────────────────────────────────────
def live_months_in_year(self, site_name: str, year: int) -> int:
"""Months ``site_name`` is live during model year ``year`` (1-based)."""
go_live = self.go_live_month.get(site_name, 0)
live_by_year_end = max(0, MONTHS_PER_YEAR * year - go_live)
live_by_prev_year_end = max(0, MONTHS_PER_YEAR * (year - 1) - go_live)
return min(MONTHS_PER_YEAR, live_by_year_end - live_by_prev_year_end)
def fraction_live(self, site_name: str, year: int) -> float:
return self.live_months_in_year(site_name, year) / MONTHS_PER_YEAR
# ── Billing ─────────────────────────────────────────────────────
def platform_factor(self, year: int) -> float:
"""Fraction of the full platform commit billed in ``year``."""
return 1.0 - self.first_year_platform_discount if year == 1 else 1.0
#: Behaviour identical to the pre-rollout model: everything live from
#: day 0, no ramp credit.
NO_ROLLOUT = RolloutPlan(
build_months=0, ramp_months=0, first_year_platform_discount=0.0
)

View File

@@ -0,0 +1,112 @@
"""
Scenario definitions — Floor / Realistic / Stretch.
Every scenario parameter the cost and benefit engines read lives here;
no magic numbers in the calculation modules. Ships with the spec
defaults; callers may construct custom :class:`Scenario` objects.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class Scenario:
name: str
# ── Cost-side drivers ───────────────────────────────────────────
voice_bot_deflection: float # share of voice volume deflected to bot
voice_bot_avg_minutes: float # bot minutes per deflected call
agentic_va_deflection: float # share of voice volume to agentic VA
voice_summarization_eligibility: float
voice_knowledge_eligibility: float
email_auto_respond_rate: float # share of email auto-responded
email_auto_suggest_acceptance: float
# year -> fraction of full benefit realized
benefit_realization: dict[int, float] = field(default_factory=dict)
# year -> fraction of steady-state consumption cost incurred.
# Per-user licenses are paid in full from day 1; consumption meters
# ramp with usage (default Y1 = 70%).
consumption_cost_realization: dict[int, float] = field(
default_factory=lambda: {1: 0.70, 2: 1.0, 3: 1.0}
)
def realization(self, year: int) -> float:
if year in self.benefit_realization:
return self.benefit_realization[year]
last = max(self.benefit_realization, default=0)
return self.benefit_realization.get(last, 1.0) if year > last else 0.0
def cost_realization(self, year: int) -> float:
if year in self.consumption_cost_realization:
return self.consumption_cost_realization[year]
last = max(self.consumption_cost_realization, default=0)
return (
self.consumption_cost_realization.get(last, 1.0) if year > last else 0.0
)
#: Benefit reduction parameters. ``claim`` = Genesys ROI-doc figure;
#: ``realistic`` = pressure-tested midpoint of the spec's Y1 range.
#: The benefit engine uses ``realistic`` by default; ``claim`` powers
#: the side-by-side comparison view.
BENEFIT_PARAMS: dict[str, dict[str, float]] = {
"voice_aht_knowledge_reduction": {"claim": 0.094, "realistic": 0.055}, # 4-7% Y1
"voice_acw_reduction": {"claim": 1.00, "realistic": 0.40}, # 30-50% Y1
"digital_aht_reduction": {"claim": 0.18, "realistic": 0.085}, # 5-12% Y1
"digital_acw_reduction": {"claim": 1.00, "realistic": 0.40}, # 30-50% Y1
"sta_aht_reduction": {"claim": 0.04, "realistic": 0.015}, # 1-2% Y1
"email_auto_suggest_time_saving": {"claim": 0.30, "realistic": 0.30}, # × acceptance
# ESTIMATED lines (no Genesys claim published):
"supervisor_copilot_time_saving": {"claim": 0.10, "realistic": 0.05},
"predictive_routing_aht_reduction": {"claim": 0.04, "realistic": 0.02},
}
SCENARIOS: dict[str, Scenario] = {
"floor": Scenario(
name="floor",
voice_bot_deflection=0.20,
voice_bot_avg_minutes=1.0,
agentic_va_deflection=0.05,
voice_summarization_eligibility=0.50,
voice_knowledge_eligibility=0.40,
email_auto_respond_rate=0.10,
email_auto_suggest_acceptance=0.25,
benefit_realization={1: 0.30, 2: 0.60, 3: 0.80},
),
"realistic": Scenario(
name="realistic",
voice_bot_deflection=0.35,
voice_bot_avg_minutes=1.5,
agentic_va_deflection=0.15,
voice_summarization_eligibility=0.70,
voice_knowledge_eligibility=0.60,
email_auto_respond_rate=0.20,
email_auto_suggest_acceptance=0.40,
benefit_realization={1: 0.50, 2: 0.80, 3: 0.95},
),
"stretch": Scenario(
name="stretch",
voice_bot_deflection=0.50,
voice_bot_avg_minutes=2.0,
agentic_va_deflection=0.25,
voice_summarization_eligibility=0.90,
voice_knowledge_eligibility=0.80,
email_auto_respond_rate=0.50,
email_auto_suggest_acceptance=0.60,
benefit_realization={1: 0.75, 2: 0.95, 3: 1.00},
),
}
def get_scenario(name: str) -> Scenario:
try:
return SCENARIOS[name.lower()]
except KeyError as e:
raise KeyError(
f"Unknown scenario {name!r}. Valid: {sorted(SCENARIOS)}"
) from e