Mercury Genesys Token Calculator
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,6 +19,8 @@ studies/*/exports/*
|
||||
!studies/*/exports/.gitkeep
|
||||
assessments/*/exports/*
|
||||
!assessments/*/exports/.gitkeep
|
||||
calculators/*/exports/*
|
||||
!calculators/*/exports/.gitkeep
|
||||
|
||||
# Client / engagement documents must NEVER be committed — masters stay
|
||||
# client-clean (see CLAUDE.md, Confidentiality). Text/image source material
|
||||
|
||||
20
CLAUDE.md
20
CLAUDE.md
@@ -64,6 +64,12 @@ and [docs/Study_Pattern_V1-00.md](docs/Study_Pattern_V1-00.md).
|
||||
- **Assessment** (`assessments/Instrument_Name`, undated) — reusable workshop
|
||||
instrument; a living master. Reference implementation:
|
||||
`assessments/CX_Discovery_Workshop/`.
|
||||
- **Calculator** (`calculators/Vendor_Subject_Calculator`, undated) — prices a
|
||||
vendor's product from that vendor's **published rate card**: verbatim
|
||||
rate-card anchor + client overlay. Undated because the anchor is immutable
|
||||
only *until the vendor republishes* — the publication date lives in the
|
||||
anchor, not the directory name. Reference implementation:
|
||||
`calculators/Genesys_Token_Calculator/`.
|
||||
- **Engagement copy** (`YYYYMM_Client_Instrument`, stamped at copy-time) — a master
|
||||
copied OUT of this repo for a client; confidential; never merges back.
|
||||
- **`template/MercuryNotebook/`** — copy-me scaffold for new studies (py-engine model
|
||||
@@ -96,7 +102,10 @@ structural suite. Running a master's pytest from the wrong venv is the classic
|
||||
**Show first** — produce the diff/output, present it, wait for a human "go":
|
||||
content edits to `topic-bank`/`engagement-data` cells or anchor-adjacent wording;
|
||||
re-pinning a gate or test after a content/engine change (show the pin diff and the
|
||||
KPI moves honestly); moving or renaming a master; `.gitignore` changes; `git commit`.
|
||||
KPI moves honestly); **re-anchoring a Calculator to a newer vendor publication**
|
||||
(show the rate diff, the source-date bump, and the cost moves — the protocol is in
|
||||
the Calculator Pattern); moving or renaming a master; `.gitignore` changes;
|
||||
`git commit`.
|
||||
|
||||
**Forbidden without explicit go-ahead:**
|
||||
`git push`; editing `*_VERBATIM` anchors; committing any client document (SOW, quote,
|
||||
@@ -230,6 +239,12 @@ quietly leaving it. Live ones worth knowing:
|
||||
- **`template/MercuryNotebook/` encodes only the py-engine model** (and its
|
||||
`staging.py` lacks the mypy-strict `backstage_md` variant); rework is a recorded
|
||||
follow-up. Its `exports/*.{html,md}` are tracked — predates the exports rule.
|
||||
- **`make lint` fails repo-wide — 143 pre-existing ruff errors** (94 in the CTM
|
||||
study, 16 in AI Diagnostic, the rest spread across the TEI twins, the template
|
||||
and `00_setup.ipynb`); mostly import sorting and E402 in notebook cells. Newer
|
||||
masters are clean (`ruff check calculators/` passes), so lint per-directory
|
||||
when you touch one; a repo-wide sweep is its own commit, not a rider on
|
||||
feature work.
|
||||
- **`docs/brand.md` references a `brand_dark.md` that does not exist.**
|
||||
- **`core.bootstrap.init(study=…)` imports `studies.<slug>.config`** — vestigial
|
||||
(no study ships a `config.py`; `studies/__init__.py` exists to serve it). Don't
|
||||
@@ -258,6 +273,9 @@ this file summarises:**
|
||||
notebook-first content, engagement-data cell, copy-out checklist
|
||||
- [docs/Study_Pattern_V1-00.md](docs/Study_Pattern_V1-00.md) — studies: verbatim
|
||||
anchor + overlay, reproduction gate, published-PDF exception
|
||||
- [docs/Calculator_Pattern_V1-00.md](docs/Calculator_Pattern_V1-00.md) —
|
||||
calculators: the rate-card anchor (and why it lives in `.py`), the
|
||||
re-anchoring protocol, the proportionality rule (price it; don't build a case)
|
||||
|
||||
Everything else:
|
||||
|
||||
|
||||
9
Makefile
9
Makefile
@@ -6,12 +6,15 @@ PIP := $(VENV)/bin/pip
|
||||
|
||||
.PHONY: setup lab test check-notebooks lint format clean
|
||||
|
||||
## One-time: create venv, install deps + palladium (editable)
|
||||
## One-time: create venv, install deps + palladium (editable).
|
||||
## `pyproject.toml` carries the whole runtime toolchain as core deps and
|
||||
## pytest/ruff in the `dev` extra — `pip install -e ".[dev]"` is enough to
|
||||
## run, test, and lint. (There is deliberately no requirements.txt: a
|
||||
## second dependency list is how `make setup` silently rots.)
|
||||
setup:
|
||||
python3 -m venv $(VENV)
|
||||
$(PIP) install --upgrade pip
|
||||
$(PIP) install -r requirements.txt
|
||||
$(PIP) install -e .
|
||||
$(PIP) install -e ".[dev]"
|
||||
@echo ""
|
||||
@echo "✅ Done. Next: make lab → open 00_setup.ipynb"
|
||||
|
||||
|
||||
@@ -23,12 +23,14 @@ Every master obeys one **three-layer contract** (the always-on rules live in
|
||||
|---|---|---|---|
|
||||
| **Study** | Reproduction of a dated base document (Forrester TEI or similar), personalized as an overlay | `YYYYMM_TEI_Vendor_Product` / `YYYYMM_Client_Engagement` (dated) | `studies/` |
|
||||
| **Assessment** | Reusable workshop instrument (discovery workshop, diagnostic) | `Instrument_Name` (undated, living) | `assessments/` |
|
||||
| **Calculator** | Prices a vendor's product from that vendor's published rate card — verbatim anchor + client overlay | `Vendor_Subject_Calculator` (undated, living) | `calculators/` |
|
||||
| **Engagement copy** | A master copied out for a client engagement — acquires client data, becomes **confidential** | `YYYYMM_Client_Instrument`, stamped at copy-time | **outside this repo** |
|
||||
|
||||
Masters in this repo stay **client-clean**: placeholder engagement data, published or
|
||||
synthetic numbers, nothing a client said. Patterns:
|
||||
[Assessment](docs/Assessment_Pattern_V1-00.md) ·
|
||||
[Study](docs/Study_Pattern_V1-00.md) ·
|
||||
[Calculator](docs/Calculator_Pattern_V1-00.md) ·
|
||||
[shared Mercury mechanics](docs/Mercury_Notebook_Pattern_V1-00.md).
|
||||
|
||||
## Repository layout
|
||||
@@ -39,6 +41,8 @@ palladium/
|
||||
├── assessments/
|
||||
│ ├── CX_Discovery_Workshop/ # ★ reference implementation (notebook-first model)
|
||||
│ └── CX_AI_Diagnostic/ # capability diagnostic (pre-redesign: generated notebook)
|
||||
├── calculators/
|
||||
│ └── Genesys_Token_Calculator/ # ★ Genesys Cloud AI token cost — published rate card, 2026-07-12
|
||||
├── studies/
|
||||
│ ├── 202512_TEI_Genesys_CX_Cloud/ # Forrester TEI reproduction — NPV $10.8M · ROI 266%
|
||||
│ ├── 202602_TEI_Amazon_Connect/ # Forrester TEI reproduction — NPV $78.7M · ROI 342%
|
||||
@@ -109,7 +113,9 @@ For an **Assessment**, start from the reference implementation and its pattern d
|
||||
`assessments/CX_Discovery_Workshop/` + [Assessment Pattern](docs/Assessment_Pattern_V1-00.md).
|
||||
For a **Study**, copy `template/MercuryNotebook/` and follow the
|
||||
[Study Pattern](docs/Study_Pattern_V1-00.md) (note: the template still encodes the
|
||||
py-engine model; its notebook-first rework is a recorded follow-up). Either way:
|
||||
py-engine model; its notebook-first rework is a recorded follow-up). For a
|
||||
**Calculator**, start from `calculators/Genesys_Token_Calculator/` +
|
||||
[Calculator Pattern](docs/Calculator_Pattern_V1-00.md). Any of them:
|
||||
underscores in names (never dashes — directories are Python packages), and register
|
||||
the new notebook in [tests/nbcheck.py](tests/nbcheck.py) — the completeness test
|
||||
fails until you classify it.
|
||||
|
||||
File diff suppressed because one or more lines are too long
172
calculators/Genesys_Token_Calculator/README.md
Normal file
172
calculators/Genesys_Token_Calculator/README.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Genesys Cloud AI — Token Calculator
|
||||
|
||||
A **Calculator** — a reusable vendor-pricing master (see
|
||||
[`docs/Calculator_Pattern_V1-00.md`](../../docs/Calculator_Pattern_V1-00.md))
|
||||
— built on the
|
||||
[Mercury Notebook Deliverable Pattern](../../docs/Mercury_Notebook_Pattern_V1-00.md).
|
||||
It prices what Genesys Cloud AI **costs to run**, from the vendor's own
|
||||
published rate card. Unlike a TEI study it computes no benefits and no NPV:
|
||||
its "numbers" are a run-rate, a per-feature breakdown, and a range.
|
||||
|
||||
The Mercury stage is the screen you share with a client while you size their
|
||||
AI footprint together; the consultant run-book, the verification gate, and the
|
||||
machine-readable appendix live backstage and export as LLM input for a
|
||||
downstream business case.
|
||||
|
||||
## The anchor: the vendor's published rate card
|
||||
|
||||
The meters are Genesys' own, transcribed verbatim into
|
||||
[`genesyscalc/ratecard.py`](genesyscalc/ratecard.py):
|
||||
|
||||
| Source | Published | What it gives |
|
||||
|---|---|---|
|
||||
| [Genesys Cloud tokens model](https://help.genesys.cloud/articles/genesys-cloud-tokens-model/) | **2026-07-12** | 20 token meters, the free monthly allowance, the highest-tier rule, the Copilot exclusion |
|
||||
| [Genesys Enhanced TTS pricing](https://help.genesys.cloud/articles/genesys-enhanced-tts-pricing/) | **2026-05-22** | $/million characters — **not** token-metered |
|
||||
|
||||
That file is **immutable**. When Genesys republishes, re-anchoring is a
|
||||
deliberate act with its own protocol — see *Re-anchoring* below.
|
||||
|
||||
### Four published rules the obvious arithmetic gets wrong
|
||||
|
||||
Each is enforced in the engine, pinned by a test, and reported in the
|
||||
notebook's warnings rather than silently applied:
|
||||
|
||||
1. **The 15-second per-call round-up.** *"Genesys rounds up each call to the
|
||||
next 15-second increment."* Applied per call and then summed — averaging
|
||||
first understates the bill. A 40-second average bills as 45 (+12.5%); a
|
||||
5-second bot greeting bills as 15 (3× its duration).
|
||||
2. **The free monthly allowance.** 250 tokens named / 350 concurrent, which
|
||||
*"renew each month and do not carry over"* — so it is a real deduction, and
|
||||
an unused remainder is discarded, never banked.
|
||||
3. **The highest-tier rule.** *"Genesys bases charges on the highest tier
|
||||
(price) resource…"* — so the deflection shares are a **partition**, not
|
||||
overlapping rates. An interaction is charged once, at the highest tier it
|
||||
touched. Summing tiers independently over-bills.
|
||||
4. **Copilot covers Supervisor summaries.** Enabling Agent Copilot makes AI
|
||||
Summary & Insights free; billing both double-charges.
|
||||
|
||||
## What the client sees (the stage)
|
||||
|
||||
The **published rate card** as the vendor prints it, then three KPI cards
|
||||
(token cost, text-to-speech, total run-rate with a $/interaction figure), the
|
||||
allowance shown as the deduction it is, a per-feature cost breakdown, the
|
||||
list-price → negotiated walk, text-to-speech as its own clearly separated
|
||||
line, an adoption scenario range, and a tornado of what actually moves the
|
||||
number.
|
||||
|
||||
Text-to-speech is **never** folded silently into the token subtotal: the grand
|
||||
total always shows tokens and TTS as two labelled components.
|
||||
|
||||
You drive it from the sidebar — licence model, currency, token price and
|
||||
concession, the three deflection shares, average bot seconds, and a
|
||||
per-feature enable. Every change re-renders the numbers (Mercury re-runs the
|
||||
cells below the widgets).
|
||||
|
||||
## Where the content lives: in the notebook
|
||||
|
||||
Two tagged cells of
|
||||
[`notebooks/genesys_token_calculator.ipynb`](notebooks/genesys_token_calculator.ipynb)
|
||||
carry everything a consultant edits — **in Jupyter, never in a `.py` file**:
|
||||
|
||||
- **`topic-bank`** — the **feature catalogue**: each priced capability's
|
||||
client-facing title, what it does for them, and the sizing question to ask.
|
||||
The `key` slugs must match published meter keys (a test pins this), and they
|
||||
are stable identities the widgets, notes and JSON export all key off.
|
||||
- **`engagement-data`** — the client facts: volumes by channel, headcount,
|
||||
licence model, and the **negotiated** commercial terms (contracted rate,
|
||||
concession). **Illustrative placeholders in this master**; filled in the
|
||||
engagement copy.
|
||||
|
||||
The published **rates** are deliberately *not* content — nobody in this repo
|
||||
authors them, and editing them is forbidden. They are the vendor's record, so
|
||||
they live in the engine as an anchor. See the Calculator Pattern for the full
|
||||
boundary.
|
||||
|
||||
`genesyscalc/` holds **code only** — the meter schema, the anchor, and the
|
||||
arithmetic. The test suite reads the tagged cells straight out of the notebook
|
||||
(no kernel) and pins content shape, so `pytest` guards the catalogue exactly
|
||||
as shipped.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
notebooks/genesys_token_calculator.ipynb # THE deliverable — content + data + presentation
|
||||
# tagged: topic-bank · engagement-data ·
|
||||
# presentation ×8 · gate · data-appendix
|
||||
genesyscalc/ # the engine — code only, no content, mypy --strict
|
||||
ratecard.py # THE VERBATIM ANCHOR — published meters, 2026-07-12
|
||||
meters.py # schema: Confidence, LicenceModel, MeterBasis, Tier, Meter, TokenPrice
|
||||
usage.py # volumes → units → tokens (round-up, tier partition, Copilot rule)
|
||||
billing.py # tokens → dollars (ceil, allowance, price, concession)
|
||||
tts.py # the NON-token line — own source, own rounding, EOS/EOL
|
||||
model.py # orchestration: CalculatorInputs → CalculatorResult
|
||||
sensitivity.py # sweeps + tornado data (no plotting)
|
||||
appendix.py # the machine-readable JSON payload
|
||||
staging.py # stage/backstage detection (+ backstage_md)
|
||||
scripts/export_report.py # execute once → exports/*.html + LLM-ready *.md
|
||||
tests/ # rate-card tripwire, hand-checked arithmetic, content pins, staging
|
||||
exports/ # generated report sources (never committed)
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
|
||||
mercury --working-dir . # serve the stage (share this screen)
|
||||
jupyter lab # analyst view
|
||||
pytest # rate card + engine + content + staging pins
|
||||
mypy # strict
|
||||
jupyter nbconvert --to notebook --execute --inplace notebooks/genesys_token_calculator.ipynb
|
||||
python scripts/export_report.py # exports/*.html + *.md for the LLM handoff
|
||||
```
|
||||
|
||||
The `.md` export opens with a generated preamble (what the document is, the
|
||||
engagement line, **which rate card produced the numbers**) and ends with the
|
||||
**Model state (JSON)** block — the machine source of truth, carrying
|
||||
`rate_card.source_date`. A cost figure without its rate-card date is not
|
||||
auditable.
|
||||
|
||||
## Re-anchoring — when Genesys republishes
|
||||
|
||||
The rate card is a *living* anchor: immutable until the vendor changes it,
|
||||
then updated deliberately. Never partially re-anchor, and never bump the date
|
||||
without the pins.
|
||||
|
||||
1. Diff the published table against `genesyscalc/ratecard.py`.
|
||||
2. Update the meters **and** `RATE_CARD_SOURCE_DATE` in the same edit.
|
||||
3. Update the pins in `tests/test_rate_card.py` in the same commit.
|
||||
4. Re-execute the notebook; update the gate's live-state pins if the defaults moved.
|
||||
5. Report the cost moves honestly.
|
||||
6. **Show-first** — the user sees the rate diff and the KPI move before it lands.
|
||||
|
||||
`tests/test_rate_card.py` is the tripwire: it pins all 20 rows by exact
|
||||
feature wording *and* exact rate string, so a silent transcription drift or an
|
||||
un-noticed republication breaks the build rather than quietly moving a
|
||||
client-facing number.
|
||||
|
||||
## Extending
|
||||
|
||||
New or reshaped catalogue content is an edit to the notebook's `topic-bank`
|
||||
cell in JupyterLab, then re-run the notebook (the **gate** recounts the
|
||||
catalogue and checks every key against the published meters) and re-pin
|
||||
`tests/test_catalogue.py`. New *logic* goes in `genesyscalc/` with pins in
|
||||
`tests/`, **before** the notebook section that renders it.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Not a business case.** No benefits, no NPV, no payback, no ROI, no P&L. If
|
||||
a client needs those, that is a **Study** — see [`studies/`](../../studies/).
|
||||
A calculator that grows a benefit model has stopped being a calculator.
|
||||
- **Not contractual pricing.** A planning model at list rates unless a
|
||||
contracted rate is entered. Genesys quotes; this estimates.
|
||||
- **Not a platform TCO.** Genesys Cloud CX seat licensing is out of scope —
|
||||
this prices the **AI** meters and Enhanced TTS only.
|
||||
|
||||
**Running this for a client?** Don't fill client data into this master —
|
||||
copy the directory out of Palladium, stamp it
|
||||
`YYYYMM_Client_Genesys_Token_Calculator`, provision a fresh venv there, fill
|
||||
the `engagement-data` cell in the copy, and treat the copy as confidential
|
||||
(CLAUDE.md § Confidentiality). A test in this master fails if a client name
|
||||
lands in `engagement-data` here.
|
||||
81
calculators/Genesys_Token_Calculator/config.toml
Normal file
81
calculators/Genesys_Token_Calculator/config.toml
Normal file
@@ -0,0 +1,81 @@
|
||||
# Mercury app-shell theme — NTT DATA brand (light), modern surfaces.
|
||||
# See docs/brand.md for the source palette. Loaded from the directory where
|
||||
# you launch `mercury` (this calculator root); restart the server to apply.
|
||||
|
||||
[main]
|
||||
title = "Genesys Token Calculator"
|
||||
favicon_emoji = "🪙"
|
||||
footer = "Genesys Cloud AI token calculator · NTT DATA"
|
||||
notebooks_button_label = "Calculators"
|
||||
|
||||
[welcome]
|
||||
header = "Genesys Cloud AI — Token Calculator"
|
||||
message = """
|
||||
What Genesys Cloud AI costs to run — open **genesys_token_calculator.ipynb**.
|
||||
Meters are the vendor's **published** rate card as of **2026-07-12**
|
||||
([tokens model](https://help.genesys.cloud/articles/genesys-cloud-tokens-model/)),
|
||||
with Enhanced TTS priced separately from its own published page (2026-05-22).
|
||||
Set volumes and headcount, move the sidebar controls to test deflection and
|
||||
commercial terms, and read the per-feature breakdown. A planning model — list
|
||||
rates unless overridden, never a quote. Export with
|
||||
`python scripts/export_report.py`.
|
||||
"""
|
||||
|
||||
[theme]
|
||||
# ── Type — Georgia headings, Arial body (web-safe; no network fetch). ──
|
||||
font_family = "Arial, 'Helvetica Neue', Helvetica, sans-serif"
|
||||
heading_font_family = "Georgia, 'Times New Roman', Times, serif"
|
||||
font_size = "15px"
|
||||
font_weight = "normal"
|
||||
heading_font_weight = "700"
|
||||
|
||||
# ── Text — NTT ink scale ──
|
||||
text_color = "#2e404d"
|
||||
muted_text_color = "#586671"
|
||||
|
||||
# ── Surfaces — white content on a soft neutral canvas ──
|
||||
background_color = "#f4f5f6"
|
||||
content_background_color = "#ffffff"
|
||||
surface_color = "#ffffff"
|
||||
card_background_color = "#f8f8f8"
|
||||
border_color = "#d5d9db"
|
||||
border_radius = "10px"
|
||||
|
||||
# ── Accents — Future Blue ──
|
||||
primary_color = "#0072bc"
|
||||
accent_color = "#0072bc"
|
||||
focus_border_color = "#0072bc"
|
||||
hover_background_color = "#eef5fb"
|
||||
selected_background_color = "#dcecfa"
|
||||
|
||||
# ── State colors — brand Success / Warning / Error (docs/brand.md) ──
|
||||
success_color = "#00cb5d"
|
||||
warning_color = "#ffc400"
|
||||
danger_color = "#e42600"
|
||||
slider_track_color = "#e2e6e9"
|
||||
|
||||
# ── Sidebar — clean white, hairline divider ──
|
||||
sidebar_background_color = "#ffffff"
|
||||
sidebar_text_color = "#2e404d"
|
||||
sidebar_title_color = "#151d2c"
|
||||
sidebar_shadow = "1px 0 0 #d5d9db"
|
||||
|
||||
# ── Top bar — deep NTT navy ──
|
||||
topbar_background_color = "#151d2c"
|
||||
topbar_text_color = "#ffffff"
|
||||
topbar_border_color = "rgba(255,255,255,0.08)"
|
||||
|
||||
# ── Footer ──
|
||||
footer_background_color = "#ffffff"
|
||||
footer_text_color = "#586671"
|
||||
footer_border_color = "#d5d9db"
|
||||
|
||||
# ── Run button — subtle brand-blue gradient ──
|
||||
run_button_background = "linear-gradient(180deg, #0087dc 0%, #0072bc 100%)"
|
||||
run_button_background_hover = "linear-gradient(180deg, #1a93e6 0%, #0079c8 100%)"
|
||||
run_button_text_color = "#ffffff"
|
||||
|
||||
# ── Depth — soft, navy-tinted shadows ──
|
||||
shadow_sm = "0 1px 2px rgba(21,29,44,0.05)"
|
||||
shadow_md = "0 6px 18px rgba(21,29,44,0.08)"
|
||||
shadow_lg = "0 16px 40px rgba(21,29,44,0.10)"
|
||||
142
calculators/Genesys_Token_Calculator/genesyscalc/__init__.py
Normal file
142
calculators/Genesys_Token_Calculator/genesyscalc/__init__.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Genesys Cloud AI token cost calculator — the engine.
|
||||
|
||||
Math only. The vendor's published rate card is the immutable anchor in
|
||||
:mod:`genesyscalc.ratecard`; everything the consultant authors or the client
|
||||
supplies lives in the notebook's tagged cells. See
|
||||
``docs/Calculator_Pattern_V1-00.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .appendix import result_json
|
||||
from .billing import (
|
||||
MONTHS_PER_YEAR,
|
||||
CostLine,
|
||||
CostTotals,
|
||||
allowance_for,
|
||||
billable_tokens_monthly,
|
||||
cost_lines,
|
||||
total_cost,
|
||||
)
|
||||
from .meters import (
|
||||
Confidence,
|
||||
LicenceModel,
|
||||
Meter,
|
||||
MeterBasis,
|
||||
Tier,
|
||||
TokenPrice,
|
||||
)
|
||||
from .model import (
|
||||
CalculatorInputs,
|
||||
CalculatorResult,
|
||||
calculate,
|
||||
compare,
|
||||
cost_per_interaction,
|
||||
metered_units,
|
||||
)
|
||||
from .ratecard import (
|
||||
ALLOWANCE_CARRIES_OVER,
|
||||
COPILOT_COVERS_SUMMARY_RULE,
|
||||
FREE_TOKENS_PER_MONTH,
|
||||
HIGHEST_TIER_RULE,
|
||||
LIST_PRICE_BY_CURRENCY,
|
||||
METERS,
|
||||
METERS_VERBATIM,
|
||||
RATE_CARD_SOURCE,
|
||||
RATE_CARD_SOURCE_DATE,
|
||||
VOICE_BOT_ROUNDUP_SECONDS,
|
||||
ZERO_RATED_VERBATIM,
|
||||
meter,
|
||||
per_user_meter,
|
||||
rate_card_rows,
|
||||
)
|
||||
from .sensitivity import DRIVERS, sweep, tornado
|
||||
from .staging import backstage, backstage_md, on_stage
|
||||
from .tts import (
|
||||
TTS_PRICE_PER_MILLION_CHARS,
|
||||
TTS_SOURCE,
|
||||
TTS_SOURCE_DATE,
|
||||
TTS_STANDARD_END_OF_SALE,
|
||||
TTS_STANDARD_END_OF_SUPPORT,
|
||||
TtsLine,
|
||||
TtsUsage,
|
||||
tts_cost,
|
||||
tts_free_share,
|
||||
)
|
||||
from .usage import (
|
||||
DeflectionMix,
|
||||
FeatureUsage,
|
||||
VoiceBotUsage,
|
||||
Volumes,
|
||||
apply_copilot_covers_summary,
|
||||
subscription_tokens,
|
||||
virtual_agent_tokens,
|
||||
voice_bot_billable_minutes,
|
||||
voice_bot_roundup_uplift,
|
||||
voice_bot_tokens,
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"ALLOWANCE_CARRIES_OVER",
|
||||
"COPILOT_COVERS_SUMMARY_RULE",
|
||||
"DRIVERS",
|
||||
"FREE_TOKENS_PER_MONTH",
|
||||
"HIGHEST_TIER_RULE",
|
||||
"LIST_PRICE_BY_CURRENCY",
|
||||
"METERS",
|
||||
"METERS_VERBATIM",
|
||||
"MONTHS_PER_YEAR",
|
||||
"RATE_CARD_SOURCE",
|
||||
"RATE_CARD_SOURCE_DATE",
|
||||
"TTS_PRICE_PER_MILLION_CHARS",
|
||||
"TTS_SOURCE",
|
||||
"TTS_SOURCE_DATE",
|
||||
"TTS_STANDARD_END_OF_SALE",
|
||||
"TTS_STANDARD_END_OF_SUPPORT",
|
||||
"VOICE_BOT_ROUNDUP_SECONDS",
|
||||
"ZERO_RATED_VERBATIM",
|
||||
"CalculatorInputs",
|
||||
"CalculatorResult",
|
||||
"Confidence",
|
||||
"CostLine",
|
||||
"CostTotals",
|
||||
"DeflectionMix",
|
||||
"FeatureUsage",
|
||||
"LicenceModel",
|
||||
"Meter",
|
||||
"MeterBasis",
|
||||
"Tier",
|
||||
"TokenPrice",
|
||||
"TtsLine",
|
||||
"TtsUsage",
|
||||
"VoiceBotUsage",
|
||||
"Volumes",
|
||||
"__version__",
|
||||
"allowance_for",
|
||||
"apply_copilot_covers_summary",
|
||||
"backstage",
|
||||
"backstage_md",
|
||||
"billable_tokens_monthly",
|
||||
"calculate",
|
||||
"compare",
|
||||
"cost_lines",
|
||||
"cost_per_interaction",
|
||||
"meter",
|
||||
"metered_units",
|
||||
"on_stage",
|
||||
"per_user_meter",
|
||||
"rate_card_rows",
|
||||
"result_json",
|
||||
"subscription_tokens",
|
||||
"sweep",
|
||||
"tornado",
|
||||
"total_cost",
|
||||
"tts_cost",
|
||||
"tts_free_share",
|
||||
"virtual_agent_tokens",
|
||||
"voice_bot_billable_minutes",
|
||||
"voice_bot_roundup_uplift",
|
||||
"voice_bot_tokens",
|
||||
]
|
||||
126
calculators/Genesys_Token_Calculator/genesyscalc/appendix.py
Normal file
126
calculators/Genesys_Token_Calculator/genesyscalc/appendix.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""The machine-readable model state (Mercury Notebook Pattern §5).
|
||||
|
||||
Plotly figures export as JavaScript an LLM cannot read, so the exported
|
||||
``.md`` ends with one JSON block carrying everything the figures showed.
|
||||
|
||||
``rate_card.source_date`` is the most important field here: it tells a
|
||||
consumer of the export *which* published rate card produced these numbers.
|
||||
A cost figure without its rate-card date is not auditable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .model import CalculatorInputs, CalculatorResult, cost_per_interaction
|
||||
from .ratecard import (
|
||||
RATE_CARD_SOURCE,
|
||||
RATE_CARD_SOURCE_DATE,
|
||||
VOICE_BOT_ROUNDUP_SECONDS,
|
||||
)
|
||||
from .tts import TTS_SOURCE, TTS_SOURCE_DATE
|
||||
|
||||
|
||||
def result_json(
|
||||
result: CalculatorResult,
|
||||
inputs: CalculatorInputs,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""The export payload: provenance, inputs, lines, totals, warnings."""
|
||||
totals = result.totals
|
||||
payload: dict[str, Any] = {
|
||||
"rate_card": {
|
||||
"source": RATE_CARD_SOURCE,
|
||||
"source_date": RATE_CARD_SOURCE_DATE,
|
||||
"voice_bot_roundup_seconds": VOICE_BOT_ROUNDUP_SECONDS,
|
||||
},
|
||||
"tts_source": {"source": TTS_SOURCE, "source_date": TTS_SOURCE_DATE},
|
||||
"inputs": {
|
||||
"users": inputs.users,
|
||||
"licence_model": inputs.licence.value,
|
||||
"enabled_features": sorted(inputs.enabled),
|
||||
"apply_allowance": inputs.apply_allowance,
|
||||
"volumes": {
|
||||
k: v for k, v in vars(inputs.volumes).items() if not k.startswith("_")
|
||||
},
|
||||
"deflection_mix": {
|
||||
"bot_only_share": inputs.mix.bot_only_share,
|
||||
"virtual_agent_share": inputs.mix.virtual_agent_share,
|
||||
"agentic_va_share": inputs.mix.agentic_va_share,
|
||||
"agent_handled_share": inputs.mix.agent_handled_share,
|
||||
},
|
||||
"price": {
|
||||
"currency": inputs.price.currency,
|
||||
"list_rate": inputs.price.list_rate,
|
||||
"contracted_rate": inputs.price.contracted_rate,
|
||||
"concession_pct": inputs.price.concession_pct,
|
||||
"effective_rate": inputs.price.effective_rate(),
|
||||
},
|
||||
"voice_bot": (
|
||||
{
|
||||
"calls_per_month": inputs.voice_bot.calls_per_month,
|
||||
"avg_bot_seconds_per_call": (
|
||||
inputs.voice_bot.avg_bot_seconds_per_call
|
||||
),
|
||||
}
|
||||
if inputs.voice_bot is not None
|
||||
else None
|
||||
),
|
||||
"tts": (
|
||||
{
|
||||
"calls_monthly": inputs.tts.calls_monthly,
|
||||
"chars_per_call": inputs.tts.chars_per_call,
|
||||
"tier": inputs.tts.tier,
|
||||
}
|
||||
if inputs.tts is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"key": line.key,
|
||||
"feature": line.feature,
|
||||
"units_monthly": line.units_monthly,
|
||||
"unit_label": line.unit_label,
|
||||
"tokens_monthly": line.tokens_monthly,
|
||||
"cost_monthly": line.cost_monthly,
|
||||
"cost_annual": line.cost_annual,
|
||||
"confidence": line.confidence.value,
|
||||
}
|
||||
for line in result.lines
|
||||
],
|
||||
"totals": {
|
||||
"consumption_tokens_monthly": totals.consumption_tokens_monthly,
|
||||
"subscription_tokens_monthly": totals.subscription_tokens_monthly,
|
||||
"gross_tokens_monthly": totals.gross_tokens_monthly,
|
||||
"allowance_tokens_monthly": totals.allowance_tokens_monthly,
|
||||
"billable_tokens_monthly": totals.billable_tokens_monthly,
|
||||
"currency": totals.currency,
|
||||
"effective_rate": totals.effective_rate,
|
||||
"token_cost_monthly": totals.token_cost_monthly,
|
||||
"token_cost_annual": totals.token_cost_annual,
|
||||
"list_cost_annual": totals.list_cost_annual,
|
||||
"concession_saving_annual": totals.concession_saving_annual,
|
||||
"tts_cost_annual": result.tts_cost_annual,
|
||||
"grand_total_annual": result.grand_total_annual,
|
||||
"cost_per_interaction": cost_per_interaction(result, inputs.volumes),
|
||||
},
|
||||
"tts": (
|
||||
{
|
||||
"tier": result.tts.tier,
|
||||
"rate_per_million_chars": result.tts.rate_per_million_chars,
|
||||
"total_chars_monthly": result.tts.total_chars_monthly,
|
||||
"free_share": result.tts.free_share,
|
||||
"billable_chars_monthly": result.tts.billable_chars_monthly,
|
||||
"billed_millions_monthly": result.tts.billed_millions_monthly,
|
||||
"cost_monthly": result.tts.cost_monthly,
|
||||
"cost_annual": result.tts.cost_annual,
|
||||
}
|
||||
if result.tts is not None
|
||||
else None
|
||||
),
|
||||
"warnings": list(result.warnings),
|
||||
}
|
||||
if meta:
|
||||
payload["meta"] = meta
|
||||
return payload
|
||||
151
calculators/Genesys_Token_Calculator/genesyscalc/billing.py
Normal file
151
calculators/Genesys_Token_Calculator/genesyscalc/billing.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Tokens → dollars.
|
||||
|
||||
The order of operations is the whole content of this module, and it is
|
||||
pinned by tests because getting it wrong moves the bill:
|
||||
|
||||
1. Round consumption up to whole tokens, monthly. Per-user subscription
|
||||
totals are exact and are NOT rounded — only metered consumption is.
|
||||
2. Deduct the free monthly allowance (250 named / 350 concurrent).
|
||||
3. Floor at zero. The allowance renews monthly and "do[es] not carry over
|
||||
to future months", so an unused remainder is discarded, never banked.
|
||||
|
||||
Everything commercial — the contracted rate, the concession — arrives via
|
||||
:class:`genesyscalc.meters.TokenPrice` from the notebook's engagement-data
|
||||
cell. This module knows arithmetic, not deals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .meters import Confidence, LicenceModel, TokenPrice
|
||||
from .ratecard import FREE_TOKENS_PER_MONTH, meter
|
||||
|
||||
MONTHS_PER_YEAR: int = 12
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CostLine:
|
||||
"""One feature's monthly and annual cost, ready to render."""
|
||||
|
||||
key: str
|
||||
feature: str
|
||||
units_monthly: float
|
||||
unit_label: str
|
||||
tokens_monthly: float
|
||||
cost_monthly: float
|
||||
cost_annual: float
|
||||
confidence: Confidence
|
||||
source_url: str | None
|
||||
note: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CostTotals:
|
||||
"""The token bill, with the allowance and the MSRP walk both visible."""
|
||||
|
||||
consumption_tokens_monthly: float
|
||||
subscription_tokens_monthly: float
|
||||
gross_tokens_monthly: float
|
||||
allowance_tokens_monthly: int
|
||||
billable_tokens_monthly: int
|
||||
effective_rate: float
|
||||
currency: str
|
||||
token_cost_monthly: float
|
||||
token_cost_annual: float
|
||||
list_cost_annual: float
|
||||
concession_saving_annual: float
|
||||
|
||||
|
||||
def billable_tokens_monthly(
|
||||
consumption_tokens: float,
|
||||
subscription_tokens: float,
|
||||
licence: LicenceModel,
|
||||
apply_allowance: bool = True,
|
||||
) -> int:
|
||||
"""Whole tokens actually billed in a month.
|
||||
|
||||
``ceil`` lands on the consumption subtotal (Genesys bills whole tokens);
|
||||
subscription totals are already whole and exact. The allowance is then
|
||||
deducted and the result floored at zero — it does not carry over, so an
|
||||
unused remainder is simply lost.
|
||||
"""
|
||||
if consumption_tokens < 0 or subscription_tokens < 0:
|
||||
raise ValueError("token counts must not be negative")
|
||||
gross = math.ceil(consumption_tokens) + subscription_tokens
|
||||
allowance = FREE_TOKENS_PER_MONTH[licence] if apply_allowance else 0
|
||||
return max(0, int(gross) - allowance)
|
||||
|
||||
|
||||
def allowance_for(licence: LicenceModel, apply_allowance: bool = True) -> int:
|
||||
"""The free monthly token allowance actually in play."""
|
||||
return FREE_TOKENS_PER_MONTH[licence] if apply_allowance else 0
|
||||
|
||||
|
||||
def cost_lines(
|
||||
tokens_by_key: dict[str, float], units_by_key: dict[str, float], price: TokenPrice
|
||||
) -> tuple[CostLine, ...]:
|
||||
"""One :class:`CostLine` per meter, in published order, at the effective rate.
|
||||
|
||||
Line costs are unrounded and un-allowanced: the allowance is an
|
||||
org-level deduction, not a per-feature one, so attributing it to a
|
||||
single line would misstate that feature's economics. It lands in
|
||||
:func:`total_cost`.
|
||||
"""
|
||||
rate = price.effective_rate()
|
||||
lines: list[CostLine] = []
|
||||
for key in sorted(tokens_by_key, key=lambda k: meter(k).feature):
|
||||
m = meter(key)
|
||||
tokens = tokens_by_key[key]
|
||||
monthly = tokens * rate
|
||||
lines.append(
|
||||
CostLine(
|
||||
key=key,
|
||||
feature=m.feature,
|
||||
units_monthly=units_by_key.get(key, 0.0),
|
||||
unit_label=m.unit_label,
|
||||
tokens_monthly=tokens,
|
||||
cost_monthly=monthly,
|
||||
cost_annual=monthly * MONTHS_PER_YEAR,
|
||||
confidence=m.confidence,
|
||||
source_url=m.source_url,
|
||||
note=m.note,
|
||||
)
|
||||
)
|
||||
return tuple(lines)
|
||||
|
||||
|
||||
def total_cost(
|
||||
consumption_tokens_monthly: float,
|
||||
subscription_tokens_monthly: float,
|
||||
licence: LicenceModel,
|
||||
price: TokenPrice,
|
||||
apply_allowance: bool = True,
|
||||
) -> CostTotals:
|
||||
"""The org-level bill: allowance deducted, MSRP walk carried alongside."""
|
||||
billable = billable_tokens_monthly(
|
||||
consumption_tokens_monthly,
|
||||
subscription_tokens_monthly,
|
||||
licence,
|
||||
apply_allowance,
|
||||
)
|
||||
rate = price.effective_rate()
|
||||
monthly = billable * rate
|
||||
annual = monthly * MONTHS_PER_YEAR
|
||||
list_annual = billable * price.list_rate * MONTHS_PER_YEAR
|
||||
return CostTotals(
|
||||
consumption_tokens_monthly=consumption_tokens_monthly,
|
||||
subscription_tokens_monthly=subscription_tokens_monthly,
|
||||
gross_tokens_monthly=(
|
||||
math.ceil(consumption_tokens_monthly) + subscription_tokens_monthly
|
||||
),
|
||||
allowance_tokens_monthly=allowance_for(licence, apply_allowance),
|
||||
billable_tokens_monthly=billable,
|
||||
effective_rate=rate,
|
||||
currency=price.currency,
|
||||
token_cost_monthly=monthly,
|
||||
token_cost_annual=annual,
|
||||
list_cost_annual=list_annual,
|
||||
concession_saving_annual=list_annual - annual,
|
||||
)
|
||||
165
calculators/Genesys_Token_Calculator/genesyscalc/meters.py
Normal file
165
calculators/Genesys_Token_Calculator/genesyscalc/meters.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Schema for the rate card — types only, no rates.
|
||||
|
||||
Rates live in :mod:`genesyscalc.ratecard` as an immutable verbatim anchor.
|
||||
This module defines what a metered feature *is*, so the anchor can be typed
|
||||
and the arithmetic can be tested without either knowing the other's
|
||||
business.
|
||||
|
||||
The invariants in ``__post_init__`` are the point: a meter flagged
|
||||
:attr:`Confidence.CONFIRMED` that carries no source URL and date cannot be
|
||||
constructed. That is what stops an unsourced number wearing a green tick on
|
||||
a client-facing stage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Confidence(Enum):
|
||||
"""How much weight a number carries. Rendered on stage as an icon."""
|
||||
|
||||
CONFIRMED = "confirmed" # published by the vendor, with a source URL + date
|
||||
ESTIMATED = "estimated" # working assumption, stated
|
||||
UNKNOWN = "unknown" # not sourced; bound it with sensitivity if material
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return {"confirmed": "🟢", "estimated": "🟡", "unknown": "🔴"}[self.value]
|
||||
|
||||
|
||||
class LicenceModel(Enum):
|
||||
"""Genesys prices per-user AI features differently by licence model."""
|
||||
|
||||
NAMED = "named"
|
||||
CONCURRENT = "concurrent"
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.value.capitalize()
|
||||
|
||||
|
||||
class MeterBasis(Enum):
|
||||
"""How a feature converts activity into tokens."""
|
||||
|
||||
PER_USER_PER_MONTH = "per_user_per_month" # Copilot 40/60, STA 30/45
|
||||
UNITS_PER_TOKEN = "units_per_token" # "51 sessions per token"
|
||||
TOKENS_PER_UNIT = "tokens_per_unit" # "0.5 tokens per interaction"
|
||||
VOICE_MINUTES = "voice_minutes" # 17 min/token, 15s per-call round-up
|
||||
ZERO_RATED = "zero_rated" # "no charge for token usage"
|
||||
|
||||
|
||||
class Tier(Enum):
|
||||
"""Ordering for the published highest-tier rule.
|
||||
|
||||
"Genesys bases charges on the highest tier (price) resource that Genesys
|
||||
Cloud uses during the interaction." Higher value = higher tier = wins.
|
||||
An interaction is charged once, at the highest tier it touched — see
|
||||
:class:`genesyscalc.usage.DeflectionMix`.
|
||||
"""
|
||||
|
||||
BOT = 1
|
||||
VIRTUAL_AGENT = 2
|
||||
AGENTIC_VIRTUAL_AGENT = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Meter:
|
||||
"""One line of the vendor's published rate card.
|
||||
|
||||
``feature`` and ``published_rate`` hold the article's *exact* wording;
|
||||
``rate`` holds the parsed number the arithmetic uses. Keeping both is
|
||||
what makes a transcription slip catchable — ``test_rate_card`` pins the
|
||||
strings, and a separate test proves the float agrees with the string.
|
||||
"""
|
||||
|
||||
key: str
|
||||
feature: str # the article's exact feature name
|
||||
published_rate: str # the article's exact rate wording
|
||||
basis: MeterBasis
|
||||
rate: float = 0.0 # tokens per unit (0.0 for per-user/zero-rated)
|
||||
per_user_named: float | None = None
|
||||
per_user_concurrent: float | None = None
|
||||
tier: Tier | None = None
|
||||
confidence: Confidence = Confidence.CONFIRMED
|
||||
source_url: str | None = None
|
||||
source_date: str | None = None
|
||||
unit_label: str = "units"
|
||||
note: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.rate < 0:
|
||||
raise ValueError(f"{self.key}: rate must not be negative")
|
||||
if self.confidence is Confidence.CONFIRMED and not (
|
||||
self.source_url and self.source_date
|
||||
):
|
||||
raise ValueError(
|
||||
f"{self.key}: a confirmed (🟢) meter must carry a source URL and date"
|
||||
)
|
||||
per_user = self.basis is MeterBasis.PER_USER_PER_MONTH
|
||||
has_pair = (
|
||||
self.per_user_named is not None and self.per_user_concurrent is not None
|
||||
)
|
||||
if per_user != has_pair:
|
||||
raise ValueError(
|
||||
f"{self.key}: per-user rates are required for "
|
||||
f"{MeterBasis.PER_USER_PER_MONTH.value} and forbidden otherwise"
|
||||
)
|
||||
if self.basis is MeterBasis.ZERO_RATED and self.rate != 0.0:
|
||||
raise ValueError(f"{self.key}: a zero-rated meter must have rate 0.0")
|
||||
|
||||
def tokens_per_user_month(self, licence: LicenceModel) -> float:
|
||||
"""Subscription tokens for one user for one month."""
|
||||
if self.basis is not MeterBasis.PER_USER_PER_MONTH:
|
||||
raise ValueError(f"{self.key} is not a per-user meter")
|
||||
rate = (
|
||||
self.per_user_named
|
||||
if licence is LicenceModel.NAMED
|
||||
else self.per_user_concurrent
|
||||
)
|
||||
assert rate is not None # guaranteed by __post_init__
|
||||
return rate
|
||||
|
||||
def tokens_for(self, units: float) -> float:
|
||||
"""Consumption tokens for ``units`` of activity.
|
||||
|
||||
Voice-bot minutes are NOT priced here — they carry a per-call
|
||||
round-up that needs call counts, so
|
||||
:func:`genesyscalc.usage.voice_bot_tokens` owns that path.
|
||||
"""
|
||||
if self.basis in (MeterBasis.ZERO_RATED,):
|
||||
return 0.0
|
||||
if self.basis is MeterBasis.PER_USER_PER_MONTH:
|
||||
raise ValueError(f"{self.key}: use tokens_per_user_month()")
|
||||
return units * self.rate
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenPrice:
|
||||
"""What one token costs, list versus negotiated.
|
||||
|
||||
``contracted_rate`` (a signed per-token rate) wins outright when set.
|
||||
Otherwise the concession is a percentage off list — the lever a
|
||||
negotiation actually moves. Both are client data and arrive from the
|
||||
notebook's ``engagement-data`` cell, never from the anchor.
|
||||
"""
|
||||
|
||||
currency: str = "USD"
|
||||
list_rate: float = 1.00
|
||||
contracted_rate: float | None = None
|
||||
concession_pct: float = 0.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.list_rate <= 0:
|
||||
raise ValueError("list_rate must be positive")
|
||||
if not 0.0 <= self.concession_pct < 1.0:
|
||||
raise ValueError("concession_pct must be in [0, 1)")
|
||||
if self.contracted_rate is not None and self.contracted_rate < 0:
|
||||
raise ValueError("contracted_rate must not be negative")
|
||||
|
||||
def effective_rate(self) -> float:
|
||||
"""The rate actually billed per token."""
|
||||
if self.contracted_rate is not None:
|
||||
return self.contracted_rate
|
||||
return self.list_rate * (1.0 - self.concession_pct)
|
||||
270
calculators/Genesys_Token_Calculator/genesyscalc/model.py
Normal file
270
calculators/Genesys_Token_Calculator/genesyscalc/model.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""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
|
||||
358
calculators/Genesys_Token_Calculator/genesyscalc/ratecard.py
Normal file
358
calculators/Genesys_Token_Calculator/genesyscalc/ratecard.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""THE VERBATIM ANCHOR — Genesys Cloud tokens model, as published.
|
||||
|
||||
Transcribed from
|
||||
https://help.genesys.cloud/articles/genesys-cloud-tokens-model/
|
||||
**last updated 2026-07-12**. Feature names and rate strings below are the
|
||||
article's own wording, character for character.
|
||||
|
||||
VERBATIM — DO NOT EDIT. A vendor republication is a deliberate
|
||||
**re-anchoring**, not a patch (docs/Calculator_Pattern_V1-00.md):
|
||||
|
||||
1. diff the published table against this file
|
||||
2. update the meters AND bump RATE_CARD_SOURCE_DATE in the same edit
|
||||
3. update the pins in tests/test_rate_card.py in the same commit
|
||||
4. re-execute the notebook
|
||||
5. report the cost moves honestly
|
||||
6. Show-first — the user sees the rate diff before it lands
|
||||
|
||||
Never partially re-anchor; never bump the date without the pins.
|
||||
|
||||
Negotiated or contracted values NEVER land here. They are the overlay, and
|
||||
they live in the notebook's ``engagement-data`` cell — this file is the
|
||||
vendor's record, not the client's deal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .meters import LicenceModel, Meter, MeterBasis, Tier
|
||||
|
||||
RATE_CARD_SOURCE: str = "https://help.genesys.cloud/articles/genesys-cloud-tokens-model/"
|
||||
RATE_CARD_SOURCE_DATE: str = "2026-07-12"
|
||||
|
||||
# Every meter below is published on the same page on the same date, so the
|
||||
# source pair is spelled out once here and passed explicitly. Explicit beats
|
||||
# a splat: this file is diffed against the article by eye.
|
||||
_URL = RATE_CARD_SOURCE
|
||||
_DATE = RATE_CARD_SOURCE_DATE
|
||||
|
||||
|
||||
#: The published meter table, verbatim. Twenty rows, in the article's order.
|
||||
METERS_VERBATIM: tuple[Meter, ...] = (
|
||||
Meter(
|
||||
key="bots_voice",
|
||||
feature="Bots (Voice)",
|
||||
published_rate="17 minutes per token",
|
||||
basis=MeterBasis.VOICE_MINUTES,
|
||||
rate=1.0 / 17.0,
|
||||
tier=Tier.BOT,
|
||||
unit_label="bot minutes",
|
||||
note=(
|
||||
"Genesys rounds up each call to the next 15-second increment "
|
||||
"(clarified 2026-07-12)."
|
||||
),
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="bots_digital",
|
||||
feature="Bots (Digital)",
|
||||
published_rate="51 sessions per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0 / 51.0,
|
||||
tier=Tier.BOT,
|
||||
unit_label="sessions",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="virtual_agent",
|
||||
feature="Virtual Agent",
|
||||
published_rate="0.5 tokens per Virtual Agent interaction",
|
||||
basis=MeterBasis.TOKENS_PER_UNIT,
|
||||
rate=0.5,
|
||||
tier=Tier.VIRTUAL_AGENT,
|
||||
unit_label="interactions",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="agentic_virtual_agent",
|
||||
feature="Agentic Virtual Agent",
|
||||
published_rate="1.2 tokens per interaction",
|
||||
basis=MeterBasis.TOKENS_PER_UNIT,
|
||||
rate=1.2,
|
||||
tier=Tier.AGENTIC_VIRTUAL_AGENT,
|
||||
unit_label="interactions",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="agent_copilot_named",
|
||||
feature="Agent Copilot [named]",
|
||||
published_rate="40 tokens per user",
|
||||
basis=MeterBasis.PER_USER_PER_MONTH,
|
||||
per_user_named=40.0,
|
||||
per_user_concurrent=60.0,
|
||||
unit_label="users",
|
||||
note="Enabling Copilot makes Supervisor summaries and insights free.",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="agent_copilot_concurrent",
|
||||
feature="Agent Copilot [concurrent]",
|
||||
published_rate="60 tokens per user",
|
||||
basis=MeterBasis.PER_USER_PER_MONTH,
|
||||
per_user_named=40.0,
|
||||
per_user_concurrent=60.0,
|
||||
unit_label="users",
|
||||
note="The concurrent-licence face of the same meter.",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="ai_scoring",
|
||||
feature="AI Scoring",
|
||||
published_rate="20 evaluations per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0 / 20.0,
|
||||
unit_label="evaluations",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="ai_translate",
|
||||
feature="AI Translate",
|
||||
published_rate="2 translations per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0 / 2.0,
|
||||
unit_label="translations",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="ai_summary_and_insights",
|
||||
feature="AI Summary and Insights",
|
||||
published_rate="50 summaries/insights per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0 / 50.0,
|
||||
unit_label="summaries",
|
||||
note=(
|
||||
"Free when Agent Copilot is enabled — see "
|
||||
"COPILOT_COVERS_SUMMARY_RULE."
|
||||
),
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="apple_messages_for_business",
|
||||
feature="Apple Messages for Business",
|
||||
published_rate="400 inbound or outbound messages per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0 / 400.0,
|
||||
unit_label="messages",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="facebook_messenger",
|
||||
feature="Facebook Messenger",
|
||||
published_rate="400 messages per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0 / 400.0,
|
||||
unit_label="messages",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="instagram_direct_messaging",
|
||||
feature="Instagram Direct Messaging",
|
||||
published_rate="400 messages per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0 / 400.0,
|
||||
unit_label="messages",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="whatsapp_messaging",
|
||||
feature="WhatsApp Messaging",
|
||||
published_rate="400 messages per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0 / 400.0,
|
||||
unit_label="messages",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="x_direct_messaging",
|
||||
feature="X Direct Messaging",
|
||||
published_rate="400 messages per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0 / 400.0,
|
||||
unit_label="messages",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="genesys_cloud_social",
|
||||
feature="Genesys Cloud Social",
|
||||
published_rate="400 social post ingestions per channel per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0 / 400.0,
|
||||
unit_label="post ingestions",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="social_post_responses",
|
||||
feature="Social Post Responses",
|
||||
published_rate="400 outbound messages per channel per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0 / 400.0,
|
||||
unit_label="outbound messages",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="predictive_routing",
|
||||
feature="Predictive Routing",
|
||||
published_rate="17 routes per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0 / 17.0,
|
||||
unit_label="routes",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="speech_and_text_analytics_named",
|
||||
feature="Speech and Text Analytics [named]",
|
||||
published_rate="30 tokens per user",
|
||||
basis=MeterBasis.PER_USER_PER_MONTH,
|
||||
per_user_named=30.0,
|
||||
per_user_concurrent=45.0,
|
||||
unit_label="users",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="speech_and_text_analytics_concurrent",
|
||||
feature="Speech and Text Analytics [concurrent]",
|
||||
published_rate="45 tokens per user",
|
||||
basis=MeterBasis.PER_USER_PER_MONTH,
|
||||
per_user_named=30.0,
|
||||
per_user_concurrent=45.0,
|
||||
unit_label="users",
|
||||
note="The concurrent-licence face of the same meter.",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="genesys_cloud_copilot",
|
||||
feature="Genesys Cloud Copilot",
|
||||
published_rate="20 AI actions per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0 / 20.0,
|
||||
unit_label="AI actions",
|
||||
note="Genesys Cloud knowledge queries are not charged.",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
#: Published as included at no token cost. Shown on stage as explicit $0
|
||||
#: lines — a zero the vendor put in writing is a finding, not an omission.
|
||||
ZERO_RATED_VERBATIM: tuple[Meter, ...] = (
|
||||
Meter(
|
||||
key="predictive_engagement",
|
||||
feature="Predictive Engagement",
|
||||
published_rate="No charge for token usage",
|
||||
basis=MeterBasis.ZERO_RATED,
|
||||
unit_label="interactions",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
Meter(
|
||||
key="knowledge_queries",
|
||||
feature="Genesys Cloud knowledge queries",
|
||||
published_rate="no charge for Genesys Cloud knowledge queries",
|
||||
basis=MeterBasis.ZERO_RATED,
|
||||
unit_label="queries",
|
||||
source_url=_URL,
|
||||
source_date=_DATE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
#: "Named organizations receive 250 tokens; concurrent organizations receive
|
||||
#: 350 tokens" per month.
|
||||
FREE_TOKENS_PER_MONTH: dict[LicenceModel, int] = {
|
||||
LicenceModel.NAMED: 250,
|
||||
LicenceModel.CONCURRENT: 350,
|
||||
}
|
||||
|
||||
#: "These tokens renew each month and do not carry over to future months."
|
||||
ALLOWANCE_CARRIES_OVER: bool = False
|
||||
|
||||
#: "Genesys rounds up each call to the next 15-second increment."
|
||||
#: Clarified in the 2026-07-12 revision. Applied PER CALL, then summed.
|
||||
VOICE_BOT_ROUNDUP_SECONDS: int = 15
|
||||
|
||||
#: The published highest-tier rule, verbatim. Renders on stage.
|
||||
HIGHEST_TIER_RULE: str = (
|
||||
"In cases where an interaction uses multiple AI resources, such as bot "
|
||||
"flows, virtual agents, and agentic virtual agents, Genesys bases charges "
|
||||
"on the highest tier (price) resource that Genesys Cloud uses during the "
|
||||
"interaction."
|
||||
)
|
||||
|
||||
#: The published Copilot exclusion, verbatim. Renders on stage.
|
||||
COPILOT_COVERS_SUMMARY_RULE: str = (
|
||||
"if you enable Agent Copilot simultaneously, then Supervisor Copilot "
|
||||
"summaries and insights do not consume tokens"
|
||||
)
|
||||
|
||||
#: List price per token. The article points to the pricing hub and cites
|
||||
#: "USD 1.00 to JPY 120 per token by currency".
|
||||
LIST_PRICE_BY_CURRENCY: dict[str, float] = {"USD": 1.00, "JPY": 120.0}
|
||||
|
||||
|
||||
#: Every meter by key — the consumption table plus the zero-rated lines.
|
||||
METERS: dict[str, Meter] = {
|
||||
m.key: m for m in METERS_VERBATIM + ZERO_RATED_VERBATIM
|
||||
}
|
||||
|
||||
|
||||
def meter(key: str) -> Meter:
|
||||
"""Look up a published meter, with a useful error when the key is wrong."""
|
||||
try:
|
||||
return METERS[key]
|
||||
except KeyError:
|
||||
raise KeyError(
|
||||
f"{key!r} is not a published Genesys meter. Known keys: "
|
||||
f"{', '.join(sorted(METERS))}"
|
||||
) from None
|
||||
|
||||
|
||||
def per_user_meter(licence: LicenceModel, family: str) -> Meter:
|
||||
"""The Copilot / STA meter face matching ``licence``.
|
||||
|
||||
``family`` is ``"agent_copilot"`` or ``"speech_and_text_analytics"``.
|
||||
Both rates live on both faces, so this is presentation sugar — it picks
|
||||
the row whose published wording matches the licence being modelled.
|
||||
"""
|
||||
return meter(f"{family}_{licence.value}")
|
||||
|
||||
|
||||
def rate_card_rows() -> list[dict[str, str]]:
|
||||
"""The published table as display rows, in publication order."""
|
||||
return [
|
||||
{
|
||||
"Feature": m.feature,
|
||||
"Published rate": m.published_rate,
|
||||
"Confidence": m.confidence.icon,
|
||||
"Source": m.source_date or "",
|
||||
}
|
||||
for m in METERS_VERBATIM + ZERO_RATED_VERBATIM
|
||||
]
|
||||
133
calculators/Genesys_Token_Calculator/genesyscalc/sensitivity.py
Normal file
133
calculators/Genesys_Token_Calculator/genesyscalc/sensitivity.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""One-at-a-time sweeps and tornado data.
|
||||
|
||||
Data only — the notebook draws the figures. Drivers are addressed by a
|
||||
short dotted path so the notebook can name them without reaching into the
|
||||
dataclasses itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from .model import CalculatorInputs, CalculatorResult, calculate
|
||||
|
||||
#: Driver name → (container attribute on CalculatorInputs, field on it).
|
||||
#: ``None`` container means the field sits directly on CalculatorInputs.
|
||||
DRIVERS: dict[str, tuple[str | None, str]] = {
|
||||
"token_price": ("price", "list_rate"),
|
||||
"concession_pct": ("price", "concession_pct"),
|
||||
"users": (None, "users"),
|
||||
"voice_volume": ("volumes", "voice_inbound_monthly"),
|
||||
"bot_share": ("mix", "bot_only_share"),
|
||||
"virtual_agent_share": ("mix", "virtual_agent_share"),
|
||||
"agentic_va_share": ("mix", "agentic_va_share"),
|
||||
"bot_seconds": ("voice_bot", "avg_bot_seconds_per_call"),
|
||||
"tts_chars_per_call": ("tts", "chars_per_call"),
|
||||
}
|
||||
|
||||
|
||||
def driver_value(inputs: CalculatorInputs, driver: str) -> float:
|
||||
"""The current value of ``driver`` in ``inputs``."""
|
||||
container, attr = _resolve(driver)
|
||||
target = inputs if container is None else getattr(inputs, container)
|
||||
if target is None:
|
||||
raise ValueError(f"{driver} is not active in this scenario")
|
||||
return float(getattr(target, attr))
|
||||
|
||||
|
||||
def set_driver(inputs: CalculatorInputs, driver: str, value: float) -> CalculatorInputs:
|
||||
"""A copy of ``inputs`` with ``driver`` set to ``value``."""
|
||||
container, attr = _resolve(driver)
|
||||
if container is None:
|
||||
return inputs.with_(**{attr: _coerce(attr, value)})
|
||||
current = getattr(inputs, container)
|
||||
if current is None:
|
||||
raise ValueError(f"{driver} is not active in this scenario")
|
||||
return inputs.with_(**{container: replace(current, **{attr: _coerce(attr, value)})})
|
||||
|
||||
|
||||
def _resolve(driver: str) -> tuple[str | None, str]:
|
||||
try:
|
||||
return DRIVERS[driver]
|
||||
except KeyError:
|
||||
raise KeyError(
|
||||
f"{driver!r} is not a known driver. Known: {', '.join(sorted(DRIVERS))}"
|
||||
) from None
|
||||
|
||||
|
||||
def _coerce(attr: str, value: float) -> Any:
|
||||
"""``users`` and ``chars_per_call`` are integers by contract."""
|
||||
if attr in ("users", "chars_per_call"):
|
||||
return int(round(value))
|
||||
return float(value)
|
||||
|
||||
|
||||
def sweep(
|
||||
base: CalculatorInputs, driver: str, values: Sequence[float]
|
||||
) -> list[dict[str, float]]:
|
||||
"""Grand total across a range of one driver, everything else held."""
|
||||
rows: list[dict[str, float]] = []
|
||||
for value in values:
|
||||
result: CalculatorResult = calculate(set_driver(base, driver, value))
|
||||
rows.append(
|
||||
{
|
||||
driver: float(value),
|
||||
"token_cost_annual": result.totals.token_cost_annual,
|
||||
"tts_cost_annual": result.tts_cost_annual,
|
||||
"grand_total_annual": result.grand_total_annual,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def tornado(
|
||||
base: CalculatorInputs, drivers: Sequence[str], delta: float = 0.25
|
||||
) -> list[dict[str, Any]]:
|
||||
"""±``delta`` on each driver in turn, sorted by the swing it causes.
|
||||
|
||||
Drivers that are not active in the scenario (no TTS, no voice bot) are
|
||||
skipped rather than raising — a tornado over an inactive lever is noise.
|
||||
Shares are clamped to keep the highest-tier partition legal; a driver
|
||||
whose low and high both clamp to the same value contributes no swing.
|
||||
"""
|
||||
if not 0.0 < delta < 1.0:
|
||||
raise ValueError("delta must be in (0, 1)")
|
||||
|
||||
baseline = calculate(base).grand_total_annual
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
for driver in drivers:
|
||||
try:
|
||||
current = driver_value(base, driver)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
|
||||
low_value = current * (1.0 - delta)
|
||||
high_value = current * (1.0 + delta)
|
||||
if driver.endswith("_share"):
|
||||
low_value = max(0.0, min(1.0, low_value))
|
||||
high_value = max(0.0, min(1.0, high_value))
|
||||
|
||||
try:
|
||||
low = calculate(set_driver(base, driver, low_value)).grand_total_annual
|
||||
high = calculate(set_driver(base, driver, high_value)).grand_total_annual
|
||||
except ValueError:
|
||||
# e.g. raising a share would break the partition — skip the lever
|
||||
continue
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"driver": driver,
|
||||
"baseline_value": current,
|
||||
"low_value": low_value,
|
||||
"high_value": high_value,
|
||||
"low": low,
|
||||
"high": high,
|
||||
"baseline": baseline,
|
||||
"swing": abs(high - low),
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(rows, key=lambda r: float(r["swing"]), reverse=True)
|
||||
61
calculators/Genesys_Token_Calculator/genesyscalc/staging.py
Normal file
61
calculators/Genesys_Token_Calculator/genesyscalc/staging.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Stage vs backstage — is this notebook render stakeholder-facing?
|
||||
|
||||
The Mercury app (3.2.x) is a hybrid Jupyter server: the SAME server (and
|
||||
kernel pool) can serve both the client-facing app view and JupyterLab, so a
|
||||
server-level signal cannot tell who is looking. The reliable, per-view
|
||||
signal is the session name: the app runs every session against a shadow
|
||||
copy named ``<notebook>__mercury__<id>.ipynb``
|
||||
(``mercury_app/handlers.py``), and the kernel sees that path in
|
||||
``JPY_SESSION_NAME``. JupyterLab and nbconvert sessions carry the plain
|
||||
notebook path (or no session name at all).
|
||||
|
||||
``MERCURY_CONFIG_DIR`` is kept as a fallback: ``mercury --working-dir …``
|
||||
exports it into the server process and every kernel inherits it. It is a
|
||||
server-level signal — on such a server even JupyterLab kernels carry it, so
|
||||
diagnostics are then hidden in that Lab view too (hidden, never leaked; use
|
||||
a separate ``jupyter lab`` for analysis, which is the normal workflow). It
|
||||
is NOT set when mercury is launched without ``--working-dir``, which is why
|
||||
it cannot be the primary signal.
|
||||
|
||||
Diagnostics routed through :func:`backstage` / :func:`backstage_md` stay
|
||||
visible in JupyterLab and land in the nbconvert exports (where the
|
||||
machine-readable appendix must appear for LLM consumption) but never render
|
||||
in the Mercury app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
def on_stage() -> bool:
|
||||
"""True when this kernel renders the client-facing Mercury app view."""
|
||||
if "__mercury__" in os.getenv("JPY_SESSION_NAME", ""):
|
||||
return True # the app's shadow-copy session
|
||||
return os.getenv("MERCURY_CONFIG_DIR") is not None
|
||||
|
||||
|
||||
def backstage(*args: object, **kwargs: Any) -> None:
|
||||
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
|
||||
if not on_stage():
|
||||
print(*args, **kwargs)
|
||||
|
||||
|
||||
def backstage_md(text: str) -> None:
|
||||
"""Markdown that renders only backstage (JupyterLab, nbconvert).
|
||||
|
||||
Emitted as a ``text/markdown`` display, so nbconvert's markdown export
|
||||
carries it verbatim — a stream ``print`` would be indented as a code
|
||||
block. Falls back to ``print`` when IPython isn't importable (plain
|
||||
pytest), where ``display`` itself already degrades to ``print``.
|
||||
"""
|
||||
if on_stage():
|
||||
return
|
||||
try:
|
||||
from IPython.display import Markdown, display
|
||||
except ImportError:
|
||||
print(text)
|
||||
return
|
||||
display(Markdown(text)) # type: ignore[no-untyped-call]
|
||||
147
calculators/Genesys_Token_Calculator/genesyscalc/tts.py
Normal file
147
calculators/Genesys_Token_Calculator/genesyscalc/tts.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Genesys Enhanced text-to-speech — billed in dollars, NOT in tokens.
|
||||
|
||||
A separate published page, a separate meter, a separate subtotal:
|
||||
https://help.genesys.cloud/articles/genesys-enhanced-tts-pricing/
|
||||
**last updated 2026-05-22**.
|
||||
|
||||
TTS never appears in the tokens-model article and is never converted to
|
||||
tokens here. It is carried because Genesys AI spend is understated without
|
||||
it — but it is reported as its own line, and the grand total shows tokens
|
||||
and TTS as two labelled components rather than one blended figure.
|
||||
|
||||
Three published facts the obvious model misses:
|
||||
|
||||
* **"The monthly billing unit is one million characters, rounded up."**
|
||||
200,000 characters bills as $5; 1,000,005 characters bills as $10.
|
||||
* **Free during virtual agents.** "Genesys Enhanced TTS is included free of
|
||||
charge when you use a virtual agent or agentic virtual agent during the
|
||||
interaction" — so deflection cuts this line as well as the token line.
|
||||
* **Standard voices are ending.** End of sale 2026-03-29, end of support
|
||||
2026-08-05. Selecting Standard for a future-state model is a finding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .meters import Confidence
|
||||
from .usage import DeflectionMix
|
||||
|
||||
TTS_SOURCE: str = "https://help.genesys.cloud/articles/genesys-enhanced-tts-pricing/"
|
||||
TTS_SOURCE_DATE: str = "2026-05-22"
|
||||
|
||||
#: "$20 per one million TTS characters" (advanced: NTTS, neural, wavenet)
|
||||
#: and "$5 per one million TTS characters" (standard).
|
||||
TTS_PRICE_PER_MILLION_CHARS: dict[str, float] = {"advanced": 20.0, "standard": 5.0}
|
||||
|
||||
#: "The monthly billing unit is one million characters, rounded up."
|
||||
TTS_BILLING_UNIT_CHARS: int = 1_000_000
|
||||
|
||||
#: Standard Enhanced TTS lifecycle — published end-of-sale / end-of-support.
|
||||
TTS_STANDARD_END_OF_SALE: str = "2026-03-29"
|
||||
TTS_STANDARD_END_OF_SUPPORT: str = "2026-08-05"
|
||||
|
||||
#: Published, so 🟢 — unlike the tokens article, this page states the rates.
|
||||
TTS_CONFIDENCE: Confidence = Confidence.CONFIRMED
|
||||
|
||||
MONTHS_PER_YEAR: int = 12
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TtsUsage:
|
||||
"""Synthesised speech volume. Client data, from ``engagement-data``.
|
||||
|
||||
"Usage includes all TTS characters, including letters, numbers,
|
||||
punctuation, and spaces."
|
||||
"""
|
||||
|
||||
calls_monthly: float
|
||||
chars_per_call: int = 2_000
|
||||
tier: str = "advanced"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.calls_monthly < 0:
|
||||
raise ValueError("calls_monthly must not be negative")
|
||||
if self.chars_per_call < 0:
|
||||
raise ValueError("chars_per_call must not be negative")
|
||||
if self.tier not in TTS_PRICE_PER_MILLION_CHARS:
|
||||
raise ValueError(
|
||||
f"unknown TTS tier {self.tier!r}; known: "
|
||||
f"{', '.join(sorted(TTS_PRICE_PER_MILLION_CHARS))}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TtsLine:
|
||||
"""The TTS bill, reported separately from the token bill."""
|
||||
|
||||
tier: str
|
||||
rate_per_million_chars: float
|
||||
total_chars_monthly: float
|
||||
free_share: float
|
||||
free_chars_monthly: float
|
||||
billable_chars_monthly: float
|
||||
billed_millions_monthly: int
|
||||
cost_monthly: float
|
||||
cost_annual: float
|
||||
confidence: Confidence
|
||||
source_url: str
|
||||
source_date: str
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def tts_free_share(mix: DeflectionMix) -> float:
|
||||
"""Share of TTS characters Genesys does not charge for.
|
||||
|
||||
"included free of charge when you use a virtual agent or agentic virtual
|
||||
agent during the interaction". Reuses the same partition as the token
|
||||
model, so the two lines cannot disagree about how much was deflected.
|
||||
"""
|
||||
return min(1.0, mix.virtual_agent_share + mix.agentic_va_share)
|
||||
|
||||
|
||||
def tts_cost(
|
||||
usage: TtsUsage, mix: DeflectionMix | None = None, concession_pct: float = 0.0
|
||||
) -> TtsLine:
|
||||
"""The monthly and annual TTS bill, with the published per-1M round-up.
|
||||
|
||||
The round-up lands on the *billable* character total each month, after
|
||||
the virtual-agent free share is removed.
|
||||
"""
|
||||
if not 0.0 <= concession_pct < 1.0:
|
||||
raise ValueError("concession_pct must be in [0, 1)")
|
||||
|
||||
free_share = tts_free_share(mix) if mix is not None else 0.0
|
||||
total_chars = usage.calls_monthly * usage.chars_per_call
|
||||
free_chars = total_chars * free_share
|
||||
billable_chars = total_chars - free_chars
|
||||
|
||||
billed_millions = math.ceil(billable_chars / TTS_BILLING_UNIT_CHARS)
|
||||
rate = TTS_PRICE_PER_MILLION_CHARS[usage.tier]
|
||||
monthly = billed_millions * rate * (1.0 - concession_pct)
|
||||
|
||||
warnings: list[str] = []
|
||||
if usage.tier == "standard":
|
||||
warnings.append(
|
||||
"Standard Enhanced TTS reached end of sale on "
|
||||
f"{TTS_STANDARD_END_OF_SALE} and end of support on "
|
||||
f"{TTS_STANDARD_END_OF_SUPPORT} — price a future state on "
|
||||
"advanced voices."
|
||||
)
|
||||
|
||||
return TtsLine(
|
||||
tier=usage.tier,
|
||||
rate_per_million_chars=rate,
|
||||
total_chars_monthly=total_chars,
|
||||
free_share=free_share,
|
||||
free_chars_monthly=free_chars,
|
||||
billable_chars_monthly=billable_chars,
|
||||
billed_millions_monthly=billed_millions,
|
||||
cost_monthly=monthly,
|
||||
cost_annual=monthly * MONTHS_PER_YEAR,
|
||||
confidence=TTS_CONFIDENCE,
|
||||
source_url=TTS_SOURCE,
|
||||
source_date=TTS_SOURCE_DATE,
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
262
calculators/Genesys_Token_Calculator/genesyscalc/usage.py
Normal file
262
calculators/Genesys_Token_Calculator/genesyscalc/usage.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""Volumes → metered units → tokens.
|
||||
|
||||
Three published rules are modelled here that the obvious arithmetic gets
|
||||
wrong. Each one is traceable to a sentence in the tokens-model article:
|
||||
|
||||
1. **The 15-second per-call round-up.** "Genesys rounds up each call to the
|
||||
next 15-second increment." Applied per call and *then* summed — averaging
|
||||
first and rounding once understates the bill, badly, on short bot touches.
|
||||
|
||||
2. **The highest-tier rule.** "Genesys bases charges on the highest tier
|
||||
(price) resource that Genesys Cloud uses during the interaction." So the
|
||||
AI-resource shares of a volume are a **partition**, not overlapping slices:
|
||||
an interaction is charged once, at the highest tier it touched.
|
||||
|
||||
3. **Copilot covers Supervisor summaries.** "if you enable Agent Copilot
|
||||
simultaneously, then Supervisor Copilot summaries and insights do not
|
||||
consume tokens." Billing AI Summary while Copilot is on double-charges.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .meters import LicenceModel, MeterBasis, Tier
|
||||
from .ratecard import VOICE_BOT_ROUNDUP_SECONDS, meter
|
||||
|
||||
#: Meters covered by the Copilot exclusion when Agent Copilot is enabled.
|
||||
COPILOT_COVERED_METERS: frozenset[str] = frozenset({"ai_summary_and_insights"})
|
||||
|
||||
#: Which per-interaction meter prices each tier of the partition.
|
||||
TIER_METER: dict[Tier, str] = {
|
||||
Tier.VIRTUAL_AGENT: "virtual_agent",
|
||||
Tier.AGENTIC_VIRTUAL_AGENT: "agentic_virtual_agent",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceBotUsage:
|
||||
"""Voice-bot activity, in the shape the round-up needs.
|
||||
|
||||
The round-up is per *call*, so a call count is required — an aggregate
|
||||
minute total cannot be billed correctly.
|
||||
"""
|
||||
|
||||
calls_per_month: float
|
||||
avg_bot_seconds_per_call: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.calls_per_month < 0:
|
||||
raise ValueError("calls_per_month must not be negative")
|
||||
if self.avg_bot_seconds_per_call < 0:
|
||||
raise ValueError("avg_bot_seconds_per_call must not be negative")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeflectionMix:
|
||||
"""Shares of interactions by the **highest tier** each one touched.
|
||||
|
||||
This is a partition, not a set of overlapping rates. ``bot_only_share``
|
||||
is the share whose highest resource was a bot flow; an interaction that
|
||||
escalated bot → virtual agent counts once, in ``virtual_agent_share``.
|
||||
The shares therefore sum to at most 1.0 — the remainder went straight to
|
||||
an agent and consumed no AI-resource token.
|
||||
|
||||
Modelling these as independent percentages (and summing the tokens) is
|
||||
the over-billing bug the published rule exists to prevent.
|
||||
"""
|
||||
|
||||
bot_only_share: float = 0.0
|
||||
virtual_agent_share: float = 0.0
|
||||
agentic_va_share: float = 0.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name, value in (
|
||||
("bot_only_share", self.bot_only_share),
|
||||
("virtual_agent_share", self.virtual_agent_share),
|
||||
("agentic_va_share", self.agentic_va_share),
|
||||
):
|
||||
if not 0.0 <= value <= 1.0:
|
||||
raise ValueError(f"{name} must be in [0, 1], got {value}")
|
||||
if self.total_deflected_share > 1.0 + 1e-9:
|
||||
raise ValueError(
|
||||
"tier shares must sum to at most 1.0 — they partition the "
|
||||
"volume by highest tier touched, they do not overlap "
|
||||
f"(got {self.total_deflected_share})"
|
||||
)
|
||||
|
||||
@property
|
||||
def total_deflected_share(self) -> float:
|
||||
"""Share of interactions that touched any AI resource."""
|
||||
return self.bot_only_share + self.virtual_agent_share + self.agentic_va_share
|
||||
|
||||
@property
|
||||
def agent_handled_share(self) -> float:
|
||||
"""Share that reached a human without touching an AI resource."""
|
||||
return 1.0 - self.total_deflected_share
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Volumes:
|
||||
"""Monthly interaction volumes by channel. Client data — from the
|
||||
notebook's ``engagement-data`` cell, never invented by the engine."""
|
||||
|
||||
voice_inbound_monthly: float = 0.0
|
||||
voice_outbound_monthly: float = 0.0
|
||||
digital_sessions_monthly: float = 0.0
|
||||
email_monthly: float = 0.0
|
||||
messaging_monthly: float = 0.0
|
||||
social_posts_monthly: float = 0.0
|
||||
social_responses_monthly: float = 0.0
|
||||
translations_monthly: float = 0.0
|
||||
evaluations_monthly: float = 0.0
|
||||
ai_actions_monthly: float = 0.0
|
||||
routed_interactions_monthly: float = 0.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name, value in vars(self).items():
|
||||
if value < 0:
|
||||
raise ValueError(f"{name} must not be negative")
|
||||
|
||||
@property
|
||||
def voice_total_monthly(self) -> float:
|
||||
return self.voice_inbound_monthly + self.voice_outbound_monthly
|
||||
|
||||
@property
|
||||
def all_interactions_monthly(self) -> float:
|
||||
return (
|
||||
self.voice_total_monthly
|
||||
+ self.digital_sessions_monthly
|
||||
+ self.email_monthly
|
||||
+ self.messaging_monthly
|
||||
)
|
||||
|
||||
|
||||
def voice_bot_billable_minutes(
|
||||
usage: VoiceBotUsage, roundup_seconds: int = VOICE_BOT_ROUNDUP_SECONDS
|
||||
) -> float:
|
||||
"""Billable bot minutes per month, with the published per-call round-up.
|
||||
|
||||
"Genesys rounds up each call to the next 15-second increment." The
|
||||
round-up lands on every call, so it is applied first and summed after::
|
||||
|
||||
per_call = ceil(seconds / 15) * 15 / 60
|
||||
total = per_call * calls
|
||||
|
||||
A 40-second average bills as 45 seconds — 0.75 min/call, not 0.667. The
|
||||
penalty is worst on short touches: a 5-second bot greeting bills as 15
|
||||
seconds, three times its duration.
|
||||
"""
|
||||
if roundup_seconds <= 0:
|
||||
raise ValueError("roundup_seconds must be positive")
|
||||
increments = math.ceil(usage.avg_bot_seconds_per_call / roundup_seconds)
|
||||
billable_seconds_per_call = increments * roundup_seconds
|
||||
return billable_seconds_per_call / 60.0 * usage.calls_per_month
|
||||
|
||||
|
||||
def voice_bot_roundup_uplift(
|
||||
usage: VoiceBotUsage, roundup_seconds: int = VOICE_BOT_ROUNDUP_SECONDS
|
||||
) -> float:
|
||||
"""How much the round-up adds, as a fraction of the raw duration.
|
||||
|
||||
The number worth saying out loud on stage: at a 40-second average it is
|
||||
0.125 (12.5% more bot cost than the raw minutes suggest).
|
||||
"""
|
||||
raw_minutes = usage.avg_bot_seconds_per_call / 60.0 * usage.calls_per_month
|
||||
if raw_minutes == 0:
|
||||
return 0.0
|
||||
return voice_bot_billable_minutes(usage, roundup_seconds) / raw_minutes - 1.0
|
||||
|
||||
|
||||
def voice_bot_tokens(
|
||||
usage: VoiceBotUsage, roundup_seconds: int = VOICE_BOT_ROUNDUP_SECONDS
|
||||
) -> float:
|
||||
"""Voice-bot tokens per month: billable minutes ÷ 17."""
|
||||
minutes = voice_bot_billable_minutes(usage, roundup_seconds)
|
||||
return minutes * meter("bots_voice").rate
|
||||
|
||||
|
||||
def tier_interactions(volume: float, mix: DeflectionMix) -> dict[Tier, float]:
|
||||
"""Split ``volume`` across tiers by highest resource touched.
|
||||
|
||||
A partition — the returned values sum to at most ``volume``, so no
|
||||
interaction can be billed twice.
|
||||
"""
|
||||
if volume < 0:
|
||||
raise ValueError("volume must not be negative")
|
||||
return {
|
||||
Tier.BOT: volume * mix.bot_only_share,
|
||||
Tier.VIRTUAL_AGENT: volume * mix.virtual_agent_share,
|
||||
Tier.AGENTIC_VIRTUAL_AGENT: volume * mix.agentic_va_share,
|
||||
}
|
||||
|
||||
|
||||
def virtual_agent_tokens(volume: float, mix: DeflectionMix) -> dict[str, float]:
|
||||
"""Tokens for the VA and Agentic VA tiers of a deflection partition.
|
||||
|
||||
The bot tier is deliberately absent: voice bots are metered in minutes
|
||||
with a per-call round-up, so :func:`voice_bot_tokens` owns that line.
|
||||
"""
|
||||
split = tier_interactions(volume, mix)
|
||||
return {
|
||||
TIER_METER[tier]: meter(TIER_METER[tier]).tokens_for(interactions)
|
||||
for tier, interactions in split.items()
|
||||
if tier in TIER_METER
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureUsage:
|
||||
"""Metered units per meter key, plus the notes explaining any zeroing."""
|
||||
|
||||
units: dict[str, float] = field(default_factory=dict)
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
def get(self, key: str) -> float:
|
||||
return self.units.get(key, 0.0)
|
||||
|
||||
|
||||
def apply_copilot_covers_summary(
|
||||
units: dict[str, float], copilot_enabled: bool
|
||||
) -> FeatureUsage:
|
||||
"""Zero the summary meters when Agent Copilot is enabled.
|
||||
|
||||
"if you enable Agent Copilot simultaneously, then Supervisor Copilot
|
||||
summaries and insights do not consume tokens." The zeroing is reported,
|
||||
not silent — a number that shrank for a published reason should say so.
|
||||
"""
|
||||
if not copilot_enabled:
|
||||
return FeatureUsage(units=dict(units))
|
||||
|
||||
adjusted = dict(units)
|
||||
notes: list[str] = []
|
||||
for key in sorted(COPILOT_COVERED_METERS):
|
||||
covered = adjusted.get(key, 0.0)
|
||||
if covered > 0:
|
||||
adjusted[key] = 0.0
|
||||
notes.append(
|
||||
f"{meter(key).feature}: {covered:,.0f} units not billed — "
|
||||
"Agent Copilot is enabled, and Genesys does not charge "
|
||||
"Supervisor summaries and insights alongside it."
|
||||
)
|
||||
return FeatureUsage(units=adjusted, notes=tuple(notes))
|
||||
|
||||
|
||||
def subscription_tokens(
|
||||
users: int, enabled_per_user_keys: frozenset[str], licence: LicenceModel
|
||||
) -> dict[str, float]:
|
||||
"""Per-user subscription tokens per month, by meter key.
|
||||
|
||||
Exact — per-user totals are not rounded; only consumption is (see
|
||||
:mod:`genesyscalc.billing`).
|
||||
"""
|
||||
if users < 0:
|
||||
raise ValueError("users must not be negative")
|
||||
out: dict[str, float] = {}
|
||||
for key in sorted(enabled_per_user_keys):
|
||||
m = meter(key)
|
||||
if m.basis is not MeterBasis.PER_USER_PER_MONTH:
|
||||
raise ValueError(f"{key} is not a per-user meter")
|
||||
out[key] = users * m.tokens_per_user_month(licence)
|
||||
return out
|
||||
File diff suppressed because one or more lines are too long
36
calculators/Genesys_Token_Calculator/pyproject.toml
Normal file
36
calculators/Genesys_Token_Calculator/pyproject.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "genesyscalc"
|
||||
version = "0.1.0"
|
||||
description = "Genesys Cloud AI token cost calculator — published rate card, 2026-07-12 (Mercury Notebook Pattern)"
|
||||
requires-python = ">=3.10"
|
||||
# The notebook is the deliverable (served with Mercury, exported via
|
||||
# nbconvert, tables via tabulate) — the whole toolchain is a required
|
||||
# runtime dependency, not an extra. `pip install -e .` must be enough.
|
||||
dependencies = [
|
||||
"pandas>=2.0",
|
||||
"plotly>=5.18",
|
||||
"mercury>=3.2",
|
||||
"jupyterlab>=4.0",
|
||||
"ipywidgets>=8.0",
|
||||
"nbconvert>=7",
|
||||
"nbformat>=5.9",
|
||||
"tabulate>=0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=7.4", "mypy>=1.8"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["genesyscalc*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
packages = ["genesyscalc"]
|
||||
140
calculators/Genesys_Token_Calculator/scripts/export_report.py
Normal file
140
calculators/Genesys_Token_Calculator/scripts/export_report.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Export the token calculator as an LLM-readable report source.
|
||||
|
||||
Executes the notebook ONCE (widget defaults — or whatever scenario you've
|
||||
set and saved in the notebook), then converts the executed copy twice:
|
||||
|
||||
exports/genesys_token_calculator.html — human-reviewable, full presentation
|
||||
exports/genesys_token_calculator.md — leanest LLM input: presentation-tagged
|
||||
cells (setup, widgets, tables, charts)
|
||||
are stripped, a framing preamble is
|
||||
prepended, and the data appendix ends
|
||||
the file with one fenced JSON block —
|
||||
the machine source of truth.
|
||||
|
||||
Engagement identity (client, date, preparer) is read from the notebook's
|
||||
``engagement-data`` cell and stamped into the preamble, alongside the
|
||||
rate-card source date — a cost figure without its rate card is not auditable.
|
||||
|
||||
Run from the calculator root: python scripts/export_report.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NOTEBOOK = ROOT / "notebooks" / "genesys_token_calculator.ipynb"
|
||||
EXPORTS = ROOT / "exports"
|
||||
|
||||
# Cells tagged with any of these never reach the .md export — they are stage
|
||||
# presentation (source and widget-repr noise), not the priced record.
|
||||
STRIP_TAGS_FROM_MD = '{"presentation"}'
|
||||
|
||||
|
||||
def engagement_data() -> dict[str, Any]:
|
||||
"""Exec the engagement-data cell (same trick as tests/conftest.py)."""
|
||||
import nbformat
|
||||
|
||||
nb = nbformat.read(NOTEBOOK, as_version=4)
|
||||
cells = [c for c in nb.cells if "engagement-data" in c.metadata.get("tags", [])]
|
||||
assert len(cells) == 1, "expected exactly one engagement-data cell"
|
||||
ns: dict[str, Any] = {}
|
||||
exec(compile(cells[0].source, f"{NOTEBOOK.name} [engagement-data]", "exec"), ns)
|
||||
return ns["ENGAGEMENT"] # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def preamble() -> str:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from genesyscalc.ratecard import RATE_CARD_SOURCE, RATE_CARD_SOURCE_DATE
|
||||
from genesyscalc.tts import TTS_SOURCE, TTS_SOURCE_DATE
|
||||
|
||||
eng = engagement_data()
|
||||
who = " · ".join(
|
||||
str(eng[k]).strip()
|
||||
for k in ("client", "prepared_date", "prepared_by")
|
||||
if str(eng.get(k, "")).strip()
|
||||
)
|
||||
line = who or "master copy — illustrative placeholders; no client data."
|
||||
return "\n".join(
|
||||
[
|
||||
"<!-- Export preamble — generated by scripts/export_report.py -->",
|
||||
"**What this is** — a costed Genesys Cloud AI scenario, produced from",
|
||||
"the Mercury-served token calculator. It is a **planning model**: list",
|
||||
"rates unless a contracted rate was entered, and never a quote.",
|
||||
"",
|
||||
f"**Engagement** — {line}",
|
||||
"",
|
||||
f"**Rate card** — [{RATE_CARD_SOURCE}]({RATE_CARD_SOURCE}), published",
|
||||
f"**{RATE_CARD_SOURCE_DATE}**. Text-to-speech is priced separately from",
|
||||
f"[{TTS_SOURCE}]({TTS_SOURCE}) (published {TTS_SOURCE_DATE}) and is",
|
||||
"billed per character, not in tokens — it is reported as its own line",
|
||||
"and never folded into the token subtotal.",
|
||||
"",
|
||||
"**How to read it** — first the content as annotated source (the feature",
|
||||
"catalogue, then the engagement data), then the scenario state, the",
|
||||
"verification gate, and finally the data appendix: the published rate",
|
||||
"card, the per-feature cost table, the scenario comparison, the applied",
|
||||
"rules and warnings, and — last — **Model state (JSON)**, one fenced",
|
||||
"`json` block. Where prose and JSON disagree, the JSON block is the",
|
||||
"source of truth; its `rate_card.source_date` says which published rate",
|
||||
"card produced these numbers.",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) > 1 and sys.argv[1] not in NOTEBOOK.name:
|
||||
sys.exit(f"no notebook matches {sys.argv[1]!r}")
|
||||
EXPORTS.mkdir(exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
executed = Path(tmp) / NOTEBOOK.name
|
||||
# 1. Execute once — both formats convert the same scenario state.
|
||||
# (Never combine --execute with TagRemovePreprocessor in one call:
|
||||
# the cell could be stripped before it runs.)
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable, "-m", "nbconvert", "--execute",
|
||||
"--to", "notebook", "--output", str(executed), str(NOTEBOOK),
|
||||
],
|
||||
check=True, cwd=ROOT,
|
||||
)
|
||||
# 2. HTML — full presentation, human review artifact.
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable, "-m", "nbconvert", "--to", "html",
|
||||
"--output-dir", str(EXPORTS), "--output", NOTEBOOK.stem,
|
||||
str(executed),
|
||||
],
|
||||
check=True, cwd=ROOT,
|
||||
)
|
||||
# 3. Markdown — LLM artifact: strip presentation cells.
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable, "-m", "nbconvert", "--to", "markdown",
|
||||
"--output-dir", str(EXPORTS), "--output", NOTEBOOK.stem,
|
||||
"--TagRemovePreprocessor.enabled=True",
|
||||
f"--TagRemovePreprocessor.remove_cell_tags={STRIP_TAGS_FROM_MD}",
|
||||
str(executed),
|
||||
],
|
||||
check=True, cwd=ROOT,
|
||||
)
|
||||
|
||||
# 4. Prepend the framing preamble to the markdown export.
|
||||
md = EXPORTS / f"{NOTEBOOK.stem}.md"
|
||||
md.write_text(preamble() + md.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
for p in sorted(EXPORTS.iterdir()):
|
||||
if p.suffix in (".html", ".md"):
|
||||
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
70
calculators/Genesys_Token_Calculator/tests/conftest.py
Normal file
70
calculators/Genesys_Token_Calculator/tests/conftest.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Test plumbing: import path + notebook content served from tagged cells.
|
||||
|
||||
Content and client data live in the notebook, never in ``.py`` — the cells
|
||||
tagged ``topic-bank`` (the feature catalogue) and ``engagement-data`` (the
|
||||
client's volumes and commercial terms) in
|
||||
``notebooks/genesys_token_calculator.ipynb``. The fixtures below read those
|
||||
cells with nbformat and exec them, so pytest pins the exact content the
|
||||
deliverable ships (no kernel needed — tagged cells are self-contained by
|
||||
contract).
|
||||
|
||||
The published Genesys rate card is deliberately NOT here: it is a vendor
|
||||
record nobody in this repo authors, so it lives in ``genesyscalc/ratecard.py``
|
||||
as an immutable anchor and is pinned by ``test_rate_card.py`` (see
|
||||
docs/Calculator_Pattern_V1-00.md).
|
||||
|
||||
The sys.path insert makes genesyscalc importable even without the master
|
||||
venv active (the normal setup is ``pip install -e ".[dev]"`` into the
|
||||
master-local ``.venv/``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
MASTER_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(MASTER_ROOT))
|
||||
|
||||
NOTEBOOK = MASTER_ROOT / "notebooks" / "genesys_token_calculator.ipynb"
|
||||
|
||||
|
||||
def tagged_cell_ns(tag: str) -> dict[str, Any]:
|
||||
"""Exec the single cell carrying ``tag`` and return its namespace."""
|
||||
import nbformat
|
||||
|
||||
nb = nbformat.read(NOTEBOOK, as_version=4)
|
||||
cells = [c for c in nb.cells if tag in c.metadata.get("tags", [])]
|
||||
assert len(cells) == 1, (
|
||||
f"expected exactly one cell tagged {tag!r} in {NOTEBOOK.name}, "
|
||||
f"found {len(cells)}"
|
||||
)
|
||||
ns: dict[str, Any] = {}
|
||||
exec(compile(cells[0].source, f"{NOTEBOOK.name} [{tag}]", "exec"), ns)
|
||||
return ns
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def catalogue_ns() -> dict[str, Any]:
|
||||
"""The executed namespace of the notebook's topic-bank cell."""
|
||||
return tagged_cell_ns("topic-bank")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def features(catalogue_ns: dict[str, Any]) -> tuple[Any, ...]:
|
||||
"""The FEATURES tuple as the deliverable defines it."""
|
||||
return catalogue_ns["FEATURES"] # type: ignore[no-any-return]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def feature_by_key(features: tuple[Any, ...]) -> dict[str, Any]:
|
||||
return {f.key: f for f in features}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def engagement() -> dict[str, Any]:
|
||||
"""The ENGAGEMENT dict as the deliverable's engagement-data cell ships it."""
|
||||
return tagged_cell_ns("engagement-data")["ENGAGEMENT"] # type: ignore[no-any-return]
|
||||
154
calculators/Genesys_Token_Calculator/tests/test_billing.py
Normal file
154
calculators/Genesys_Token_Calculator/tests/test_billing.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Tokens → dollars: the allowance, the rounding, and the commercial levers.
|
||||
|
||||
Hand-checked first, then pinned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from genesyscalc.billing import (
|
||||
MONTHS_PER_YEAR,
|
||||
allowance_for,
|
||||
billable_tokens_monthly,
|
||||
cost_lines,
|
||||
total_cost,
|
||||
)
|
||||
from genesyscalc.meters import LicenceModel, TokenPrice
|
||||
|
||||
NAMED = LicenceModel.NAMED
|
||||
CONCURRENT = LicenceModel.CONCURRENT
|
||||
|
||||
|
||||
# ── the free monthly allowance ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_free_allowance_deducted_named() -> None:
|
||||
"""1,000 consumption tokens, named org: 1,000 − 250 = 750 billable."""
|
||||
assert billable_tokens_monthly(1_000, 0, NAMED) == 750
|
||||
|
||||
|
||||
def test_free_allowance_deducted_concurrent() -> None:
|
||||
"""Same consumption, concurrent org: 1,000 − 350 = 650."""
|
||||
assert billable_tokens_monthly(1_000, 0, CONCURRENT) == 650
|
||||
|
||||
|
||||
def test_allowance_floors_at_zero() -> None:
|
||||
assert billable_tokens_monthly(120, 0, NAMED) == 0
|
||||
|
||||
|
||||
def test_allowance_does_not_carry_over() -> None:
|
||||
"""Two quiet months do not bank credit for a busy third.
|
||||
|
||||
120 → 0 billable. 120 → 0 billable. 400 → 150 billable, NOT 0: the
|
||||
unused 130 + 130 is discarded, because the allowance "renew[s] each
|
||||
month and do[es] not carry over to future months".
|
||||
"""
|
||||
assert billable_tokens_monthly(120, 0, NAMED) == 0
|
||||
assert billable_tokens_monthly(120, 0, NAMED) == 0
|
||||
assert billable_tokens_monthly(400, 0, NAMED) == 150
|
||||
|
||||
|
||||
def test_allowance_can_be_switched_off() -> None:
|
||||
"""So the gap is measurable on stage rather than merely asserted."""
|
||||
assert billable_tokens_monthly(1_000, 0, NAMED, apply_allowance=False) == 1_000
|
||||
assert allowance_for(NAMED, apply_allowance=False) == 0
|
||||
assert allowance_for(NAMED) == 250
|
||||
|
||||
|
||||
def test_allowance_applies_across_consumption_and_subscription() -> None:
|
||||
"""It is an org-level deduction, not a per-line one.
|
||||
|
||||
44.12 consumption → ceil 45; + 20,000 subscription = 20,045; − 250 =
|
||||
19,795.
|
||||
"""
|
||||
assert billable_tokens_monthly(44.117647, 20_000, NAMED) == 19_795
|
||||
|
||||
|
||||
# ── rounding ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_consumption_tokens_rounded_up_monthly() -> None:
|
||||
"""44.117647 tokens of bot time bills as 45."""
|
||||
assert billable_tokens_monthly(44.117647, 0, NAMED, apply_allowance=False) == 45
|
||||
|
||||
|
||||
def test_per_user_tokens_are_exact_not_rounded() -> None:
|
||||
"""500 named users × 40 = 20,000 exactly — no ceil on subscription."""
|
||||
assert billable_tokens_monthly(0, 20_000, NAMED, apply_allowance=False) == 20_000
|
||||
|
||||
|
||||
def test_negative_tokens_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="must not be negative"):
|
||||
billable_tokens_monthly(-1, 0, NAMED)
|
||||
|
||||
|
||||
# ── price, concession, currency ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_concession_applies_to_msrp() -> None:
|
||||
"""$1.00 list, 30% concession → $0.70/token; 1,000 tokens → $700/mo."""
|
||||
price = TokenPrice(list_rate=1.00, concession_pct=0.30)
|
||||
assert price.effective_rate() == pytest.approx(0.70)
|
||||
totals = total_cost(1_000, 0, NAMED, price, apply_allowance=False)
|
||||
assert totals.token_cost_monthly == pytest.approx(700.0)
|
||||
assert totals.token_cost_annual == pytest.approx(8_400.0)
|
||||
|
||||
|
||||
def test_msrp_walk_is_carried_alongside() -> None:
|
||||
"""The deck-frame column: list beside effective, and the saving."""
|
||||
price = TokenPrice(list_rate=1.00, concession_pct=0.30)
|
||||
totals = total_cost(1_000, 0, NAMED, price, apply_allowance=False)
|
||||
assert totals.list_cost_annual == pytest.approx(12_000.0)
|
||||
assert totals.concession_saving_annual == pytest.approx(3_600.0)
|
||||
assert totals.token_cost_annual <= totals.list_cost_annual
|
||||
|
||||
|
||||
def test_contracted_rate_overrides_concession() -> None:
|
||||
price = TokenPrice(list_rate=1.00, contracted_rate=0.55, concession_pct=0.30)
|
||||
assert price.effective_rate() == pytest.approx(0.55)
|
||||
|
||||
|
||||
def test_currency_carried_through() -> None:
|
||||
price = TokenPrice(currency="JPY", list_rate=120.0)
|
||||
totals = total_cost(1_000, 0, NAMED, price, apply_allowance=False)
|
||||
assert totals.currency == "JPY"
|
||||
assert totals.token_cost_monthly == pytest.approx(120_000.0)
|
||||
|
||||
|
||||
def test_invalid_concession_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="concession_pct"):
|
||||
TokenPrice(concession_pct=1.0)
|
||||
|
||||
|
||||
# ── cost lines ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cost_lines_price_each_meter_at_the_effective_rate() -> None:
|
||||
price = TokenPrice(list_rate=1.00, concession_pct=0.25)
|
||||
lines = cost_lines(
|
||||
{"ai_scoring": 100.0, "virtual_agent": 7_500.0},
|
||||
{"ai_scoring": 2_000.0, "virtual_agent": 15_000.0},
|
||||
price,
|
||||
)
|
||||
by_key = {line.key: line for line in lines}
|
||||
assert by_key["ai_scoring"].cost_monthly == pytest.approx(75.0)
|
||||
assert by_key["virtual_agent"].cost_monthly == pytest.approx(5_625.0)
|
||||
assert by_key["virtual_agent"].cost_annual == pytest.approx(
|
||||
5_625.0 * MONTHS_PER_YEAR
|
||||
)
|
||||
assert by_key["ai_scoring"].units_monthly == pytest.approx(2_000.0)
|
||||
assert by_key["ai_scoring"].unit_label == "evaluations"
|
||||
|
||||
|
||||
def test_cost_lines_are_ordered_by_feature_name() -> None:
|
||||
lines = cost_lines(
|
||||
{"virtual_agent": 1.0, "ai_scoring": 1.0}, {}, TokenPrice()
|
||||
)
|
||||
assert [line.feature for line in lines] == ["AI Scoring", "Virtual Agent"]
|
||||
|
||||
|
||||
def test_totals_expose_the_allowance_that_was_applied() -> None:
|
||||
totals = total_cost(1_000, 20_000, NAMED, TokenPrice())
|
||||
assert totals.allowance_tokens_monthly == 250
|
||||
assert totals.gross_tokens_monthly == pytest.approx(21_000.0)
|
||||
assert totals.billable_tokens_monthly == 20_750
|
||||
94
calculators/Genesys_Token_Calculator/tests/test_catalogue.py
Normal file
94
calculators/Genesys_Token_Calculator/tests/test_catalogue.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""The feature catalogue — content pins, read from the notebook by tag.
|
||||
|
||||
The catalogue is the master's authored content: it lives in the notebook's
|
||||
``topic-bank`` cell, and these tests exec that cell rather than importing a
|
||||
module. The published RATES are not here — they are the vendor's record, in
|
||||
``genesyscalc/ratecard.py``, pinned by ``test_rate_card.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from genesyscalc.ratecard import METERS
|
||||
|
||||
EXPECTED_KEYS = (
|
||||
"agent_copilot_named",
|
||||
"speech_and_text_analytics_named",
|
||||
"bots_voice",
|
||||
"virtual_agent",
|
||||
"agentic_virtual_agent",
|
||||
"ai_summary_and_insights",
|
||||
"ai_scoring",
|
||||
"bots_digital",
|
||||
"ai_translate",
|
||||
"predictive_routing",
|
||||
"whatsapp_messaging",
|
||||
"genesys_cloud_copilot",
|
||||
"predictive_engagement",
|
||||
)
|
||||
|
||||
|
||||
def test_catalogue_size(features: tuple[Any, ...]) -> None:
|
||||
assert len(features) == 13
|
||||
|
||||
|
||||
def test_catalogue_keys_and_order(features: tuple[Any, ...]) -> None:
|
||||
"""Keys are stable identities — widgets, notes and the export key off them."""
|
||||
assert tuple(f.key for f in features) == EXPECTED_KEYS
|
||||
|
||||
|
||||
def test_keys_are_unique(features: tuple[Any, ...]) -> None:
|
||||
assert len({f.key for f in features}) == len(features)
|
||||
|
||||
|
||||
def test_every_catalogue_key_is_a_published_meter(features: tuple[Any, ...]) -> None:
|
||||
"""The cross-layer tie: a rename here silently unprices a feature."""
|
||||
for f in features:
|
||||
assert f.key in METERS, f"{f.key} is not a published Genesys meter"
|
||||
|
||||
|
||||
def test_every_feature_carries_its_content(features: tuple[Any, ...]) -> None:
|
||||
for f in features:
|
||||
assert f.title.strip(), f"{f.key} has no title"
|
||||
assert f.description.strip(), f"{f.key} has no description"
|
||||
assert f.ask.strip(), f"{f.key} has no sizing question"
|
||||
assert f.ask.strip().endswith(("?", ")")), (
|
||||
f"{f.key}: `ask` should read as a question to put to the client"
|
||||
)
|
||||
|
||||
|
||||
def test_titles_are_distinct(features: tuple[Any, ...]) -> None:
|
||||
"""Titles become widget labels; duplicates collide in the sidebar."""
|
||||
titles = [f.title for f in features]
|
||||
assert len(set(titles)) == len(titles)
|
||||
|
||||
|
||||
def test_default_scenario_is_a_coherent_starting_point(
|
||||
features: tuple[Any, ...],
|
||||
) -> None:
|
||||
"""Headless nbconvert takes every widget at its seed, so the defaults
|
||||
must form a scenario the gate can pass — and a plausible one to show."""
|
||||
default_on = {f.key for f in features if f.default_on}
|
||||
assert len(default_on) == 8
|
||||
assert "agent_copilot_named" in default_on
|
||||
assert "bots_voice" in default_on
|
||||
# Copilot on by default means the summary line must be visible but free —
|
||||
# that pairing is the published exclusion the calculator demonstrates.
|
||||
assert "ai_summary_and_insights" in default_on
|
||||
|
||||
|
||||
def test_catalogue_uses_a_schema_not_loose_dicts(features: tuple[Any, ...]) -> None:
|
||||
"""Content is structured, so the notebook and tests agree on its shape."""
|
||||
first = features[0]
|
||||
assert type(first).__name__ == "Feature"
|
||||
assert {"key", "title", "description", "ask", "default_on"} <= set(
|
||||
type(first).__dataclass_fields__
|
||||
)
|
||||
|
||||
|
||||
def test_catalogue_cell_is_self_contained(catalogue_ns: dict[str, Any]) -> None:
|
||||
"""It execs with no setup cell — the conftest fixture proves it, but pin
|
||||
the contract explicitly so a stray dependency is caught here."""
|
||||
assert "FEATURES" in catalogue_ns
|
||||
assert "Feature" in catalogue_ns
|
||||
115
calculators/Genesys_Token_Calculator/tests/test_engagement.py
Normal file
115
calculators/Genesys_Token_Calculator/tests/test_engagement.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Engagement data — SHAPE pins only.
|
||||
|
||||
The master ships placeholders; an engagement copy fills them in. So these
|
||||
tests pin the *shape* of the cell and never its emptiness — asserting that
|
||||
``client`` is blank would fail the moment the copy is used for real, which
|
||||
is precisely backwards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import nbformat
|
||||
|
||||
from tests.conftest import NOTEBOOK
|
||||
|
||||
IDENTITY_KEYS = {"client", "prepared_date", "prepared_by"}
|
||||
|
||||
VOLUME_KEYS = {
|
||||
"voice_inbound_monthly",
|
||||
"voice_outbound_monthly",
|
||||
"digital_sessions_monthly",
|
||||
"email_monthly",
|
||||
"messaging_monthly",
|
||||
"social_posts_monthly",
|
||||
"social_responses_monthly",
|
||||
"translations_monthly",
|
||||
"evaluations_monthly",
|
||||
"ai_actions_monthly",
|
||||
"routed_interactions_monthly",
|
||||
}
|
||||
|
||||
COMMERCIAL_KEYS = {
|
||||
"agent_users",
|
||||
"licence_model",
|
||||
"currency",
|
||||
"contracted_rate_per_token",
|
||||
"concession_pct",
|
||||
}
|
||||
|
||||
TTS_KEYS = {"tts_chars_per_call", "tts_voice_tier"}
|
||||
|
||||
|
||||
def test_engagement_shape(engagement: dict[str, Any]) -> None:
|
||||
assert set(engagement) == (
|
||||
IDENTITY_KEYS | VOLUME_KEYS | COMMERCIAL_KEYS | TTS_KEYS
|
||||
)
|
||||
|
||||
|
||||
def test_identity_fields_are_strings(engagement: dict[str, Any]) -> None:
|
||||
"""Blank in the master, filled in the copy — either way, strings."""
|
||||
for key in IDENTITY_KEYS:
|
||||
assert isinstance(engagement[key], str)
|
||||
|
||||
|
||||
def test_volumes_are_non_negative_numbers(engagement: dict[str, Any]) -> None:
|
||||
for key in VOLUME_KEYS:
|
||||
value = engagement[key]
|
||||
assert isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
assert value >= 0, f"{key} must not be negative"
|
||||
|
||||
|
||||
def test_commercial_terms_are_well_formed(engagement: dict[str, Any]) -> None:
|
||||
assert isinstance(engagement["agent_users"], int)
|
||||
assert engagement["agent_users"] >= 0
|
||||
assert engagement["licence_model"] in ("named", "concurrent")
|
||||
assert isinstance(engagement["currency"], str) and engagement["currency"]
|
||||
assert 0.0 <= float(engagement["concession_pct"]) < 1.0
|
||||
contracted = engagement["contracted_rate_per_token"]
|
||||
assert contracted is None or float(contracted) >= 0
|
||||
|
||||
|
||||
def test_currency_is_one_the_rate_card_publishes(engagement: dict[str, Any]) -> None:
|
||||
from genesyscalc.ratecard import LIST_PRICE_BY_CURRENCY
|
||||
|
||||
assert engagement["currency"] in LIST_PRICE_BY_CURRENCY
|
||||
|
||||
|
||||
def test_tts_settings(engagement: dict[str, Any]) -> None:
|
||||
from genesyscalc.tts import TTS_PRICE_PER_MILLION_CHARS
|
||||
|
||||
assert isinstance(engagement["tts_chars_per_call"], int)
|
||||
assert engagement["tts_chars_per_call"] >= 0
|
||||
assert engagement["tts_voice_tier"] in TTS_PRICE_PER_MILLION_CHARS
|
||||
|
||||
|
||||
def test_master_carries_no_client_identity(engagement: dict[str, Any]) -> None:
|
||||
"""The one emptiness check that IS correct: masters stay client-clean.
|
||||
|
||||
This guards the master in THIS repo. An engagement copy lives outside
|
||||
Palladium, so it never runs this suite — filling the cell there does not
|
||||
break anything (CLAUDE.md § Confidentiality).
|
||||
"""
|
||||
assert engagement["client"] == "", (
|
||||
"a client name in the master means the copy-out step was skipped — "
|
||||
"copy the directory out of Palladium before entering client data"
|
||||
)
|
||||
|
||||
|
||||
def test_engagement_cell_sits_above_the_widget_cell() -> None:
|
||||
"""Client data must not re-run on a sidebar change (Mercury re-runs only
|
||||
cells below a changed widget)."""
|
||||
nb = nbformat.read(NOTEBOOK, as_version=4)
|
||||
eng = [
|
||||
i
|
||||
for i, c in enumerate(nb.cells)
|
||||
if "engagement-data" in c.metadata.get("tags", [])
|
||||
]
|
||||
widgets = [
|
||||
i
|
||||
for i, c in enumerate(nb.cells)
|
||||
if c.cell_type == "code" and "mr.Select(" in c.source
|
||||
]
|
||||
assert len(eng) == 1 and widgets
|
||||
assert eng[0] < min(widgets)
|
||||
263
calculators/Genesys_Token_Calculator/tests/test_model.py
Normal file
263
calculators/Genesys_Token_Calculator/tests/test_model.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""End-to-end pricing, hand-checked.
|
||||
|
||||
The reference scenario below is the one the notebook ships as its default.
|
||||
Every figure was computed by hand from the published rates before it was
|
||||
pinned here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from genesyscalc.appendix import result_json
|
||||
from genesyscalc.meters import LicenceModel, TokenPrice
|
||||
from genesyscalc.model import (
|
||||
CalculatorInputs,
|
||||
calculate,
|
||||
compare,
|
||||
cost_per_interaction,
|
||||
metered_units,
|
||||
)
|
||||
from genesyscalc.ratecard import RATE_CARD_SOURCE_DATE
|
||||
from genesyscalc.tts import TtsUsage
|
||||
from genesyscalc.usage import DeflectionMix, VoiceBotUsage, Volumes
|
||||
|
||||
REFERENCE_ENABLED = frozenset(
|
||||
{
|
||||
"agent_copilot_named",
|
||||
"speech_and_text_analytics_named",
|
||||
"bots_voice",
|
||||
"virtual_agent",
|
||||
"agentic_virtual_agent",
|
||||
"ai_summary_and_insights",
|
||||
"ai_scoring",
|
||||
"whatsapp_messaging",
|
||||
"predictive_engagement",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def volumes() -> Volumes:
|
||||
return Volumes(
|
||||
voice_inbound_monthly=100_000,
|
||||
voice_outbound_monthly=20_000,
|
||||
digital_sessions_monthly=8_000,
|
||||
email_monthly=12_000,
|
||||
messaging_monthly=6_000,
|
||||
evaluations_monthly=2_000,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reference(volumes: Volumes) -> CalculatorInputs:
|
||||
return CalculatorInputs(
|
||||
volumes=volumes,
|
||||
users=500,
|
||||
licence=LicenceModel.NAMED,
|
||||
enabled=REFERENCE_ENABLED,
|
||||
mix=DeflectionMix(
|
||||
bot_only_share=0.30, virtual_agent_share=0.15, agentic_va_share=0.05
|
||||
),
|
||||
price=TokenPrice(),
|
||||
voice_bot=VoiceBotUsage(
|
||||
calls_per_month=30_000, avg_bot_seconds_per_call=40
|
||||
),
|
||||
tts=TtsUsage(calls_monthly=100_000, chars_per_call=2_000),
|
||||
)
|
||||
|
||||
|
||||
# ── the hand-checked reference scenario ──────────────────────────────────
|
||||
|
||||
|
||||
def test_subscription_lines(reference: CalculatorInputs) -> None:
|
||||
"""500 users: Copilot 500×40 = 20,000; STA 500×30 = 15,000 tokens/mo."""
|
||||
by_key = {line.key: line for line in calculate(reference).lines}
|
||||
assert by_key["agent_copilot_named"].tokens_monthly == pytest.approx(20_000.0)
|
||||
assert by_key["speech_and_text_analytics_named"].tokens_monthly == pytest.approx(
|
||||
15_000.0
|
||||
)
|
||||
assert by_key["agent_copilot_named"].cost_annual == pytest.approx(240_000.0)
|
||||
|
||||
|
||||
def test_voice_bot_line(reference: CalculatorInputs) -> None:
|
||||
"""30,000 calls × ceil(40/15)×15s = 45s = 0.75 min → 22,500 min.
|
||||
22,500 / 17 = 1,323.53 tokens/mo → $15,882.35/yr at $1.00."""
|
||||
by_key = {line.key: line for line in calculate(reference).lines}
|
||||
assert by_key["bots_voice"].units_monthly == pytest.approx(22_500.0)
|
||||
assert by_key["bots_voice"].tokens_monthly == pytest.approx(1_323.529412, rel=1e-6)
|
||||
assert by_key["bots_voice"].cost_annual == pytest.approx(15_882.35, abs=0.01)
|
||||
|
||||
|
||||
def test_virtual_agent_lines_use_the_partition(reference: CalculatorInputs) -> None:
|
||||
"""120,000 voice: VA 15% = 18,000 × 0.5 = 9,000 tokens;
|
||||
AVA 5% = 6,000 × 1.2 = 7,200 tokens."""
|
||||
by_key = {line.key: line for line in calculate(reference).lines}
|
||||
assert by_key["virtual_agent"].units_monthly == pytest.approx(18_000.0)
|
||||
assert by_key["virtual_agent"].tokens_monthly == pytest.approx(9_000.0)
|
||||
assert by_key["agentic_virtual_agent"].units_monthly == pytest.approx(6_000.0)
|
||||
assert by_key["agentic_virtual_agent"].tokens_monthly == pytest.approx(7_200.0)
|
||||
|
||||
|
||||
def test_summary_is_zeroed_because_copilot_is_on(reference: CalculatorInputs) -> None:
|
||||
result = calculate(reference)
|
||||
by_key = {line.key: line for line in result.lines}
|
||||
assert by_key["ai_summary_and_insights"].units_monthly == 0.0
|
||||
assert by_key["ai_summary_and_insights"].cost_annual == 0.0
|
||||
assert any("Agent Copilot is enabled" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_zero_rated_line_is_shown_not_hidden(reference: CalculatorInputs) -> None:
|
||||
"""A published $0 is a finding — it renders as a line, at zero."""
|
||||
by_key = {line.key: line for line in calculate(reference).lines}
|
||||
assert "predictive_engagement" in by_key
|
||||
assert by_key["predictive_engagement"].units_monthly > 0
|
||||
assert by_key["predictive_engagement"].cost_annual == 0.0
|
||||
|
||||
|
||||
def test_reference_totals(reference: CalculatorInputs) -> None:
|
||||
"""consumption 17,638.53 → ceil 17,639; + 35,000 subscription = 52,639;
|
||||
− 250 allowance = 52,389 billable → $52,389/mo → $628,668/yr.
|
||||
TTS 200M chars, 20% VA-free → 160M → $3,200/mo → $38,400/yr.
|
||||
Grand total $667,068/yr."""
|
||||
totals = calculate(reference).totals
|
||||
assert totals.consumption_tokens_monthly == pytest.approx(17_638.529412, rel=1e-6)
|
||||
assert totals.subscription_tokens_monthly == pytest.approx(35_000.0)
|
||||
assert totals.gross_tokens_monthly == pytest.approx(52_639.0)
|
||||
assert totals.allowance_tokens_monthly == 250
|
||||
assert totals.billable_tokens_monthly == 52_389
|
||||
assert totals.token_cost_annual == pytest.approx(628_668.0)
|
||||
|
||||
|
||||
def test_reference_grand_total_splits_tokens_from_tts(
|
||||
reference: CalculatorInputs,
|
||||
) -> None:
|
||||
result = calculate(reference)
|
||||
assert result.tts_cost_annual == pytest.approx(38_400.0)
|
||||
assert result.grand_total_annual == pytest.approx(667_068.0)
|
||||
assert result.grand_total_annual == pytest.approx(
|
||||
result.token_cost_annual + result.tts_cost_annual
|
||||
)
|
||||
|
||||
|
||||
def test_cost_per_interaction(reference: CalculatorInputs) -> None:
|
||||
"""$667,068 / (146,000 × 12) = $0.3807 per interaction."""
|
||||
result = calculate(reference)
|
||||
assert cost_per_interaction(result, reference.volumes) == pytest.approx(
|
||||
0.380747, rel=1e-5
|
||||
)
|
||||
|
||||
|
||||
def test_roundup_warning_is_reported(reference: CalculatorInputs) -> None:
|
||||
assert any("15-second per-call round-up" in w for w in calculate(reference).warnings)
|
||||
|
||||
|
||||
# ── structural ties that must hold at any setting ────────────────────────
|
||||
|
||||
|
||||
def test_lines_sum_to_the_gross_token_count(reference: CalculatorInputs) -> None:
|
||||
result = calculate(reference)
|
||||
assert sum(line.tokens_monthly for line in result.lines) == pytest.approx(
|
||||
result.totals.consumption_tokens_monthly
|
||||
+ result.totals.subscription_tokens_monthly
|
||||
)
|
||||
|
||||
|
||||
def test_effective_cost_never_exceeds_list(reference: CalculatorInputs) -> None:
|
||||
discounted = reference.with_(price=TokenPrice(concession_pct=0.4))
|
||||
result = calculate(discounted)
|
||||
assert result.totals.token_cost_annual <= result.totals.list_cost_annual
|
||||
assert result.totals.concession_saving_annual >= 0
|
||||
|
||||
|
||||
def test_allowance_only_ever_reduces_the_bill(reference: CalculatorInputs) -> None:
|
||||
with_allowance = calculate(reference).totals.billable_tokens_monthly
|
||||
without = calculate(
|
||||
reference.with_(apply_allowance=False)
|
||||
).totals.billable_tokens_monthly
|
||||
assert with_allowance == without - 250
|
||||
|
||||
|
||||
def test_allowance_absorbing_a_month_is_reported() -> None:
|
||||
quiet = CalculatorInputs(
|
||||
volumes=Volumes(voice_inbound_monthly=100),
|
||||
users=0,
|
||||
enabled=frozenset({"ai_scoring"}),
|
||||
)
|
||||
result = calculate(quiet)
|
||||
assert result.totals.billable_tokens_monthly == 0
|
||||
assert any("free monthly allowance" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_unknown_feature_key_rejected(volumes: Volumes) -> None:
|
||||
with pytest.raises(ValueError, match="not published meters"):
|
||||
CalculatorInputs(volumes=volumes, users=1, enabled=frozenset({"telepathy"}))
|
||||
|
||||
|
||||
def test_metered_units_covers_every_enabled_key(reference: CalculatorInputs) -> None:
|
||||
assert set(metered_units(reference)) == set(reference.enabled)
|
||||
|
||||
|
||||
def test_empty_scenario_costs_nothing() -> None:
|
||||
result = calculate(CalculatorInputs(volumes=Volumes(), users=0))
|
||||
assert result.grand_total_annual == 0.0
|
||||
assert result.lines == ()
|
||||
|
||||
|
||||
# ── licence model, scenarios ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_concurrent_licence_costs_more_per_user(reference: CalculatorInputs) -> None:
|
||||
"""Copilot 40→60 and STA 30→45, so 35,000 → 52,500 subscription tokens."""
|
||||
concurrent = reference.with_(
|
||||
licence=LicenceModel.CONCURRENT,
|
||||
enabled=frozenset(
|
||||
{"agent_copilot_named", "speech_and_text_analytics_named"}
|
||||
),
|
||||
)
|
||||
named = reference.with_(
|
||||
enabled=frozenset({"agent_copilot_named", "speech_and_text_analytics_named"})
|
||||
)
|
||||
assert calculate(named).totals.subscription_tokens_monthly == pytest.approx(35_000.0)
|
||||
assert calculate(concurrent).totals.subscription_tokens_monthly == pytest.approx(
|
||||
52_500.0
|
||||
)
|
||||
|
||||
|
||||
def test_compare_is_monotone_in_deflection(reference: CalculatorInputs) -> None:
|
||||
"""More virtual-agent deflection means more consumption tokens."""
|
||||
rows = compare(
|
||||
{
|
||||
"Conservative": reference.with_(
|
||||
mix=DeflectionMix(bot_only_share=0.20, virtual_agent_share=0.05)
|
||||
),
|
||||
"Base": reference,
|
||||
"Aggressive": reference.with_(
|
||||
mix=DeflectionMix(
|
||||
bot_only_share=0.35,
|
||||
virtual_agent_share=0.25,
|
||||
agentic_va_share=0.10,
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
assert [r["Scenario"] for r in rows] == ["Conservative", "Base", "Aggressive"]
|
||||
totals = [r["Token cost / yr"] for r in rows]
|
||||
assert totals[0] < totals[1] < totals[2]
|
||||
|
||||
|
||||
# ── the export payload ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_result_json_round_trips_and_carries_provenance(
|
||||
reference: CalculatorInputs,
|
||||
) -> None:
|
||||
payload = result_json(calculate(reference), reference, meta={"client": "Example"})
|
||||
assert json.loads(json.dumps(payload)) == payload
|
||||
assert payload["rate_card"]["source_date"] == RATE_CARD_SOURCE_DATE
|
||||
assert payload["rate_card"]["voice_bot_roundup_seconds"] == 15
|
||||
assert payload["tts_source"]["source_date"] == "2026-05-22"
|
||||
assert payload["totals"]["grand_total_annual"] == pytest.approx(667_068.0)
|
||||
assert payload["meta"]["client"] == "Example"
|
||||
assert len(payload["lines"]) == len(REFERENCE_ENABLED)
|
||||
174
calculators/Genesys_Token_Calculator/tests/test_rate_card.py
Normal file
174
calculators/Genesys_Token_Calculator/tests/test_rate_card.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""The vendor-change tripwire.
|
||||
|
||||
Every published row is pinned by its exact feature wording AND its exact
|
||||
rate string, so a transcription slip or a vendor republication breaks the
|
||||
build rather than quietly moving a client-facing number. When Genesys
|
||||
republishes, these pins are updated in the SAME commit as the anchor — see
|
||||
the re-anchoring protocol in docs/Calculator_Pattern_V1-00.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from genesyscalc.meters import Confidence, LicenceModel, MeterBasis
|
||||
from genesyscalc.ratecard import (
|
||||
ALLOWANCE_CARRIES_OVER,
|
||||
COPILOT_COVERS_SUMMARY_RULE,
|
||||
FREE_TOKENS_PER_MONTH,
|
||||
HIGHEST_TIER_RULE,
|
||||
LIST_PRICE_BY_CURRENCY,
|
||||
METERS,
|
||||
METERS_VERBATIM,
|
||||
RATE_CARD_SOURCE,
|
||||
RATE_CARD_SOURCE_DATE,
|
||||
VOICE_BOT_ROUNDUP_SECONDS,
|
||||
ZERO_RATED_VERBATIM,
|
||||
meter,
|
||||
rate_card_rows,
|
||||
)
|
||||
|
||||
# The published table, as it reads on the page. Transcribed 2026-07-12.
|
||||
PUBLISHED = {
|
||||
"Bots (Voice)": "17 minutes per token",
|
||||
"Bots (Digital)": "51 sessions per token",
|
||||
"Virtual Agent": "0.5 tokens per Virtual Agent interaction",
|
||||
"Agentic Virtual Agent": "1.2 tokens per interaction",
|
||||
"Agent Copilot [named]": "40 tokens per user",
|
||||
"Agent Copilot [concurrent]": "60 tokens per user",
|
||||
"AI Scoring": "20 evaluations per token",
|
||||
"AI Translate": "2 translations per token",
|
||||
"AI Summary and Insights": "50 summaries/insights per token",
|
||||
"Apple Messages for Business": "400 inbound or outbound messages per token",
|
||||
"Facebook Messenger": "400 messages per token",
|
||||
"Instagram Direct Messaging": "400 messages per token",
|
||||
"WhatsApp Messaging": "400 messages per token",
|
||||
"X Direct Messaging": "400 messages per token",
|
||||
"Genesys Cloud Social": "400 social post ingestions per channel per token",
|
||||
"Social Post Responses": "400 outbound messages per channel per token",
|
||||
"Predictive Routing": "17 routes per token",
|
||||
"Speech and Text Analytics [named]": "30 tokens per user",
|
||||
"Speech and Text Analytics [concurrent]": "45 tokens per user",
|
||||
"Genesys Cloud Copilot": "20 AI actions per token",
|
||||
}
|
||||
|
||||
|
||||
def test_published_meter_table_verbatim() -> None:
|
||||
assert {m.feature: m.published_rate for m in METERS_VERBATIM} == PUBLISHED
|
||||
|
||||
|
||||
def test_meter_count() -> None:
|
||||
assert len(METERS_VERBATIM) == 20
|
||||
assert len(ZERO_RATED_VERBATIM) == 2
|
||||
|
||||
|
||||
def test_source_pinned() -> None:
|
||||
"""Bumping the date without updating the pins is the failure mode."""
|
||||
assert RATE_CARD_SOURCE_DATE == "2026-07-12"
|
||||
assert RATE_CARD_SOURCE == (
|
||||
"https://help.genesys.cloud/articles/genesys-cloud-tokens-model/"
|
||||
)
|
||||
|
||||
|
||||
def test_every_confirmed_meter_carries_source_url_and_date() -> None:
|
||||
for m in METERS.values():
|
||||
if m.confidence is Confidence.CONFIRMED:
|
||||
assert m.source_url, f"{m.key} is 🟢 without a source URL"
|
||||
assert m.source_date, f"{m.key} is 🟢 without a source date"
|
||||
|
||||
|
||||
def test_unsourced_confirmed_meter_cannot_be_constructed() -> None:
|
||||
from genesyscalc.meters import Meter
|
||||
|
||||
with pytest.raises(ValueError, match="source URL and date"):
|
||||
Meter(
|
||||
key="made_up",
|
||||
feature="Made Up",
|
||||
published_rate="1 per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "expected"),
|
||||
[
|
||||
("bots_voice", 1 / 17),
|
||||
("bots_digital", 1 / 51),
|
||||
("virtual_agent", 0.5),
|
||||
("agentic_virtual_agent", 1.2),
|
||||
("ai_scoring", 1 / 20),
|
||||
("ai_translate", 1 / 2),
|
||||
("ai_summary_and_insights", 1 / 50),
|
||||
("whatsapp_messaging", 1 / 400),
|
||||
("genesys_cloud_social", 1 / 400),
|
||||
("predictive_routing", 1 / 17),
|
||||
("genesys_cloud_copilot", 1 / 20),
|
||||
],
|
||||
)
|
||||
def test_numeric_rates_match_their_published_strings(key: str, expected: float) -> None:
|
||||
"""Catches a float drifting away from the wording beside it."""
|
||||
assert meter(key).rate == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_per_user_rates() -> None:
|
||||
copilot = meter("agent_copilot_named")
|
||||
assert copilot.tokens_per_user_month(LicenceModel.NAMED) == 40.0
|
||||
assert copilot.tokens_per_user_month(LicenceModel.CONCURRENT) == 60.0
|
||||
|
||||
sta = meter("speech_and_text_analytics_named")
|
||||
assert sta.tokens_per_user_month(LicenceModel.NAMED) == 30.0
|
||||
assert sta.tokens_per_user_month(LicenceModel.CONCURRENT) == 45.0
|
||||
|
||||
|
||||
def test_free_allowance() -> None:
|
||||
assert FREE_TOKENS_PER_MONTH[LicenceModel.NAMED] == 250
|
||||
assert FREE_TOKENS_PER_MONTH[LicenceModel.CONCURRENT] == 350
|
||||
assert ALLOWANCE_CARRIES_OVER is False
|
||||
|
||||
|
||||
def test_voice_roundup_granularity() -> None:
|
||||
assert VOICE_BOT_ROUNDUP_SECONDS == 15
|
||||
|
||||
|
||||
def test_zero_rated_features() -> None:
|
||||
assert {m.key for m in ZERO_RATED_VERBATIM} == {
|
||||
"predictive_engagement",
|
||||
"knowledge_queries",
|
||||
}
|
||||
assert all(m.rate == 0.0 for m in ZERO_RATED_VERBATIM)
|
||||
assert all(m.tokens_for(1_000_000) == 0.0 for m in ZERO_RATED_VERBATIM)
|
||||
|
||||
|
||||
def test_highest_tier_rule_text_verbatim() -> None:
|
||||
"""It renders on stage, so the wording is part of the deliverable."""
|
||||
assert HIGHEST_TIER_RULE == (
|
||||
"In cases where an interaction uses multiple AI resources, such as bot "
|
||||
"flows, virtual agents, and agentic virtual agents, Genesys bases "
|
||||
"charges on the highest tier (price) resource that Genesys Cloud uses "
|
||||
"during the interaction."
|
||||
)
|
||||
|
||||
|
||||
def test_copilot_covers_summary_rule_text_verbatim() -> None:
|
||||
assert COPILOT_COVERS_SUMMARY_RULE == (
|
||||
"if you enable Agent Copilot simultaneously, then Supervisor Copilot "
|
||||
"summaries and insights do not consume tokens"
|
||||
)
|
||||
|
||||
|
||||
def test_list_price() -> None:
|
||||
assert LIST_PRICE_BY_CURRENCY["USD"] == 1.00
|
||||
assert LIST_PRICE_BY_CURRENCY["JPY"] == 120.0
|
||||
|
||||
|
||||
def test_meter_lookup_error_is_useful() -> None:
|
||||
with pytest.raises(KeyError, match="not a published Genesys meter"):
|
||||
meter("nope")
|
||||
|
||||
|
||||
def test_rate_card_rows_cover_every_published_line() -> None:
|
||||
rows = rate_card_rows()
|
||||
assert len(rows) == len(METERS_VERBATIM) + len(ZERO_RATED_VERBATIM)
|
||||
assert rows[0]["Feature"] == "Bots (Voice)"
|
||||
assert all(r["Confidence"] == "🟢" for r in rows)
|
||||
assert all(r["Source"] == RATE_CARD_SOURCE_DATE for r in rows)
|
||||
110
calculators/Genesys_Token_Calculator/tests/test_sensitivity.py
Normal file
110
calculators/Genesys_Token_Calculator/tests/test_sensitivity.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Sweeps and tornado data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from genesyscalc.meters import TokenPrice
|
||||
from genesyscalc.model import CalculatorInputs, calculate
|
||||
from genesyscalc.sensitivity import (
|
||||
DRIVERS,
|
||||
driver_value,
|
||||
set_driver,
|
||||
sweep,
|
||||
tornado,
|
||||
)
|
||||
from genesyscalc.tts import TtsUsage
|
||||
from genesyscalc.usage import DeflectionMix, VoiceBotUsage, Volumes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base() -> CalculatorInputs:
|
||||
return CalculatorInputs(
|
||||
volumes=Volumes(voice_inbound_monthly=100_000, voice_outbound_monthly=20_000),
|
||||
users=500,
|
||||
enabled=frozenset(
|
||||
{"agent_copilot_named", "bots_voice", "virtual_agent"}
|
||||
),
|
||||
mix=DeflectionMix(bot_only_share=0.30, virtual_agent_share=0.15),
|
||||
price=TokenPrice(),
|
||||
voice_bot=VoiceBotUsage(calls_per_month=30_000, avg_bot_seconds_per_call=40),
|
||||
tts=TtsUsage(calls_monthly=100_000),
|
||||
)
|
||||
|
||||
|
||||
def test_driver_round_trip(base: CalculatorInputs) -> None:
|
||||
assert driver_value(base, "users") == 500
|
||||
assert driver_value(set_driver(base, "users", 750), "users") == 750
|
||||
|
||||
|
||||
def test_nested_driver_round_trip(base: CalculatorInputs) -> None:
|
||||
assert driver_value(base, "concession_pct") == 0.0
|
||||
moved = set_driver(base, "concession_pct", 0.25)
|
||||
assert moved.price.concession_pct == pytest.approx(0.25)
|
||||
assert base.price.concession_pct == 0.0 # inputs are frozen; no mutation
|
||||
|
||||
|
||||
def test_integer_drivers_stay_integers(base: CalculatorInputs) -> None:
|
||||
assert isinstance(set_driver(base, "users", 512.6).users, int)
|
||||
assert set_driver(base, "users", 512.6).users == 513
|
||||
|
||||
|
||||
def test_unknown_driver_is_named(base: CalculatorInputs) -> None:
|
||||
with pytest.raises(KeyError, match="not a known driver"):
|
||||
driver_value(base, "vibes")
|
||||
|
||||
|
||||
def test_sweep_is_monotone_in_token_price(base: CalculatorInputs) -> None:
|
||||
rows = sweep(base, "token_price", [0.5, 1.0, 1.5, 2.0])
|
||||
costs = [r["token_cost_annual"] for r in rows]
|
||||
assert costs == sorted(costs)
|
||||
assert len(rows) == 4
|
||||
|
||||
|
||||
def test_sweep_carries_the_driver_value(base: CalculatorInputs) -> None:
|
||||
rows = sweep(base, "concession_pct", [0.0, 0.25])
|
||||
assert rows[0]["concession_pct"] == 0.0
|
||||
assert rows[1]["grand_total_annual"] < rows[0]["grand_total_annual"]
|
||||
|
||||
|
||||
def test_tornado_is_sorted_by_swing(base: CalculatorInputs) -> None:
|
||||
rows = tornado(base, ["token_price", "users", "bot_seconds", "concession_pct"])
|
||||
swings = [r["swing"] for r in rows]
|
||||
assert swings == sorted(swings, reverse=True)
|
||||
assert all(r["swing"] >= 0 for r in rows)
|
||||
|
||||
|
||||
def test_tornado_brackets_the_baseline(base: CalculatorInputs) -> None:
|
||||
baseline = calculate(base).grand_total_annual
|
||||
for row in tornado(base, ["token_price", "users"]):
|
||||
assert row["low"] <= baseline <= row["high"]
|
||||
assert row["baseline"] == pytest.approx(baseline)
|
||||
|
||||
|
||||
def test_tornado_skips_inactive_levers() -> None:
|
||||
"""No TTS and no voice bot in the scenario — those levers are noise."""
|
||||
plain = CalculatorInputs(
|
||||
volumes=Volumes(voice_inbound_monthly=1_000),
|
||||
users=10,
|
||||
enabled=frozenset({"agent_copilot_named"}),
|
||||
)
|
||||
drivers = [r["driver"] for r in tornado(plain, list(DRIVERS))]
|
||||
assert "bot_seconds" not in drivers
|
||||
assert "tts_chars_per_call" not in drivers
|
||||
assert "users" in drivers
|
||||
|
||||
|
||||
def test_tornado_skips_a_lever_that_would_break_the_partition() -> None:
|
||||
"""Raising a share past the partition is illegal, not a data point."""
|
||||
saturated = CalculatorInputs(
|
||||
volumes=Volumes(voice_inbound_monthly=1_000),
|
||||
users=10,
|
||||
enabled=frozenset({"virtual_agent"}),
|
||||
mix=DeflectionMix(bot_only_share=0.5, virtual_agent_share=0.5),
|
||||
)
|
||||
drivers = [r["driver"] for r in tornado(saturated, ["virtual_agent_share"])]
|
||||
assert drivers == []
|
||||
|
||||
|
||||
def test_invalid_delta_rejected(base: CalculatorInputs) -> None:
|
||||
with pytest.raises(ValueError, match="delta"):
|
||||
tornado(base, ["users"], delta=1.5)
|
||||
61
calculators/Genesys_Token_Calculator/tests/test_staging.py
Normal file
61
calculators/Genesys_Token_Calculator/tests/test_staging.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Stage/backstage detection.
|
||||
|
||||
Two signals mark the Mercury app view (see staging.py): the app's shadow
|
||||
session name (``__mercury__`` in ``JPY_SESSION_NAME`` — per-view, primary)
|
||||
and the ``MERCURY_CONFIG_DIR`` env var (server-level fallback, only set by
|
||||
``mercury --working-dir``). Either one means "a client may be looking".
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from genesyscalc import staging
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_stage_env(monkeypatch):
|
||||
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
|
||||
monkeypatch.delenv("JPY_SESSION_NAME", raising=False)
|
||||
|
||||
|
||||
def test_backstage_by_default(capsys):
|
||||
assert not staging.on_stage()
|
||||
staging.backstage("visible")
|
||||
assert capsys.readouterr().out == "visible\n"
|
||||
|
||||
|
||||
def test_plain_session_name_is_backstage(monkeypatch, capsys):
|
||||
# A JupyterLab or nbconvert session: the real notebook path, no marker.
|
||||
monkeypatch.setenv("JPY_SESSION_NAME", "notebooks/genesys_token_calculator.ipynb")
|
||||
assert not staging.on_stage()
|
||||
staging.backstage("visible")
|
||||
assert capsys.readouterr().out == "visible\n"
|
||||
|
||||
|
||||
def test_mercury_shadow_session_is_stage(monkeypatch, capsys):
|
||||
# The Mercury app runs against a shadow copy: <stem>__mercury__<id>.ipynb
|
||||
monkeypatch.setenv(
|
||||
"JPY_SESSION_NAME", "notebooks/genesys_token_calculator__mercury__545e5520.ipynb"
|
||||
)
|
||||
assert staging.on_stage()
|
||||
staging.backstage("hidden")
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_config_dir_fallback_is_stage(monkeypatch, capsys):
|
||||
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
|
||||
assert staging.on_stage()
|
||||
staging.backstage("hidden")
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_backstage_md_renders_only_off_stage(monkeypatch, capsys):
|
||||
# Off stage it must emit SOMETHING (rich markdown under a kernel;
|
||||
# IPython's display degrades to print under plain pytest) …
|
||||
staging.backstage_md("**visible**")
|
||||
assert capsys.readouterr().out != ""
|
||||
|
||||
# … and on stage — either signal — nothing at all.
|
||||
monkeypatch.setenv(
|
||||
"JPY_SESSION_NAME", "notebooks/genesys_token_calculator__mercury__ab12cd34.ipynb"
|
||||
)
|
||||
staging.backstage_md("**hidden**")
|
||||
assert capsys.readouterr().out == ""
|
||||
114
calculators/Genesys_Token_Calculator/tests/test_tts.py
Normal file
114
calculators/Genesys_Token_Calculator/tests/test_tts.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Enhanced TTS — the non-token line, its round-up, and its free share."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from genesyscalc.meters import Confidence
|
||||
from genesyscalc.tts import (
|
||||
TTS_PRICE_PER_MILLION_CHARS,
|
||||
TTS_SOURCE,
|
||||
TTS_SOURCE_DATE,
|
||||
TTS_STANDARD_END_OF_SALE,
|
||||
TTS_STANDARD_END_OF_SUPPORT,
|
||||
TtsUsage,
|
||||
tts_cost,
|
||||
tts_free_share,
|
||||
)
|
||||
from genesyscalc.usage import DeflectionMix
|
||||
|
||||
|
||||
def test_published_rates() -> None:
|
||||
assert TTS_PRICE_PER_MILLION_CHARS == {"advanced": 20.0, "standard": 5.0}
|
||||
|
||||
|
||||
def test_source_pinned_and_distinct_from_the_tokens_article() -> None:
|
||||
"""TTS has its own page and its own date — it is not a token meter."""
|
||||
from genesyscalc.ratecard import RATE_CARD_SOURCE, RATE_CARD_SOURCE_DATE
|
||||
|
||||
assert TTS_SOURCE_DATE == "2026-05-22"
|
||||
assert TTS_SOURCE.endswith("genesys-enhanced-tts-pricing/")
|
||||
assert TTS_SOURCE != RATE_CARD_SOURCE
|
||||
assert TTS_SOURCE_DATE != RATE_CARD_SOURCE_DATE
|
||||
|
||||
|
||||
def test_tts_is_published_so_confirmed() -> None:
|
||||
line = tts_cost(TtsUsage(calls_monthly=100_000))
|
||||
assert line.confidence is Confidence.CONFIRMED
|
||||
|
||||
|
||||
def test_hand_checked_advanced_cost() -> None:
|
||||
"""100,000 calls × 2,000 chars = 200M chars → 200 units × $20 = $4,000/mo."""
|
||||
line = tts_cost(TtsUsage(calls_monthly=100_000, chars_per_call=2_000))
|
||||
assert line.total_chars_monthly == pytest.approx(200_000_000.0)
|
||||
assert line.billed_millions_monthly == 200
|
||||
assert line.cost_monthly == pytest.approx(4_000.0)
|
||||
assert line.cost_annual == pytest.approx(48_000.0)
|
||||
|
||||
|
||||
def test_hand_checked_standard_cost() -> None:
|
||||
"""Same volume on standard voices: 200 × $5 = $1,000/mo."""
|
||||
line = tts_cost(
|
||||
TtsUsage(calls_monthly=100_000, chars_per_call=2_000, tier="standard")
|
||||
)
|
||||
assert line.cost_monthly == pytest.approx(1_000.0)
|
||||
|
||||
|
||||
def test_per_million_roundup_published_examples() -> None:
|
||||
"""The article's own examples: 200,000 chars = $5; 1,000,005 chars = $10.
|
||||
|
||||
Both on standard voices, where the unit rate is $5.
|
||||
"""
|
||||
small = tts_cost(TtsUsage(calls_monthly=1, chars_per_call=200_000, tier="standard"))
|
||||
assert small.billed_millions_monthly == 1
|
||||
assert small.cost_monthly == pytest.approx(5.0)
|
||||
|
||||
just_over = tts_cost(
|
||||
TtsUsage(calls_monthly=1, chars_per_call=1_000_005, tier="standard")
|
||||
)
|
||||
assert just_over.billed_millions_monthly == 2
|
||||
assert just_over.cost_monthly == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_free_share_tracks_the_virtual_agent_tiers() -> None:
|
||||
"""Free during VA and agentic VA — bot-only deflection does not qualify."""
|
||||
mix = DeflectionMix(
|
||||
bot_only_share=0.30, virtual_agent_share=0.15, agentic_va_share=0.05
|
||||
)
|
||||
assert tts_free_share(mix) == pytest.approx(0.20)
|
||||
|
||||
|
||||
def test_free_share_reduces_the_bill() -> None:
|
||||
"""200M chars, 20% free → 160M billable → 160 × $20 = $3,200/mo."""
|
||||
mix = DeflectionMix(virtual_agent_share=0.15, agentic_va_share=0.05)
|
||||
line = tts_cost(TtsUsage(calls_monthly=100_000, chars_per_call=2_000), mix)
|
||||
assert line.free_share == pytest.approx(0.20)
|
||||
assert line.free_chars_monthly == pytest.approx(40_000_000.0)
|
||||
assert line.billable_chars_monthly == pytest.approx(160_000_000.0)
|
||||
assert line.cost_monthly == pytest.approx(3_200.0)
|
||||
|
||||
|
||||
def test_standard_tier_warns_about_end_of_life() -> None:
|
||||
line = tts_cost(TtsUsage(calls_monthly=1_000, tier="standard"))
|
||||
assert len(line.warnings) == 1
|
||||
assert TTS_STANDARD_END_OF_SALE in line.warnings[0]
|
||||
assert TTS_STANDARD_END_OF_SUPPORT in line.warnings[0]
|
||||
|
||||
|
||||
def test_advanced_tier_does_not_warn() -> None:
|
||||
assert tts_cost(TtsUsage(calls_monthly=1_000)).warnings == ()
|
||||
|
||||
|
||||
def test_concession_applies_to_tts_too() -> None:
|
||||
line = tts_cost(TtsUsage(calls_monthly=100_000), concession_pct=0.25)
|
||||
assert line.cost_monthly == pytest.approx(3_000.0)
|
||||
|
||||
|
||||
def test_zero_volume_costs_nothing() -> None:
|
||||
line = tts_cost(TtsUsage(calls_monthly=0))
|
||||
assert line.billed_millions_monthly == 0
|
||||
assert line.cost_monthly == 0.0
|
||||
|
||||
|
||||
def test_unknown_tier_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="unknown TTS tier"):
|
||||
TtsUsage(calls_monthly=1, tier="neural-ultra")
|
||||
206
calculators/Genesys_Token_Calculator/tests/test_usage.py
Normal file
206
calculators/Genesys_Token_Calculator/tests/test_usage.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Volumes → tokens, with the three published rules that trip the naive math.
|
||||
|
||||
Every number below was computed by hand first, then pinned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from genesyscalc.meters import LicenceModel, Tier
|
||||
from genesyscalc.usage import (
|
||||
DeflectionMix,
|
||||
VoiceBotUsage,
|
||||
Volumes,
|
||||
apply_copilot_covers_summary,
|
||||
subscription_tokens,
|
||||
tier_interactions,
|
||||
virtual_agent_tokens,
|
||||
voice_bot_billable_minutes,
|
||||
voice_bot_roundup_uplift,
|
||||
voice_bot_tokens,
|
||||
)
|
||||
|
||||
# ── 1 · the 15-second per-call round-up ──────────────────────────────────
|
||||
|
||||
|
||||
def test_voice_bot_15_second_roundup() -> None:
|
||||
"""1,000 calls at a 40s average.
|
||||
|
||||
ceil(40/15) = 3 increments = 45s = 0.75 min/call → 750.0 min/month.
|
||||
The naive 40/60 × 1,000 = 666.67 min understates by 12.5%.
|
||||
"""
|
||||
usage = VoiceBotUsage(calls_per_month=1_000, avg_bot_seconds_per_call=40)
|
||||
assert voice_bot_billable_minutes(usage) == pytest.approx(750.0)
|
||||
assert voice_bot_tokens(usage) == pytest.approx(750.0 / 17.0)
|
||||
assert voice_bot_tokens(usage) == pytest.approx(44.117647, rel=1e-6)
|
||||
|
||||
|
||||
def test_roundup_uplift_is_reportable() -> None:
|
||||
"""The stage-facing number: 12.5% more than the raw duration implies."""
|
||||
usage = VoiceBotUsage(calls_per_month=1_000, avg_bot_seconds_per_call=40)
|
||||
assert voice_bot_roundup_uplift(usage) == pytest.approx(0.125)
|
||||
|
||||
|
||||
def test_roundup_is_exact_on_increment_boundaries() -> None:
|
||||
"""45s is exactly three increments — no inflation, uplift 0."""
|
||||
usage = VoiceBotUsage(calls_per_month=1_000, avg_bot_seconds_per_call=45)
|
||||
assert voice_bot_billable_minutes(usage) == pytest.approx(750.0)
|
||||
assert voice_bot_roundup_uplift(usage) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_roundup_penalises_short_calls_hardest() -> None:
|
||||
"""A 5-second bot greeting bills as 15 seconds — 3× its duration.
|
||||
|
||||
1,000 calls × 5s = 83.33 raw minutes, billed as 250.0.
|
||||
"""
|
||||
usage = VoiceBotUsage(calls_per_month=1_000, avg_bot_seconds_per_call=5)
|
||||
assert voice_bot_billable_minutes(usage) == pytest.approx(250.0)
|
||||
assert voice_bot_roundup_uplift(usage) == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_zero_bot_volume_is_free_and_does_not_divide_by_zero() -> None:
|
||||
usage = VoiceBotUsage(calls_per_month=0, avg_bot_seconds_per_call=0)
|
||||
assert voice_bot_billable_minutes(usage) == 0.0
|
||||
assert voice_bot_roundup_uplift(usage) == 0.0
|
||||
assert voice_bot_tokens(usage) == 0.0
|
||||
|
||||
|
||||
def test_negative_bot_usage_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="calls_per_month"):
|
||||
VoiceBotUsage(calls_per_month=-1, avg_bot_seconds_per_call=30)
|
||||
|
||||
|
||||
# ── 2 · the highest-tier rule as a partition ─────────────────────────────
|
||||
|
||||
|
||||
def test_tier_shares_must_partition() -> None:
|
||||
"""Shares summing past 1.0 would bill some interaction twice."""
|
||||
with pytest.raises(ValueError, match="sum to at most 1.0"):
|
||||
DeflectionMix(
|
||||
bot_only_share=0.5, virtual_agent_share=0.4, agentic_va_share=0.3
|
||||
)
|
||||
|
||||
|
||||
def test_tier_share_bounds() -> None:
|
||||
with pytest.raises(ValueError, match="must be in"):
|
||||
DeflectionMix(virtual_agent_share=1.4)
|
||||
|
||||
|
||||
def test_agent_handled_share_is_the_remainder() -> None:
|
||||
mix = DeflectionMix(
|
||||
bot_only_share=0.30, virtual_agent_share=0.15, agentic_va_share=0.05
|
||||
)
|
||||
assert mix.total_deflected_share == pytest.approx(0.50)
|
||||
assert mix.agent_handled_share == pytest.approx(0.50)
|
||||
|
||||
|
||||
def test_highest_tier_charges_each_interaction_once() -> None:
|
||||
"""100,000 interactions: 30% bot, 15% VA, 5% agentic VA.
|
||||
|
||||
VA = 100,000 × 0.15 × 0.5 tokens = 7,500
|
||||
AVA = 100,000 × 0.05 × 1.2 tokens = 6,000
|
||||
------
|
||||
13,500
|
||||
|
||||
Applying every tier to the whole volume — the additive reading — would
|
||||
give 50,000 + 120,000 = far more, billing the same interaction at
|
||||
several tiers at once.
|
||||
"""
|
||||
mix = DeflectionMix(
|
||||
bot_only_share=0.30, virtual_agent_share=0.15, agentic_va_share=0.05
|
||||
)
|
||||
tokens = virtual_agent_tokens(100_000, mix)
|
||||
assert tokens["virtual_agent"] == pytest.approx(7_500.0)
|
||||
assert tokens["agentic_virtual_agent"] == pytest.approx(6_000.0)
|
||||
assert sum(tokens.values()) == pytest.approx(13_500.0)
|
||||
|
||||
|
||||
def test_tier_interactions_never_exceed_the_volume() -> None:
|
||||
mix = DeflectionMix(
|
||||
bot_only_share=0.40, virtual_agent_share=0.35, agentic_va_share=0.25
|
||||
)
|
||||
split = tier_interactions(100_000, mix)
|
||||
assert sum(split.values()) == pytest.approx(100_000.0)
|
||||
assert split[Tier.BOT] == pytest.approx(40_000.0)
|
||||
|
||||
|
||||
def test_bot_tier_is_not_priced_per_interaction() -> None:
|
||||
"""Voice bots bill in minutes with a round-up, so they are absent here."""
|
||||
mix = DeflectionMix(bot_only_share=1.0)
|
||||
assert virtual_agent_tokens(100_000, mix) == {
|
||||
"virtual_agent": 0.0,
|
||||
"agentic_virtual_agent": 0.0,
|
||||
}
|
||||
|
||||
|
||||
# ── 3 · Copilot covers Supervisor summaries ──────────────────────────────
|
||||
|
||||
|
||||
def test_summary_billed_when_copilot_off() -> None:
|
||||
usage = apply_copilot_covers_summary(
|
||||
{"ai_summary_and_insights": 120_000.0}, copilot_enabled=False
|
||||
)
|
||||
assert usage.get("ai_summary_and_insights") == pytest.approx(120_000.0)
|
||||
assert usage.notes == ()
|
||||
|
||||
|
||||
def test_copilot_covers_summary_zeroes_the_line() -> None:
|
||||
"""The published exclusion, enforced — and reported, not silent."""
|
||||
usage = apply_copilot_covers_summary(
|
||||
{"ai_summary_and_insights": 120_000.0}, copilot_enabled=True
|
||||
)
|
||||
assert usage.get("ai_summary_and_insights") == 0.0
|
||||
assert len(usage.notes) == 1
|
||||
assert "Agent Copilot is enabled" in usage.notes[0]
|
||||
|
||||
|
||||
def test_copilot_exclusion_leaves_other_meters_alone() -> None:
|
||||
usage = apply_copilot_covers_summary(
|
||||
{"ai_summary_and_insights": 100.0, "ai_scoring": 500.0}, copilot_enabled=True
|
||||
)
|
||||
assert usage.get("ai_scoring") == pytest.approx(500.0)
|
||||
|
||||
|
||||
def test_copilot_exclusion_is_quiet_when_there_is_nothing_to_zero() -> None:
|
||||
usage = apply_copilot_covers_summary(
|
||||
{"ai_summary_and_insights": 0.0}, copilot_enabled=True
|
||||
)
|
||||
assert usage.notes == ()
|
||||
|
||||
|
||||
# ── subscription tokens ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_per_user_tokens_are_exact_and_licence_sensitive() -> None:
|
||||
"""500 users × 40 = 20,000 named; × 60 = 30,000 concurrent."""
|
||||
keys = frozenset({"agent_copilot_named"})
|
||||
assert subscription_tokens(500, keys, LicenceModel.NAMED) == {
|
||||
"agent_copilot_named": 20_000.0
|
||||
}
|
||||
assert subscription_tokens(500, keys, LicenceModel.CONCURRENT) == {
|
||||
"agent_copilot_named": 30_000.0
|
||||
}
|
||||
|
||||
|
||||
def test_subscription_rejects_a_consumption_meter() -> None:
|
||||
with pytest.raises(ValueError, match="not a per-user meter"):
|
||||
subscription_tokens(10, frozenset({"ai_scoring"}), LicenceModel.NAMED)
|
||||
|
||||
|
||||
# ── volumes ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_volume_totals() -> None:
|
||||
v = Volumes(
|
||||
voice_inbound_monthly=100_000,
|
||||
voice_outbound_monthly=20_000,
|
||||
digital_sessions_monthly=8_000,
|
||||
email_monthly=12_000,
|
||||
)
|
||||
assert v.voice_total_monthly == pytest.approx(120_000.0)
|
||||
assert v.all_interactions_monthly == pytest.approx(140_000.0)
|
||||
|
||||
|
||||
def test_negative_volume_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="voice_inbound_monthly"):
|
||||
Volumes(voice_inbound_monthly=-1)
|
||||
264
docs/Calculator_Pattern_V1-00.md
Normal file
264
docs/Calculator_Pattern_V1-00.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Calculator Pattern v1.0.0
|
||||
|
||||
Standardizes **Calculators** — Palladium masters that price a vendor's product
|
||||
from that vendor's own **published rate card**: a verbatim rate-card anchor,
|
||||
a client overlay in tagged cells, and a re-anchoring protocol for when the
|
||||
vendor republishes.
|
||||
|
||||
## 🐾 Red Panda Approval™
|
||||
|
||||
This pattern follows Red Panda Approval standards (see `CLAUDE.md` for the
|
||||
rubric).
|
||||
|
||||
**Audience note:** written to be loaded whole as context by an LLM agent
|
||||
building or modifying a Calculator. Rules are imperative (MUST/SHOULD/NEVER),
|
||||
each with a one-line *why*. It **specializes**
|
||||
[`Mercury_Notebook_Pattern_V1-00.md`](Mercury_Notebook_Pattern_V1-00.md) —
|
||||
read that first; the shared mechanics (reactivity, widget-pairs, gate,
|
||||
staging, appendix, packaging) are **not** restated here. `CLAUDE.md` is the
|
||||
always-on contract and takes precedence where documents disagree.
|
||||
|
||||
Reference implementation:
|
||||
[`calculators/Genesys_Token_Calculator/`](../calculators/Genesys_Token_Calculator/).
|
||||
|
||||
---
|
||||
|
||||
## What a Calculator is — and why it's a third kind
|
||||
|
||||
| | Study | Assessment | **Calculator** |
|
||||
|---|---|---|---|
|
||||
| Anchored to | a **dated publication** | — (it *is* the instrument) | a **living rate card** |
|
||||
| Naming | `YYYYMM_…` dated | `Instrument_Name` undated | `Vendor_Subject_Calculator` undated |
|
||||
| Anchor lifetime | immutable **forever** | n/a | immutable **until the vendor republishes** |
|
||||
| Its "numbers" | NPV / ROI / payback | status & progress | **run-rate, $/unit, per-feature cost** |
|
||||
| Lives in | `studies/` | `assessments/` | `calculators/` |
|
||||
|
||||
The distinguishing property is the **anchor's lifetime**. A Study's anchor is
|
||||
frozen forever — the December 2025 Forrester PDF will never change, so a dated
|
||||
directory name is honest. A Calculator's anchor is frozen *until the vendor
|
||||
republishes*, at which point updating it is a deliberate, test-breaking,
|
||||
dated act. A `YYYYMM_` prefix would start lying the moment the vendor updated
|
||||
their page, so **Calculators are undated**; the publication date lives inside
|
||||
the anchor (`RATE_CARD_SOURCE_DATE`), pinned by a test and shown on stage.
|
||||
|
||||
> **Note.** Mercury Pattern *Variant 3 — Exploratory calculator* describes the
|
||||
> pre-master form of this: a what-if surface inside a study. This pattern
|
||||
> promotes it to a first-class master type. A Variant-3 notebook that outlives
|
||||
> its study should graduate into `calculators/`.
|
||||
|
||||
---
|
||||
|
||||
## Required Structure & Contracts
|
||||
|
||||
Everything the Mercury pattern requires, **plus** the four below.
|
||||
|
||||
### 1 · The rate card is a verbatim anchor, and it lives in `.py`
|
||||
|
||||
`CLAUDE.md` says content never lives in `.py`. A published vendor rate card is
|
||||
**not content**, and the boundary is who authors and edits the thing:
|
||||
|
||||
> **In `.py` (the engine):** the vendor's published rate card — verbatim, with
|
||||
> a source URL and a source date. A third-party record nobody in this repo
|
||||
> authors. **In tagged notebook cells:** everything the consultant authors or
|
||||
> the client supplies — volumes, headcount, licence model, the **negotiated**
|
||||
> rate, feature enablement, adoption assumptions, scenario definitions, and
|
||||
> the narrative.
|
||||
|
||||
*Why:* the content rule exists because *"the notebook is the document the
|
||||
consultant edits"*. Nobody edits the vendor's rate card — editing it is
|
||||
**forbidden**, and changing it requires a republication plus the re-anchoring
|
||||
protocol below. Putting an immutable anchor on the surface designated as
|
||||
editable invites exactly the casual edit the anchor rule prevents, and makes
|
||||
the most-likely-wrong numbers checkable only via a notebook parse. Precedent:
|
||||
`studies/202602_TEI_Amazon_Connect/teicalc/anchor.py` holds Forrester's
|
||||
published tables the same way.
|
||||
|
||||
**Corollary that has teeth:** the *contracted overlay* is **client data** and
|
||||
belongs in the `engagement-data` cell, never in `.py`. A Calculator's engine
|
||||
MUST ship **no client volumes at all** — no `defaults.py` full of real sites.
|
||||
(`studies/202607_CTM_GenesysCX/tokencalc/defaults.py` does exactly that; it is
|
||||
the anti-precedent, not the model.)
|
||||
|
||||
The anchor module MUST carry:
|
||||
|
||||
```python
|
||||
RATE_CARD_SOURCE: str # the URL
|
||||
RATE_CARD_SOURCE_DATE: str # the publication's own "last updated" date
|
||||
METERS_VERBATIM: tuple[Meter, ...] # feature wording AND rate string, exact
|
||||
```
|
||||
|
||||
Each meter keeps the vendor's **exact wording** (`feature`) and **exact rate
|
||||
string** (`published_rate`) beside the parsed number. *Why:* the string is
|
||||
what makes a transcription slip catchable — a test pins the strings, and a
|
||||
second test proves the float still agrees with the string beside it.
|
||||
|
||||
### 2 · The `topic-bank` cell holds the catalogue, not the rates
|
||||
|
||||
Since the rates are in the engine, the required `topic-bank` cell holds what a
|
||||
consultant genuinely authors: the **feature catalogue** — per capability, a
|
||||
client-facing title, a one-line description, and the **sizing question to
|
||||
ask**. Each `key` MUST match a published meter key, pinned by a test. *Why:*
|
||||
this keeps the tag taxonomy honest rather than tag-stuffed, and the cross-layer
|
||||
pin catches a rename that would otherwise silently unprice a feature.
|
||||
|
||||
### 3 · Costs the vendor bills outside the primary meter are a separate line
|
||||
|
||||
A vendor usually bills something adjacent on a different basis (here: Enhanced
|
||||
TTS, per character rather than per token). Such a line MUST have its **own
|
||||
source URL and date**, its own confidence flag, and its own subtotal — and the
|
||||
grand total MUST show the components labelled, never one blended figure.
|
||||
*Why:* the headline is understated without it and wrong with it silently
|
||||
folded in; a reader must be able to see which meter produced which dollar.
|
||||
|
||||
### 4 · Every published rule that changes the arithmetic is modelled and reported
|
||||
|
||||
Vendors bury billing rules in prose. Each one that moves the number MUST be
|
||||
enforced in the engine, pinned with hand-checked arithmetic, and **surfaced in
|
||||
the result** (a `warnings` tuple), not applied silently. *Why:* a total that
|
||||
shrank for a published reason nobody can see is indistinguishable from a bug.
|
||||
In the reference implementation these are the per-call round-up, the free
|
||||
monthly allowance, the highest-tier rule, and the Copilot exclusion.
|
||||
|
||||
---
|
||||
|
||||
## The Re-anchoring Protocol
|
||||
|
||||
The section unique to this kind. A Calculator tracks a *living* rate card, so
|
||||
updating the anchor is expected — but never casual. Re-anchoring is
|
||||
**Show-first** (`CLAUDE.md` § Risk tier), unlike editing a Study anchor, which
|
||||
is forbidden outright.
|
||||
|
||||
When the vendor republishes:
|
||||
|
||||
1. **Diff** the published table against the anchor module.
|
||||
2. **Update the meters AND bump `RATE_CARD_SOURCE_DATE` in the same edit.**
|
||||
3. **Update the test pins in the same commit.**
|
||||
4. **Re-execute** the notebook; re-pin the gate's live-state numbers if the
|
||||
defaults moved.
|
||||
5. **Report the cost moves honestly** — say what got more expensive.
|
||||
6. **Show the user the rate diff** and the KPI move before it lands.
|
||||
|
||||
> Never partially re-anchor. Never bump the date without the pins. A date that
|
||||
> claims a rate card the numbers don't match is worse than a stale one, because
|
||||
> it is stale *and* it lies about it.
|
||||
|
||||
---
|
||||
|
||||
## Standard Choice Values
|
||||
|
||||
Beyond the Mercury pattern's confidence legend (🟢 confirmed / 🟡 estimated /
|
||||
🔴 unknown), Calculators standardize:
|
||||
|
||||
```python
|
||||
class Confidence(Enum): # 🟢 requires source_url AND source_date — enforced
|
||||
CONFIRMED = "confirmed"
|
||||
ESTIMATED = "estimated"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
class LicenceModel(Enum): # vendors price per-seat features by licence model
|
||||
NAMED = "named"
|
||||
CONCURRENT = "concurrent"
|
||||
```
|
||||
|
||||
A 🟢 meter that carries no source URL and date MUST fail to construct. *Why:*
|
||||
it is the one invariant that stops an unsourced number wearing a green tick on
|
||||
a client-facing stage.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Practices
|
||||
|
||||
- **The list → negotiated walk.** Carry `list_cost` beside the effective cost
|
||||
and show both. The client sees MSRP → their deal explicitly (the Mercury
|
||||
pattern's deck-frame column, applied to pricing).
|
||||
- **Published zeroes are findings.** A feature the vendor publishes as free
|
||||
renders as an explicit `$0` line with the quoted wording — not an omission.
|
||||
- **Adoption as a range.** Adoption is the largest uncertainty in any
|
||||
consumption estimate; ship conservative / base / aggressive rather than a
|
||||
single point.
|
||||
- **A `$/unit` figure.** Cost per interaction is the number a client remembers
|
||||
and the one that survives a change of volume assumptions.
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- ❌ **Don't build a business case in a Calculator.** NPV, payback, ROI, a
|
||||
benefit model, a P&L — any of these means the work has become a **Study**.
|
||||
Move it, don't grow it here.
|
||||
- ❌ **Don't edit `RATE_CARD_VERBATIM` to reflect a negotiated rate.** That is
|
||||
the overlay, and it belongs in `engagement-data`. The anchor is what the
|
||||
vendor published, not what the client pays.
|
||||
- ❌ **Don't bump the source date without updating the pins** (see the
|
||||
protocol).
|
||||
- ❌ **Don't ship a rate without a source URL and a confidence flag** — the
|
||||
schema should make this impossible.
|
||||
- ❌ **Don't put engagement volumes in the engine.** Masters stay client-clean;
|
||||
an engine with real client sites in it cannot be shared.
|
||||
- ❌ **Don't fold a differently-metered cost into the headline meter's
|
||||
subtotal** — separate line, separate source, labelled components.
|
||||
- ❌ **Don't model a per-unit round-up by averaging first.** Round per billing
|
||||
event, then sum; averaging first understates short events, sometimes by
|
||||
multiples.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
The Mercury pattern's layers 0–4, plus **layer 1a — the rate-card tripwire**,
|
||||
which is the highest-value test in a Calculator:
|
||||
|
||||
```python
|
||||
def test_published_meter_table_verbatim():
|
||||
"""Exact feature wording AND exact rate string, every row.
|
||||
|
||||
A transcription slip or an un-noticed republication breaks the build
|
||||
rather than quietly moving a client-facing number.
|
||||
"""
|
||||
assert {m.feature: m.published_rate for m in METERS_VERBATIM} == PUBLISHED
|
||||
|
||||
def test_source_pinned():
|
||||
assert RATE_CARD_SOURCE_DATE == "2026-07-12"
|
||||
|
||||
def test_numeric_rates_match_their_published_strings():
|
||||
"""Catches a float drifting away from the wording beside it."""
|
||||
|
||||
def test_every_confirmed_meter_carries_source_url_and_date(): ...
|
||||
```
|
||||
|
||||
Then **hand-check the arithmetic before pinning it** — compute each published
|
||||
rule's effect by hand, write the working into the test's docstring, and pin
|
||||
the result:
|
||||
|
||||
```python
|
||||
def test_voice_bot_15_second_roundup():
|
||||
"""1,000 calls at a 40s average.
|
||||
|
||||
ceil(40/15) = 3 increments = 45s = 0.75 min/call → 750.0 min/month.
|
||||
The naive 40/60 × 1,000 = 666.67 understates by 12.5%.
|
||||
"""
|
||||
assert voice_bot_billable_minutes(VoiceBotUsage(1_000, 40)) == pytest.approx(750.0)
|
||||
```
|
||||
|
||||
The in-notebook **gate** additionally asserts *structural ties that hold at any
|
||||
widget setting* — the allowance identity, effective ≤ list, components summing
|
||||
to the grand total, and each published exclusion — so a stakeholder moving a
|
||||
slider cannot produce an incoherent number.
|
||||
|
||||
---
|
||||
|
||||
## Adding a Calculator
|
||||
|
||||
1. Copy `calculators/Genesys_Token_Calculator/` and rename it
|
||||
`Vendor_Subject_Calculator` (underscores — directories are Python packages).
|
||||
2. Rename the engine package; keep `staging.py` byte-for-byte.
|
||||
3. Replace the anchor module with the new vendor's published table —
|
||||
**verbatim**, with its own source URL and date — and rewrite the rate-card
|
||||
tripwire against it.
|
||||
4. Rewrite the `topic-bank` catalogue and reset `engagement-data` to
|
||||
placeholders.
|
||||
5. Register the notebook in [`tests/nbcheck.py`](../tests/nbcheck.py)
|
||||
(`NOTEBOOK_FIRST`) — the completeness test fails until you do.
|
||||
6. Run the full verification: `pytest`, `mypy`, headless `nbconvert --execute`,
|
||||
`python scripts/export_report.py`, a stage simulation, and an actual
|
||||
`mercury --working-dir .` render.
|
||||
@@ -17,6 +17,14 @@ dependencies = [
|
||||
"plotly>=5.18",
|
||||
"numpy>=1.26",
|
||||
"nbformat>=5.9",
|
||||
"openpyxl>=3.1",
|
||||
"mercury>=3.2",
|
||||
"jupyterlab>=4.0",
|
||||
"ipywidgets>=8.0",
|
||||
"nbconvert>=7",
|
||||
"tabulate>=0.9",
|
||||
"pydantic>=2.5",
|
||||
"PyYAML>=6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -46,4 +54,5 @@ ignore = ["E501"] # line length handled by formatter
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"studies/*/notebooks/*.ipynb" = ["E402"]
|
||||
"assessments/*/notebooks/*.ipynb" = ["E402"]
|
||||
"calculators/*/notebooks/*.ipynb" = ["E402"]
|
||||
"tests/*" = ["F401"]
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
requests>=2.31
|
||||
python-dotenv>=1.0
|
||||
jupyter>=1.0
|
||||
pandas>=2.0
|
||||
plotly>=5.18
|
||||
numpy>=1.26
|
||||
nbformat>=5.9
|
||||
pytest>=7.4
|
||||
ruff>=0.1
|
||||
@@ -28,6 +28,7 @@ REPO = Path(__file__).resolve().parent.parent
|
||||
# full check set, including the tagged-cell taxonomy.
|
||||
NOTEBOOK_FIRST = {
|
||||
"assessments/CX_Discovery_Workshop/notebooks/cx_discovery.ipynb",
|
||||
"calculators/Genesys_Token_Calculator/notebooks/genesys_token_calculator.ipynb",
|
||||
}
|
||||
|
||||
# Structural tier only, each with its recorded reason (see CLAUDE.md,
|
||||
@@ -63,7 +64,7 @@ UNIQUE_TAGS = ("topic-bank", "engagement-data", "gate", "data-appendix")
|
||||
def discover() -> list[str]:
|
||||
"""Every notebook on disk under the master roots (repo-relative)."""
|
||||
found: list[str] = []
|
||||
for base in ("studies", "assessments", "template"):
|
||||
for base in ("studies", "assessments", "calculators", "template"):
|
||||
root = REPO / base
|
||||
if not root.is_dir():
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user