"""Orchestration — one call in, the whole costed picture out. The notebook assembles :class:`CalculatorInputs` from its ``engagement-data`` cell and its widgets, calls :func:`calculate`, and renders the result. The engine never invents a volume and never reads a widget. :attr:`CalculatorResult.warnings` is deliberate: when a published rule makes a number smaller — Copilot covering summaries, the allowance absorbing a month, deflection zeroing TTS — the result says so. A total that shrank for a reason nobody can see is indistinguishable from a bug. """ from __future__ import annotations from dataclasses import dataclass, field, replace from typing import Any from .billing import CostLine, CostTotals, cost_lines, total_cost from .meters import LicenceModel, MeterBasis, TokenPrice from .ratecard import meter from .tts import TtsLine, TtsUsage, tts_cost from .usage import ( DeflectionMix, VoiceBotUsage, Volumes, apply_copilot_covers_summary, subscription_tokens, virtual_agent_tokens, voice_bot_billable_minutes, voice_bot_roundup_uplift, voice_bot_tokens, ) #: Meter keys whose units come straight off a channel volume. The notebook's #: feature catalogue decides which are switched on; this maps each to the #: volume that drives it. _VOLUME_DRIVEN: dict[str, str] = { "bots_digital": "digital_sessions_monthly", "ai_summary_and_insights": "voice_total_monthly", "ai_scoring": "evaluations_monthly", "ai_translate": "translations_monthly", "predictive_routing": "routed_interactions_monthly", "genesys_cloud_copilot": "ai_actions_monthly", "apple_messages_for_business": "messaging_monthly", "facebook_messenger": "messaging_monthly", "instagram_direct_messaging": "messaging_monthly", "whatsapp_messaging": "messaging_monthly", "x_direct_messaging": "messaging_monthly", "genesys_cloud_social": "social_posts_monthly", "social_post_responses": "social_responses_monthly", "predictive_engagement": "all_interactions_monthly", "knowledge_queries": "all_interactions_monthly", } #: The per-user subscription meters, by licence-model face. _PER_USER_KEYS: frozenset[str] = frozenset( { "agent_copilot_named", "agent_copilot_concurrent", "speech_and_text_analytics_named", "speech_and_text_analytics_concurrent", } ) #: Deflection-driven meters — priced from the highest-tier partition, not a #: raw volume. _TIER_KEYS: frozenset[str] = frozenset({"virtual_agent", "agentic_virtual_agent"}) #: The Copilot family, in either licence face. _COPILOT_KEYS: frozenset[str] = frozenset( {"agent_copilot_named", "agent_copilot_concurrent"} ) def _volume_for(volumes: Volumes, attr: str) -> float: return float(getattr(volumes, attr)) @dataclass(frozen=True) class CalculatorInputs: """Everything the notebook supplies. Client data plus widget state.""" volumes: Volumes users: int licence: LicenceModel = LicenceModel.NAMED enabled: frozenset[str] = frozenset() mix: DeflectionMix = field(default_factory=DeflectionMix) price: TokenPrice = field(default_factory=TokenPrice) voice_bot: VoiceBotUsage | None = None tts: TtsUsage | None = None apply_allowance: bool = True def __post_init__(self) -> None: if self.users < 0: raise ValueError("users must not be negative") unknown = sorted(k for k in self.enabled if k not in _known_keys()) if unknown: raise ValueError( f"enabled contains keys that are not published meters: {unknown}" ) @property def copilot_enabled(self) -> bool: return bool(self.enabled & _COPILOT_KEYS) def with_(self, **changes: Any) -> CalculatorInputs: """A copy with fields replaced — for scenarios and sensitivity.""" return replace(self, **changes) def _known_keys() -> frozenset[str]: from .ratecard import METERS return frozenset(METERS) @dataclass(frozen=True) class CalculatorResult: """The costed picture, plus why any number shrank.""" lines: tuple[CostLine, ...] totals: CostTotals tts: TtsLine | None grand_total_annual: float warnings: tuple[str, ...] @property def token_cost_annual(self) -> float: return self.totals.token_cost_annual @property def tts_cost_annual(self) -> float: return self.tts.cost_annual if self.tts is not None else 0.0 def metered_units(inputs: CalculatorInputs) -> dict[str, float]: """Monthly metered units per enabled meter key, before the Copilot rule.""" units: dict[str, float] = {} for key in sorted(inputs.enabled): if key in _PER_USER_KEYS: units[key] = float(inputs.users) elif key in _TIER_KEYS: share = ( inputs.mix.virtual_agent_share if key == "virtual_agent" else inputs.mix.agentic_va_share ) units[key] = inputs.volumes.voice_total_monthly * share elif key == "bots_voice": units[key] = ( voice_bot_billable_minutes(inputs.voice_bot) if inputs.voice_bot is not None else 0.0 ) elif key in _VOLUME_DRIVEN: units[key] = _volume_for(inputs.volumes, _VOLUME_DRIVEN[key]) else: # pragma: no cover — every published key is classified above raise ValueError(f"{key} has no unit driver") return units def calculate(inputs: CalculatorInputs) -> CalculatorResult: """Price a scenario end to end.""" raw_units = metered_units(inputs) usage = apply_copilot_covers_summary(raw_units, inputs.copilot_enabled) units = usage.units warnings: list[str] = list(usage.notes) tokens: dict[str, float] = {} consumption = 0.0 # Per-user subscription meters — exact, never rounded. per_user_enabled = frozenset(inputs.enabled & _PER_USER_KEYS) subs = subscription_tokens(inputs.users, per_user_enabled, inputs.licence) tokens.update(subs) subscription = sum(subs.values()) # Voice bots — minutes with the published per-call round-up. if "bots_voice" in inputs.enabled and inputs.voice_bot is not None: bot_tokens = voice_bot_tokens(inputs.voice_bot) tokens["bots_voice"] = bot_tokens consumption += bot_tokens uplift = voice_bot_roundup_uplift(inputs.voice_bot) if uplift > 0: warnings.append( f"Voice bots: the published 15-second per-call round-up adds " f"{uplift:.1%} to billable bot minutes at a " f"{inputs.voice_bot.avg_bot_seconds_per_call:.0f}s average." ) # Virtual agents — the highest-tier partition of voice volume. tier_tokens = virtual_agent_tokens( inputs.volumes.voice_total_monthly, inputs.mix ) for key in sorted(_TIER_KEYS & inputs.enabled): tokens[key] = tier_tokens.get(key, 0.0) consumption += tokens[key] # Everything else — units × published rate. for key in sorted(inputs.enabled): if key in _PER_USER_KEYS or key in _TIER_KEYS or key == "bots_voice": continue m = meter(key) value = m.tokens_for(units.get(key, 0.0)) tokens[key] = value consumption += value if m.basis is MeterBasis.ZERO_RATED and units.get(key, 0.0) > 0: warnings.append( f"{m.feature}: {m.published_rate} — shown at $0 because " "Genesys publishes it as included." ) totals = total_cost( consumption, subscription, inputs.licence, inputs.price, inputs.apply_allowance ) if inputs.apply_allowance and totals.billable_tokens_monthly == 0: warnings.append( f"The free monthly allowance ({totals.allowance_tokens_monthly} " f"tokens, {inputs.licence.label.lower()} org) covers this month's " "consumption entirely." ) tts_line = ( tts_cost(inputs.tts, inputs.mix, inputs.price.concession_pct) if inputs.tts is not None else None ) if tts_line is not None: warnings.extend(tts_line.warnings) lines = cost_lines(tokens, units, inputs.price) grand_total = totals.token_cost_annual + ( tts_line.cost_annual if tts_line is not None else 0.0 ) return CalculatorResult( lines=lines, totals=totals, tts=tts_line, grand_total_annual=grand_total, warnings=tuple(warnings), ) def cost_per_interaction(result: CalculatorResult, volumes: Volumes) -> float: """Annual grand total ÷ annual interactions — the number clients recall.""" annual = volumes.all_interactions_monthly * 12 if annual == 0: return 0.0 return result.grand_total_annual / annual def compare(scenarios: dict[str, CalculatorInputs]) -> list[dict[str, Any]]: """One row per scenario. Cost only — a calculator does not build a case.""" rows: list[dict[str, Any]] = [] for name, inputs in scenarios.items(): result = calculate(inputs) rows.append( { "Scenario": name, "Billable tokens / mo": result.totals.billable_tokens_monthly, "Token cost / yr": result.totals.token_cost_annual, "TTS cost / yr": result.tts_cost_annual, "Total / yr": result.grand_total_annual, "$ / interaction": cost_per_interaction(result, inputs.volumes), } ) return rows