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.
134 lines
5.2 KiB
Python
134 lines
5.2 KiB
Python
"""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,
|
||
)
|