"""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}
{c.name}" for c in comps])
hover.append([
f"{c.name} — level {by_id[c.id].score}
"
f"{c.level_descriptors[by_id[c.id].score]}
"
f"{by_id[c.id].evidence or 'no evidence captured'}"
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,
)