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,107 @@
|
||||
"""Benefit engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc.benefit_model import (
|
||||
calculate_acw_summarization_benefit,
|
||||
calculate_email_ai_benefit,
|
||||
calculate_total_benefit,
|
||||
)
|
||||
from tokencalc.defaults import CTM_DEFAULT_FEATURE_SCOPES, CTM_DEFAULT_SITES
|
||||
from tokencalc.inputs import WORKING_SECONDS_PER_YEAR, FeatureScope, SiteInput
|
||||
|
||||
ALL_SITES = [s.site_name for s in CTM_DEFAULT_SITES]
|
||||
|
||||
|
||||
def _small_site() -> SiteInput:
|
||||
return SiteInput(
|
||||
"Small", "US", agents=10, supervisors=1,
|
||||
voice_volume_monthly=10_000, email_volume_monthly=1_000,
|
||||
chat_volume_monthly=0, sms_volume_monthly=0,
|
||||
voice_aht_seconds=300, email_aht_seconds=600,
|
||||
chat_aht_seconds=480, voice_acw_seconds=60,
|
||||
fully_loaded_agent_cost_annual=74_880, # → $0.01/second exactly
|
||||
fully_loaded_supervisor_cost_annual=95_000,
|
||||
)
|
||||
|
||||
|
||||
def test_acw_benefit_hand_check():
|
||||
"""10,000 calls × 12 × 70% eligible × 60s ACW × 40% reduction ×
|
||||
50% Y1 realization × $0.01/s = $10,080."""
|
||||
site = _small_site()
|
||||
assert site.agent_cost_per_second == pytest.approx(0.01)
|
||||
df = calculate_acw_summarization_benefit(
|
||||
[site], FeatureScope("Agent Copilot", ["Small"]), "realistic", year=1,
|
||||
)
|
||||
expected = 10_000 * 12 * 0.70 * 60 * 0.40 * 0.50 * 0.01
|
||||
assert df["annual_value"].sum() == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_email_benefit_split():
|
||||
site = _small_site()
|
||||
df = calculate_email_ai_benefit(
|
||||
[site], FeatureScope("Email AI (Auto-Respond)", ["Small"]),
|
||||
"realistic", year=1,
|
||||
)
|
||||
lines = set(df["benefit_line"])
|
||||
assert lines == {
|
||||
"Email Auto-Respond (displaced handling)",
|
||||
"Email Auto-Suggest (drafting time)",
|
||||
}
|
||||
# auto-respond: 1,000×12 × 20% × 600s × 50% × $0.01 = $7,200
|
||||
respond = df[df["benefit_line"].str.contains("Respond")]["annual_value"].sum()
|
||||
assert respond == pytest.approx(7_200)
|
||||
|
||||
|
||||
def test_scenarios_produce_distinct_benefits():
|
||||
totals = {
|
||||
name: calculate_total_benefit(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, name, year=2
|
||||
)["annual_value"].sum()
|
||||
for name in ("floor", "realistic", "stretch")
|
||||
}
|
||||
assert totals["floor"] < totals["realistic"] < totals["stretch"]
|
||||
|
||||
|
||||
def test_claim_exceeds_realistic():
|
||||
realistic = calculate_total_benefit(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, "realistic", year=1,
|
||||
params="realistic",
|
||||
)["annual_value"].sum()
|
||||
claim = calculate_total_benefit(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, "realistic", year=1,
|
||||
params="claim",
|
||||
)["annual_value"].sum()
|
||||
assert claim > realistic
|
||||
|
||||
|
||||
def test_benefits_ramp_by_year():
|
||||
by_year = [
|
||||
calculate_total_benefit(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, "realistic", year=y
|
||||
)["annual_value"].sum()
|
||||
for y in (1, 2, 3)
|
||||
]
|
||||
assert by_year[0] < by_year[1] < by_year[2]
|
||||
|
||||
|
||||
def test_zero_volume_site_is_safe():
|
||||
site = SiteInput(
|
||||
"Empty", "US", agents=0, supervisors=0,
|
||||
voice_volume_monthly=0, email_volume_monthly=0,
|
||||
chat_volume_monthly=0, sms_volume_monthly=0,
|
||||
voice_aht_seconds=300, email_aht_seconds=600,
|
||||
chat_aht_seconds=480, voice_acw_seconds=0,
|
||||
fully_loaded_agent_cost_annual=0,
|
||||
fully_loaded_supervisor_cost_annual=0,
|
||||
)
|
||||
df = calculate_total_benefit(
|
||||
[site], [FeatureScope("Agent Copilot", ["Empty"])], "realistic", year=1,
|
||||
)
|
||||
assert df["annual_value"].sum() == 0
|
||||
|
||||
|
||||
def test_working_seconds_constant():
|
||||
assert WORKING_SECONDS_PER_YEAR == 2_080 * 3_600
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Business case maths + exports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc.business_case import build_business_case, npv, payback_years
|
||||
from tokencalc.defaults import (
|
||||
CTM_DEFAULT_FEATURE_SCOPES,
|
||||
CTM_DEFAULT_SITES,
|
||||
CTM_DEFAULT_TAKEOUTS,
|
||||
DEFAULT_METERS,
|
||||
DEFAULT_PRICING,
|
||||
)
|
||||
from tokencalc.exports import (
|
||||
export_excel,
|
||||
scenario_state_from_json,
|
||||
scenario_state_to_json,
|
||||
)
|
||||
|
||||
|
||||
def test_npv_hand_check():
|
||||
"""100/yr for 3 years @ 8%: 92.593 + 85.734 + 79.383 = 257.710."""
|
||||
assert npv([100, 100, 100], 0.08) == pytest.approx(257.710, abs=0.001)
|
||||
|
||||
|
||||
def test_payback_interpolation():
|
||||
# -100 in Y1, +200 in Y2 → breakeven halfway through Y2 = 1.5 years
|
||||
assert payback_years([-100, 200, 0]) == pytest.approx(1.5)
|
||||
assert payback_years([-100, -100, -100]) is None
|
||||
assert payback_years([50, 50, 50]) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def _case(scenario="realistic", **kw):
|
||||
return build_business_case(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, DEFAULT_METERS,
|
||||
DEFAULT_PRICING, CTM_DEFAULT_TAKEOUTS, scenario, **kw,
|
||||
)
|
||||
|
||||
|
||||
def test_business_case_shape():
|
||||
case = _case()
|
||||
assert set(case) == {
|
||||
"cost_by_year", "benefit_by_year", "takeouts_by_year",
|
||||
"net_by_year", "cumulative_net", "npv",
|
||||
"payback_period_years", "roi_3yr",
|
||||
}
|
||||
for key in ("cost_by_year", "benefit_by_year", "net_by_year"):
|
||||
assert {"Y1", "Y2", "Y3"} <= set(case[key].columns)
|
||||
|
||||
|
||||
def test_net_consistency():
|
||||
"""NET row must equal benefits + takeouts − costs, per year."""
|
||||
case = _case()
|
||||
nb = case["net_by_year"].set_index("line")
|
||||
for y in ("Y1", "Y2", "Y3"):
|
||||
assert nb.loc["NET", y] == pytest.approx(
|
||||
nb.loc["TOTAL BENEFITS", y]
|
||||
+ nb.loc["TOTAL TAKEOUTS", y]
|
||||
- nb.loc["TOTAL COSTS", y]
|
||||
)
|
||||
# cumulative is a running sum of NET
|
||||
assert nb.loc["Cumulative net", "Y3"] == pytest.approx(
|
||||
sum(nb.loc["NET", y] for y in ("Y1", "Y2", "Y3"))
|
||||
)
|
||||
|
||||
|
||||
def test_npv_matches_net_rows():
|
||||
case = _case()
|
||||
nb = case["net_by_year"].set_index("line")
|
||||
net = [nb.loc["NET", y] for y in ("Y1", "Y2", "Y3")]
|
||||
assert case["npv"] == pytest.approx(npv(net, 0.08))
|
||||
|
||||
|
||||
def test_three_scenarios_distinct():
|
||||
npvs = {s: _case(s)["npv"] for s in ("floor", "realistic", "stretch")}
|
||||
assert len({round(v) for v in npvs.values()}) == 3
|
||||
assert npvs["floor"] < npvs["realistic"] < npvs["stretch"]
|
||||
|
||||
|
||||
def test_implementation_amortization():
|
||||
base = _case()
|
||||
with_impl = _case(implementation_cost=900_000)
|
||||
nb, nb2 = (
|
||||
c["net_by_year"].set_index("line") for c in (base, with_impl)
|
||||
)
|
||||
for y in ("Y1", "Y2", "Y3"):
|
||||
assert nb2.loc["TOTAL COSTS", y] == pytest.approx(
|
||||
nb.loc["TOTAL COSTS", y] + 300_000
|
||||
)
|
||||
|
||||
|
||||
def test_excel_export_readable(tmp_path):
|
||||
case = _case()
|
||||
path = export_excel(
|
||||
{
|
||||
"Business Case": case["net_by_year"],
|
||||
"Costs": case["cost_by_year"],
|
||||
"Benefits": case["benefit_by_year"],
|
||||
},
|
||||
tmp_path / "ctm.xlsx",
|
||||
)
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(path)
|
||||
assert set(wb.sheetnames) == {"Business Case", "Costs", "Benefits"}
|
||||
|
||||
|
||||
def test_scenario_json_roundtrip(tmp_path):
|
||||
p = tmp_path / "state.json"
|
||||
scenario_state_to_json(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_TAKEOUTS, CTM_DEFAULT_FEATURE_SCOPES, p
|
||||
)
|
||||
sites, takeouts, scopes = scenario_state_from_json(p)
|
||||
assert [s.site_name for s in sites] == [s.site_name for s in CTM_DEFAULT_SITES]
|
||||
assert takeouts[0].annual_cost == CTM_DEFAULT_TAKEOUTS[0].annual_cost
|
||||
assert scopes[0].adoption_curve == CTM_DEFAULT_FEATURE_SCOPES[0].adoption_curve
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Cost engine — including the spec's acceptance numbers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc.cost_model import (
|
||||
calculate_consumption_ai_cost,
|
||||
calculate_per_user_ai_cost,
|
||||
calculate_platform_license_cost,
|
||||
calculate_total_cost,
|
||||
)
|
||||
from tokencalc.defaults import (
|
||||
CONTRACTED_NAMED_USERS,
|
||||
CTM_DEFAULT_FEATURE_SCOPES,
|
||||
CTM_DEFAULT_SITES,
|
||||
DEFAULT_METERS,
|
||||
DEFAULT_PRICING,
|
||||
)
|
||||
from tokencalc.inputs import FeatureScope, SiteInput
|
||||
from tokencalc.scenarios import get_scenario
|
||||
|
||||
ALL_SITES = [s.site_name for s in CTM_DEFAULT_SITES]
|
||||
|
||||
|
||||
def _scope(feature, sites=None, **kw):
|
||||
return FeatureScope(feature, sites or ALL_SITES, **kw)
|
||||
|
||||
|
||||
def test_default_sites_match_contracted_users():
|
||||
assert sum(s.named_users for s in CTM_DEFAULT_SITES) == CONTRACTED_NAMED_USERS
|
||||
|
||||
|
||||
def test_sta_acceptance_number():
|
||||
"""2,088 users × 30 tokens × 12 months × $1 = $751,680."""
|
||||
df = calculate_per_user_ai_cost(
|
||||
CTM_DEFAULT_SITES, _scope("Speech & Text Analytics"),
|
||||
DEFAULT_METERS["Speech & Text Analytics"], DEFAULT_PRICING,
|
||||
)
|
||||
assert df["annual_cost"].sum() == pytest.approx(751_680)
|
||||
|
||||
|
||||
def test_agent_copilot_acceptance_number():
|
||||
"""2,088 users × 40 tokens × 12 months × $1 = $1,002,240."""
|
||||
df = calculate_per_user_ai_cost(
|
||||
CTM_DEFAULT_SITES, _scope("Agent Copilot"),
|
||||
DEFAULT_METERS["Agent Copilot"], DEFAULT_PRICING,
|
||||
)
|
||||
assert df["annual_cost"].sum() == pytest.approx(1_002_240)
|
||||
|
||||
|
||||
def test_per_user_not_active_before_phase():
|
||||
df = calculate_per_user_ai_cost(
|
||||
CTM_DEFAULT_SITES, _scope("AI Translate", phase=3),
|
||||
DEFAULT_METERS["AI Translate"], DEFAULT_PRICING, year=2,
|
||||
)
|
||||
assert df["annual_cost"].sum() == 0
|
||||
|
||||
|
||||
def test_copilot_covers_supervisor_summary():
|
||||
"""Rule 1: AI Summary cost is zero at Copilot sites."""
|
||||
scenario = get_scenario("realistic")
|
||||
total = calculate_total_cost(
|
||||
CTM_DEFAULT_SITES,
|
||||
[
|
||||
_scope("Agent Copilot"),
|
||||
_scope("AI Summary & Insights"),
|
||||
],
|
||||
DEFAULT_METERS, DEFAULT_PRICING, scenario, year=1,
|
||||
include_platform=False,
|
||||
)
|
||||
summary_row = total[total["cost_line"] == "AI Summary & Insights"].iloc[0]
|
||||
assert summary_row["annual_cost"] == 0
|
||||
# Without Copilot the same line costs real money.
|
||||
total2 = calculate_total_cost(
|
||||
CTM_DEFAULT_SITES,
|
||||
[_scope("AI Summary & Insights")],
|
||||
DEFAULT_METERS, DEFAULT_PRICING, scenario, year=1,
|
||||
include_platform=False,
|
||||
)
|
||||
assert total2[total2["cost_line"] == "AI Summary & Insights"].iloc[0][
|
||||
"annual_cost"
|
||||
] > 0
|
||||
|
||||
|
||||
def test_consumption_tokens_rounded_up_monthly():
|
||||
"""Rule 2: ceil on monthly site token totals."""
|
||||
site = SiteInput(
|
||||
"Tiny", "US", agents=5, supervisors=0,
|
||||
voice_volume_monthly=100, email_volume_monthly=0,
|
||||
chat_volume_monthly=0, sms_volume_monthly=0,
|
||||
voice_aht_seconds=300, email_aht_seconds=600,
|
||||
chat_aht_seconds=480, voice_acw_seconds=60,
|
||||
fully_loaded_agent_cost_annual=65_000,
|
||||
fully_loaded_supervisor_cost_annual=95_000,
|
||||
)
|
||||
# realistic: 100 calls × 35% × 1.5 min = 52.5 min × (1/17) = 3.088
|
||||
# tokens × 70% Y1 ramp applied to units → 36.75 min → 2.16 tokens → ceil 3
|
||||
df = calculate_consumption_ai_cost(
|
||||
[site], FeatureScope("Voice Bot", ["Tiny"]),
|
||||
DEFAULT_METERS["Voice Bot"], "realistic", DEFAULT_PRICING, year=1,
|
||||
)
|
||||
assert df.iloc[0]["tokens_monthly"] == 3
|
||||
assert df.iloc[0]["annual_cost"] == pytest.approx(3 * 12 * 1.0)
|
||||
|
||||
|
||||
def test_regional_pricing_not_hardcoded():
|
||||
pricing = dict(DEFAULT_PRICING)
|
||||
from tokencalc.meters import TokenPricing
|
||||
|
||||
pricing["APAC"] = TokenPricing(region="APAC", list_rate_per_token=2.0)
|
||||
apac_site = next(s for s in CTM_DEFAULT_SITES if s.region_pricing == "APAC")
|
||||
df = calculate_per_user_ai_cost(
|
||||
[apac_site], _scope("Speech & Text Analytics", [apac_site.site_name]),
|
||||
DEFAULT_METERS["Speech & Text Analytics"], pricing,
|
||||
)
|
||||
expected = apac_site.named_users * 30 * 12 * 2.0
|
||||
assert df["annual_cost"].sum() == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_year1_consumption_ramp_default_70pct():
|
||||
sc = get_scenario("realistic")
|
||||
assert sc.cost_realization(1) == pytest.approx(0.70)
|
||||
assert sc.cost_realization(2) == 1.0
|
||||
|
||||
|
||||
def test_platform_license_cost():
|
||||
df = calculate_platform_license_cost(CTM_DEFAULT_SITES)
|
||||
expected = CONTRACTED_NAMED_USERS * 111.28 * 12
|
||||
assert df["annual_cost"].sum() == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_total_cost_default_scopes_runs_all_years():
|
||||
for year in (1, 2, 3):
|
||||
df = calculate_total_cost(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES,
|
||||
DEFAULT_METERS, DEFAULT_PRICING, "realistic", year,
|
||||
)
|
||||
assert (df["annual_cost"] >= 0).all()
|
||||
assert {"cost_line", "scope", "annual_cost", "confidence"} <= set(df.columns)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Meter catalogue integrity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc.defaults import DEFAULT_METERS, DEFAULT_PRICING
|
||||
from tokencalc.meters import Confidence, MeterType, TokenMeter, TokenPricing
|
||||
|
||||
|
||||
def test_all_spec_meters_present():
|
||||
expected = {
|
||||
"Voice Bot", "Virtual Agent (legacy)", "Agentic Virtual Agent",
|
||||
"AI Summary & Insights", "Direct Messaging", "Social Listening",
|
||||
"Social Responses", "Speech & Text Analytics", "Agent Copilot",
|
||||
"Email AI (Auto-Suggest)", "Email AI (Auto-Respond)", "AI Translate",
|
||||
}
|
||||
assert expected == set(DEFAULT_METERS)
|
||||
|
||||
|
||||
def test_confirmed_rates():
|
||||
m = DEFAULT_METERS
|
||||
assert m["Voice Bot"].units_per_token == 17
|
||||
assert m["Voice Bot"].tokens_per_unit == pytest.approx(0.0588, abs=1e-3)
|
||||
assert m["Agentic Virtual Agent"].tokens_per_unit == 1.2
|
||||
assert m["AI Summary & Insights"].tokens_per_unit == 0.02
|
||||
assert m["Direct Messaging"].units_per_token == 400
|
||||
assert m["Speech & Text Analytics"].tokens_per_unit == 30
|
||||
assert m["Agent Copilot"].tokens_per_unit == 40
|
||||
|
||||
|
||||
def test_unknown_meters_flagged():
|
||||
unknown = {f for f, m in DEFAULT_METERS.items() if m.confidence is Confidence.UNKNOWN}
|
||||
assert unknown == {
|
||||
"Email AI (Auto-Suggest)", "Email AI (Auto-Respond)", "AI Translate"
|
||||
}
|
||||
assert Confidence.UNKNOWN.icon == "🔴"
|
||||
assert Confidence.CONFIRMED.icon == "🟢"
|
||||
|
||||
|
||||
def test_inverse_consistency_validated():
|
||||
with pytest.raises(ValueError, match="not inverses"):
|
||||
TokenMeter(
|
||||
feature="Bad", meter_type=MeterType.PER_MINUTE,
|
||||
units_per_token=10, tokens_per_unit=0.5,
|
||||
confidence=Confidence.ESTIMATED, notes="",
|
||||
)
|
||||
|
||||
|
||||
def test_every_confirmed_meter_has_source_url():
|
||||
for m in DEFAULT_METERS.values():
|
||||
if m.confidence is Confidence.CONFIRMED:
|
||||
assert m.source_url, f"{m.feature} missing source URL"
|
||||
|
||||
|
||||
def test_pricing_effective_rate():
|
||||
p = TokenPricing(region="US", list_rate_per_token=1.0,
|
||||
contracted_rate_per_token=0.85)
|
||||
assert p.effective_rate(use_contracted=False) == 1.0
|
||||
assert p.effective_rate(use_contracted=True) == 0.85
|
||||
# no contracted rate → falls back to list
|
||||
assert DEFAULT_PRICING["US"].effective_rate(use_contracted=True) == 1.0
|
||||
|
||||
|
||||
def test_all_regions_priced():
|
||||
assert set(DEFAULT_PRICING) == {"US", "EU", "AU", "APAC"}
|
||||
Reference in New Issue
Block a user