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:
@@ -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",
|
||||
]
|
||||
@@ -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"]]
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user