Files
Robert Helewka a967f73d09 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.
2026-07-31 16:16:07 +00:00

134 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
)