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:
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,
|
||||
)
|
||||
Reference in New Issue
Block a user