docs: add migration notebook and clarify export usage
This commit is contained in:
@@ -33,8 +33,9 @@ mercury --working-dir notebooks/
|
|||||||
# Or work on them directly in JupyterLab
|
# Or work on them directly in JupyterLab
|
||||||
jupyter lab notebooks/
|
jupyter lab notebooks/
|
||||||
|
|
||||||
# Export the corrected business case as LLM-readable report sources
|
# Export the business-case notebooks as LLM-readable report sources
|
||||||
# (exports/*.html for review, exports/*.md for feeding an LLM)
|
# (exports/*.html for review, exports/*.md for feeding an LLM;
|
||||||
|
# optional filter: python scripts/export_report.py migration)
|
||||||
python scripts/export_report.py
|
python scripts/export_report.py
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
@@ -46,17 +47,18 @@ pytest
|
|||||||
**The notebooks are the deliverables.** All math lives in the pure-Python
|
**The notebooks are the deliverables.** All math lives in the pure-Python
|
||||||
`tokencalc/` library; the notebooks are thin presentation layers over it.
|
`tokencalc/` library; the notebooks are thin presentation layers over it.
|
||||||
[Mercury](https://runmercury.com) serves them as interactive web apps — the
|
[Mercury](https://runmercury.com) serves them as interactive web apps — the
|
||||||
`mercury` input widgets in `ctm_business_case_corrected.ipynb` let you tune
|
`mercury` input widgets in the business-case notebooks let you tune
|
||||||
contract values, termination dates, token assumptions, and implementation
|
contract values, termination dates, token assumptions, and implementation
|
||||||
pricing live for a client, and headless runs (nbconvert, the section-10 regression
|
pricing live for a client, and headless runs (nbconvert, each notebook's
|
||||||
gate) simply use the widget defaults. `scripts/export_report.py` executes the
|
regression-gate section) simply use the widget defaults. `scripts/export_report.py`
|
||||||
notebook and writes HTML + markdown to `exports/`; the notebook's section-12
|
executes the notebooks and writes HTML + markdown to `exports/`; each notebook's
|
||||||
machine-readable appendix carries every number behind the figures so an LLM
|
machine-readable appendix section carries every number behind the figures so an
|
||||||
can draft the client report from the export.
|
LLM can draft the client report from the export.
|
||||||
|
|
||||||
| Notebook | Purpose |
|
| Notebook | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `notebooks/ctm_business_case_corrected.ipynb` | Client-facing corrected business case (Mercury-interactive) |
|
| `notebooks/ctm_business_case_corrected.ipynb` | Client-facing corrected business case (Mercury-interactive) |
|
||||||
|
| `notebooks/ctm_migration_wfm.ipynb` | Migration + WFM only, all AI removed — the no-AI floor of the case (Mercury-interactive) |
|
||||||
| `notebooks/ctm_token_calculator.ipynb` | Full token-cost / scenario workbench |
|
| `notebooks/ctm_token_calculator.ipynb` | Full token-cost / scenario workbench |
|
||||||
|
|
||||||
| Module | Purpose |
|
| Module | Purpose |
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ notebooks_button_label = "Analyses"
|
|||||||
header = "CTM × Genesys CCaaS"
|
header = "CTM × Genesys CCaaS"
|
||||||
message = """
|
message = """
|
||||||
Interactive business-case notebooks. **Corrected Business Case** keeps
|
Interactive business-case notebooks. **Corrected Business Case** keeps
|
||||||
Genesys's claimed benefits verbatim and adds the costs the pitch omitted —
|
Genesys's claimed benefits verbatim and adds the costs the pitch omitted;
|
||||||
tune the 🟡 inputs live for the client, then export the personalized report
|
**Migration + WFM** strips out every AI capability and prices the platform
|
||||||
source with `python scripts/export_report.py`.
|
move alone. Tune the 🟡 inputs live for the client, then export the
|
||||||
|
personalized report source with `python scripts/export_report.py`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
[theme]
|
[theme]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,15 +1,17 @@
|
|||||||
"""Export the corrected business case notebook as LLM-readable report sources.
|
"""Export the deliverable notebooks as LLM-readable report sources.
|
||||||
|
|
||||||
Executes the notebook fresh (widget defaults — or whatever defaults you edit in),
|
Executes each notebook fresh (widget defaults — or whatever defaults you edit in),
|
||||||
then writes both formats to exports/:
|
then writes both formats to exports/:
|
||||||
|
|
||||||
exports/ctm_business_case_corrected.html — human-reviewable, tables render
|
exports/<notebook>.html — human-reviewable, tables render
|
||||||
exports/ctm_business_case_corrected.md — leanest LLM input
|
exports/<notebook>.md — leanest LLM input
|
||||||
|
|
||||||
Plotly figures export as JavaScript an LLM cannot read; the notebook's section-12
|
Plotly figures export as JavaScript an LLM cannot read; each notebook's
|
||||||
machine-readable appendix carries every number behind them.
|
machine-readable appendix section carries every number behind them.
|
||||||
|
|
||||||
Run from the project root: python scripts/export_report.py
|
Run from the project root: python scripts/export_report.py [name-filter]
|
||||||
|
An optional argument exports only notebooks whose filename contains it,
|
||||||
|
e.g. python scripts/export_report.py migration
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -18,18 +20,26 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
NOTEBOOK = ROOT / "notebooks" / "ctm_business_case_corrected.ipynb"
|
NOTEBOOKS = [
|
||||||
|
ROOT / "notebooks" / "ctm_business_case_corrected.ipynb",
|
||||||
|
ROOT / "notebooks" / "ctm_migration_wfm.ipynb",
|
||||||
|
]
|
||||||
EXPORTS = ROOT / "exports"
|
EXPORTS = ROOT / "exports"
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
picked = [nb for nb in NOTEBOOKS
|
||||||
|
if len(sys.argv) < 2 or sys.argv[1] in nb.name]
|
||||||
|
if not picked:
|
||||||
|
sys.exit(f"no notebook matches {sys.argv[1]!r}")
|
||||||
EXPORTS.mkdir(exist_ok=True)
|
EXPORTS.mkdir(exist_ok=True)
|
||||||
for fmt in ("html", "markdown"):
|
for nb in picked:
|
||||||
subprocess.run(
|
for fmt in ("html", "markdown"):
|
||||||
[sys.executable, "-m", "nbconvert", "--execute",
|
subprocess.run(
|
||||||
"--to", fmt, "--output-dir", str(EXPORTS), str(NOTEBOOK)],
|
[sys.executable, "-m", "nbconvert", "--execute",
|
||||||
check=True, cwd=ROOT,
|
"--to", fmt, "--output-dir", str(EXPORTS), str(nb)],
|
||||||
)
|
check=True, cwd=ROOT,
|
||||||
|
)
|
||||||
for p in sorted(EXPORTS.iterdir()):
|
for p in sorted(EXPORTS.iterdir()):
|
||||||
if p.suffix in (".html", ".md"):
|
if p.suffix in (".html", ".md"):
|
||||||
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)")
|
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)")
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Migration + WFM (no-AI) scenario — hand-check acceptance numbers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tokencalc import appendix4 as a4
|
||||||
|
from tokencalc import migration_wfm as mw
|
||||||
|
from tokencalc.defaults import CTM_DEFAULT_SITES
|
||||||
|
|
||||||
|
SITES = list(CTM_DEFAULT_SITES)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_benefit_rollout():
|
||||||
|
_, _, benefit_rollout = a4.build_rollouts(SITES)
|
||||||
|
return benefit_rollout
|
||||||
|
|
||||||
|
|
||||||
|
def test_wfm_scope_and_verbatim_total():
|
||||||
|
ben = mw.wfm_benefits_by_year(_default_benefit_rollout())
|
||||||
|
assert set(ben["capability"]) == {"WFM"}
|
||||||
|
assert set(ben["region"]) == set(mw.DEFAULT_WFM_REGIONS)
|
||||||
|
assert "EMEA" not in set(ben["region"]), "EMEA WFM is out of scope"
|
||||||
|
# NA $0 (migration) + ANZ $1.4M + ASIA $914K — verbatim, exact.
|
||||||
|
assert ben["benefit"].sum() == pytest.approx(2_314_000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wfm_phasing_on_deck_schedule():
|
||||||
|
ben = mw.wfm_benefits_by_year(_default_benefit_rollout())
|
||||||
|
by_year = ben.groupby("year")["benefit"].sum()
|
||||||
|
assert by_year[2026] == 0.0
|
||||||
|
# ANZ realizes Dec 2027 (1 of 13 live months lands in 2027).
|
||||||
|
assert by_year[2027] == pytest.approx(1_400_000 / 13)
|
||||||
|
assert by_year[2028] == pytest.approx(2_314_000 - 1_400_000 / 13)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runrate_saving_annual():
|
||||||
|
# (7.3M − 4.3M) licence + 1.3M ANZ + 1.6M ASIA + 0 NA = 5.9M.
|
||||||
|
assert mw.wfm_annual_runrate() == pytest.approx(2_900_000)
|
||||||
|
assert mw.runrate_saving_annual() == pytest.approx(5_900_000)
|
||||||
|
assert mw.runrate_saving_annual(regions=["NA"]) == pytest.approx(3_000_000)
|
||||||
|
assert mw.runrate_saving_annual(licence_annual=4_800_000,
|
||||||
|
regions=[]) == pytest.approx(2_500_000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_breakeven_extrapolates_past_window():
|
||||||
|
cs = a4.current_state_inputs(SITES)
|
||||||
|
cur = a4.current_costs_by_year(cs)
|
||||||
|
lic = a4.licence_costs_by_year()
|
||||||
|
ps = a4.ps_costs_by_year()
|
||||||
|
total = {y: cur[y] + lic[y] + ps[y] for y in a4.YEARS}
|
||||||
|
ben = mw.wfm_benefits_by_year(_default_benefit_rollout())
|
||||||
|
ben_y = {y: float(ben.loc[ben.year == y, "benefit"].sum()) for y in a4.YEARS}
|
||||||
|
inc, net = a4.case_flows(total, ben_y)
|
||||||
|
assert sum(net.values()) == pytest.approx(-1_553_000, abs=1_000)
|
||||||
|
label = mw.runrate_breakeven_label(net, mw.runrate_saving_annual())
|
||||||
|
assert label == "40 months (~Apr 2029, extrapolated)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_breakeven_defers_in_window_and_guards_zero_runrate():
|
||||||
|
positive = {2026: 1_000_000.0, 2027: 0.0, 2028: 0.0}
|
||||||
|
assert mw.runrate_breakeven_label(positive, 5_900_000) == \
|
||||||
|
a4.payback_label(positive)
|
||||||
|
negative = {2026: -1_000_000.0, 2027: 0.0, 2028: 0.0}
|
||||||
|
assert mw.runrate_breakeven_label(negative, 0.0) == \
|
||||||
|
"never at current run-rate"
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
Migration + WFM scenario — the no-AI business case.
|
||||||
|
|
||||||
|
Current state → Genesys Cloud CX migration, NA users migrated onto
|
||||||
|
Genesys WFM, WFM implemented for APAC (ANZ + ASIA). Migration and WFM
|
||||||
|
are included in the base implementation price (the verbatim PS +
|
||||||
|
training), so the only cost lines are the existing-platform run-off,
|
||||||
|
the licence ramp, and base PS — no token consumption, no AI
|
||||||
|
implementation labour. The only benefits kept are the deck's verbatim
|
||||||
|
WFM lines for the regions in WFM scope.
|
||||||
|
|
||||||
|
Single source of truth behind ``notebooks/ctm_migration_wfm.ipynb``
|
||||||
|
(served with Mercury) — the presentation layer holds no math. All
|
||||||
|
primitives come from :mod:`tokencalc.appendix4`; this module only
|
||||||
|
scopes and extrapolates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from . import appendix4 as a4
|
||||||
|
|
||||||
|
#: WFM scope in this scenario. NA is a migration off its existing
|
||||||
|
#: WFM-like tool (verbatim benefit $0 — "has similar feature"); ANZ and
|
||||||
|
#: ASIA are new implementations ("APAC"); EMEA is out of scope, so the
|
||||||
|
#: deck's EMEA WFM benefit line is dropped.
|
||||||
|
DEFAULT_WFM_REGIONS = ["NA", "ANZ", "ASIA"]
|
||||||
|
|
||||||
|
|
||||||
|
def wfm_benefits_by_year(
|
||||||
|
benefit_rollout, regions: list[str] | None = None
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Verbatim WFM benefits for the scoped regions, phased on the
|
||||||
|
deck's deployment schedule (realize = impl + 3 months, inclusive).
|
||||||
|
|
||||||
|
Long DataFrame: region, capability, year, benefit — the WFM slice
|
||||||
|
of :func:`tokencalc.appendix4.benefits_by_year`.
|
||||||
|
"""
|
||||||
|
scope = DEFAULT_WFM_REGIONS if regions is None else regions
|
||||||
|
df = a4.benefits_by_year(benefit_rollout)
|
||||||
|
return df[(df["capability"] == "WFM")
|
||||||
|
& (df["region"].isin(scope))].reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
def wfm_annual_runrate(regions: list[str] | None = None) -> float:
|
||||||
|
"""Sum of the verbatim WFM *annual* values for the scoped regions."""
|
||||||
|
scope = DEFAULT_WFM_REGIONS if regions is None else regions
|
||||||
|
return float(sum(annual for (r, c), (annual, _t) in
|
||||||
|
a4.VERBATIM_BENEFITS.items()
|
||||||
|
if c == "WFM" and r in scope))
|
||||||
|
|
||||||
|
|
||||||
|
def runrate_saving_annual(
|
||||||
|
licence_annual: float | None = None,
|
||||||
|
regions: list[str] | None = None,
|
||||||
|
baseline_annual: float | None = None,
|
||||||
|
) -> float:
|
||||||
|
"""Steady-state annual saving once term contracts end and the ramp
|
||||||
|
is over: (baseline − licence run-rate) + scoped WFM annual values.
|
||||||
|
"""
|
||||||
|
lic = a4.TCO_VERBATIM["ccaas_annual"] if licence_annual is None else licence_annual
|
||||||
|
base = a4.TCO_VERBATIM["current_annual"] if baseline_annual is None else baseline_annual
|
||||||
|
return (base - lic) + wfm_annual_runrate(regions)
|
||||||
|
|
||||||
|
|
||||||
|
def runrate_breakeven_label(
|
||||||
|
net_by_year: dict[int, float], runrate_annual: float
|
||||||
|
) -> str:
|
||||||
|
"""Payback label, extrapolated past the model window at a run-rate.
|
||||||
|
|
||||||
|
Inside 2026-28 this defers to :func:`tokencalc.appendix4.payback_label`;
|
||||||
|
a deficit at end-2028 fills at ``runrate_annual`` per year.
|
||||||
|
"""
|
||||||
|
deficit = -sum(net_by_year[y] for y in a4.YEARS)
|
||||||
|
if deficit <= 0:
|
||||||
|
return a4.payback_label(net_by_year)
|
||||||
|
if runrate_annual <= 0:
|
||||||
|
return "never at current run-rate"
|
||||||
|
m = 12 * len(a4.YEARS) + math.ceil(12 * deficit / runrate_annual)
|
||||||
|
return f"{m} months (~{a4.month_label(m)}, extrapolated)"
|
||||||
Reference in New Issue
Block a user