Token Calculator
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -8,9 +8,11 @@ from tokencalc.benefit_model import (
|
||||
calculate_acw_summarization_benefit,
|
||||
calculate_email_ai_benefit,
|
||||
calculate_total_benefit,
|
||||
calculate_va_deflection_benefit,
|
||||
)
|
||||
from tokencalc.defaults import CTM_DEFAULT_FEATURE_SCOPES, CTM_DEFAULT_SITES
|
||||
from tokencalc.inputs import WORKING_SECONDS_PER_YEAR, FeatureScope, SiteInput
|
||||
from tokencalc.scenarios import BENEFIT_PARAMS
|
||||
|
||||
ALL_SITES = [s.site_name for s in CTM_DEFAULT_SITES]
|
||||
|
||||
@@ -105,3 +107,133 @@ def test_zero_volume_site_is_safe():
|
||||
|
||||
def test_working_seconds_constant():
|
||||
assert WORKING_SECONDS_PER_YEAR == 2_080 * 3_600
|
||||
|
||||
|
||||
# ── Virtual Agent deflection tests ───────────────────────────────────────────
|
||||
|
||||
def test_va_bot_deflection_hand_check():
|
||||
"""Voice Bot: 10,000 calls/mo × 12 × 35% bot_rate × 300s AHT
|
||||
× 50% Y1 realization × realization_factor × $0.01/s.
|
||||
|
||||
realistic realization_factor = 0.70 × 0.80 × (1 − 0.05) = 0.532
|
||||
"""
|
||||
site = _small_site()
|
||||
df = calculate_va_deflection_benefit(
|
||||
[site],
|
||||
FeatureScope("Voice Bot", ["Small"], deflection_target=0.35),
|
||||
"realistic",
|
||||
year=1,
|
||||
params="realistic",
|
||||
)
|
||||
completion = BENEFIT_PARAMS["va_completion_rate"]["realistic"]
|
||||
labour = BENEFIT_PARAMS["va_labour_realization"]["realistic"]
|
||||
callback = BENEFIT_PARAMS["va_callback_discount"]["realistic"]
|
||||
real_factor = completion * labour * (1.0 - callback)
|
||||
expected = (
|
||||
10_000 * 12 # annual calls
|
||||
* 0.35 # bot deflection rate
|
||||
* 300 # AHT seconds
|
||||
* 0.50 # Y1 scenario realization
|
||||
* real_factor # completion × labour × (1 − callback)
|
||||
* 0.01 # labour rate per second
|
||||
)
|
||||
assert df["annual_value"].sum() == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_va_agentic_deflection_uses_residual():
|
||||
"""Agentic VA must operate on the residual (1 − bot_rate) call pool,
|
||||
not the full volume.
|
||||
|
||||
With bot_rate=0.35 and va_rate=0.15:
|
||||
residual = 10,000 × (1 − 0.35) = 6,500 calls/mo
|
||||
va_deflected = 6,500 × 0.15 = 975 calls/mo
|
||||
"""
|
||||
site = _small_site()
|
||||
df = calculate_va_deflection_benefit(
|
||||
[site],
|
||||
FeatureScope("Agentic Virtual Agent", ["Small"], deflection_target=0.15),
|
||||
"realistic",
|
||||
year=1,
|
||||
params="realistic",
|
||||
)
|
||||
completion = BENEFIT_PARAMS["va_completion_rate"]["realistic"]
|
||||
labour = BENEFIT_PARAMS["va_labour_realization"]["realistic"]
|
||||
callback = BENEFIT_PARAMS["va_callback_discount"]["realistic"]
|
||||
real_factor = completion * labour * (1.0 - callback)
|
||||
# realistic scenario: voice_bot_deflection = 0.35
|
||||
bot_rate = 0.35
|
||||
va_rate = 0.15
|
||||
expected = (
|
||||
10_000 * 12 # annual calls
|
||||
* (1.0 - bot_rate) * va_rate # residual × va_rate (layered)
|
||||
* 300 # AHT seconds
|
||||
* 0.50 # Y1 scenario realization
|
||||
* real_factor
|
||||
* 0.01
|
||||
)
|
||||
assert df["annual_value"].sum() == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_va_no_double_count():
|
||||
"""Combined bot + VA benefit must be less than the naive additive sum.
|
||||
|
||||
Naive (wrong): volume × (bot_rate + va_rate) × AHT × ...
|
||||
Correct (layered): volume × (bot_rate + (1−bot_rate)×va_rate) × AHT × ...
|
||||
|
||||
With bot=35%, va=15%:
|
||||
naive total deflection = 50%
|
||||
layered total deflection = 35% + 65%×15% = 44.75%
|
||||
"""
|
||||
site = _small_site()
|
||||
bot_scope = FeatureScope("Voice Bot", ["Small"], deflection_target=0.35)
|
||||
va_scope = FeatureScope("Agentic Virtual Agent", ["Small"], deflection_target=0.15)
|
||||
|
||||
bot_df = calculate_va_deflection_benefit([site], bot_scope, "realistic", year=1)
|
||||
va_df = calculate_va_deflection_benefit([site], va_scope, "realistic", year=1)
|
||||
combined = bot_df["annual_value"].sum() + va_df["annual_value"].sum()
|
||||
|
||||
# Naive additive (the old broken model): both on full volume
|
||||
completion = BENEFIT_PARAMS["va_completion_rate"]["realistic"]
|
||||
labour = BENEFIT_PARAMS["va_labour_realization"]["realistic"]
|
||||
callback = BENEFIT_PARAMS["va_callback_discount"]["realistic"]
|
||||
real_factor = completion * labour * (1.0 - callback)
|
||||
naive = (
|
||||
10_000 * 12 * (0.35 + 0.15) * 300 * 0.50 * real_factor * 0.01
|
||||
)
|
||||
assert combined < naive, (
|
||||
f"Combined layered benefit ({combined:.2f}) should be less than "
|
||||
f"naive additive ({naive:.2f}) — double-count not fixed"
|
||||
)
|
||||
|
||||
# Also verify the exact layered total
|
||||
layered_deflection = 0.35 + (1.0 - 0.35) * 0.15 # = 0.4475
|
||||
expected_combined = (
|
||||
10_000 * 12 * layered_deflection * 300 * 0.50 * real_factor * 0.01
|
||||
)
|
||||
assert combined == pytest.approx(expected_combined)
|
||||
|
||||
|
||||
def test_va_claim_params_reproduce_no_haircut():
|
||||
"""params='claim' must apply zero haircuts (all factors = 1.0),
|
||||
reproducing the original Genesys ROI-doc assumption."""
|
||||
site = _small_site()
|
||||
df_claim = calculate_va_deflection_benefit(
|
||||
[site],
|
||||
FeatureScope("Voice Bot", ["Small"], deflection_target=0.35),
|
||||
"realistic",
|
||||
year=1,
|
||||
params="claim",
|
||||
)
|
||||
df_realistic = calculate_va_deflection_benefit(
|
||||
[site],
|
||||
FeatureScope("Voice Bot", ["Small"], deflection_target=0.35),
|
||||
"realistic",
|
||||
year=1,
|
||||
params="realistic",
|
||||
)
|
||||
# claim should be strictly higher (no haircuts applied)
|
||||
assert df_claim["annual_value"].sum() > df_realistic["annual_value"].sum()
|
||||
|
||||
# claim realization_factor = 1.0 × 1.0 × (1 − 0.0) = 1.0
|
||||
expected_claim = 10_000 * 12 * 0.35 * 300 * 0.50 * 1.0 * 0.01
|
||||
assert df_claim["annual_value"].sum() == pytest.approx(expected_claim)
|
||||
|
||||
@@ -111,7 +111,7 @@ def test_scenario_json_roundtrip(tmp_path):
|
||||
scenario_state_to_json(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_TAKEOUTS, CTM_DEFAULT_FEATURE_SCOPES, p
|
||||
)
|
||||
sites, takeouts, scopes = scenario_state_from_json(p)
|
||||
sites, takeouts, scopes, _rollout = 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
|
||||
|
||||
@@ -34,8 +34,8 @@ def test_default_sites_match_contracted_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,
|
||||
CTM_DEFAULT_SITES, _scope("Speech & Text Analytics [named]"),
|
||||
DEFAULT_METERS["Speech & Text Analytics [named]"], DEFAULT_PRICING,
|
||||
)
|
||||
assert df["annual_cost"].sum() == pytest.approx(751_680)
|
||||
|
||||
@@ -43,16 +43,20 @@ def test_sta_acceptance_number():
|
||||
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,
|
||||
CTM_DEFAULT_SITES, _scope("Agent Copilot [named]"),
|
||||
DEFAULT_METERS["Agent Copilot [named]"], 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,
|
||||
def test_ai_translate_not_active_before_phase():
|
||||
"""AI Translate (consumption meter) produces zero cost before its phase."""
|
||||
scenario = get_scenario("realistic")
|
||||
apac_sites = [s.site_name for s in CTM_DEFAULT_SITES if s.region_pricing == "APAC"]
|
||||
df = calculate_consumption_ai_cost(
|
||||
CTM_DEFAULT_SITES,
|
||||
_scope("AI Translate", apac_sites, phase=3),
|
||||
DEFAULT_METERS["AI Translate"], scenario, DEFAULT_PRICING, year=2,
|
||||
)
|
||||
assert df["annual_cost"].sum() == 0
|
||||
|
||||
@@ -63,7 +67,7 @@ def test_copilot_covers_supervisor_summary():
|
||||
total = calculate_total_cost(
|
||||
CTM_DEFAULT_SITES,
|
||||
[
|
||||
_scope("Agent Copilot"),
|
||||
_scope("Agent Copilot [named]"),
|
||||
_scope("AI Summary & Insights"),
|
||||
],
|
||||
DEFAULT_METERS, DEFAULT_PRICING, scenario, year=1,
|
||||
@@ -111,8 +115,8 @@ def test_regional_pricing_not_hardcoded():
|
||||
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,
|
||||
[apac_site], _scope("Speech & Text Analytics [named]", [apac_site.site_name]),
|
||||
DEFAULT_METERS["Speech & Text Analytics [named]"], pricing,
|
||||
)
|
||||
expected = apac_site.named_users * 30 * 12 * 2.0
|
||||
assert df["annual_cost"].sum() == pytest.approx(expected)
|
||||
|
||||
@@ -10,10 +10,26 @@ 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",
|
||||
# Voice / Bot
|
||||
"Voice Bot", "Digital Bot",
|
||||
# Virtual Agent
|
||||
"Virtual Agent (legacy)", "Agentic Virtual Agent",
|
||||
# Agent Copilot (named + concurrent)
|
||||
"Agent Copilot [named]", "Agent Copilot [concurrent]",
|
||||
# AI Quality / Analytics
|
||||
"AI Scoring", "AI Summary & Insights",
|
||||
# Speech & Text Analytics (named + concurrent)
|
||||
"Speech & Text Analytics [named]", "Speech & Text Analytics [concurrent]",
|
||||
# Routing
|
||||
"Predictive Routing",
|
||||
# Messaging
|
||||
"Direct Messaging", "Social Listening", "Social Responses",
|
||||
# Language
|
||||
"AI Translate",
|
||||
# Genesys Cloud Copilot
|
||||
"Genesys Cloud Copilot",
|
||||
# Email AI (rates TBD)
|
||||
"Email AI (Auto-Suggest)", "Email AI (Auto-Respond)",
|
||||
}
|
||||
assert expected == set(DEFAULT_METERS)
|
||||
|
||||
@@ -22,17 +38,29 @@ 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["Digital Bot"].units_per_token == 51
|
||||
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
|
||||
# Named variants
|
||||
assert m["Speech & Text Analytics [named]"].tokens_per_unit == 30
|
||||
assert m["Speech & Text Analytics [concurrent]"].tokens_per_unit == 45
|
||||
assert m["Agent Copilot [named]"].tokens_per_unit == 40
|
||||
assert m["Agent Copilot [concurrent]"].tokens_per_unit == 60
|
||||
# AI Translate is now a confirmed consumption meter
|
||||
assert m["AI Translate"].tokens_per_unit == 0.5
|
||||
assert m["AI Translate"].units_per_token == 2
|
||||
assert m["AI Translate"].confidence is Confidence.CONFIRMED
|
||||
# New meters
|
||||
assert m["AI Scoring"].units_per_token == 20
|
||||
assert m["Predictive Routing"].units_per_token == 17
|
||||
assert m["Genesys Cloud Copilot"].units_per_token == 20
|
||||
|
||||
|
||||
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"
|
||||
"Email AI (Auto-Suggest)", "Email AI (Auto-Respond)",
|
||||
}
|
||||
assert Confidence.UNKNOWN.icon == "🔴"
|
||||
assert Confidence.CONFIRMED.icon == "🟢"
|
||||
|
||||
@@ -206,7 +206,7 @@ def calculate_sta_benefit(
|
||||
return _df(rows)
|
||||
|
||||
|
||||
def calculate_bot_deflection_benefit(
|
||||
def calculate_va_deflection_benefit(
|
||||
sites: list[SiteInput],
|
||||
feature_scope: FeatureScope,
|
||||
scenario: str | Scenario,
|
||||
@@ -214,41 +214,85 @@ def calculate_bot_deflection_benefit(
|
||||
params: str = "realistic",
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Agent labour avoided on calls deflected to Voice Bot / Agentic VA.
|
||||
"""Agent labour avoided on calls deflected to Voice Bot or 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.
|
||||
**Layered (sequential) deflection model** — Voice Bot runs first on
|
||||
the full call pool; Agentic VA handles a share of the *residual*
|
||||
(calls the bot did not deflect). The two mechanisms are substitutes
|
||||
operating on the same call base, not independent additive benefits.
|
||||
|
||||
Effective total deflection:
|
||||
bot_rate + (1 − bot_rate) × va_rate
|
||||
e.g. 35% + 65% × 15% = 44.75% (not 50%)
|
||||
|
||||
**Three realization haircuts** are applied to convert raw deflected
|
||||
volume into realizable labour savings:
|
||||
|
||||
1. ``completion_rate`` — share of "deflected" calls that don't
|
||||
escalate to an agent mid-session (bot/VA fully handles the call).
|
||||
2. ``labour_realization`` — staffing flexibility factor; deflected
|
||||
volume doesn't reduce headcount 1:1 due to minimums, shrinkage,
|
||||
and occupancy ceilings.
|
||||
3. ``callback_discount`` — fraction of deflected calls that re-enter
|
||||
as repeat contacts (poorly-handled deflections drive callbacks).
|
||||
|
||||
Combined realistic factor: 0.70 × 0.80 × (1 − 0.05) ≈ 0.53
|
||||
|
||||
The ``params="claim"`` path sets all three factors to their
|
||||
``claim`` values (1.0 / 1.0 / 0.0) to reproduce the original
|
||||
Genesys ROI-doc figures for side-by-side comparison.
|
||||
"""
|
||||
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||
ro = rollout or NO_ROLLOUT
|
||||
realization = sc.realization(year)
|
||||
|
||||
# Realization haircuts — read from BENEFIT_PARAMS so claim/realistic
|
||||
# paths are consistent with all other benefit lines.
|
||||
completion_rate = _param("va_completion_rate", params)
|
||||
labour_real = _param("va_labour_realization", params)
|
||||
callback_disc = _param("va_callback_discount", params)
|
||||
realization_factor = completion_rate * labour_real * (1.0 - callback_disc)
|
||||
|
||||
rows = []
|
||||
for s in sites:
|
||||
if not feature_scope.active(s.site_name, year):
|
||||
continue
|
||||
|
||||
if feature_scope.feature == "Voice Bot":
|
||||
deflection = (
|
||||
# Bot operates on the full call pool.
|
||||
bot_rate = (
|
||||
feature_scope.deflection_target
|
||||
if feature_scope.deflection_target is not None
|
||||
else sc.voice_bot_deflection
|
||||
)
|
||||
deflected_calls = s.voice_volume_monthly * MONTHS_PER_YEAR * bot_rate
|
||||
|
||||
else: # Agentic Virtual Agent
|
||||
deflection = (
|
||||
# VA operates on the residual after the bot has deflected its share.
|
||||
# If Voice Bot is not in scope (VA-only deployment), bot_rate = 0
|
||||
# and the VA works on the full pool — still correct.
|
||||
bot_rate = sc.voice_bot_deflection
|
||||
va_rate = (
|
||||
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
|
||||
)
|
||||
residual_calls = (
|
||||
s.voice_volume_monthly * MONTHS_PER_YEAR * (1.0 - bot_rate)
|
||||
)
|
||||
deflected_calls = residual_calls * va_rate
|
||||
|
||||
seconds_saved = deflected_calls * 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),
|
||||
"annual_value": (
|
||||
seconds_saved
|
||||
* s.agent_cost_per_second
|
||||
* realization_factor
|
||||
* ro.fraction_live(s.site_name, year)
|
||||
),
|
||||
"confidence": Confidence.ESTIMATED.value,
|
||||
}
|
||||
)
|
||||
@@ -320,19 +364,30 @@ def calculate_predictive_routing_benefit(
|
||||
|
||||
|
||||
#: Which calculator handles which feature scope.
|
||||
#: Agent Copilot and STA exist in named/concurrent variants — both map
|
||||
#: to the same benefit calculators.
|
||||
#: Voice Bot and Agentic VA both route to calculate_va_deflection_benefit,
|
||||
#: which implements the layered sequential model — VA operates on the
|
||||
#: residual after the bot has deflected its share.
|
||||
_BENEFIT_DISPATCH = {
|
||||
"Agent Copilot": (
|
||||
"Agent Copilot [named]": (
|
||||
calculate_voice_handle_time_benefit,
|
||||
calculate_acw_summarization_benefit,
|
||||
),
|
||||
"Agent Copilot [concurrent]": (
|
||||
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,),
|
||||
"Speech & Text Analytics [named]": (calculate_sta_benefit,),
|
||||
"Speech & Text Analytics [concurrent]": (calculate_sta_benefit,),
|
||||
"Voice Bot": (calculate_va_deflection_benefit,),
|
||||
"Agentic Virtual Agent": (calculate_va_deflection_benefit,),
|
||||
"Predictive Routing": (calculate_predictive_routing_benefit,),
|
||||
}
|
||||
|
||||
_COPILOT_FEATURES = {"Agent Copilot [named]", "Agent Copilot [concurrent]"}
|
||||
|
||||
|
||||
def calculate_total_benefit(
|
||||
sites: list[SiteInput],
|
||||
@@ -346,10 +401,18 @@ def calculate_total_benefit(
|
||||
"""All benefit lines for one scenario-year, aggregated per line.
|
||||
|
||||
Returns DataFrame: benefit_line, scope, annual_value, confidence.
|
||||
|
||||
Voice Bot and Agentic VA deflection benefits use the layered
|
||||
sequential model: the bot deflects from the full call pool; the VA
|
||||
deflects from the residual. The two features are NOT additive on
|
||||
the same base — see :func:`calculate_va_deflection_benefit`.
|
||||
"""
|
||||
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||
frames: list[pd.DataFrame] = []
|
||||
copilot_scope = _scope_for(feature_scopes, "Agent Copilot")
|
||||
# Find whichever Copilot variant is in scope (named or concurrent).
|
||||
copilot_scope = next(
|
||||
(s for s in feature_scopes if s.feature in _COPILOT_FEATURES), None
|
||||
)
|
||||
|
||||
for scope in feature_scopes:
|
||||
for fn in _BENEFIT_DISPATCH.get(scope.feature, ()): # type: ignore[arg-type]
|
||||
|
||||
@@ -7,8 +7,8 @@ Correctness rules implemented here (see spec §4.1):
|
||||
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/
|
||||
token metering,
|
||||
https://help.genesys.cloud/articles/genesys-cloud-tokens-model/
|
||||
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)
|
||||
@@ -133,12 +133,18 @@ def _monthly_units(site: SiteInput, feature: str, scope: FeatureScope,
|
||||
site.voice_volume_monthly * deflection * scenario.voice_bot_avg_minutes
|
||||
) # minutes
|
||||
if feature == "Agentic Virtual Agent":
|
||||
deflection = (
|
||||
# Layered model: VA operates on the residual volume after the voice bot
|
||||
# has already deflected its share. Cost base = residual × va_rate.
|
||||
# This is consistent with the benefit model and avoids double-counting
|
||||
# the same call pool across both deflection mechanisms.
|
||||
bot_deflection = scenario.voice_bot_deflection
|
||||
va_deflection = (
|
||||
scope.deflection_target
|
||||
if scope.deflection_target is not None
|
||||
else scenario.agentic_va_deflection
|
||||
)
|
||||
return site.voice_volume_monthly * deflection # interactions
|
||||
residual = site.voice_volume_monthly * (1.0 - bot_deflection)
|
||||
return residual * va_deflection # interactions
|
||||
if feature == "Virtual Agent (legacy)":
|
||||
deflection = scope.deflection_target or 0.0
|
||||
return site.voice_volume_monthly * deflection
|
||||
@@ -159,6 +165,11 @@ def _monthly_units(site: SiteInput, feature: str, scope: FeatureScope,
|
||||
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
|
||||
if feature == "AI Translate":
|
||||
# Each voice interaction generates one translation; eligibility_pct
|
||||
# can be used to scope to a subset of interactions (e.g. non-English only).
|
||||
eligibility = scope.eligibility_pct if scope.eligibility_pct is not None else 1.0
|
||||
return site.voice_volume_monthly * eligibility # translations
|
||||
raise KeyError(f"No consumption-volume mapping for feature {feature!r}")
|
||||
|
||||
|
||||
@@ -260,11 +271,12 @@ def calculate_total_cost(
|
||||
|
||||
# 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's per-user token rate already includes interaction summarization.
|
||||
# https://help.genesys.cloud/articles/genesys-cloud-tokens-model/
|
||||
_COPILOT_FEATURES = {"Agent Copilot [named]", "Agent Copilot [concurrent]"}
|
||||
copilot_sites: set[str] = set()
|
||||
for scope in feature_scopes:
|
||||
if scope.feature == "Agent Copilot":
|
||||
if scope.feature in _COPILOT_FEATURES:
|
||||
copilot_sites |= {
|
||||
s.site_name for s in sites if scope.active(s.site_name, year)
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ DEFAULT_DISCOUNT_RATE = 0.08
|
||||
#: 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/"
|
||||
_GENESYS_TOKEN_METERS = (
|
||||
"https://help.genesys.cloud/articles/genesys-cloud-tokens-model/"
|
||||
)
|
||||
|
||||
# ── Token meters ─────────────────────────────────────────────────────
|
||||
@@ -41,6 +41,7 @@ _GENESYS_TOKEN_FAQ = (
|
||||
DEFAULT_METERS: dict[str, TokenMeter] = {
|
||||
m.feature: m
|
||||
for m in [
|
||||
# ── Voice / Bot ───────────────────────────────────────────────
|
||||
TokenMeter(
|
||||
feature="Voice Bot",
|
||||
meter_type=MeterType.PER_MINUTE,
|
||||
@@ -48,16 +49,26 @@ DEFAULT_METERS: dict[str, TokenMeter] = {
|
||||
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,
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Digital Bot",
|
||||
meter_type=MeterType.PER_INTERACTION,
|
||||
units_per_token=51.0,
|
||||
tokens_per_unit=1 / 51, # 0.0196
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="Digital (non-voice) bot sessions; 51 sessions per token.",
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
# ── Virtual Agent ─────────────────────────────────────────────
|
||||
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,
|
||||
notes="Legacy (non-agentic) virtual agent; 0.5 tokens per interaction.",
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Agentic Virtual Agent",
|
||||
@@ -66,7 +77,42 @@ DEFAULT_METERS: dict[str, TokenMeter] = {
|
||||
tokens_per_unit=1.2,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="Agentic VA; 1.2 tokens per interaction.",
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
# ── Agent Copilot (named vs concurrent) ───────────────────────
|
||||
TokenMeter(
|
||||
feature="Agent Copilot [named]",
|
||||
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_METERS,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Agent Copilot [concurrent]",
|
||||
meter_type=MeterType.PER_USER_PER_MONTH,
|
||||
units_per_token=0.0,
|
||||
tokens_per_unit=60.0,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes=(
|
||||
"60 tokens per concurrent user per month. Includes interaction "
|
||||
"summarization (covers AI Summary & Insights)."
|
||||
),
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
# ── AI Quality / Analytics ────────────────────────────────────
|
||||
TokenMeter(
|
||||
feature="AI Scoring",
|
||||
meter_type=MeterType.PER_INTERACTION,
|
||||
units_per_token=20.0,
|
||||
tokens_per_unit=0.05,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="AI-scored quality evaluations; 20 evaluations per token.",
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="AI Summary & Insights",
|
||||
@@ -78,16 +124,50 @@ DEFAULT_METERS: dict[str, TokenMeter] = {
|
||||
"Supervisor standalone summarization; 50 summaries per token. "
|
||||
"NOT metered where Agent Copilot is assigned — see cost model."
|
||||
),
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
# ── Speech & Text Analytics (named vs concurrent) ─────────────
|
||||
TokenMeter(
|
||||
feature="Speech & Text Analytics [named]",
|
||||
meter_type=MeterType.PER_USER_PER_MONTH,
|
||||
units_per_token=0.0,
|
||||
tokens_per_unit=30.0,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="STA named licence; 30 tokens per named user per month.",
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Speech & Text Analytics [concurrent]",
|
||||
meter_type=MeterType.PER_USER_PER_MONTH,
|
||||
units_per_token=0.0,
|
||||
tokens_per_unit=45.0,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="STA concurrent licence; 45 tokens per concurrent user per month.",
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
# ── Routing / Engagement ──────────────────────────────────────
|
||||
TokenMeter(
|
||||
feature="Predictive Routing",
|
||||
meter_type=MeterType.PER_INTERACTION,
|
||||
units_per_token=17.0,
|
||||
tokens_per_unit=1 / 17, # 0.0588
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="Predictive routing; 17 routes per token.",
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
# ── Messaging ─────────────────────────────────────────────────
|
||||
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,
|
||||
notes=(
|
||||
"Apple Messages for Business, Facebook Messenger, Instagram DM, "
|
||||
"WhatsApp, and X (Twitter) DM; 400 inbound or outbound messages "
|
||||
"per token. Additional carrier charges apply for WhatsApp and X."
|
||||
),
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Social Listening",
|
||||
@@ -95,8 +175,8 @@ DEFAULT_METERS: dict[str, TokenMeter] = {
|
||||
units_per_token=400.0,
|
||||
tokens_per_unit=0.0025,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="400 messages per token.",
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
notes="Genesys Cloud Social; 400 social post ingestions per channel per token.",
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Social Responses",
|
||||
@@ -104,53 +184,48 @@ DEFAULT_METERS: dict[str, TokenMeter] = {
|
||||
units_per_token=400.0,
|
||||
tokens_per_unit=0.0025,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="400 messages per token.",
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
notes="Social Post Responses; 400 outbound messages per channel per token.",
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
# ── Language / Translation ────────────────────────────────────
|
||||
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,
|
||||
feature="AI Translate",
|
||||
meter_type=MeterType.PER_INTERACTION,
|
||||
units_per_token=2.0,
|
||||
tokens_per_unit=0.5,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="STA: 30 tokens per named user per month.",
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
notes="AI translation; 2 translations per token.",
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
# ── Genesys Cloud Copilot ─────────────────────────────────────
|
||||
TokenMeter(
|
||||
feature="Agent Copilot",
|
||||
meter_type=MeterType.PER_USER_PER_MONTH,
|
||||
units_per_token=0.0,
|
||||
tokens_per_unit=40.0,
|
||||
feature="Genesys Cloud Copilot",
|
||||
meter_type=MeterType.PER_INTERACTION,
|
||||
units_per_token=20.0,
|
||||
tokens_per_unit=0.05,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes=(
|
||||
"40 tokens per named user per month. Includes interaction "
|
||||
"summarization (covers AI Summary & Insights)."
|
||||
"20 AI actions per token; Genesys Cloud knowledge queries "
|
||||
"are not charged."
|
||||
),
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
source_url=_GENESYS_TOKEN_METERS,
|
||||
),
|
||||
# ── Email AI (rates not yet published) ────────────────────────
|
||||
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
|
||||
tokens_per_unit=0.0,
|
||||
confidence=Confidence.UNKNOWN,
|
||||
notes="Rate not yet sourced. Working default 30 tokens/user/month.",
|
||||
notes="Requires Agent Copilot. Token rate not yet published.",
|
||||
),
|
||||
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
|
||||
tokens_per_unit=0.0,
|
||||
confidence=Confidence.UNKNOWN,
|
||||
notes="Rate not yet sourced. Working default 20 tokens/user/month.",
|
||||
notes="Feature not yet available; rate TBD.",
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -342,14 +417,13 @@ 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),
|
||||
# CTM has named licences — use the [named] variant for both STA and Copilot.
|
||||
FeatureScope("Speech & Text Analytics [named]", ALL_SITE_NAMES, phase=1),
|
||||
FeatureScope("Agent Copilot [named]", 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),
|
||||
|
||||
@@ -36,9 +36,15 @@ class SiteInput:
|
||||
voice_acw_seconds: int
|
||||
fully_loaded_agent_cost_annual: float
|
||||
fully_loaded_supervisor_cost_annual: float
|
||||
licence_type: str = "named" # "named" | "concurrent"
|
||||
languages: list[str] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.licence_type not in ("named", "concurrent"):
|
||||
raise ValueError(
|
||||
f"{self.site_name}: licence_type must be 'named' or 'concurrent', "
|
||||
f"got {self.licence_type!r}"
|
||||
)
|
||||
if self.agents < 0 or self.supervisors < 0:
|
||||
raise ValueError(f"{self.site_name}: agent/supervisor counts must be >= 0")
|
||||
for name in (
|
||||
|
||||
@@ -18,12 +18,28 @@ class Scenario:
|
||||
# ── 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
|
||||
# Agentic VA deflection is INCREMENTAL — applied to the residual volume
|
||||
# after the voice bot has already handled its share (layered model).
|
||||
# Effective total deflection = bot_rate + (1 − bot_rate) × va_rate.
|
||||
agentic_va_deflection: float # share of RESIDUAL 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
|
||||
|
||||
# ── Virtual Agent benefit realization factors ───────────────────
|
||||
# Applied to both Voice Bot and Agentic VA deflection benefits.
|
||||
# completion_rate — share of "deflected" calls that don't escalate to an agent
|
||||
# mid-session (bot/VA fully handles the interaction).
|
||||
# labour_realization — staffing flexibility: deflected volume doesn't reduce
|
||||
# headcount 1:1 due to minimums, shrinkage, occupancy ceilings.
|
||||
# callback_discount — fraction of deflected calls that re-enter as repeat contacts
|
||||
# (poorly-handled deflections drive callbacks).
|
||||
# Combined realistic factor: 0.70 × 0.80 × (1 − 0.05) ≈ 0.53
|
||||
va_completion_rate: float = 0.70
|
||||
va_labour_realization: float = 0.80
|
||||
va_callback_discount: float = 0.05
|
||||
|
||||
# year -> fraction of full benefit realized
|
||||
benefit_realization: dict[int, float] = field(default_factory=dict)
|
||||
|
||||
@@ -59,10 +75,16 @@ BENEFIT_PARAMS: dict[str, dict[str, float]] = {
|
||||
"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
|
||||
"email_auto_suggest_time_saving": {"claim": 0.40, "realistic": 0.30}, # × acceptance; Genesys claims 40%
|
||||
# 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},
|
||||
# Virtual Agent realization factors.
|
||||
# ``claim`` = 100% realization (original model assumption — no haircuts).
|
||||
# ``realistic`` = production-calibrated midpoints per the spec analysis.
|
||||
"va_completion_rate": {"claim": 1.00, "realistic": 0.70}, # 60-75% voice bot; 50-70% agentic VA Y1
|
||||
"va_labour_realization": {"claim": 1.00, "realistic": 0.80}, # 70-85% staffing flexibility
|
||||
"va_callback_discount": {"claim": 0.00, "realistic": 0.05}, # 5-10% deflected re-enter as repeat contacts
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +98,11 @@ SCENARIOS: dict[str, Scenario] = {
|
||||
voice_knowledge_eligibility=0.40,
|
||||
email_auto_respond_rate=0.10,
|
||||
email_auto_suggest_acceptance=0.25,
|
||||
# VA realization: conservative — low completion, limited staffing flex
|
||||
# Combined: 0.60 × 0.70 × (1 − 0.05) ≈ 0.40
|
||||
va_completion_rate=0.60,
|
||||
va_labour_realization=0.70,
|
||||
va_callback_discount=0.05,
|
||||
benefit_realization={1: 0.30, 2: 0.60, 3: 0.80},
|
||||
),
|
||||
"realistic": Scenario(
|
||||
@@ -87,6 +114,11 @@ SCENARIOS: dict[str, Scenario] = {
|
||||
voice_knowledge_eligibility=0.60,
|
||||
email_auto_respond_rate=0.20,
|
||||
email_auto_suggest_acceptance=0.40,
|
||||
# VA realization: production midpoints per spec analysis
|
||||
# Combined: 0.70 × 0.80 × (1 − 0.05) ≈ 0.53
|
||||
va_completion_rate=0.70,
|
||||
va_labour_realization=0.80,
|
||||
va_callback_discount=0.05,
|
||||
benefit_realization={1: 0.50, 2: 0.80, 3: 0.95},
|
||||
),
|
||||
"stretch": Scenario(
|
||||
@@ -98,6 +130,11 @@ SCENARIOS: dict[str, Scenario] = {
|
||||
voice_knowledge_eligibility=0.80,
|
||||
email_auto_respond_rate=0.50,
|
||||
email_auto_suggest_acceptance=0.60,
|
||||
# VA realization: optimistic — high completion, good staffing flexibility
|
||||
# Combined: 0.75 × 0.85 × (1 − 0.03) ≈ 0.62
|
||||
va_completion_rate=0.75,
|
||||
va_labour_realization=0.85,
|
||||
va_callback_discount=0.03,
|
||||
benefit_realization={1: 0.75, 2: 0.95, 3: 1.00},
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user