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:
74
assessments/CX_AI_Diagnostic/README.md
Normal file
74
assessments/CX_AI_Diagnostic/README.md
Normal 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, 4–8 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.
|
||||
73
assessments/CX_AI_Diagnostic/config.toml
Normal file
73
assessments/CX_AI_Diagnostic/config.toml
Normal 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)"
|
||||
184
assessments/CX_AI_Diagnostic/configs/base.yaml
Normal file
184
assessments/CX_AI_Diagnostic/configs/base.yaml
Normal 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
|
||||
62
assessments/CX_AI_Diagnostic/configs/contact_center.yaml
Normal file
62
assessments/CX_AI_Diagnostic/configs/contact_center.yaml
Normal 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}
|
||||
14
assessments/CX_AI_Diagnostic/configs/financial_services.yaml
Normal file
14
assessments/CX_AI_Diagnostic/configs/financial_services.yaml
Normal 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: {}
|
||||
62
assessments/CX_AI_Diagnostic/diaglib/__init__.py
Normal file
62
assessments/CX_AI_Diagnostic/diaglib/__init__.py
Normal 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",
|
||||
]
|
||||
77
assessments/CX_AI_Diagnostic/diaglib/config.py
Normal file
77
assessments/CX_AI_Diagnostic/diaglib/config.py
Normal 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)
|
||||
62
assessments/CX_AI_Diagnostic/diaglib/export.py
Normal file
62
assessments/CX_AI_Diagnostic/diaglib/export.py
Normal 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"))
|
||||
281
assessments/CX_AI_Diagnostic/diaglib/models.py
Normal file
281
assessments/CX_AI_Diagnostic/diaglib/models.py
Normal 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 1–5")
|
||||
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 1–5")
|
||||
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)
|
||||
133
assessments/CX_AI_Diagnostic/diaglib/scoring.py
Normal file
133
assessments/CX_AI_Diagnostic/diaglib/scoring.py
Normal 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,
|
||||
)
|
||||
29
assessments/CX_AI_Diagnostic/diaglib/staging.py
Normal file
29
assessments/CX_AI_Diagnostic/diaglib/staging.py
Normal 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)
|
||||
228
assessments/CX_AI_Diagnostic/diaglib/value_math.py
Normal file
228
assessments/CX_AI_Diagnostic/diaglib/value_math.py
Normal 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("$", "$")
|
||||
|
||||
|
||||
# ── 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 1–8 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,
|
||||
)
|
||||
208
assessments/CX_AI_Diagnostic/diaglib/visuals.py
Normal file
208
assessments/CX_AI_Diagnostic/diaglib/visuals.py
Normal 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 1–5 · 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
|
||||
111
assessments/CX_AI_Diagnostic/docs/build_spec_v1.md
Normal file
111
assessments/CX_AI_Diagnostic/docs/build_spec_v1.md
Normal 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 4–8 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 1–5, 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 1–5; capping heuristic
|
||||
(weakest foundational score → realization band): 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; foundational
|
||||
competencies: process_discovery, data_readiness, technical_architecture.
|
||||
|
||||
**contact_center.yaml** — value drivers: deflection/containment lift
|
||||
(+15–35 pts × volume × cost/contact), AHT reduction (15–25%), attrition
|
||||
reduction (10–20%, $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.
|
||||
|
||||
## 7–8. 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 +
|
||||
4–8 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.
|
||||
0
assessments/CX_AI_Diagnostic/exports/.gitkeep
Normal file
0
assessments/CX_AI_Diagnostic/exports/.gitkeep
Normal file
10520
assessments/CX_AI_Diagnostic/notebooks/diagnostic.ipynb
Normal file
10520
assessments/CX_AI_Diagnostic/notebooks/diagnostic.ipynb
Normal file
File diff suppressed because one or more lines are too long
38
assessments/CX_AI_Diagnostic/pyproject.toml
Normal file
38
assessments/CX_AI_Diagnostic/pyproject.toml
Normal 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"]
|
||||
630
assessments/CX_AI_Diagnostic/scripts/build_notebook.py
Normal file
630
assessments/CX_AI_Diagnostic/scripts/build_notebook.py
Normal 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 1–5 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()
|
||||
47
assessments/CX_AI_Diagnostic/scripts/export_report.py
Normal file
47
assessments/CX_AI_Diagnostic/scripts/export_report.py
Normal 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()
|
||||
70
assessments/CX_AI_Diagnostic/tests/conftest.py
Normal file
70
assessments/CX_AI_Diagnostic/tests/conftest.py
Normal 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)
|
||||
73
assessments/CX_AI_Diagnostic/tests/test_config.py
Normal file
73
assessments/CX_AI_Diagnostic/tests/test_config.py
Normal 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)
|
||||
52
assessments/CX_AI_Diagnostic/tests/test_export.py
Normal file
52
assessments/CX_AI_Diagnostic/tests/test_export.py
Normal 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()
|
||||
85
assessments/CX_AI_Diagnostic/tests/test_scoring.py
Normal file
85
assessments/CX_AI_Diagnostic/tests/test_scoring.py
Normal 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"]
|
||||
15
assessments/CX_AI_Diagnostic/tests/test_staging.py
Normal file
15
assessments/CX_AI_Diagnostic/tests/test_staging.py
Normal 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 == ""
|
||||
145
assessments/CX_AI_Diagnostic/tests/test_value_math.py
Normal file
145
assessments/CX_AI_Diagnostic/tests/test_value_math.py
Normal 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 × (1−0.40) = 1,525,500
|
||||
high 5,085,000 × (1−0.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"
|
||||
Reference in New Issue
Block a user