feat: add master notebook library scaffolding and review tooling

Add CLAUDE.md defining the Palladium master notebook conventions and
Red Panda Approval criteria, plus a review-notebook slash command for
LLM-driven notebook review.

Expand .gitignore to block client/engagement documents and generated
exports, keeping masters client-clean while allowing text/image sources.

Normalize slider widget numeric values from floats to integers in
notebook JSON.
This commit is contained in:
2026-07-31 16:16:07 +00:00
parent 53c069fddb
commit a967f73d09
61 changed files with 4881 additions and 4257 deletions

View File

@@ -0,0 +1,74 @@
# CX AI Advisory Diagnostic
Facilitator's cockpit for the CX AI Advisory diagnostic workshop
(Mercury Notebook Pattern — see
[`docs/Mercury_Notebook_Pattern_V1-00.md`](../../docs/Mercury_Notebook_Pattern_V1-00.md)).
Captures capability scores across **12 competencies** in four dimensions
(Strategy & Value, Foundations, Delivery, Sustain), ingests the client's
operational baseline, computes **value-at-stake bounded by capability
gaps**, and exports a structured engagement record.
Single facilitator, live half-day workshop, 48 client participants.
Not for client self-service, unattended use, or deployment.
Specification: [`docs/build_spec_v1.md`](docs/build_spec_v1.md).
## Run
```bash
python -m venv .venv && .venv/bin/pip install -e ".[dev]" # once
source .venv/bin/activate
mercury --working-dir . # the workshop stage (serve from study root)
jupyter lab # analyst view (backstage)
pytest # engine pins
python scripts/export_report.py # exports/diagnostic.{html,md}
```
The notebook is **generated** — edit cell sources in
`scripts/build_notebook.py`, then:
```bash
python scripts/build_notebook.py
jupyter nbconvert --to notebook --execute --inplace notebooks/diagnostic.ipynb
```
## Layout
```
diaglib/ # THE study package — all math and contracts
│ models.py # pydantic models: engagement data + config schema
│ config.py # configs/*.yaml loading, base+overlay merge
│ scoring.py # score aggregation, participants, assembly
│ value_math.py # drivers, capping, trapped value, unlock sequence
│ visuals.py # plotly builders (house chrome)
│ export.py # exports/{engagement_id}.json + .csv
│ staging.py # stage/backstage detection (pattern verbatim)
configs/
│ base.yaml # the instrument: 12 competencies, capping heuristic
│ contact_center.yaml # value drivers + unlock costs (🟡 ranges)
│ financial_services.yaml # stub — scoring only until drivers added
notebooks/diagnostic.ipynb # the deliverable (generated)
scripts/ # build_notebook.py · export_report.py
tests/ # hand-checked pins for every engine number
exports/ # per-engagement JSON/CSV + report sources (gitignored)
```
## Design decisions (deviations from the build spec, both additive)
- **Unlock moves are tier lifts of the binding set** (`competency_ids`
plural). When several foundations tie at the weakest level, lifting one
alone honestly unlocks nothing — the set is the move, costs summed,
weeks = longest parallel workstream.
- **"One screen per competency" is a `Now scoring` selector**, not a
Next button — Mercury's reactivity model makes stateless buttons
awkward, and the selector adds random access for revisits. All score
sliders stay live in the sidebar.
- `requirements.txt` is replaced by `pyproject.toml` per the pattern
(`pip install -e .` provisions everything).
## Before using with a real client
The value-driver ranges and unlock costs in `contact_center.yaml` are
🟡 placeholders (build spec §12): back them with cited public sources or
"based on N engagements" framing, and tune `cost_per_replacement_default`
per engagement.

View File

