"""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, )