@@ -0,0 +1,73 @@
# 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 study root); restart the server to apply.
[main]
title = "CX AI Advisory Diagnostic"
favicon_emoji = "🧪"
footer = "CX AI Advisory Diagnostic"
notebooks_button_label = "Diagnostics"
[welcome]
header = "CX AI Advisory Diagnostic"
message = """
The facilitator's cockpit for the CX AI Advisory diagnostic workshop.
Capture the engagement, the operational baseline, and capability scores
across 12 competencies in the sidebar; the value-at-stake analysis and
visuals recompute live on the page. Click **Export JSON + CSV** at the
bottom of the page to write the structured engagement record to
`exports/`. Report sources: `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"
# ── 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)"

View File

@@ -0,0 +1,184 @@
# CX AI Advisory Diagnostic — universal config (industry-independent).
#
# This file is the diagnostic's *_VERBATIM anchor (Mercury Notebook Pattern):
# the competency model, level descriptors, and capping heuristic that every
# industry overlay extends. Editing wording here is editing the instrument —
# do it deliberately, and re-run tests + notebooks afterwards.
version: "1.0"
dimensions:
- id: strategy_value
name: "Strategy & Value"
- id: foundations
name: "Foundations"
- id: delivery
name: "Delivery"
- id: sustain
name: "Sustain"
competencies:
# ── Strategy & Value ─────────────────────────────────────────────────
- id: automation_ai_strategy
dimension: strategy_value
name: "Automation & AI Strategy"
description: "Is there a written thesis for why AI, where, and what it changes about the operating model?"
failure_vignette: "We're doing AI because the CEO read an article."
level_descriptors:
1: "No thesis. AI driven by executive impulse or vendor pitch."
2: "Aspirational vision, no operating model implications defined."
3: "Documented strategy, partial linkage to operating model."
4: "Strategy drives portfolio decisions and operating model changes."
5: "Strategy is reviewed quarterly; operating model evolves with capability."
- id: value_realization
dimension: strategy_value
name: "Value Realization"
description: "Are AI benefits defined before investment, measured after go-live, and actually harvested?"
failure_vignette: "The chatbot saved us four million dollars — nobody can say where it went."
level_descriptors:
1: "No benefit definition. Success is anecdote and vendor slideware."
2: "Business cases exist pre-investment; nobody measures after go-live."
3: "Benefits tracked for flagship initiatives; harvesting is ad hoc."
4: "Standard value framework; benefits measured and attributed per initiative."
5: "Value realization steers the portfolio: funding follows measured returns."
- id: executive_alignment
dimension: strategy_value
name: "Executive Alignment"
description: "Do the executives who own budget, operations, and technology pull in the same direction on AI?"
failure_vignette: "The CIO and the COO each run their own AI program — neither knows the other's roadmap."
level_descriptors:
1: "No accountable executive. AI initiatives appear wherever budget leaks."
2: "One sponsor evangelizes; peer executives are indifferent or resistant."
3: "Named executive owner; cross-functional steering exists on paper."
4: "Steering meets and decides; budget and priorities move as one portfolio."
5: "AI accountability sits in executive scorecards and compensation."
# ── Foundations ──────────────────────────────────────────────────────
- id: process_discovery
dimension: foundations
name: "Process Discovery"
description: "Do you know, at task level, how customer-facing work actually flows today?"
failure_vignette: "We automated the process as documented — it turns out nobody follows it."
level_descriptors:
1: "Processes undocumented; the knowledge lives in agents' heads."
2: "High-level process maps exist — stale and aspirational."
3: "Priority journeys mapped at task level with volumes and handle data."
4: "Discovery is instrumented (mining, analytics); maps reflect observed work."
5: "Continuous process intelligence feeds an automation pipeline."
- id: data_readiness
dimension: foundations
name: "Data Readiness"
description: "Is the data AI needs — knowledge, interactions, customer context — accessible, clean, and governed?"
failure_vignette: "The bot's knowledge base is a SharePoint folder last updated two reorgs ago."
level_descriptors:
1: "Data siloed and unmanaged; no owner, no quality measures."
2: "Key sources identified; access is manual and quality unknown."
3: "Priority data consolidated and cleansed for first use cases; stewardship assigned."
4: "Governed pipelines feed AI in production; quality is monitored."
5: "Data products with SLAs; a new use case onboards in days, not quarters."
- id: technical_architecture
dimension: foundations
name: "Technical Architecture"
description: "Can your platform stack integrate, orchestrate, and scale AI services safely?"
failure_vignette: "Every new bot needs a six-month integration project and its own credentials spreadsheet."
level_descriptors:
1: "Legacy estate; point-to-point integrations; no API layer."
2: "Some APIs exist; each AI effort builds bespoke plumbing."
3: "Reference architecture defined; shared integration layer for priority systems."
4: "Platform approach: reusable services, identity, and observability across AI workloads."
5: "Composable architecture; new AI capability ships on a paved road."
# ── Delivery ─────────────────────────────────────────────────────────
- id: use_case_prioritization
dimension: delivery
name: "Use Case Prioritization"
description: "Is there a managed portfolio that chooses AI work by value and feasibility?"
failure_vignette: "We have forty AI ideas on a whiteboard, and the loudest stakeholder goes first."
level_descriptors:
1: "No pipeline; initiatives start on executive impulse."
2: "An idea list exists; no scoring, no sequencing."
3: "Value and feasibility scoring; a prioritized backlog is reviewed."
4: "Portfolio managed against capacity and dependencies; stop/pivot rules are applied."
5: "The portfolio rebalances continuously on measured value and capability growth."
- id: delivery_capability
dimension: delivery
name: "Delivery Capability"
description: "Can you take an AI use case from concept to production, repeatably?"
failure_vignette: "Every pilot succeeds; nothing ever reaches production."
level_descriptors:
1: "No delivery method for AI; experiments die in the lab."
2: "Vendor-led one-off projects; nothing reusable remains."
3: "A standard delivery path exists; a few use cases run in production."
4: "Product teams ship AI iteratively; reusable components accelerate delivery."
5: "Factory model: concept-to-production in weeks, with automated quality gates."
- id: talent_and_skills
dimension: delivery
name: "Talent & Skills"
description: "Are the skills AI delivery needs — conversation design, prompting, data, MLOps — in the right seats?"
failure_vignette: "Our bot team is one hero contractor whose contract ends in March."
level_descriptors:
1: "No AI-relevant skills in-house; total vendor dependence."
2: "Isolated enthusiasts self-teach; no roles or paths defined."
3: "Core roles staffed for current initiatives; a training program has started."
4: "Skills strategy: career paths, internal academy, knowledge-transfer clauses with vendors."
5: "Talent is a differentiator: bench depth, low key-person risk, a magnet for hires."
# ── Sustain ──────────────────────────────────────────────────────────
- id: ai_operations
dimension: sustain
name: "AI Operations"
description: "Once AI is live, who watches it, tunes it, and fixes it — with what telemetry?"
failure_vignette: "Containment fell for three weeks before anyone noticed — a menu change had broken the intents."
level_descriptors:
1: "No monitoring; failures surface as customer complaints."
2: "Manual spot checks; tuning happens when someone escalates."
3: "Dashboards for containment and accuracy; scheduled tuning cycles."
4: "Full observability: drift alerts, feedback loops, a named run team."
5: "Self-optimizing operations; automated retraining inside governed guardrails."
- id: change_adoption
dimension: sustain
name: "Change & Adoption"
description: "Are agents, supervisors, and customers brought along — or does AI happen to them?"
failure_vignette: "Agents learned about the copilot from the go-live email — they've been closing it ever since."
level_descriptors:
1: "No change effort; adoption is assumed."
2: "Announcement-and-training-deck change; adoption unmeasured."
3: "Structured change program for major rollouts; adoption tracked."
4: "Co-design with the front line; champions network; adoption is a launch KPI."
5: "Change muscle is institutional; the front line pulls the roadmap forward."
- id: governance_and_risk
dimension: sustain
name: "Governance & Risk"
description: "Are AI risk, compliance, and ethics governed — at the speed production AI moves?"
failure_vignette: "Legal found out about the voice bot when a customer complaint reached the regulator."
level_descriptors:
1: "No AI governance; risk is handled after incidents."
2: "Generic IT policies stretched over AI; approvals ad hoc and slow."
3: "AI policy and a review board for high-risk use cases."
4: "Risk-tiered governance embedded in delivery; audit trails standard."
5: "Governance is an accelerator: pre-approved patterns, continuous compliance."
# ── Value capping heuristic ────────────────────────────────────────────
# Applied to the WEAKEST foundational competency score: the fraction of
# theoretical annual value an organization at that level can realistically
# capture. All bands are ranges — never point estimates.
capping_heuristic:
1: {realized_low: 0.00, realized_high: 0.15}
2: {realized_low: 0.25, realized_high: 0.40}
3: {realized_low: 0.50, realized_high: 0.65}
4: {realized_low: 0.65, realized_high: 0.85}
5: {realized_low: 0.80, realized_high: 1.00}
# Which competencies act as "foundational" — their weakness caps everything.
foundational_competencies:
- process_discovery
- data_readiness
- technical_architecture

View File

@@ -0,0 +1,62 @@
# CX AI Advisory Diagnostic — contact-center industry overlay.
#
# Value-driver ranges are 🟡 placeholder benchmarks pending citation work
# (see docs/build_spec_v1.md §12): before running with a real client, back
# each range with public sources or "based on N engagements" framing.
# `value_formula` strings are documentation of the math implemented in
# diaglib/value_math.py (dispatched on `kind`) — they are carried into
# exports verbatim, not evaluated.
extends: base
industry: contact_center
display_name: "Contact Center"
value_drivers:
- id: deflection_lift
name: "Deflection / containment improvement"
kind: containment_lift
baseline_field: current_containment_rate
lift_range_pts_low: 0.15
lift_range_pts_high: 0.35
value_formula: "annual_contact_volume * lift_pts * blended_cost_per_contact"
source: "Public benchmarks; virtual agent maturity studies" # 🟡 placeholder
- id: aht_reduction
name: "Average handle time reduction"
kind: aht_reduction
baseline_field: average_handle_time_seconds
reduction_pct_low: 0.15
reduction_pct_high: 0.25
value_formula: "annual_contact_volume * (baseline_seconds * reduction_pct) * (blended_cost_per_contact / baseline_seconds)"
source: "Agent assist / copilot case data" # 🟡 placeholder
- id: attrition_reduction
name: "Attrition reduction"
kind: attrition_reduction
baseline_field: annual_attrition_rate
reduction_pct_low: 0.10
reduction_pct_high: 0.20
cost_per_replacement_default: 15000 # 🟡 industry-tunable per engagement
value_formula: "agent_headcount * (annual_attrition_rate * reduction_pct) * cost_per_replacement"
source: "Job quality / copilot studies" # 🟡 placeholder
# ── Unlock costs ───────────────────────────────────────────────────────
# Rough cost/time ranges per one-level lift, per foundational competency.
# 🟡 estimates — sparse is OK for MVP; a missing lift renders as
# "cost not configured" in the unlock sequence rather than a guess.
unlock_costs:
data_readiness:
lift_1_to_2: {cost_low: 150000, cost_high: 300000, weeks: 8}
lift_2_to_3: {cost_low: 300000, cost_high: 600000, weeks: 12}
lift_3_to_4: {cost_low: 400000, cost_high: 800000, weeks: 16}
lift_4_to_5: {cost_low: 500000, cost_high: 1000000, weeks: 20}
process_discovery:
lift_1_to_2: {cost_low: 80000, cost_high: 160000, weeks: 6}
lift_2_to_3: {cost_low: 120000, cost_high: 250000, weeks: 8}
lift_3_to_4: {cost_low: 150000, cost_high: 300000, weeks: 10}
lift_4_to_5: {cost_low: 200000, cost_high: 400000, weeks: 12}
technical_architecture:
lift_1_to_2: {cost_low: 100000, cost_high: 200000, weeks: 8}
lift_2_to_3: {cost_low: 200000, cost_high: 400000, weeks: 10}
lift_3_to_4: {cost_low: 250000, cost_high: 500000, weeks: 12}
lift_4_to_5: {cost_low: 350000, cost_high: 700000, weeks: 16}

View File

@@ -0,0 +1,14 @@
# CX AI Advisory Diagnostic — financial-services overlay (STUB, MVP).
#
# The competency model and capping heuristic come from base.yaml unchanged.
# Value drivers and unlock costs are not yet configured for this industry:
# selecting it in the notebook scores capability normally but computes no
# value-at-stake (an on-stage notice says so). Populate value_drivers and
# unlock_costs before using this config with a client.
extends: base
industry: financial_services
display_name: "Financial Services (stub)"
value_drivers: []
unlock_costs: {}

View File

@@ -0,0 +1,62 @@
"""diaglib — the CX AI Advisory Diagnostic engine (Mercury Notebook Pattern).
All math and data contracts live here; the notebook only arranges and
renders. See docs/build_spec_v1.md for the instrument's specification.
"""
from .config import configs_dir, list_industries, load_config
from .export import (
CSV_COLUMNS,
engagement_json,
load_engagement,
scores_dataframe,
write_exports,
)
from .models import (
BASELINE_FIELDS,
CONFIDENCE_ICON,
Competency,
CompetencyScore,
DiagnosticConfig,
DriverValue,
Engagement,
OperationalBaseline,
Participant,
UnlockMove,
ValueAtStake,
)
from .scoring import (
build_engagement,
build_scores,
dimension_rollup,
evidence_coverage,
heatmap_grid,
make_engagement_id,
parse_participants,
)
from .staging import backstage, on_stage
from .value_math import (
MONTHS_18,
binding_constraints,
driver_value,
html_money,
money,
unlock_sequence,
value_at_stake,
weakest_foundational_score,
)
from .visuals import heatmap_fig, split_fig, unlock_fig, value_bands_fig
__all__ = [
"BASELINE_FIELDS", "CONFIDENCE_ICON", "CSV_COLUMNS", "MONTHS_18",
"Competency", "CompetencyScore", "DiagnosticConfig", "DriverValue",
"Engagement", "OperationalBaseline", "Participant", "UnlockMove",
"ValueAtStake", "backstage", "binding_constraints", "build_engagement",
"build_scores", "configs_dir", "dimension_rollup", "driver_value",
"engagement_json", "evidence_coverage", "heatmap_fig", "heatmap_grid",
"html_money", "list_industries", "load_config", "load_engagement",
"make_engagement_id", "money", "on_stage", "parse_participants",
"scores_dataframe", "split_fig", "unlock_fig", "unlock_sequence",
"value_at_stake", "value_bands_fig", "weakest_foundational_score",
"write_exports",
]

View File

@@ -0,0 +1,77 @@
"""Load and merge ``configs/*.yaml`` into a validated DiagnosticConfig.
``base.yaml`` holds the industry-independent competency model; every other
YAML in the directory is an industry overlay declaring ``extends: base``
plus its value drivers and unlock costs. Merging is shallow and explicit:
the overlay contributes industry identity, drivers, and costs; the base
contributes everything else. Overlays may not redefine the competency
model — one instrument, many industries.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from .models import DiagnosticConfig
#: Overlay keys an industry file may set. Anything else (competencies,
#: capping_heuristic, …) belongs in base.yaml and is rejected loudly.
_OVERLAY_KEYS = {"extends", "industry", "display_name", "value_drivers", "unlock_costs"}
def configs_dir(start: Path | None = None) -> Path:
"""The study's ``configs/`` directory, found from ``start`` (or CWD).
Walks up so it works from the study root, ``notebooks/``, or ``tests/``.
"""
here = (start or Path.cwd()).resolve()
for candidate in (here, *here.parents):
d = candidate / "configs"
if (d / "base.yaml").exists():
return d
raise FileNotFoundError("configs/base.yaml not found above " + str(here))
def list_industries(directory: Path | None = None) -> list[str]:
"""Industry config names (file stems), base excluded, sorted."""
d = directory or configs_dir()
return sorted(p.stem for p in d.glob("*.yaml") if p.stem != "base")
def _read_yaml(path: Path) -> dict[str, Any]:
with open(path, encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not isinstance(data, dict):
raise ValueError(f"{path.name}: expected a mapping at top level")
return data
def load_config(industry: str, directory: Path | None = None) -> DiagnosticConfig:
"""Load ``base.yaml`` + the named industry overlay, validated."""
d = directory or configs_dir()
base = _read_yaml(d / "base.yaml")
overlay = _read_yaml(d / f"{industry}.yaml")
if overlay.get("extends") != "base":
raise ValueError(f"{industry}.yaml must declare 'extends: base'")
stray = set(overlay) - _OVERLAY_KEYS
if stray:
raise ValueError(
f"{industry}.yaml sets base-only keys {sorted(stray)}"
"the competency model lives in base.yaml")
merged: dict[str, Any] = {
"version": base["version"],
"dimensions": base["dimensions"],
"competencies": base["competencies"],
"capping_heuristic": base["capping_heuristic"],
"foundational_competencies": base["foundational_competencies"],
"industry": overlay["industry"],
"display_name": overlay.get("display_name", overlay["industry"]),
"value_drivers": overlay.get("value_drivers") or [],
"unlock_costs": overlay.get("unlock_costs") or {},
}
return DiagnosticConfig.model_validate(merged)

View File

@@ -0,0 +1,62 @@
"""Structured engagement exports — the JSON is the source-of-truth artifact.
``exports/{engagement_id}.json`` — the full Engagement, serialized.
``exports/{engagement_id}.csv`` — one row per competency, for
cross-engagement spreadsheet analysis (build spec §8).
"""
from __future__ import annotations
import json
from pathlib import Path
import pandas as pd
from .models import DiagnosticConfig, Engagement
CSV_COLUMNS = [
"engagement_id", "client_name", "industry", "workshop_date",
"competency_id", "dimension", "score", "evidence",
"is_foundational", "is_binding_constraint",
]
def engagement_json(engagement: Engagement) -> str:
return json.dumps(engagement.model_dump(mode="json"), indent=2,
ensure_ascii=False)
def scores_dataframe(engagement: Engagement,
config: DiagnosticConfig) -> pd.DataFrame:
binding = set(engagement.computed_value.binding_constraints
if engagement.computed_value else [])
foundational = set(config.foundational_competencies)
rows = [{
"engagement_id": engagement.engagement_id,
"client_name": engagement.client_name,
"industry": engagement.industry_config,
"workshop_date": engagement.workshop_date.isoformat(),
"competency_id": s.competency_id,
"dimension": s.dimension,
"score": s.score,
"evidence": s.evidence,
"is_foundational": s.competency_id in foundational,
"is_binding_constraint": s.competency_id in binding,
} for s in engagement.scores]
return pd.DataFrame(rows, columns=CSV_COLUMNS)
def write_exports(engagement: Engagement, config: DiagnosticConfig,
exports_dir: Path) -> tuple[Path, Path]:
"""Write both artifacts; returns ``(json_path, csv_path)``."""
exports_dir.mkdir(parents=True, exist_ok=True)
json_path = exports_dir / f"{engagement.engagement_id}.json"
csv_path = exports_dir / f"{engagement.engagement_id}.csv"
json_path.write_text(engagement_json(engagement), encoding="utf-8")
scores_dataframe(engagement, config).to_csv(csv_path, index=False)
return json_path, csv_path
def load_engagement(json_path: Path) -> Engagement:
"""Reload a saved engagement for review (acceptance §10 nice-to-have)."""
return Engagement.model_validate_json(json_path.read_text(encoding="utf-8"))

View File

@@ -0,0 +1,281 @@
"""Pydantic models — every data structure the diagnostic captures or computes.
Two families live here:
* **Engagement data** — what the workshop records (participants, baseline,
scores) and what the engine computes (:class:`ValueAtStake`). The
serialized :class:`Engagement` is the source-of-truth export artifact.
* **Config data** — the validated shape of ``configs/*.yaml``: the
competency model (base) and the industry overlay (drivers, unlock costs).
Deviations from the build spec (docs/build_spec_v1.md), both additive:
* :class:`UnlockMove` carries ``competency_ids`` (plural). When several
foundational competencies tie at the weakest level, the binding
constraint *is the set* — a single-competency move would honestly unlock
nothing. Moves are tier lifts of the whole binding set.
* :class:`ValueAtStake` also records the driver breakdown, the realization
band, and guard-rail warnings, so the export explains its own numbers.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
Function = Literal["cx", "it", "ops", "finance", "other"]
Confidence = Literal["known", "estimated", "unknown"]
CONFIDENCE_ICON: dict[str, str] = {
"known": "🟢", "estimated": "🟡", "unknown": "🔴",
}
#: The six numeric baseline fields collected in the workshop form.
BASELINE_FIELDS: tuple[str, ...] = (
"annual_contact_volume",
"blended_cost_per_contact",
"agent_headcount",
"annual_attrition_rate",
"current_containment_rate",
"average_handle_time_seconds",
)
# ── Engagement data ──────────────────────────────────────────────────
class Participant(BaseModel):
name: str
role: str = ""
function: Function = "other"
class OperationalBaseline(BaseModel):
"""Contact-center MVP fields; other industries overlay their own."""
annual_contact_volume: int = Field(ge=0)
blended_cost_per_contact: float = Field(ge=0)
agent_headcount: int = Field(ge=0)
annual_attrition_rate: float = Field(ge=0, le=1)
current_containment_rate: float = Field(ge=0, le=1)
average_handle_time_seconds: int = Field(ge=0)
csat_baseline: float | None = None
revenue_at_risk: float | None = None
#: Per-field confidence flags (🟢 known / 🟡 estimated / 🔴 unknown).
field_confidence: dict[str, Confidence] = Field(default_factory=dict)
@field_validator("field_confidence")
@classmethod
def _known_fields_only(cls, v: dict[str, Confidence]) -> dict[str, Confidence]:
unknown = set(v) - set(BASELINE_FIELDS)
if unknown:
raise ValueError(f"confidence flags for unknown fields: {sorted(unknown)}")
return v
def confidence_for(self, field: str) -> Confidence:
return self.field_confidence.get(field, "estimated")
class CompetencyScore(BaseModel):
competency_id: str
dimension: str
score: int = Field(ge=1, le=5)
evidence: str = "" # one line: why this score
scorer_role: str = "facilitator" # MVP: facilitator consensus
scored_at: datetime
class DriverValue(BaseModel):
"""One value driver's theoretical annual value, as a range."""
driver_id: str
name: str
theoretical_low: float
theoretical_high: float
class UnlockMove(BaseModel):
"""One move in the unlock sequence: lift the binding set one level.
``value_unlocked_*`` is the **annual run-rate** realizable value the
lift adds (delta of the realization band times theoretical value).
Costs are ``None`` when the config has no entry for a lift — shown as
"cost not configured", never guessed.
"""
competency_ids: list[str]
current_level: int = Field(ge=1, le=4)
target_level: int = Field(ge=2, le=5)
est_cost_low: float | None = None
est_cost_high: float | None = None
est_weeks: int | None = None
value_unlocked_low: float
value_unlocked_high: float
note: str = ""
@model_validator(mode="after")
def _one_level_lift(self) -> "UnlockMove":
if self.target_level != self.current_level + 1:
raise ValueError("unlock moves lift exactly one level")
return self
class ValueAtStake(BaseModel):
theoretical_annual_value_low: float
theoretical_annual_value_high: float
realizable_18mo_low: float
realizable_18mo_high: float
trapped_value_low: float # annual: theoretical realizable run-rate
trapped_value_high: float
binding_constraints: list[str] # competency_ids capping realization
unlock_sequence: list[UnlockMove]
# Self-explaining extras (additive to the build spec):
weakest_foundational_score: int = Field(ge=1, le=5)
realization_factor_low: float = Field(ge=0, le=1)
realization_factor_high: float = Field(ge=0, le=1)
driver_values: list[DriverValue] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
class Engagement(BaseModel):
engagement_id: str # e.g. "acme_2026-07-19"
client_name: str
industry_config: str # which config was loaded
facilitator: str
workshop_date: date
participants: list[Participant] = Field(default_factory=list)
operational_baseline: OperationalBaseline
scores: list[CompetencyScore] = Field(default_factory=list)
computed_value: ValueAtStake | None = None
notes: str = ""
# ── Config data (configs/*.yaml) ─────────────────────────────────────
class Dimension(BaseModel):
id: str
name: str
class Competency(BaseModel):
id: str
dimension: str
name: str
description: str
failure_vignette: str
level_descriptors: dict[int, str]
@field_validator("level_descriptors")
@classmethod
def _five_levels(cls, v: dict[int, str]) -> dict[int, str]:
if set(v) != {1, 2, 3, 4, 5}:
raise ValueError("level_descriptors must cover exactly levels 15")
return v
class CappingBand(BaseModel):
realized_low: float = Field(ge=0, le=1)
realized_high: float = Field(ge=0, le=1)
@model_validator(mode="after")
def _ordered(self) -> "CappingBand":
if self.realized_low > self.realized_high:
raise ValueError("realized_low > realized_high")
return self
class ValueDriver(BaseModel):
"""A configured value driver. ``kind`` selects the math in
value_math.py; ``value_formula`` documents it verbatim in exports."""
id: str
name: str
kind: Literal["containment_lift", "aht_reduction", "attrition_reduction"]
baseline_field: str
value_formula: str = ""
source: str = ""
# kind-specific parameters (validated in value_math dispatch):
lift_range_pts_low: float | None = None
lift_range_pts_high: float | None = None
reduction_pct_low: float | None = None
reduction_pct_high: float | None = None
cost_per_replacement_default: float | None = None
class LiftCost(BaseModel):
cost_low: float = Field(ge=0)
cost_high: float = Field(ge=0)
weeks: int = Field(ge=0)
@model_validator(mode="after")
def _ordered(self) -> "LiftCost":
if self.cost_low > self.cost_high:
raise ValueError("cost_low > cost_high")
return self
class DiagnosticConfig(BaseModel):
"""base.yaml merged with one industry overlay — what the engine consumes."""
version: str
industry: str
display_name: str
dimensions: list[Dimension]
competencies: list[Competency]
capping_heuristic: dict[int, CappingBand]
foundational_competencies: list[str]
value_drivers: list[ValueDriver] = Field(default_factory=list)
#: unlock_costs[competency_id]["lift_2_to_3"] -> LiftCost
unlock_costs: dict[str, dict[str, LiftCost]] = Field(default_factory=dict)
@model_validator(mode="after")
def _consistent(self) -> "DiagnosticConfig":
comp_ids = [c.id for c in self.competencies]
if len(comp_ids) != len(set(comp_ids)):
raise ValueError("duplicate competency ids")
dim_ids = {d.id for d in self.dimensions}
for c in self.competencies:
if c.dimension not in dim_ids:
raise ValueError(f"competency {c.id}: unknown dimension {c.dimension}")
missing = set(self.foundational_competencies) - set(comp_ids)
if missing:
raise ValueError(f"foundational competencies not defined: {sorted(missing)}")
if set(self.capping_heuristic) != {1, 2, 3, 4, 5}:
raise ValueError("capping_heuristic must cover exactly scores 15")
for lo, hi in zip(sorted(self.capping_heuristic), sorted(self.capping_heuristic)[1:]):
a, b = self.capping_heuristic[lo], self.capping_heuristic[hi]
if a.realized_low > b.realized_low or a.realized_high > b.realized_high:
raise ValueError("capping_heuristic must be non-decreasing in score")
for cid, lifts in self.unlock_costs.items():
if cid not in comp_ids:
raise ValueError(f"unlock_costs for unknown competency {cid}")
for key in lifts:
if not _valid_lift_key(key):
raise ValueError(f"unlock_costs[{cid}]: bad lift key {key!r}")
return self
def competency(self, competency_id: str) -> Competency:
for c in self.competencies:
if c.id == competency_id:
return c
raise KeyError(competency_id)
def dimension_name(self, dimension_id: str) -> str:
for d in self.dimensions:
if d.id == dimension_id:
return d.name
raise KeyError(dimension_id)
def lift_cost(self, competency_id: str, from_level: int) -> LiftCost | None:
return self.unlock_costs.get(competency_id, {}).get(
f"lift_{from_level}_to_{from_level + 1}")
def _valid_lift_key(key: str) -> bool:
parts = key.split("_") # "lift", a, "to", b — one-level lifts only
return (len(parts) == 4 and parts[0] == "lift" and parts[2] == "to"
and parts[1].isdigit() and parts[3].isdigit()
and int(parts[3]) == int(parts[1]) + 1 and 1 <= int(parts[1]) <= 4)

View File

@@ -0,0 +1,133 @@
"""Score aggregation, gap analysis, and engagement assembly.
Everything the notebook needs between raw widget values and the engine's
value math lives here — the notebook itself computes nothing.
"""
from __future__ import annotations
import re
from datetime import date, datetime
from typing import get_args
from .models import (
CompetencyScore,
DiagnosticConfig,
Engagement,
Function,
OperationalBaseline,
Participant,
ValueAtStake,
)
FUNCTIONS: tuple[str, ...] = get_args(Function)
# ── Engagement identity ──────────────────────────────────────────────
def make_engagement_id(client_name: str, workshop_date: date) -> str:
"""``"Acme Corp!" + 2026-07-19 -> "acme_corp_2026-07-19"``."""
slug = re.sub(r"[^a-z0-9]+", "_", client_name.lower()).strip("_") or "client"
return f"{slug}_{workshop_date.isoformat()}"
def parse_participants(text: str) -> list[Participant]:
"""Parse ``"Name | Role | function; Name | Role | function"``.
Forgiving by design — the facilitator types this live. Missing parts
default (role empty, function ``other``); unknown functions map to
``other`` rather than erroring mid-workshop.
"""
participants: list[Participant] = []
for entry in text.split(";"):
parts = [p.strip() for p in entry.split("|")]
if not parts or not parts[0]:
continue
function = parts[2].lower() if len(parts) > 2 else "other"
participants.append(Participant(
name=parts[0],
role=parts[1] if len(parts) > 1 else "",
function=function if function in FUNCTIONS else "other",
))
return participants
# ── Scores ───────────────────────────────────────────────────────────
def build_scores(config: DiagnosticConfig, raw: dict[str, tuple[int, str]],
scored_at: datetime,
scorer_role: str = "facilitator") -> list[CompetencyScore]:
"""``raw[competency_id] = (score, evidence)`` → validated scores, config order."""
missing = [c.id for c in config.competencies if c.id not in raw]
if missing:
raise ValueError(f"unscored competencies: {missing}")
return [
CompetencyScore(
competency_id=c.id, dimension=c.dimension,
score=raw[c.id][0], evidence=raw[c.id][1].strip(),
scorer_role=scorer_role, scored_at=scored_at,
)
for c in config.competencies
]
def dimension_rollup(config: DiagnosticConfig,
scores: list[CompetencyScore]) -> list[tuple[str, str, float]]:
"""``(dimension_id, dimension_name, mean score)`` per dimension, config order."""
by_dim: dict[str, list[int]] = {d.id: [] for d in config.dimensions}
for s in scores:
by_dim[s.dimension].append(s.score)
return [(d.id, d.name, sum(v) / len(v))
for d in config.dimensions if (v := by_dim[d.id])]
def evidence_coverage(scores: list[CompetencyScore]) -> tuple[int, int]:
"""``(scores with evidence captured, total scores)``."""
return sum(1 for s in scores if s.evidence), len(scores)
def heatmap_grid(config: DiagnosticConfig, scores: list[CompetencyScore]) -> dict:
"""Pure data for the 4×3 heatmap — rows are dimensions, three
competencies per row in config order. Returned as plain lists so the
visuals layer holds no logic."""
by_id = {s.competency_id: s for s in scores}
rows, z, text, hover = [], [], [], []
for d in config.dimensions:
comps = [c for c in config.competencies if c.dimension == d.id]
rows.append(d.name)
z.append([by_id[c.id].score for c in comps])
text.append([f"{by_id[c.id].score}<br>{c.name}" for c in comps])
hover.append([
f"<b>{c.name}</b> — level {by_id[c.id].score}<br>"
f"{c.level_descriptors[by_id[c.id].score]}<br>"
f"<i>{by_id[c.id].evidence or 'no evidence captured'}</i>"
for c in comps
])
return {"rows": rows, "z": z, "text": text, "hover": hover,
"cols": ["", "", ""]}
# ── Assembly ─────────────────────────────────────────────────────────
def build_engagement(*, config: DiagnosticConfig, client_name: str,
facilitator: str, workshop_date: date,
participants: list[Participant],
baseline: OperationalBaseline,
scores: list[CompetencyScore],
computed_value: ValueAtStake | None,
notes: str = "") -> Engagement:
return Engagement(
engagement_id=make_engagement_id(client_name, workshop_date),
client_name=client_name.strip() or "Unnamed client",
industry_config=config.industry,
facilitator=facilitator.strip(),
workshop_date=workshop_date,
participants=participants,
operational_baseline=baseline,
scores=scores,
computed_value=computed_value,
notes=notes,
)

View File

@@ -0,0 +1,29 @@
"""
Stage vs backstage — is this notebook render stakeholder-facing?
The Mercury CLI (``mercury --working-dir …``) exports ``MERCURY_CONFIG_DIR``
into the server process so the widget library can locate ``config.toml``
(see ``mercury/config.py``); every kernel that server spawns inherits it.
JupyterLab and nbconvert kernels don't have it. That makes the variable a
reliable signal for "the audience is looking" (the stage) versus an
analyst session or a headless export run (backstage).
Diagnostics routed through :func:`backstage` 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
def on_stage() -> bool:
"""True when running under the Mercury app (stakeholder-facing)."""
return os.getenv("MERCURY_CONFIG_DIR") is not None
def backstage(*args, **kwargs) -> None:
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
if not on_stage():
print(*args, **kwargs)

View File

@@ -0,0 +1,228 @@
"""Value-at-stake math: driver values, capability capping, unlock sequence.
The pipeline (build spec §6):
1. Each configured value driver yields a theoretical annual value range
from the operational baseline (dispatch on ``driver.kind``).
2. Theoretical annual value = sum of drivers.
3. The **weakest foundational competency score** selects a realization
band from the capping heuristic.
4. ``realizable_18mo = theoretical × realization_factor × 1.5``
(18 months of annual run-rate).
5. Trapped value (annual) = theoretical realizable run-rate. Range
pairing is conservative-consistent: the low trapped estimate assumes
the low theoretical *and* the high realization factor, and vice versa.
6. Binding constraints = every foundational competency sitting at the
weakest score.
7. Unlock sequence = up to three **tier lifts**: raise the whole binding
set one level, recompute the band, attribute the delta. When several
competencies tie at the weakest level a single-competency lift would
honestly unlock nothing — the set is the move (see UnlockMove docs).
Guard rails: every output is a range; 🔴-unknown inputs raise warnings on
the result; money *display* is capped at two significant figures
(:func:`money`) while raw floats stay exact in exports.
"""
from __future__ import annotations
from .models import (
CompetencyScore,
DiagnosticConfig,
DriverValue,
OperationalBaseline,
UnlockMove,
ValueAtStake,
ValueDriver,
)
#: 18 months expressed in years of annual run-rate.
MONTHS_18 = 1.5
#: How many unlock moves the sequence proposes.
MAX_UNLOCK_MOVES = 3
# ── Money display (guard rail: ≤ 2 significant figures) ──────────────
def _round_2sf(v: float) -> float:
if v == 0:
return 0.0
from math import floor, log10
exp = floor(log10(abs(v)))
return round(v, -exp + 1)
def money(v: float) -> str:
"""House money format, capped at two significant figures: $2.5M, $950K."""
sign, a = ("-" if v < 0 else ""), _round_2sf(abs(v))
if a >= 1e6:
m = a / 1e6
return f"{sign}${m:,.1f}M" if m < 10 else f"{sign}${m:,.0f}M"
if a >= 1e3:
return f"{sign}${a / 1e3:,.0f}K"
return f"{sign}${a:,.0f}"
def html_money(v: float) -> str:
"""Plotly text with two or more bare ``$`` triggers MathJax math mode —
annotations holding several amounts must use the HTML entity instead."""
return money(v).replace("$", "&#36;")
# ── Driver math (dispatch on kind) ───────────────────────────────────
def driver_value(driver: ValueDriver, baseline: OperationalBaseline) -> DriverValue:
"""Theoretical annual value range for one configured driver."""
if driver.kind == "containment_lift":
if driver.lift_range_pts_low is None or driver.lift_range_pts_high is None:
raise ValueError(f"driver {driver.id}: containment_lift needs lift_range_pts_low/high")
low = baseline.annual_contact_volume * driver.lift_range_pts_low \
* baseline.blended_cost_per_contact
high = baseline.annual_contact_volume * driver.lift_range_pts_high \
* baseline.blended_cost_per_contact
elif driver.kind == "aht_reduction":
# volume × (AHT × pct) seconds saved × ($/contact ÷ AHT) per second
# — the baseline AHT cancels: volume × $/contact × pct.
if driver.reduction_pct_low is None or driver.reduction_pct_high is None:
raise ValueError(f"driver {driver.id}: aht_reduction needs reduction_pct_low/high")
low = baseline.annual_contact_volume * baseline.blended_cost_per_contact \
* driver.reduction_pct_low
high = baseline.annual_contact_volume * baseline.blended_cost_per_contact \
* driver.reduction_pct_high
elif driver.kind == "attrition_reduction":
if driver.reduction_pct_low is None or driver.reduction_pct_high is None:
raise ValueError(f"driver {driver.id}: attrition_reduction needs reduction_pct_low/high")
cost_per_replacement = driver.cost_per_replacement_default or 0.0
low = baseline.agent_headcount * baseline.annual_attrition_rate \
* driver.reduction_pct_low * cost_per_replacement
high = baseline.agent_headcount * baseline.annual_attrition_rate \
* driver.reduction_pct_high * cost_per_replacement
else: # pragma: no cover — Literal already restricts kinds
raise ValueError(f"driver {driver.id}: unknown kind {driver.kind}")
return DriverValue(driver_id=driver.id, name=driver.name,
theoretical_low=low, theoretical_high=high)
# ── Capping ──────────────────────────────────────────────────────────
def weakest_foundational_score(config: DiagnosticConfig,
scores: list[CompetencyScore]) -> int:
by_id = {s.competency_id: s.score for s in scores}
missing = [c for c in config.foundational_competencies if c not in by_id]
if missing:
raise ValueError(f"foundational competencies unscored: {missing}")
return min(by_id[c] for c in config.foundational_competencies)
def binding_constraints(config: DiagnosticConfig,
scores: list[CompetencyScore]) -> list[str]:
"""Foundational competencies sitting at the weakest score, config order."""
weakest = weakest_foundational_score(config, scores)
by_id = {s.competency_id: s.score for s in scores}
return [c for c in config.foundational_competencies if by_id[c] == weakest]
# ── Unlock sequence (tier lifts of the binding set) ──────────────────
def _tier_move(config: DiagnosticConfig, level: int, members: list[str],
th_low: float, th_high: float) -> UnlockMove:
band_now = config.capping_heuristic[level]
band_next = config.capping_heuristic[level + 1]
costs = {m: config.lift_cost(m, level) for m in members}
missing = [m for m, c in costs.items() if c is None]
note = ""
if len(members) > 1:
note = "joint lift — the tied competencies must move together to shift the cap"
if missing:
note = (note + "; " if note else "") + \
f"cost not configured for: {', '.join(missing)}"
have_all = not missing
return UnlockMove(
competency_ids=members,
current_level=level,
target_level=level + 1,
est_cost_low=sum(c.cost_low for c in costs.values() if c) if have_all else None,
est_cost_high=sum(c.cost_high for c in costs.values() if c) if have_all else None,
est_weeks=max((c.weeks for c in costs.values() if c), default=None) if have_all else None,
value_unlocked_low=th_low * (band_next.realized_low - band_now.realized_low),
value_unlocked_high=th_high * (band_next.realized_high - band_now.realized_high),
note=note,
)
def unlock_sequence(config: DiagnosticConfig, scores: list[CompetencyScore],
th_low: float, th_high: float,
max_moves: int = MAX_UNLOCK_MOVES) -> list[UnlockMove]:
"""Up to ``max_moves`` sequential tier lifts of the binding set.
Each move lifts every foundational competency at the current weakest
level by one level (weeks = the longest workstream, run in parallel;
costs summed). Value unlocked is the annual realizable delta from the
capping-band shift. Moves stay in sequence order — each one is the
prerequisite of the next, so ranking them against each other would be
meaningless; the ratio walk (value/cost declining) is the story.
"""
if th_low == 0 and th_high == 0:
return []
current = {s.competency_id: s.score for s in scores
if s.competency_id in config.foundational_competencies}
moves: list[UnlockMove] = []
for _ in range(max_moves):
level = min(current.values())
if level >= 5:
break
members = [c for c in config.foundational_competencies
if current[c] == level]
moves.append(_tier_move(config, level, members, th_low, th_high))
for m in members:
current[m] = level + 1
return moves
# ── The full computation ─────────────────────────────────────────────
def value_at_stake(config: DiagnosticConfig, baseline: OperationalBaseline,
scores: list[CompetencyScore]) -> ValueAtStake:
"""Steps 18 of the build spec, as one call. See module docstring."""
drivers = [driver_value(d, baseline) for d in config.value_drivers]
th_low = sum(d.theoretical_low for d in drivers)
th_high = sum(d.theoretical_high for d in drivers)
weakest = weakest_foundational_score(config, scores)
band = config.capping_heuristic[weakest]
warnings: list[str] = []
if not config.value_drivers:
warnings.append(
f"config '{config.industry}' has no value drivers — "
"value-at-stake is zero (stub config)")
used_fields = sorted({d.baseline_field for d in config.value_drivers}
| ({"annual_contact_volume", "blended_cost_per_contact"}
if config.value_drivers else set()))
for f in used_fields:
if baseline.confidence_for(f) == "unknown":
warnings.append(
f"baseline input '{f}' is flagged 🔴 unknown — "
"the ranges below inherit that uncertainty")
return ValueAtStake(
theoretical_annual_value_low=th_low,
theoretical_annual_value_high=th_high,
realizable_18mo_low=th_low * band.realized_low * MONTHS_18,
realizable_18mo_high=th_high * band.realized_high * MONTHS_18,
trapped_value_low=th_low * (1 - band.realized_high),
trapped_value_high=th_high * (1 - band.realized_low),
binding_constraints=binding_constraints(config, scores),
unlock_sequence=unlock_sequence(config, scores, th_low, th_high),
weakest_foundational_score=weakest,
realization_factor_low=band.realized_low,
realization_factor_high=band.realized_high,
driver_values=drivers,
warnings=warnings,
)

View File

@@ -0,0 +1,208 @@
"""Plotly figure builders — presentation only, consuming engine outputs.
Chart chrome follows the house dataviz rules (see the repo dataviz
reference and docs/brand.md): recessive grid and axes, ink text tokens,
fixed entity→color assignments so a color means one thing across every
figure, 2px surface gaps between adjacent fills, selective direct labels,
one axis per chart. Room-facing: sized and typed to hold attention on a
shared screen, not for print.
"""
from __future__ import annotations
import plotly.graph_objects as go
from .models import DiagnosticConfig, ValueAtStake
from .value_math import html_money
# ── Chrome (dataviz reference palette, light surface) ────────────────
INK, INK2, MUTED = "#0b0b0b", "#52514e", "#898781"
SURFACE, GRID, BASELINE = "#fcfcfb", "#e1e0d9", "#c3c2b7"
FONT_STACK = 'system-ui, -apple-system, "Segoe UI", sans-serif'
# Fixed entity colors — color follows the entity across every figure.
THEORETICAL = "#9ec5f4" # light blue: the outer envelope
REALIZABLE = "#2a78d6" # blue: what capability can actually capture
TRAPPED = "#eda100" # amber: value the capability gap strands
COST = "#e34948" # red: unlock investment
UNLOCKED = "#1baf7a" # aqua-green: unlock payoff
# Diverging maturity scale, centered on level 3 — soft poles so ink text
# stays readable in every cell (two hues + neutral midpoint, never rainbow).
SCORE_SCALE = [
(0.0, "#ef8a76"), (0.5, "#f0efe9"), (1.0, "#57c993"),
]
def diag_layout(fig: go.Figure, title: str, subtitle: str | None = None,
height: int = 440) -> go.Figure:
t = f"<b>{title}</b>"
if subtitle:
t += f"<br><span style='font-size:12px;color:{MUTED}'>{subtitle}</span>"
fig.update_layout(
title=dict(text=t, font=dict(size=16, color=INK), x=0.02, xanchor="left"),
paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,
font=dict(family=FONT_STACK, size=13, color=INK2),
legend=dict(orientation="h", yanchor="top", y=-0.12, x=0,
font=dict(size=11, color=INK2)),
height=height, margin=dict(t=70, r=30, b=60, l=70),
)
return fig
# ── 1 · Capability heatmap (4 dimensions × 3 competencies) ───────────
def heatmap_fig(grid: dict) -> go.Figure:
"""``grid`` comes from scoring.heatmap_grid — pure data in."""
n_rows = len(grid["rows"])
fig = go.Figure(go.Heatmap(
z=grid["z"], text=grid["text"], customdata=grid["hover"],
x=list(range(len(grid["cols"]))), y=grid["rows"],
zmin=1, zmax=5, colorscale=SCORE_SCALE, showscale=False,
texttemplate="%{text}", textfont=dict(size=13, color=INK),
hovertemplate="%{customdata}<extra></extra>",
xgap=3, ygap=3,
))
fig.update_xaxes(visible=False)
fig.update_yaxes(autorange="reversed", tickfont=dict(size=13, color=INK2),
showgrid=False)
diag_layout(fig, "Capability heatmap",
"12 competencies, levels 15 · hover a cell for the evidence",
height=90 * n_rows + 120)
return fig
# ── 2 · Value at stake (theoretical vs realizable ranges) ────────────
def value_bands_fig(vas: ValueAtStake) -> go.Figure:
rows = [
("Theoretical value (annual)", vas.theoretical_annual_value_low,
vas.theoretical_annual_value_high, THEORETICAL),
("Realizable over 18 months", vas.realizable_18mo_low,
vas.realizable_18mo_high, REALIZABLE),
]
fig = go.Figure()
for label, low, high, color in rows:
fig.add_trace(go.Bar(
y=[label], x=[max(high - low, 1)], base=[low], orientation="h",
marker=dict(color=color, line=dict(width=2, color=SURFACE)),
showlegend=False,
hovertemplate=(f"{label}: {html_money(low)} {html_money(high)}"
"<extra></extra>"),
))
fig.add_annotation(x=high, y=label, xanchor="left", xshift=6,
text=f"{html_money(low)} {html_money(high)}",
showarrow=False, font=dict(size=12, color=INK))
fig.add_annotation(
xref="paper", yref="paper", x=0.02, y=-0.32, xanchor="left",
showarrow=False, align="left",
text=(f"Trapped by the capability gap: "
f"<b>{html_money(vas.trapped_value_low)} "
f"{html_money(vas.trapped_value_high)}</b> per year"),
font=dict(size=13, color=INK))
fig.update_xaxes(tickformat="$~s", gridcolor=GRID, zeroline=False,
tickfont=dict(color=MUTED), rangemode="tozero")
fig.update_yaxes(tickfont=dict(size=13, color=INK2), showgrid=False,
autorange="reversed") # theoretical on top, then realizable
diag_layout(fig, "Value at stake",
"ranges, never points · realization capped by the weakest foundation",
height=300)
fig.update_layout(margin=dict(b=90), bargap=0.5)
return fig
# ── 3 · Realizable vs trapped split (scenario-consistent) ────────────
def split_fig(vas: ValueAtStake) -> go.Figure:
"""Each scenario bar splits its own theoretical total: realizable
run-rate vs trapped, at that scenario's realization factor."""
scenarios = [
("Conservative", vas.theoretical_annual_value_low, vas.realization_factor_low),
("Optimistic", vas.theoretical_annual_value_high, vas.realization_factor_high),
]
labels = [s[0] for s in scenarios]
realizable = [th * r for _, th, r in scenarios]
trapped = [th * (1 - r) for _, th, r in scenarios]
fig = go.Figure([
go.Bar(name="Realizable (annual run-rate)", y=labels, x=realizable,
orientation="h",
marker=dict(color=REALIZABLE, line=dict(width=2, color=SURFACE)),
text=[html_money(v) for v in realizable],
textposition="inside", insidetextfont=dict(color="#ffffff"),
hovertemplate="Realizable: %{x:$,.0f}<extra>%{y}</extra>"),
go.Bar(name="Trapped by capability gap", y=labels, x=trapped,
orientation="h",
marker=dict(color=TRAPPED, line=dict(width=2, color=SURFACE)),
text=[html_money(v) for v in trapped],
textposition="inside", insidetextfont=dict(color=INK),
hovertemplate="Trapped: %{x:$,.0f}<extra>%{y}</extra>"),
])
fig.update_layout(barmode="stack", bargap=0.5)
fig.update_xaxes(tickformat="$~s", gridcolor=GRID, zeroline=False,
tickfont=dict(color=MUTED))
fig.update_yaxes(tickfont=dict(size=13, color=INK2), showgrid=False,
autorange="reversed") # conservative on top
diag_layout(fig, "Where the annual value goes",
"each scenario splits its own theoretical total", height=300)
fig.update_layout(legend=dict(traceorder="normal"))
return fig
# ── 4 · Unlock sequence (cost vs value per move) ─────────────────────
def _move_label(vas_move, config: DiagnosticConfig, idx: int) -> str:
"""Compact tick label — full competency names live in the hover and
in the on-stage moves table (long joint-lift names don't fit ticks)."""
if len(vas_move.competency_ids) == 1:
what = config.competency(vas_move.competency_ids[0]).name
else:
what = f"joint lift ×{len(vas_move.competency_ids)}"
return (f"<b>{idx} · {what}</b><br>"
f"level {vas_move.current_level}{vas_move.target_level}")
def unlock_fig(vas: ValueAtStake, config: DiagnosticConfig) -> go.Figure:
moves = vas.unlock_sequence
labels = [_move_label(m, config, i + 1) for i, m in enumerate(moves)]
names = [" + ".join(config.competency(c).name for c in m.competency_ids)
for m in moves]
fig = go.Figure()
fig.add_trace(go.Bar(
name="Investment (range)", x=labels,
y=[(m.est_cost_high - m.est_cost_low) if m.est_cost_low is not None else 0
for m in moves],
base=[m.est_cost_low if m.est_cost_low is not None else 0 for m in moves],
customdata=[[n, html_money(m.est_cost_low) + " " + html_money(m.est_cost_high)
if m.est_cost_low is not None else "not configured"]
for n, m in zip(names, moves)],
marker=dict(color=COST, line=dict(width=2, color=SURFACE)),
hovertemplate="%{customdata[0]}<br>Investment: %{customdata[1]}<extra></extra>",
))
fig.add_trace(go.Bar(
name="Annual value unlocked (range)", x=labels,
y=[m.value_unlocked_high - m.value_unlocked_low for m in moves],
base=[m.value_unlocked_low for m in moves],
customdata=[[n, html_money(m.value_unlocked_low) + " "
+ html_money(m.value_unlocked_high)]
for n, m in zip(names, moves)],
marker=dict(color=UNLOCKED, line=dict(width=2, color=SURFACE)),
hovertemplate="%{customdata[0]}<br>Unlocked: %{customdata[1]}<extra></extra>",
))
for i, m in enumerate(moves):
if m.est_cost_low is None:
fig.add_annotation(x=labels[i], y=0, yanchor="bottom",
text="cost not<br>configured", showarrow=False,
font=dict(size=11, color=MUTED))
fig.update_layout(barmode="group", bargap=0.35, bargroupgap=0.12)
fig.update_xaxes(tickfont=dict(size=12, color=INK2), showgrid=False,
tickangle=0)
fig.update_yaxes(tickformat="$~s", gridcolor=GRID, zerolinecolor=BASELINE,
tickfont=dict(color=MUTED))
diag_layout(fig, "Unlock sequence",
"sequential moves — each is the prerequisite of the next",
height=420)
return fig

View File

@@ -0,0 +1,111 @@
# CX AI Advisory Diagnostic — Build Spec v1.0
> Source record for this study (received 2026-07-19, owner Robert
> Helewka). Kept verbatim in intent; §2 layout was adapted to the Mercury
> Notebook Pattern (study package `diaglib/`, generated notebook,
> `pyproject.toml` instead of requirements.txt) — see README for the
> deviations log.
**Purpose:** Facilitator's cockpit for the CX AI Advisory diagnostic
workshop. Captures capability scores across 12 competencies, ingests
client operational baseline, computes value-at-stake bounded by
capability gaps, exports structured data.
**Users:** Single facilitator (Robert) running a live half-day workshop
with 48 client participants.
**NOT for:** Client self-service, unattended use, public deployment, SaaS.
## 1. Stack
Python 3.11+, Jupyter Notebook, Mercury (mljar-mercury), pandas, plotly
(matplotlib fallback), pydantic, PyYAML. Runs locally. No server, no
auth, no database.
## 2. Structure
Study package (`diaglib/`) with models / scoring / value_math / visuals /
export; `configs/` (base + industry overlays); one deliverable notebook;
gitignored `exports/`.
## 3. Data model
`Engagement` (id `client_slug_YYYY-MM-DD`, client, industry config,
facilitator, date, participants, baseline, scores, computed value,
notes) · `Participant` (name, role, function cx|it|ops|finance|other) ·
`OperationalBaseline` (annual_contact_volume, blended_cost_per_contact,
agent_headcount, annual_attrition_rate, current_containment_rate,
average_handle_time_seconds; optional csat_baseline, revenue_at_risk;
per-field confidence known|estimated|unknown) · `CompetencyScore`
(competency, dimension, score 15, evidence line, scorer role,
timestamp) · `ValueAtStake` (theoretical annual low/high, realizable
18-mo low/high, trapped low/high, binding constraints, unlock sequence)
· `UnlockMove` (competencies, level lift, cost range, weeks, value
unlocked range).
## 4. Configs
**base.yaml** — 12 competencies across 4 dimensions, each with
description, failure vignette, level descriptors 15; capping heuristic
(weakest foundational score → realization band): 1: 0.000.15,
2: 0.250.40, 3: 0.500.65, 4: 0.650.85, 5: 0.801.00; foundational
competencies: process_discovery, data_readiness, technical_architecture.
**contact_center.yaml** — value drivers: deflection/containment lift
(+1535 pts × volume × cost/contact), AHT reduction (1525%), attrition
reduction (1020%, $15K/replacement default); unlock costs per
level-lift per foundational competency (sparse OK).
**financial_services.yaml** — stub for MVP.
## 5. Notebook sections
0 Setup (hidden) · 1 Engagement form · 2 Operational baseline with
confidence flags · 3 Capability scoring, one screen per competency
(name, description, vignette, level descriptors; score + evidence) ·
4 Live analysis · 5 Visuals (heatmap, value bands, trapped/realizable
split, unlock chart) · 6 Export button → JSON + CSV.
## 6. Value math
theoretical = Σ driver ranges → realization band from weakest
foundational score → realizable_18mo = theoretical × factor × 1.5 →
trapped = theoretical realizable run-rate → binding constraints = all
foundational competencies at the weakest score → unlock sequence: lift
binding constraints one level, recompute factor, up to 3 moves.
Guard rails: all outputs are ranges; explicit warnings on 🔴-unknown
inputs; money display ≤ 2 significant figures.
## 78. Visuals & exports
Room-facing interactive plotly. Heatmap 4×3 with scores + evidence
hover; horizontal value bands (theoretical light / realizable dark,
trapped labeled); split chart; unlock cost-vs-value chart.
`exports/{engagement_id}.json` (full Engagement — source of truth) +
`.csv` (one row per competency: engagement_id, client_name, industry,
workshop_date, competency_id, dimension, score, evidence,
is_foundational, is_binding_constraint).
## 9. Non-goals (MVP)
No auth · no multi-stakeholder independent scoring (v1.1) · no LLM
recommendations · no PDF · no history dashboard · no cloud · no SaaS ·
no client-facing scoring.
## 10. Acceptance
Launch Mercury → contact_center → new engagement; enter client info +
48 participants; six baseline numbers with confidence flags; score all
12 competencies with evidence; four visuals render live; Export writes
valid JSON + CSV; (nice-to-have) reload saved JSON; edit YAML ranges →
recomputed outputs. Done = end-to-end in under 90 minutes with mock
inputs and sensible output.
## 12. Open questions (Robert)
- Benchmark citations for §4 ranges before first real client (Alan's
research subagent offer pending go-ahead).
- $15K cost-per-replacement default — config-driven, tune per engagement.
- Multi-stakeholder scoring — v1.1 if workshops routinely score
independently; don't build now.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "diaglib"
version = "0.1.0"
description = "CX AI Advisory Diagnostic — facilitator's workshop cockpit (Mercury Notebook Pattern)"
requires-python = ">=3.11"
# 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",
"pydantic>=2.5",
"PyYAML>=6.0",
]
[project.optional-dependencies]
dev = ["pytest>=7.4", "mypy>=1.8"]
[tool.setuptools.packages.find]
include = ["diaglib*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.mypy]
strict = true
packages = ["diaglib"]

View File

@@ -0,0 +1,630 @@
"""Generate notebooks/diagnostic.ipynb from source cell text.
The diagnostic has ~50 Mercury widgets (engagement form, six baseline
inputs with confidence flags, 12 score sliders + 12 evidence fields, an
export button). Hand-maintaining that JSON is error-prone, so the
notebook is *generated* — cell sources live here as readable Python
strings and nbformat writes valid JSON.
Re-run after editing any cell: python scripts/build_notebook.py
Then execute + export as usual (nbconvert / scripts/export_report.py).
This is a build tool, not the engine — all study logic stays in diaglib.
"""
from __future__ import annotations
import pathlib
import nbformat as nbf
ROOT = pathlib.Path(__file__).resolve().parent.parent
OUT = ROOT / "notebooks" / "diagnostic.ipynb"
# ── Cell sources ─────────────────────────────────────────────────────
MD_TITLE = """\
# CX AI Advisory Diagnostic
The facilitator's cockpit for the CX AI Advisory diagnostic workshop:
capture capability scores across **12 competencies** in four dimensions,
ingest the client's operational baseline, and watch the **value-at-stake
analysis** — bounded by the capability gaps — recompute live in the room.
**This notebook is the deliverable.** Serve it with
`mercury --working-dir .` from the study root and share the screen. Work
the sidebar top-to-bottom: engagement, baseline, then one competency at a
time. The stage shows the current competency's card, the heatmap, the
value analysis, and the unlock sequence. Click **Export JSON + CSV** at
the bottom to write the structured engagement record to `exports/`.
All content and math live in `diaglib/` and `configs/*.yaml` — the
notebook only arranges and renders them. Outputs are **ranges, never
point estimates**, and money displays at two significant figures.
Confidence legend: 🟢 known · 🟡 estimated · 🔴 unknown — flag each
baseline input; unknowns surface as explicit warnings in the analysis."""
SETUP = '''\
# ── Setup ──────────────────────────────────────────────────────────
import sys, pathlib
_ROOT = pathlib.Path.cwd()
if not (_ROOT / "diaglib").exists(): # notebook lives in notebooks/
_ROOT = _ROOT.parent
sys.path.insert(0, str(_ROOT))
import datetime as dt
import html as _html
import mercury as mr
import pandas as pd
from IPython.display import display
# Single source of truth — all math and content live in the library;
# only presentation (and Mercury widgets) lives here.
from diaglib import (
BASELINE_FIELDS, CONFIDENCE_ICON, CSV_COLUMNS,
OperationalBaseline, backstage, build_engagement, build_scores,
configs_dir, dimension_rollup, engagement_json, evidence_coverage,
heatmap_fig, heatmap_grid, list_industries, load_config, money,
parse_participants, scores_dataframe, split_fig, unlock_fig,
value_at_stake, value_bands_fig, write_exports,
)
pd.options.display.float_format = "{:,.0f}".format
CONFIGS_DIR = configs_dir(_ROOT)
EXPORTS_DIR = _ROOT / "exports"
INDUSTRIES = list_industries(CONFIGS_DIR)
# The competency model is industry-independent (overlays may not redefine
# it — the loader enforces that), so widgets can build from any config.
_BASE = load_config("contact_center", CONFIGS_DIR)
COMPETENCIES = _BASE.competencies
DIMENSION_NAME = {d.id: d.name for d in _BASE.dimensions}
# ── Brand palette (docs/brand.md, light theme) ─────────────────────
NAVY, INK, MUTED = "#151d2c", "#2e404d", "#586671"
BLUE, GREEN, LINE = "#0072bc", "#00a34c", "#e2e6e9"
CARD_BG, HAIRLINE, HILITE = "#f8f8f8", "#d5d9db", "#dcecfa"
FONT = "Georgia, 'Times New Roman', serif"
BODY_FONT = "Arial, 'Helvetica Neue', Helvetica, sans-serif"
def esc(s):
return _html.escape(str(s))
# ── Workshop seeds — the gate's mock scenario; overwrite live ──────
# Headless nbconvert renders every widget at its seed, so the seeds form
# a coherent, gate-passing scenario (Pattern §3).
SEED_CLIENT = "Acme Demo Co"
SEED_DATE = "2026-07-19"
SEED_FACILITATOR = "Robert Helewka"
SEED_PARTICIPANTS = ("Jane Example | VP Customer Experience | cx; "
"Sam Sample | Contact Center Ops Director | ops")
SEED_BASELINE = {
"annual_contact_volume": 1_200_000,
"blended_cost_per_contact": 6.50,
"agent_headcount": 450,
"annual_attrition_rate": 0.30,
"current_containment_rate": 0.20,
"average_handle_time_seconds": 420,
}
CONFIDENCE_CHOICES = ["🟢 known", "🟡 estimated", "🔴 unknown"]
SEED_CONFIDENCE = {
"annual_contact_volume": "🟢 known",
"blended_cost_per_contact": "🟡 estimated",
"agent_headcount": "🟢 known",
"annual_attrition_rate": "🟡 estimated",
"current_containment_rate": "🟡 estimated",
"average_handle_time_seconds": "🟢 known",
}
# Seed capability profile: data_readiness is the unique weakest
# foundation, so the demo shows a single binding constraint.
SEED_SCORES = {
"automation_ai_strategy": (2, "AI driven by board pressure; no written thesis"),
"value_realization": (2, "Business cases pre-investment only"),
"executive_alignment": (3, "COO owns CX AI; steering meets quarterly"),
"process_discovery": (3, "Top 10 call reasons mapped with volumes"),
"data_readiness": (2, "KB stale; interaction data siloed in recordings"),
"technical_architecture": (3, "CCaaS APIs available; shared integration layer WIP"),
"use_case_prioritization": (3, "Scored backlog reviewed monthly"),
"delivery_capability": (3, "Two bots in production via SI partner"),
"talent_and_skills": (2, "One conversation designer, contractor"),
"ai_operations": (2, "Containment eyeballed weekly, no drift alerts"),
"change_adoption": (3, "Agent champions for copilot rollout"),
"governance_and_risk": (3, "AI policy signed; review board for voice bots"),
}
# label, min, max, step per baseline field (explicit min/max — Pattern §3).
BASELINE_META = {
"annual_contact_volume": ("Annual contact volume", 0, 100_000_000, 10_000),
"blended_cost_per_contact": ("Blended cost per contact ($)", 0, 100, 0.25),
"agent_headcount": ("Agent headcount", 0, 100_000, 10),
"annual_attrition_rate": ("Annual attrition rate (0-1)", 0, 1, 0.01),
"current_containment_rate": ("Current containment rate (0-1)", 0, 1, 0.01),
"average_handle_time_seconds": ("Average handle time (seconds)", 0, 3600, 10),
}
backstage(f"diaglib loaded — {len(COMPETENCIES)} competencies · "
f"industries: {', '.join(INDUSTRIES)}")'''
MD_HOWTO = """\
## How to run this session
- **Sidebar §1 — Engagement.** Client, industry config, date, participants
(`Name | Role | Function` separated by `;` — functions: cx, it, ops,
finance, other), and a running notes box.
- **Sidebar §2 — Operational baseline.** Six numbers. If unknown, best
estimate is fine — set the confidence flag and the analysis will carry
the uncertainty explicitly.
- **Sidebar §3 — Capability scoring.** Pick **Now scoring**, read the
competency card on stage with the room, set the 15 slider, capture one
line of evidence, move to the next. The heatmap and value analysis
update live as you go.
- **Export.** The button at the bottom of the page writes
`exports/{engagement_id}.json` (source of truth) and `.csv` (flat
scores) — a deliberate snapshot at click time.
- **Backstage** (JupyterLab / nbconvert) — the verification gate and the
machine-readable appendix; neither shows on the Mercury stage."""
W_ENGAGEMENT = '''\
# ── 1 · Engagement setup (sidebar — widgets ONLY, no other output) ──
# Mercury re-runs only cells BELOW a changed widget: every .value is
# read in the state cell further down, never here (Pattern §3).
mr.Markdown("#### 1 · Engagement", position="sidebar")
_client_w = mr.TextInput(label="Client name", value=SEED_CLIENT)
_industry_w = mr.Select(label="Industry config", value="contact_center",
choices=INDUSTRIES)
_date_w = mr.DateInput(label="Workshop date", value=SEED_DATE)
_facilitator_w = mr.TextInput(label="Facilitator", value=SEED_FACILITATOR)
_participants_w = mr.TextInput(
label="Participants — Name | Role | Function; ...",
value=SEED_PARTICIPANTS)
_notes_w = mr.TextInput(label="Session notes", value="")'''
W_BASELINE = '''\
# ── 2 · Operational baseline (sidebar — widgets ONLY) ───────────────
# Six numbers + a confidence flag each. Explicit min/max on every
# NumberInput — Mercury clamps out-of-range seeds to a default range.
mr.Markdown("#### 2 · Operational baseline", position="sidebar")
_baseline_w, _conf_w = {}, {}
for _f in BASELINE_FIELDS:
_label, _min, _max, _step = BASELINE_META[_f]
_baseline_w[_f] = mr.NumberInput(label=_label, value=SEED_BASELINE[_f],
min=_min, max=_max, step=_step)
_conf_w[_f] = mr.Select(label=f"{_label} — confidence",
value=SEED_CONFIDENCE[_f],
choices=CONFIDENCE_CHOICES)'''
W_SCORING = '''\
# ── 3 · Capability scoring (sidebar — widgets ONLY) ─────────────────
# One screen per competency on stage: pick "Now scoring", read the card
# with the room, set the slider, capture one line of evidence. Labels
# are distinct per widget so Mercury's cache never collides.
mr.Markdown("#### 3 · Capability scoring", position="sidebar")
_now_scoring_w = mr.Select(
label="Now scoring",
value=f"1 · {COMPETENCIES[0].name}",
choices=[f"{_i + 1} · {_c.name}" for _i, _c in enumerate(COMPETENCIES)])
_score_w, _evidence_w = {}, {}
_prev_dim = None
for _c in COMPETENCIES:
if _c.dimension != _prev_dim:
mr.Markdown(f"**{DIMENSION_NAME[_c.dimension]}**", position="sidebar")
_prev_dim = _c.dimension
_score_w[_c.id] = mr.Slider(label=f"Score — {_c.name}",
min=1, max=5, value=SEED_SCORES[_c.id][0])
_evidence_w[_c.id] = mr.TextInput(label=f"Evidence — {_c.name}",
value=SEED_SCORES[_c.id][1])'''
STATE = '''\
# ── Session state (re-runs on any sidebar change) ───────────────────
# Read every widget .value; all computation happens in diaglib.
CLIENT_NAME = str(_client_w.value).strip() or SEED_CLIENT
INDUSTRY = str(_industry_w.value)
CONFIG = load_config(INDUSTRY, CONFIGS_DIR)
WORKSHOP_DATE = dt.date.fromisoformat(str(_date_w.value) or SEED_DATE)
PARTICIPANTS = parse_participants(str(_participants_w.value))
NOTES = str(_notes_w.value)
_vals = {f: float(_baseline_w[f].value) for f in BASELINE_FIELDS}
BASELINE = OperationalBaseline(
annual_contact_volume=int(_vals["annual_contact_volume"]),
blended_cost_per_contact=_vals["blended_cost_per_contact"],
agent_headcount=int(_vals["agent_headcount"]),
annual_attrition_rate=_vals["annual_attrition_rate"],
current_containment_rate=_vals["current_containment_rate"],
average_handle_time_seconds=int(_vals["average_handle_time_seconds"]),
field_confidence={f: str(_conf_w[f].value).split()[-1]
for f in BASELINE_FIELDS},
)
RAW_SCORES = {c.id: (int(_score_w[c.id].value), str(_evidence_w[c.id].value))
for c in COMPETENCIES}
SCORES = build_scores(CONFIG, RAW_SCORES, scored_at=dt.datetime.now())
VAS = value_at_stake(CONFIG, BASELINE, SCORES)
ENGAGEMENT = build_engagement(
config=CONFIG, client_name=CLIENT_NAME,
facilitator=str(_facilitator_w.value), workshop_date=WORKSHOP_DATE,
participants=PARTICIPANTS, baseline=BASELINE, scores=SCORES,
computed_value=VAS, notes=NOTES)
NOW_SCORING = COMPETENCIES[int(str(_now_scoring_w.value).split(" · ")[0]) - 1]
_done, _total = evidence_coverage(SCORES)
# One curated line on stage; warnings surface for the room; echo backstage.
if CONFIG.value_drivers:
_bind = ", ".join(CONFIG.competency(c).name for c in VAS.binding_constraints)
print(f"Value at stake: {money(VAS.theoretical_annual_value_low)}-"
f"{money(VAS.theoretical_annual_value_high)} theoretical per year · "
f"{money(VAS.realizable_18mo_low)}-{money(VAS.realizable_18mo_high)} "
f"realizable over 18 months — capped at level "
f"{VAS.weakest_foundational_score} by {_bind}")
for _warning in VAS.warnings:
print(f"{_warning}")
backstage(f"engagement {ENGAGEMENT.engagement_id} · "
f"{len(PARTICIPANTS)} participants · evidence {_done}/{_total}")'''
STAGE_HEADER = '''\
# ── Stage: engagement banner + baseline echo ────────────────────────
_chips = "".join(
f'<span style="display:inline-block;border:1px solid {HAIRLINE};'
f'border-radius:14px;padding:2px 10px;margin:2px 6px 2px 0;'
f'font:12px {BODY_FONT};color:{MUTED}">{esc(p.name)}'
+ (f" · {esc(p.role)}" if p.role else "")
+ f' <b style="color:{BLUE}">{esc(p.function)}</b></span>'
for p in PARTICIPANTS) or (
f'<span style="font:13px {BODY_FONT};color:{MUTED}">'
f'no participants captured yet</span>')
_rows = ""
for _f in BASELINE_FIELDS:
_v = getattr(BASELINE, _f)
_shown = f"{_v:,.2f}" if isinstance(_v, float) and _v < 10 else f"{_v:,.0f}"
_rows += (
f'<tr><td style="padding:3px 14px 3px 0;font:13px {BODY_FONT};'
f'color:{MUTED}">{BASELINE_META[_f][0]}</td>'
f'<td style="padding:3px 10px;font:600 13px {BODY_FONT};color:{INK};'
f'text-align:right">{_shown}</td>'
f'<td style="padding:3px 0">{CONFIDENCE_ICON[BASELINE.confidence_for(_f)]}'
f'</td></tr>')
_ = mr.Markdown(text=(
f'<div style="max-width:860px">'
f'<div style="font:700 24px {FONT};color:{NAVY};margin:4px 0 2px">'
f'{esc(CLIENT_NAME)} — CX AI Diagnostic</div>'
f'<div style="font:14px {BODY_FONT};color:{MUTED};margin-bottom:8px">'
f'{esc(CONFIG.display_name)} · {WORKSHOP_DATE.isoformat()} · '
f'facilitated by {esc(ENGAGEMENT.facilitator)} · '
f'evidence captured {_done}/{_total}</div>'
f'<div style="margin:6px 0 10px">{_chips}</div>'
f'<div style="border:1px solid {HAIRLINE};border-radius:10px;'
f'background:{CARD_BG};padding:10px 16px;display:inline-block">'
f'<div style="font:700 13px {FONT};color:{NAVY};margin-bottom:4px">'
f'Operational baseline</div>'
f'<table style="border-collapse:collapse">{_rows}</table></div></div>'))'''
STAGE_CARD = '''\
# ── Stage: the scoring screen (one competency at a time) ────────────
_c = NOW_SCORING
_score, _evidence = RAW_SCORES[_c.id]
# Progress strip: one box per competency — its current score, solid
# border once evidence is captured, highlighted while on screen.
_boxes = ""
for _i, _cc in enumerate(COMPETENCIES):
_s, _e = RAW_SCORES[_cc.id]
_bg = HILITE if _cc.id == _c.id else CARD_BG
_border = f"1px solid {HAIRLINE}" if not _e else f"1px solid {MUTED}"
if _cc.id == _c.id:
_border = f"2px solid {BLUE}"
_boxes += (
f'<span title="{esc(_cc.name)}" style="display:inline-block;'
f'width:30px;height:30px;line-height:28px;text-align:center;'
f'border:{_border};border-radius:6px;background:{_bg};'
f'font:600 14px {BODY_FONT};color:{INK};margin-right:5px">{_s}</span>')
_levels = ""
for _lvl in range(1, 6):
_sel = _lvl == _score
_levels += (
f'<tr><td style="padding:5px 12px;font:700 14px {BODY_FONT};'
f'color:{BLUE if _sel else MUTED};border-left:4px solid '
f'{BLUE if _sel else "transparent"};background:'
f'{HILITE if _sel else "transparent"}">{_lvl}</td>'
f'<td style="padding:5px 8px;font:{"600 " if _sel else ""}14px '
f'{BODY_FONT};color:{INK if _sel else MUTED};background:'
f'{HILITE if _sel else "transparent"}">'
f'{esc(_c.level_descriptors[_lvl])}</td></tr>')
_evidence_html = (
f'<span style="color:{GREEN}">✓</span> {esc(_evidence)}' if _evidence
else f'<span style="color:{MUTED}">no evidence captured yet — '
f'one line: what makes this a level {_score}?</span>')
_ = mr.Markdown(text=(
f'<div style="max-width:860px">'
f'<div style="margin:14px 0 8px">{_boxes}</div>'
f'<div style="border:1px solid {HAIRLINE};border-radius:10px;'
f'background:#ffffff;padding:16px 20px">'
f'<div style="font:600 12px {BODY_FONT};color:{BLUE};'
f'text-transform:uppercase;letter-spacing:.06em">'
f'{DIMENSION_NAME[_c.dimension]}</div>'
f'<div style="font:700 21px {FONT};color:{NAVY};margin:2px 0 6px">'
f'{esc(_c.name)}</div>'
f'<div style="font:15px {BODY_FONT};color:{INK};margin-bottom:6px">'
f'{esc(_c.description)}</div>'
f'<div style="font:italic 14px {FONT};color:{MUTED};margin-bottom:10px">'
f'"{esc(_c.failure_vignette)}"</div>'
f'<table style="border-collapse:collapse;width:100%">{_levels}</table>'
f'<div style="font:13px {BODY_FONT};color:{INK};margin-top:10px;'
f'border-top:1px solid {LINE};padding-top:8px">'
f'<b>Evidence:</b> {_evidence_html}</div>'
f'</div></div>'))'''
STAGE_ANALYSIS = '''\
# ── Stage: live analysis & visuals ──────────────────────────────────
heatmap_fig(heatmap_grid(CONFIG, SCORES)).show()
if CONFIG.value_drivers:
value_bands_fig(VAS).show()
split_fig(VAS).show()
unlock_fig(VAS, CONFIG).show()
# The unlock moves as a table — the room verifies numbers by
# reading them, not by trusting the bars.
UNLOCK_DF = pd.DataFrame([{
"Move": _i + 1,
"Competencies": " + ".join(CONFIG.competency(c).name
for c in _m.competency_ids),
"Lift": f"{_m.current_level}{_m.target_level}",
"Est. cost": (f"{money(_m.est_cost_low)} {money(_m.est_cost_high)}"
if _m.est_cost_low is not None else "not configured"),
"Weeks": _m.est_weeks if _m.est_weeks is not None else "",
"Annual value unlocked": (f"{money(_m.value_unlocked_low)} "
f"{money(_m.value_unlocked_high)}"),
"Note": _m.note,
} for _i, _m in enumerate(VAS.unlock_sequence)])
display(UNLOCK_DF.style.hide(axis="index"))
else:
_ = mr.Markdown(text=(
f'<div style="border:1px solid {HAIRLINE};border-radius:10px;'
f'background:{CARD_BG};padding:12px 16px;max-width:860px;'
f'font:14px {BODY_FONT};color:{MUTED}">'
f'<b style="color:{INK}">{esc(CONFIG.display_name)}</b> has no value '
f'drivers configured yet — capability scoring works normally, but '
f'value-at-stake needs drivers in '
f'<code>configs/{esc(INDUSTRY)}.yaml</code>.</div>'))'''
MD_EXPORT = """\
## Export
The button writes `exports/{engagement_id}.json` — the full engagement
record, the source-of-truth artifact — and `exports/{engagement_id}.csv`,
one row per competency for cross-engagement analysis. A **deliberate
snapshot**: it captures the state at click time; click again after
changes to refresh."""
W_EXPORT = '''\
# ── Export trigger (widgets ONLY — the click is handled below) ──────
_export_w = mr.Button(label="Export JSON + CSV", position="inline")'''
EXPORT_STATE = '''\
# ── Export on click — snapshot semantics ────────────────────────────
# Kernel globals persist across Mercury re-runs; the guard writes
# exactly once per click, at whatever state the cockpit showed then.
try:
_LAST_EXPORT_CLICKS
except NameError:
_LAST_EXPORT_CLICKS = 0
if int(_export_w.n_clicks) > _LAST_EXPORT_CLICKS:
_json_path, _csv_path = write_exports(ENGAGEMENT, CONFIG, EXPORTS_DIR)
_LAST_EXPORT_CLICKS = int(_export_w.n_clicks)
print(f"Exported {_json_path.name} + {_csv_path.name} → exports/")
else:
backstage("No export click this run — engagement exports come from the "
"stage button; report sources from scripts/export_report.py.")'''
MD_GATE = """\
## Verification & assertions
Engine pins use the explicit seed scenario, independent of the sidebar,
so the gate tests `diaglib` + `configs/`, not the current session;
live-state pins are guarded so a facilitator moving a slider never
crashes the room; structural ties hold at **any** widget state. This
cell must pass under headless `nbconvert --execute` — it is the study's
smoke test. Output renders backstage only."""
GATE = '''\
# ── Verification gate — must pass under headless nbconvert ──────────
def _approx(got, want, tol=0.5):
assert abs(got - want) <= tol, f"got {got:,.2f}, want {want:,.2f}"
# Engine pins — EXPLICIT seed scenario, independent of widget state.
# Hand arithmetic in tests/test_value_math.py (same scenario).
_gate_cfg = load_config("contact_center", CONFIGS_DIR)
_gate_baseline = OperationalBaseline(
annual_contact_volume=1_200_000, blended_cost_per_contact=6.50,
agent_headcount=450, annual_attrition_rate=0.30,
current_containment_rate=0.20, average_handle_time_seconds=420)
_gate_scores = build_scores(_gate_cfg, SEED_SCORES,
scored_at=dt.datetime(2026, 7, 19, 9, 0))
_gate_vas = value_at_stake(_gate_cfg, _gate_baseline, _gate_scores)
_approx(_gate_vas.theoretical_annual_value_low, 2_542_500)
_approx(_gate_vas.theoretical_annual_value_high, 5_085_000)
_approx(_gate_vas.realizable_18mo_low, 953_437.50)
_approx(_gate_vas.realizable_18mo_high, 3_051_000)
_approx(_gate_vas.trapped_value_low, 1_525_500)
_approx(_gate_vas.trapped_value_high, 3_813_750)
assert _gate_vas.binding_constraints == ["data_readiness"]
assert len(_gate_vas.unlock_sequence) == 3
_m1 = _gate_vas.unlock_sequence[0]
assert _m1.competency_ids == ["data_readiness"]
assert (_m1.est_cost_low, _m1.est_cost_high, _m1.est_weeks) == (300_000, 600_000, 12)
_approx(_m1.value_unlocked_low, 635_625)
_approx(_m1.value_unlocked_high, 1_271_250)
# Live-state pins — guarded, so a moved slider can't crash the room.
_at_default = (
INDUSTRY == "contact_center"
and all(getattr(BASELINE, f) == getattr(_gate_baseline, f)
for f in BASELINE_FIELDS)
and all(RAW_SCORES[k][0] == SEED_SCORES[k][0] for k in SEED_SCORES))
if _at_default:
_approx(VAS.theoretical_annual_value_low, 2_542_500)
_approx(VAS.realizable_18mo_high, 3_051_000)
_approx(VAS.trapped_value_high, 3_813_750)
# Structural ties — hold at ANY widget state.
assert len(SCORES) == 12 and len({s.competency_id for s in SCORES}) == 12
assert VAS.theoretical_annual_value_low <= VAS.theoretical_annual_value_high
assert VAS.realizable_18mo_low <= VAS.realizable_18mo_high
assert VAS.trapped_value_low <= VAS.trapped_value_high
_approx(sum(d.theoretical_low for d in VAS.driver_values),
VAS.theoretical_annual_value_low)
_approx(sum(d.theoretical_high for d in VAS.driver_values),
VAS.theoretical_annual_value_high)
_approx(VAS.realizable_18mo_low,
VAS.theoretical_annual_value_low * VAS.realization_factor_low * 1.5)
_approx(VAS.realizable_18mo_high,
VAS.theoretical_annual_value_high * VAS.realization_factor_high * 1.5)
assert set(VAS.binding_constraints) <= set(CONFIG.foundational_competencies)
assert len(VAS.unlock_sequence) <= 3
# Export payload is serializable and matches the live session.
import json as _json
_payload = _json.loads(engagement_json(ENGAGEMENT))
assert _payload["engagement_id"] == ENGAGEMENT.engagement_id
assert len(_payload["scores"]) == 12
_df = scores_dataframe(ENGAGEMENT, CONFIG)
assert list(_df.columns) == CSV_COLUMNS and len(_df) == 12
backstage("All assertions passed.")'''
MD_APPENDIX = """\
## Data appendix — for the machines
The full engagement as markdown tables plus one JSON block of state, so
the exported report is complete LLM input — and the future Athena
study-export payload. Renders **backstage** — hidden on the Mercury
stage."""
APPENDIX = '''\
# ── Data appendix — LLM-readable dump of the engagement ─────────────
# Renders backstage only (JupyterLab / nbconvert exports).
backstage("#### Capability scores\\n")
_rows = ["| Competency | Dimension | Score | Evidence |", "|---|---|---:|---|"]
for _s in SCORES:
_cc = CONFIG.competency(_s.competency_id)
_ev = _s.evidence.replace("|", "\\\\|") or ""
_rows.append(f"| {_cc.name} | {DIMENSION_NAME[_s.dimension]} | "
f"{_s.score} | {_ev} |")
backstage("\\n".join(_rows))
backstage("\\n#### Dimension rollup\\n")
_rows = ["| Dimension | Mean score |", "|---|---:|"]
for _did, _dname, _mean in dimension_rollup(CONFIG, SCORES):
_rows.append(f"| {_dname} | {_mean:.2f} |")
backstage("\\n".join(_rows))
if CONFIG.value_drivers:
backstage("\\n#### Value drivers (theoretical annual)\\n")
_rows = ["| Driver | Low | High |", "|---|---:|---:|"]
for _d in VAS.driver_values:
_rows.append(f"| {_d.name} | {money(_d.theoretical_low)} | "
f"{money(_d.theoretical_high)} |")
_rows.append(f"| **Total** | **{money(VAS.theoretical_annual_value_low)}** "
f"| **{money(VAS.theoretical_annual_value_high)}** |")
backstage("\\n".join(_rows))
backstage(f"\\nRealization band at weakest foundation level "
f"{VAS.weakest_foundational_score}: "
f"{VAS.realization_factor_low:.0%}-{VAS.realization_factor_high:.0%} · "
f"realizable 18-mo {money(VAS.realizable_18mo_low)}-"
f"{money(VAS.realizable_18mo_high)} · trapped "
f"{money(VAS.trapped_value_low)}-{money(VAS.trapped_value_high)} "
f"per year · binding: {', '.join(VAS.binding_constraints)}")
backstage("\\n#### Engagement state (JSON)\\n")
backstage("```json")
backstage(engagement_json(ENGAGEMENT))
backstage("```")'''
REVIEW = '''\
# ── Review a saved engagement (backstage utility, optional) ─────────
# Point _REVIEW_JSON at an exports/*.json and run in JupyterLab to
# reload a past engagement for review; the live session is untouched.
_REVIEW_JSON = ""
if _REVIEW_JSON:
from diaglib import load_engagement
_prev = load_engagement(pathlib.Path(_REVIEW_JSON))
backstage(f"loaded {_prev.engagement_id}: {_prev.client_name} · "
f"{len(_prev.scores)} scores · " + (
"binding: " + ", ".join(_prev.computed_value.binding_constraints)
if _prev.computed_value else "no computed value"))'''
def md(source: str) -> nbf.NotebookNode:
return nbf.v4.new_markdown_cell(source)
def code(source: str) -> nbf.NotebookNode:
return nbf.v4.new_code_cell(source)
def build() -> nbf.NotebookNode:
nb = nbf.v4.new_notebook()
nb.cells = [
md(MD_TITLE),
code(SETUP),
md(MD_HOWTO),
code(W_ENGAGEMENT),
code(W_BASELINE),
code(W_SCORING),
code(STATE),
code(STAGE_HEADER),
code(STAGE_CARD),
code(STAGE_ANALYSIS),
md(MD_EXPORT),
code(W_EXPORT),
code(EXPORT_STATE),
md(MD_GATE),
code(GATE),
md(MD_APPENDIX),
code(APPENDIX),
code(REVIEW),
]
nb.metadata = {
"kernelspec": {"display_name": "Python 3", "language": "python",
"name": "python3"},
"language_info": {"name": "python"},
}
return nb
def main() -> None:
OUT.parent.mkdir(parents=True, exist_ok=True)
nbf.write(build(), OUT)
print(f"wrote {OUT.relative_to(ROOT)} ({len(build().cells)} cells)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,47 @@
"""Export the deliverable notebook as LLM-readable report sources.
Executes the notebook fresh (widget seeds — the mock scenario, or
whatever seeds you edit in), then writes both formats to exports/:
exports/diagnostic.html — human-reviewable, tables render
exports/diagnostic.md — leanest LLM input
Plotly figures export as JavaScript an LLM cannot read; the notebook's
machine-readable appendix section carries every number behind them.
Run from the project root: python scripts/export_report.py [name-filter]
An optional argument exports only notebooks whose filename contains it.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
NOTEBOOKS = [
ROOT / "notebooks" / "diagnostic.ipynb",
]
EXPORTS = ROOT / "exports"
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)
for nb in picked:
for fmt in ("html", "markdown"):
subprocess.run(
[sys.executable, "-m", "nbconvert", "--execute",
"--to", fmt, "--output-dir", str(EXPORTS), str(nb)],
check=True, cwd=ROOT,
)
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()

View File

@@ -0,0 +1,70 @@
"""Shared fixtures: the mock workshop scenario every pin is hand-checked
against (see test_value_math for the arithmetic). Also makes diaglib
importable without the study venv active (normal setup is
``pip install -e ".[dev]"`` into the study-local ``.venv/``)."""
import pathlib
import sys
from datetime import datetime
import pytest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from diaglib import OperationalBaseline, build_scores, load_config # noqa: E402
CONFIGS = pathlib.Path(__file__).resolve().parent.parent / "configs"
SCORED_AT = datetime(2026, 7, 19, 9, 0)
#: The seed capability profile: data_readiness is the unique weakest
#: foundation (level 2), so the binding constraint is a single competency.
SEED_SCORES = {
"automation_ai_strategy": (2, "AI driven by board pressure; no written thesis"),
"value_realization": (2, "Business cases pre-investment only"),
"executive_alignment": (3, "COO owns CX AI; steering meets quarterly"),
"process_discovery": (3, "Top 10 call reasons mapped with volumes"),
"data_readiness": (2, "KB stale; interaction data siloed in recordings"),
"technical_architecture": (3, "CCaaS APIs available; shared integration layer WIP"),
"use_case_prioritization": (3, "Scored backlog reviewed monthly"),
"delivery_capability": (3, "Two bots in production via SI partner"),
"talent_and_skills": (2, "One conversation designer, contractor"),
"ai_operations": (2, "Containment eyeballed weekly, no drift alerts"),
"change_adoption": (3, "Agent champions for copilot rollout"),
"governance_and_risk": (3, "AI policy signed; review board for voice bots"),
}
@pytest.fixture(scope="session")
def config():
return load_config("contact_center", CONFIGS)
@pytest.fixture(scope="session")
def stub_config():
return load_config("financial_services", CONFIGS)
@pytest.fixture()
def baseline():
return OperationalBaseline(
annual_contact_volume=1_200_000,
blended_cost_per_contact=6.50,
agent_headcount=450,
annual_attrition_rate=0.30,
current_containment_rate=0.20,
average_handle_time_seconds=420,
field_confidence={
"annual_contact_volume": "known",
"blended_cost_per_contact": "estimated",
"agent_headcount": "known",
"annual_attrition_rate": "estimated",
"current_containment_rate": "estimated",
"average_handle_time_seconds": "known",
},
)
@pytest.fixture()
def scores(config):
return build_scores(config, SEED_SCORES, scored_at=SCORED_AT)

View File

@@ -0,0 +1,73 @@
"""Config loading and validation — the instrument's shape can't drift."""
import shutil
import pytest
from diaglib import list_industries, load_config
from tests.conftest import CONFIGS
def test_contact_center_loads(config):
assert config.industry == "contact_center"
assert config.version == "1.0"
assert len(config.competencies) == 12
assert [d.id for d in config.dimensions] == [
"strategy_value", "foundations", "delivery", "sustain"]
# 4 dimensions × 3 competencies — the heatmap contract
for d in config.dimensions:
assert sum(1 for c in config.competencies if c.dimension == d.id) == 3
assert config.foundational_competencies == [
"process_discovery", "data_readiness", "technical_architecture"]
assert [d.id for d in config.value_drivers] == [
"deflection_lift", "aht_reduction", "attrition_reduction"]
def test_capping_bands_pinned(config):
bands = {k: (v.realized_low, v.realized_high)
for k, v in config.capping_heuristic.items()}
assert bands == {
1: (0.00, 0.15), 2: (0.25, 0.40), 3: (0.50, 0.65),
4: (0.65, 0.85), 5: (0.80, 1.00),
}
def test_every_competency_has_five_levels_and_vignette(config):
for c in config.competencies:
assert set(c.level_descriptors) == {1, 2, 3, 4, 5}
assert c.failure_vignette
assert c.description
def test_unlock_costs_reachable(config):
# Every foundational competency can be lifted 1→5 in the CC config.
for cid in config.foundational_competencies:
for level in (1, 2, 3, 4):
lift = config.lift_cost(cid, level)
assert lift is not None, f"{cid} lift {level}->{level + 1} missing"
assert lift.cost_low <= lift.cost_high
def test_stub_config_loads(stub_config):
assert stub_config.industry == "financial_services"
assert stub_config.value_drivers == []
assert len(stub_config.competencies) == 12 # model comes from base
def test_list_industries():
assert list_industries(CONFIGS) == ["contact_center", "financial_services"]
def test_overlay_may_not_redefine_base_keys(tmp_path):
shutil.copy(CONFIGS / "base.yaml", tmp_path / "base.yaml")
(tmp_path / "rogue.yaml").write_text(
"extends: base\nindustry: rogue\ncompetencies: []\n", encoding="utf-8")
with pytest.raises(ValueError, match="base-only"):
load_config("rogue", tmp_path)
def test_overlay_must_extend_base(tmp_path):
shutil.copy(CONFIGS / "base.yaml", tmp_path / "base.yaml")
(tmp_path / "loner.yaml").write_text("industry: loner\n", encoding="utf-8")
with pytest.raises(ValueError, match="extends"):
load_config("loner", tmp_path)

View File

@@ -0,0 +1,52 @@
"""Export contracts — JSON round-trips, CSV shape pinned to the spec."""
from datetime import date
import pandas as pd
from diaglib import (
CSV_COLUMNS,
build_engagement,
load_engagement,
parse_participants,
scores_dataframe,
value_at_stake,
write_exports,
)
def _engagement(config, baseline, scores):
return build_engagement(
config=config, client_name="Acme Demo Co", facilitator="Robert Helewka",
workshop_date=date(2026, 7, 19),
participants=parse_participants(
"Jane Example | VP Customer Experience | cx; Sam Sample | Ops Director | ops"),
baseline=baseline, scores=scores,
computed_value=value_at_stake(config, baseline, scores))
def test_write_exports_and_reload(config, baseline, scores, tmp_path):
eng = _engagement(config, baseline, scores)
json_path, csv_path = write_exports(eng, config, tmp_path)
assert json_path.name == "acme_demo_co_2026-07-19.json"
assert csv_path.name == "acme_demo_co_2026-07-19.csv"
# JSON is the source-of-truth artifact — it must round-trip losslessly.
reloaded = load_engagement(json_path)
assert reloaded == eng
df = pd.read_csv(csv_path)
assert list(df.columns) == CSV_COLUMNS
assert len(df) == 12
def test_scores_dataframe_flags(config, baseline, scores):
df = scores_dataframe(_engagement(config, baseline, scores), config)
by_id = df.set_index("competency_id")
assert bool(by_id.loc["data_readiness", "is_foundational"])
assert bool(by_id.loc["data_readiness", "is_binding_constraint"])
assert bool(by_id.loc["process_discovery", "is_foundational"])
assert not bool(by_id.loc["process_discovery", "is_binding_constraint"])
assert not bool(by_id.loc["automation_ai_strategy", "is_foundational"])
assert (df["engagement_id"] == "acme_demo_co_2026-07-19").all()
assert (df["industry"] == "contact_center").all()

View File

@@ -0,0 +1,85 @@
"""Scoring, parsing, and assembly — the glue the notebook leans on."""
from datetime import date
import pytest
from diaglib import (
build_engagement,
build_scores,
dimension_rollup,
evidence_coverage,
heatmap_grid,
make_engagement_id,
parse_participants,
value_at_stake,
)
from tests.conftest import SCORED_AT, SEED_SCORES
approx = pytest.approx
def test_engagement_id_slug():
assert make_engagement_id("Acme", date(2026, 7, 19)) == "acme_2026-07-19"
assert make_engagement_id(" Acme & Söhne GmbH! ",
date(2026, 7, 19)) == "acme_s_hne_gmbh_2026-07-19"
assert make_engagement_id("", date(2026, 7, 19)) == "client_2026-07-19"
def test_parse_participants_forgiving():
got = parse_participants(
"Jane Example | VP Customer Experience | cx; "
"Raj Patel|CIO|IT; Sam Sample | Ops Director; Solo")
assert [(p.name, p.role, p.function) for p in got] == [
("Jane Example", "VP Customer Experience", "cx"),
("Raj Patel", "CIO", "it"), # case-normalized
("Sam Sample", "Ops Director", "other"), # function missing
("Solo", "", "other"),
]
assert parse_participants("") == []
assert parse_participants(" ; ; ") == []
def test_build_scores_orders_and_validates(config):
scores = build_scores(config, SEED_SCORES, scored_at=SCORED_AT)
assert [s.competency_id for s in scores] == [c.id for c in config.competencies]
assert all(s.scored_at == SCORED_AT for s in scores)
with pytest.raises(ValueError, match="unscored"):
build_scores(config, {"data_readiness": (3, "")}, scored_at=SCORED_AT)
def test_dimension_rollup_pins(config, scores):
rollup = {dim_id: mean for dim_id, _, mean in dimension_rollup(config, scores)}
assert rollup["strategy_value"] == approx((2 + 2 + 3) / 3)
assert rollup["foundations"] == approx((3 + 2 + 3) / 3)
assert rollup["delivery"] == approx((3 + 3 + 2) / 3)
assert rollup["sustain"] == approx((2 + 3 + 3) / 3)
def test_evidence_coverage(config, scores):
assert evidence_coverage(scores) == (12, 12) # fixture captures all evidence
blank = [s.model_copy(update={"evidence": ""}) for s in scores[:3]] + scores[3:]
assert evidence_coverage(blank) == (9, 12)
def test_heatmap_grid_shape(config, scores):
grid = heatmap_grid(config, scores)
assert grid["rows"] == ["Strategy & Value", "Foundations", "Delivery", "Sustain"]
assert [len(r) for r in grid["z"]] == [3, 3, 3, 3]
assert grid["z"][1] == [3, 2, 3] # foundations row: pd, dr, ta
assert "Data Readiness" in grid["text"][1][1]
assert "KB stale" in grid["hover"][1][1] # evidence surfaces on hover
def test_build_engagement_assembles(config, baseline, scores):
vas = value_at_stake(config, baseline, scores)
eng = build_engagement(
config=config, client_name="Acme Demo Co", facilitator="Robert Helewka",
workshop_date=date(2026, 7, 19),
participants=parse_participants("Jane Example | VP CX | cx"),
baseline=baseline, scores=scores, computed_value=vas,
notes="dry run")
assert eng.engagement_id == "acme_demo_co_2026-07-19"
assert eng.industry_config == "contact_center"
assert len(eng.scores) == 12
assert eng.computed_value.binding_constraints == ["data_readiness"]

View File

@@ -0,0 +1,15 @@
"""Stage/backstage detection — Mercury kernels carry MERCURY_CONFIG_DIR."""
from diaglib import staging
def test_backstage_prints_only_off_stage(monkeypatch, capsys):
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
assert not staging.on_stage()
staging.backstage("visible")
assert capsys.readouterr().out == "visible\n"
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
assert staging.on_stage()
staging.backstage("hidden")
assert capsys.readouterr().out == ""

View File

@@ -0,0 +1,145 @@
"""Value-math pins — every number hand-checked before pinning.
Mock scenario (the notebook's widget seeds use the same values):
volume 1,200,000 · $6.50/contact · 450 agents · 30% attrition ·
20% containment · 420s AHT · weakest foundation = data_readiness @ 2
Hand arithmetic:
deflection low 1.2M × 0.15 × 6.50 = 1,170,000 high ×0.35 = 2,730,000
AHT low 1.2M × 6.50 × 0.15 = 1,170,000 high ×0.25 = 1,950,000
attrition low 450 × 0.30 × 0.10 × 15,000 = 202,500 high ×0.20 = 405,000
theoretical low 2,542,500 high 5,085,000
band @2 = (0.25, 0.40)
realizable 18mo low 2,542,500 × 0.25 × 1.5 = 953,437.50
high 5,085,000 × 0.40 × 1.5 = 3,051,000
trapped (annual) low 2,542,500 × (10.40) = 1,525,500
high 5,085,000 × (10.25) = 3,813,750
"""
import pytest
from diaglib import (
binding_constraints,
driver_value,
money,
value_at_stake,
weakest_foundational_score,
)
approx = pytest.approx
def test_driver_pins(config, baseline):
by_id = {d.id: driver_value(d, baseline) for d in config.value_drivers}
assert by_id["deflection_lift"].theoretical_low == approx(1_170_000)
assert by_id["deflection_lift"].theoretical_high == approx(2_730_000)
assert by_id["aht_reduction"].theoretical_low == approx(1_170_000)
assert by_id["aht_reduction"].theoretical_high == approx(1_950_000)
assert by_id["attrition_reduction"].theoretical_low == approx(202_500)
assert by_id["attrition_reduction"].theoretical_high == approx(405_000)
def test_value_at_stake_pins(config, baseline, scores):
vas = value_at_stake(config, baseline, scores)
assert vas.theoretical_annual_value_low == approx(2_542_500)
assert vas.theoretical_annual_value_high == approx(5_085_000)
assert vas.weakest_foundational_score == 2
assert (vas.realization_factor_low, vas.realization_factor_high) == (0.25, 0.40)
assert vas.realizable_18mo_low == approx(953_437.50)
assert vas.realizable_18mo_high == approx(3_051_000)
assert vas.trapped_value_low == approx(1_525_500)
assert vas.trapped_value_high == approx(3_813_750)
assert vas.binding_constraints == ["data_readiness"]
assert vas.warnings == [] # nothing flagged unknown in the fixture
def test_unlock_sequence_pins(config, baseline, scores):
vas = value_at_stake(config, baseline, scores)
m1, m2, m3 = vas.unlock_sequence
# Move 1 — the unique weakest foundation lifts alone.
assert m1.competency_ids == ["data_readiness"]
assert (m1.current_level, m1.target_level) == (2, 3)
assert (m1.est_cost_low, m1.est_cost_high, m1.est_weeks) == (300_000, 600_000, 12)
assert m1.value_unlocked_low == approx(2_542_500 * 0.25) # 635,625
assert m1.value_unlocked_high == approx(5_085_000 * 0.25) # 1,271,250
# Move 2 — all three foundations now tie at 3: joint lift.
assert m2.competency_ids == [
"process_discovery", "data_readiness", "technical_architecture"]
assert (m2.current_level, m2.target_level) == (3, 4)
assert m2.est_cost_low == approx(150_000 + 400_000 + 250_000) # 800,000
assert m2.est_cost_high == approx(300_000 + 800_000 + 500_000) # 1,600,000
assert m2.est_weeks == 16 # longest workstream
assert m2.value_unlocked_low == approx(2_542_500 * 0.15) # 381,375
assert m2.value_unlocked_high == approx(5_085_000 * 0.20) # 1,017,000
assert "joint lift" in m2.note
# Move 3 — the trio lifts again, 4 → 5.
assert (m3.current_level, m3.target_level) == (4, 5)
assert m3.est_cost_low == approx(200_000 + 500_000 + 350_000) # 1,050,000
assert m3.est_cost_high == approx(400_000 + 1_000_000 + 700_000) # 2,100,000
assert m3.est_weeks == 20
assert m3.value_unlocked_low == approx(2_542_500 * 0.15)
assert m3.value_unlocked_high == approx(5_085_000 * 0.15) # 762,750
# The ratio walk declines — the first unlock is the cheapest value.
ratios = [((m.value_unlocked_low + m.value_unlocked_high) / 2)
/ ((m.est_cost_low + m.est_cost_high) / 2)
for m in (m1, m2, m3)]
assert ratios[0] > ratios[1] > ratios[2]
def test_structural_ties_hold_at_any_scores(config, baseline, scores):
vas = value_at_stake(config, baseline, scores)
assert vas.theoretical_annual_value_low <= vas.theoretical_annual_value_high
assert vas.realizable_18mo_low <= vas.realizable_18mo_high
assert vas.trapped_value_low <= vas.trapped_value_high
assert sum(d.theoretical_low for d in vas.driver_values) == approx(
vas.theoretical_annual_value_low)
assert sum(d.theoretical_high for d in vas.driver_values) == approx(
vas.theoretical_annual_value_high)
assert set(vas.binding_constraints) <= set(config.foundational_competencies)
assert len(vas.unlock_sequence) <= 3
def test_weakest_and_binding_with_ties(config, baseline, scores):
assert weakest_foundational_score(config, scores) == 2
# Drag process_discovery down to 2 as well — binding set becomes a pair.
tied = [s.model_copy(update={"score": 2})
if s.competency_id == "process_discovery" else s for s in scores]
assert binding_constraints(config, tied) == ["process_discovery", "data_readiness"]
vas = value_at_stake(config, baseline, tied)
m1 = vas.unlock_sequence[0]
assert m1.competency_ids == ["process_discovery", "data_readiness"]
assert "joint lift" in m1.note
assert m1.est_cost_low == approx(120_000 + 300_000)
def test_unknown_inputs_raise_warnings(config, baseline, scores):
flagged = baseline.model_copy(update={"field_confidence": {
**baseline.field_confidence, "annual_contact_volume": "unknown"}})
vas = value_at_stake(config, flagged, scores)
assert any("annual_contact_volume" in w and "unknown" in w for w in vas.warnings)
def test_stub_config_yields_empty_value(stub_config, baseline, scores):
vas = value_at_stake(stub_config, baseline, scores)
assert vas.theoretical_annual_value_low == 0
assert vas.theoretical_annual_value_high == 0
assert vas.unlock_sequence == []
assert any("no value drivers" in w for w in vas.warnings)
def test_money_two_significant_figures():
assert money(953_437.50) == "$950K"
assert money(2_542_500) == "$2.5M"
assert money(1_271_250) == "$1.3M"
assert money(5_085_000) == "$5.1M"
assert money(15_000_000) == "$15M"
assert money(202_500) == "$200K"
assert money(-450_000) == "-$450K"
assert money(85) == "$85"
assert money(0) == "$0"