diff --git a/backend/alembic/versions/0009_team_rationale.py b/backend/alembic/versions/0009_team_rationale.py new file mode 100644 index 0000000..cc53bb7 --- /dev/null +++ b/backend/alembic/versions/0009_team_rationale.py @@ -0,0 +1,27 @@ +"""team rationale + +Adds teams.rationale (cached AI explanation per team). + +Revision ID: 0009_team_rationale +Revises: 0008_payout_team_unique +Create Date: 2026-06-20 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0009_team_rationale" +down_revision: Union[str, None] = "0008_payout_team_unique" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("teams") as b: + b.add_column(sa.Column("rationale", sa.JSON(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("teams") as b: + b.drop_column("rationale") diff --git a/backend/app/api/v1/public.py b/backend/app/api/v1/public.py index 2aa80c6..e262627 100644 --- a/backend/app/api/v1/public.py +++ b/backend/app/api/v1/public.py @@ -50,6 +50,7 @@ def public_allocation(allocation_id: UUID, db: Session = Depends(get_db)): name=team.name, fairness_score=team.fairness_score, members=[PublicTeamMember.model_validate(m) for m in members], + rationale=team.rationale, )) payouts = [] for p in db.query(Payout).filter(Payout.allocation_id == allocation.id).all(): diff --git a/backend/app/api/v1/rationale.py b/backend/app/api/v1/rationale.py new file mode 100644 index 0000000..c7958c1 --- /dev/null +++ b/backend/app/api/v1/rationale.py @@ -0,0 +1,25 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db +from app.models.user import User +from app.services.event_service import assert_allocation_organizer +from app.services import rationale_service +from app.services.rationale_service import RationaleUnavailable + +router = APIRouter() + + +@router.post("/{allocation_id}/rationale") +def generate_rationale( + allocation_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + allocation = assert_allocation_organizer(db, allocation_id, current_user.id) + try: + return rationale_service.generate(db, allocation) + except RationaleUnavailable as exc: + raise HTTPException(status_code=400, detail=str(exc)) diff --git a/backend/app/api/v1/teams.py b/backend/app/api/v1/teams.py index 09a410e..b93a21e 100644 --- a/backend/app/api/v1/teams.py +++ b/backend/app/api/v1/teams.py @@ -36,6 +36,7 @@ def list_teams(allocation_id: UUID, db: Session = Depends(get_db), current_user= skill_score=team.skill_score, role_balance_score=team.role_balance_score, members=[TeamMemberOut.model_validate(m) for m in members], + rationale=team.rationale, )) return result @@ -60,6 +61,7 @@ def get_team(allocation_id: UUID, team_id: UUID, db: Session = Depends(get_db), skill_score=team.skill_score, role_balance_score=team.role_balance_score, members=[TeamMemberOut.model_validate(m) for m in members], + rationale=team.rationale, ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 857a915..bb248f8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -27,6 +27,8 @@ def cors_origins(self) -> list[str]: # When unset, allocation falls back to a deterministic slug per Other entry. ANTHROPIC_API_KEY: str | None = None CATEGORIZATION_MODEL: str = "claude-haiku-4-5-20251001" + # Model for AI team rationales (separate from categorization so it can be tuned). + RATIONALE_MODEL: str = "claude-haiku-4-5-20251001" # --- Nostr DM sender (all optional; unset → DM sending is a no-op) --- # Dedicated *bot* secret key (bech32 `nsec1…`) used ONLY to sign/encrypt diff --git a/backend/app/main.py b/backend/app/main.py index a1ae08a..708730b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.core.config import settings -from app.api.v1 import auth, events, participants, allocation, teams, export, public, feedback, payouts +from app.api.v1 import auth, events, participants, allocation, teams, export, public, feedback, payouts, rationale import app.models # noqa: F401 @@ -32,6 +32,7 @@ async def lifespan(_: FastAPI): app.include_router(public.router, prefix="/api/v1/public", tags=["public"]) app.include_router(feedback.router, prefix="/api/v1/feedback", tags=["feedback"]) app.include_router(payouts.router, prefix="/api/v1/allocations", tags=["payouts"]) +app.include_router(rationale.router, prefix="/api/v1/allocations", tags=["rationale"]) @app.get("/health") diff --git a/backend/app/models/team.py b/backend/app/models/team.py index bb1fedb..2893ecd 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -1,5 +1,5 @@ import uuid -from sqlalchemy import Column, String, Float, ForeignKey, Uuid +from sqlalchemy import Column, String, Float, ForeignKey, Uuid, JSON from app.core.database import Base @@ -13,6 +13,8 @@ class Team(Base): fairness_score = Column(Float, nullable=True) skill_score = Column(Float, nullable=True) role_balance_score = Column(Float, nullable=True) + # Cached AI explanation: {"title","summary","strengths":[...],"gaps":[...]} or None. + rationale = Column(JSON, nullable=True) class TeamMember(Base): diff --git a/backend/app/schemas/allocation.py b/backend/app/schemas/allocation.py index 06b97ca..d77a3b3 100644 --- a/backend/app/schemas/allocation.py +++ b/backend/app/schemas/allocation.py @@ -38,6 +38,7 @@ class TeamOut(BaseModel): skill_score: Optional[float] role_balance_score: Optional[float] members: list[TeamMemberOut] = [] + rationale: Optional[dict] = None model_config = {"from_attributes": True} @@ -71,6 +72,7 @@ class PublicTeam(BaseModel): name: str fairness_score: Optional[float] members: list[PublicTeamMember] = [] + rationale: Optional[dict] = None class PublicPayoutSummary(BaseModel): diff --git a/backend/app/services/allocation_engine.py b/backend/app/services/allocation_engine.py index 5992b88..674da9f 100644 --- a/backend/app/services/allocation_engine.py +++ b/backend/app/services/allocation_engine.py @@ -91,6 +91,7 @@ def move_participant(db: Session, allocation: Allocation, participant_id: UUID, team.skill_score = round(skill_score, 1) team.role_balance_score = round(role_balance_score, 1) team.fairness_score = round(fairness_score, 1) + team.rationale = None db.commit() diff --git a/backend/app/services/rationale_service.py b/backend/app/services/rationale_service.py new file mode 100644 index 0000000..9b8bf6c --- /dev/null +++ b/backend/app/services/rationale_service.py @@ -0,0 +1,151 @@ +"""Generate a short, PII-free "why this team works" rationale per team. + +Descriptive only — never reads into or changes the deterministic allocation +engine. Mirrors categorization_service: pure _build_request/_parse helpers plus a +monkeypatchable _classify. The AI input carries no participant names or emails. +""" +import logging + +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.models.allocation import Allocation +from app.models.event import Event +from app.models.participant import Participant +from app.models.team import Team, TeamMember + +logger = logging.getLogger(__name__) + +_MAX_TOKENS = 4096 + +_SYSTEM_INSTRUCTIONS = ( + "You explain why each already-formed team is balanced. You do NOT form or change " + "teams — you describe what exists. For each team return a short title (<=5 words), a " + "one-sentence summary, a list of strengths, and a list of coverage gaps, grounded in " + "the members' roles and experience. Never name or invent individuals; describe the " + "composition only. If a team is too sparse to assess, omit it from the response." +) + + +class RationaleUnavailable(Exception): + """Raised when AI rationale cannot run (no ANTHROPIC_API_KEY configured).""" + + +def _team_payloads(db: Session, allocation: Allocation) -> list[dict]: + """PII-free composition per team: roles, experience, and any tech_stack/interests.""" + payloads: list[dict] = [] + teams = db.query(Team).filter(Team.allocation_id == allocation.id).all() + for team in teams: + members = ( + db.query(Participant) + .join(TeamMember, Participant.id == TeamMember.participant_id) + .filter(TeamMember.team_id == team.id) + .all() + ) + payloads.append({ + "id": str(team.id), + "name": team.name, + "members": [{ + "role": m.normalized_strength or m.primary_strength, + "experience": m.experience_level, + "tech_stack": m.tech_stack or [], + "interests": m.interests or [], + } for m in members], + }) + return payloads + + +def _build_request(event: Event, payloads: list[dict]) -> dict: + import json + + tool = { + "name": "explain_teams", + "description": "Return a structured rationale for each team.", + "input_schema": { + "type": "object", + "properties": { + "rationales": { + "type": "array", + "items": { + "type": "object", + "properties": { + "team_id": {"type": "string"}, + "title": {"type": "string"}, + "summary": {"type": "string"}, + "strengths": {"type": "array", "items": {"type": "string"}}, + "gaps": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["team_id", "title", "summary", "strengths", "gaps"], + }, + } + }, + "required": ["rationales"], + }, + } + return { + "model": settings.RATIONALE_MODEL, + "max_tokens": _MAX_TOKENS, + "system": [{ + "type": "text", + "text": _SYSTEM_INSTRUCTIONS, + "cache_control": {"type": "ephemeral"}, + }], + "tools": [tool], + "tool_choice": {"type": "tool", "name": "explain_teams"}, + "messages": [{ + "role": "user", + "content": ( + f"Event: {event.title}\nDescription: {event.description or '(none)'}\n\n" + f"Explain each team from this composition (JSON):\n{json.dumps(payloads)}" + ), + }], + } + + +_REQUIRED_KEYS = {"team_id", "title", "summary", "strengths", "gaps"} + + +def _parse_rationales(content_blocks) -> dict[str, dict]: + out: dict[str, dict] = {} + for block in content_blocks: + if getattr(block, "type", None) == "tool_use": + for r in block.input.get("rationales", []): + if _REQUIRED_KEYS.issubset(r) and r["team_id"]: + out[r["team_id"]] = { + "title": r["title"], "summary": r["summary"], + "strengths": list(r["strengths"]), "gaps": list(r["gaps"]), + } + return out + + +def _classify(event: Event, payloads: list[dict]) -> dict[str, dict]: + """Call Claude for all teams; return {team_id: rationale}. Raises on failure.""" + import anthropic + + client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY) + msg = client.messages.create(**_build_request(event, payloads)) + return _parse_rationales(msg.content) + + +def generate(db: Session, allocation: Allocation) -> dict[str, dict]: + """Generate + persist a rationale per team. Raises RationaleUnavailable with no key.""" + if not settings.ANTHROPIC_API_KEY: + raise RationaleUnavailable("AI rationale requires ANTHROPIC_API_KEY") + event = db.query(Event).filter(Event.id == allocation.event_id).first() + if event is None: + raise RationaleUnavailable("event not found for allocation") + payloads = _team_payloads(db, allocation) + + mapping: dict[str, dict] = {} + try: + mapping = _classify(event, payloads) + except Exception as exc: # noqa: BLE001 — best-effort; teams without a rationale just stay null + logger.warning("Rationale generation failed: %s", exc) + + teams = db.query(Team).filter(Team.allocation_id == allocation.id).all() + for team in teams: + r = mapping.get(str(team.id)) + if r: + team.rationale = r + db.commit() + return mapping diff --git a/backend/tests/test_rationale_endpoint.py b/backend/tests/test_rationale_endpoint.py new file mode 100644 index 0000000..4ff0ce0 --- /dev/null +++ b/backend/tests/test_rationale_endpoint.py @@ -0,0 +1,41 @@ +from tests.test_payout_endpoint import _setup_team + + +def test_rationale_requires_api_key_returns_400(client, auth_headers, monkeypatch): + from app.core.config import settings + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", None, raising=False) + _, allocation_id, _team_id, _members = _setup_team(client, auth_headers, all_have_addresses=False) + res = client.post(f"/api/v1/allocations/{allocation_id}/rationale", headers=auth_headers) + assert res.status_code == 400 + assert "anthropic" in res.text.lower() + + +def test_rationale_generates_and_persists(client, auth_headers, monkeypatch): + from app.core.config import settings + from app.services import rationale_service + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "k", raising=False) + monkeypatch.setattr(rationale_service, "_classify", lambda event, payloads: { + p["id"]: {"title": "Squad", "summary": "Balanced.", "strengths": ["a"], "gaps": ["b"]} + for p in payloads + }) + _, allocation_id, _team_id, _members = _setup_team(client, auth_headers, all_have_addresses=False) + res = client.post(f"/api/v1/allocations/{allocation_id}/rationale", headers=auth_headers) + assert res.status_code == 200, res.text + body = res.json() + assert all(r["title"] == "Squad" for r in body.values()) + teams = client.get(f"/api/v1/allocations/{allocation_id}/teams", headers=auth_headers).json() + assert all(t["rationale"]["summary"] == "Balanced." for t in teams) + + +def test_rationale_requires_organizer(client, auth_headers, monkeypatch): + from app.core.config import settings + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "k", raising=False) + _, allocation_id, _team_id, _members = _setup_team(client, auth_headers, all_have_addresses=False) + from tests.conftest import make_nostr_event + from coincurve import PrivateKey + other = PrivateKey() + pubkey = other.public_key.format(compressed=True)[1:].hex() + token = client.post("/auth/nostr", json={"pubkey": pubkey, "event": make_nostr_event(other)}).json()["access_token"] + res = client.post(f"/api/v1/allocations/{allocation_id}/rationale", + headers={"Authorization": f"Bearer {token}"}) + assert res.status_code in (401, 403, 404) diff --git a/backend/tests/test_rationale_model.py b/backend/tests/test_rationale_model.py new file mode 100644 index 0000000..7b4d663 --- /dev/null +++ b/backend/tests/test_rationale_model.py @@ -0,0 +1,32 @@ +import uuid +from app.models.allocation import Allocation +from app.models.team import Team + + +def test_team_rationale_persists_json(db): + alloc = Allocation(event_id=uuid.uuid4(), snapshot_hash="h", status="draft", + constraint_warnings={}) + db.add(alloc) + db.flush() + team = Team(allocation_id=alloc.id, name="Team 01", + rationale={"title": "Build squad", "summary": "Strong delivery.", + "strengths": ["2 advanced engineers"], "gaps": ["limited outreach"]}) + db.add(team) + db.commit() + db.refresh(team) + assert team.rationale["title"] == "Build squad" + assert team.rationale["gaps"] == ["limited outreach"] + assert team.rationale["summary"] == "Strong delivery." + assert team.rationale["strengths"] == ["2 advanced engineers"] + + +def test_team_rationale_defaults_none(db): + alloc = Allocation(event_id=uuid.uuid4(), snapshot_hash="h2", status="draft", + constraint_warnings={}) + db.add(alloc) + db.flush() + team = Team(allocation_id=alloc.id, name="Team 02") + db.add(team) + db.commit() + db.refresh(team) + assert team.rationale is None diff --git a/backend/tests/test_rationale_move.py b/backend/tests/test_rationale_move.py new file mode 100644 index 0000000..866f1aa --- /dev/null +++ b/backend/tests/test_rationale_move.py @@ -0,0 +1,22 @@ +from tests.test_payout_endpoint import _setup_team + + +def test_move_clears_rationale(client, auth_headers, monkeypatch): + from app.core.config import settings + from app.services import rationale_service + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "k", raising=False) + monkeypatch.setattr(rationale_service, "_classify", lambda event, payloads: { + p["id"]: {"title": "Squad", "summary": "Balanced.", "strengths": ["a"], "gaps": []} + for p in payloads + }) + _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=False) + client.post(f"/api/v1/allocations/{allocation_id}/rationale", headers=auth_headers) + + teams = client.get(f"/api/v1/allocations/{allocation_id}/teams", headers=auth_headers).json() + target = next(t["id"] for t in teams if t["id"] != team_id) + mover = members[0]["id"] + client.patch(f"/api/v1/allocations/{allocation_id}/members/{mover}", + headers=auth_headers, json={"team_id": target}) + + teams_after = client.get(f"/api/v1/allocations/{allocation_id}/teams", headers=auth_headers).json() + assert all(t["rationale"] is None for t in teams_after) diff --git a/backend/tests/test_rationale_public.py b/backend/tests/test_rationale_public.py new file mode 100644 index 0000000..bc20b38 --- /dev/null +++ b/backend/tests/test_rationale_public.py @@ -0,0 +1,19 @@ +from tests.test_payout_endpoint import _setup_team + + +def test_public_results_include_rationale(client, auth_headers, monkeypatch): + from app.core.config import settings + from app.services import rationale_service + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "k", raising=False) + monkeypatch.setattr(rationale_service, "_classify", lambda event, payloads: { + p["id"]: {"title": "Squad", "summary": "Balanced.", "strengths": ["a"], "gaps": []} + for p in payloads + }) + event_id, allocation_id, _team_id, _members = _setup_team(client, auth_headers, all_have_addresses=False) + client.post(f"/api/v1/allocations/{allocation_id}/rationale", headers=auth_headers) + client.post(f"/api/v1/events/{event_id}/allocations/{allocation_id}/publish", headers=auth_headers) + + res = client.get(f"/api/v1/public/allocations/{allocation_id}") + assert res.status_code == 200, res.text + teams = res.json()["teams"] + assert any(t.get("rationale") and t["rationale"]["title"] == "Squad" for t in teams) diff --git a/backend/tests/test_rationale_service.py b/backend/tests/test_rationale_service.py new file mode 100644 index 0000000..980bdce --- /dev/null +++ b/backend/tests/test_rationale_service.py @@ -0,0 +1,110 @@ +import uuid +import pytest +from app.models.allocation import Allocation +from app.models.event import Event +from app.models.participant import Participant +from app.models.team import Team, TeamMember +from app.models.user import User +from app.services import rationale_service as rat + + +def _payloads(): + return [{ + "id": "team-1", "name": "Team 01", + "members": [ + {"role": "technical", "experience": "advanced", "tech_stack": [], "interests": []}, + {"role": "design", "experience": "beginner", "tech_stack": [], "interests": []}, + ], + }] + + +class _Event: + title = "Hack Night" + description = "Build something" + + +def test_build_request_is_cacheable_and_pii_free(): + req = rat._build_request(_Event(), _payloads()) + # static instructions in a cacheable system block + assert req["system"][0]["cache_control"]["type"] == "ephemeral" + assert req["max_tokens"] >= 2048 + # the team id reaches the model; the tool returns the structured shape + blob = req["system"][0]["text"] + req["messages"][0]["content"] + assert "team-1" in blob + props = req["tools"][0]["input_schema"]["properties"]["rationales"]["items"]["properties"] + assert set(props) >= {"team_id", "title", "summary", "strengths", "gaps"} + + +def test_build_request_contains_no_pii(): + payloads = _payloads() + # composition only — names/emails never appear in the payload + req = rat._build_request(_Event(), payloads) + serialized = str(req) + for leaked in ("@", "Alice", "Bob"): + assert leaked not in serialized + + +def test_parse_rationales_keeps_valid_drops_malformed(): + class Block: + type = "tool_use" + input = {"rationales": [ + {"team_id": "a", "title": "T", "summary": "S", "strengths": ["x"], "gaps": []}, + {"team_id": "b", "title": "only-title"}, # missing required keys -> dropped + ]} + out = rat._parse_rationales([Block()]) + assert out["a"]["summary"] == "S" + assert "b" not in out + + +def test_parse_rationales_tolerates_omitted_team(): + class Block: + type = "tool_use" + input = {"rationales": []} + assert rat._parse_rationales([Block()]) == {} + + +def _alloc_with_team(db): + u = User(pubkey="c" * 64); db.add(u); db.commit() + e = Event(owner_id=u.id, title="Hack", description="d", team_count=1, + registration_slug=f"s{uuid.uuid4().hex[:6]}", status="allocated") + db.add(e); db.commit() + alloc = Allocation(event_id=e.id, snapshot_hash=uuid.uuid4().hex, status="draft", + constraint_warnings={}) + db.add(alloc); db.flush() + team = Team(allocation_id=alloc.id, name="Team 01"); db.add(team); db.flush() + p = Participant(event_id=e.id, name="Alice", email="a@t.com", primary_strength="technical", + experience_level="advanced", strength_source="preset", composite_score=3.0, + tech_stack=[], interests=[]); db.add(p); db.flush() + db.add(TeamMember(team_id=team.id, participant_id=p.id)); db.commit() + return e, alloc, team + + +def test_generate_persists_rationale(db, monkeypatch): + e, alloc, team = _alloc_with_team(db) + monkeypatch.setattr(rat.settings, "ANTHROPIC_API_KEY", "k") + monkeypatch.setattr(rat, "_classify", lambda event, payloads: { + str(team.id): {"title": "Builders", "summary": "Strong.", "strengths": ["x"], "gaps": []}, + }) + out = rat.generate(db, alloc) + assert out[str(team.id)]["title"] == "Builders" + db.refresh(team) + assert team.rationale["summary"] == "Strong." + + +def test_generate_without_key_raises(db, monkeypatch): + _e, alloc, _team = _alloc_with_team(db) + monkeypatch.setattr(rat.settings, "ANTHROPIC_API_KEY", None) + with pytest.raises(rat.RationaleUnavailable): + rat.generate(db, alloc) + + +def test_generate_swallows_classify_failure(db, monkeypatch): + _e, alloc, _team = _alloc_with_team(db) + monkeypatch.setattr(rat.settings, "ANTHROPIC_API_KEY", "k") + + def _boom(event, payloads): + raise RuntimeError("model down") + + monkeypatch.setattr(rat, "_classify", _boom) + # best-effort: returns an empty mapping and commits without raising + assert rat.generate(db, alloc) == {} diff --git a/docs/superpowers/plans/2026-06-20-team-rationale.md b/docs/superpowers/plans/2026-06-20-team-rationale.md new file mode 100644 index 0000000..5588820 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-team-rationale.md @@ -0,0 +1,997 @@ +# Team Rationale Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an on-demand, PII-free AI "why this team works" rationale per team, cached on the team and shown to organizers and on public results. + +**Architecture:** A new `rationale_service` builds one batched Claude (Haiku) request from PII-free team composition, parses a structured tool response, and persists `{title, summary, strengths, gaps}` JSON on each `Team`. An organizer-only endpoint triggers it; the organizer and public team views surface it. Descriptive only — the deterministic allocation engine is untouched. Mirrors the existing `categorization_service` pattern (pure `_build_request`/`_parse_*` helpers + a monkeypatchable `_classify`). + +**Tech Stack:** FastAPI, SQLAlchemy 2, Alembic, Pydantic v2, `anthropic` SDK; Next.js 16 / React 19 / Vitest frontend. + +## Global Constraints + +- Python deps pinned in `backend/requirements.txt`; do not add new ones (`anthropic==0.40.0` is present). +- AI calls must degrade gracefully: no `ANTHROPIC_API_KEY` → a clear error, never a crash. No deterministic fallback for rationale. +- The AI request must contain **no participant name or email**. Public output is PII-free by construction. +- Backend tests run on SQLite via the existing `tests/conftest.py` fixtures (`client`, `auth_headers`, `db`, `nostr_privkey`). +- Frontend: this is Next.js 16 — read `node_modules/next/dist/docs/` before using Next APIs. No new deps. +- Reuse the batched, system-cacheable Claude pattern from `app/services/categorization_service.py`. + +--- + +### Task 1: `teams.rationale` column + migration + +**Files:** +- Modify: `backend/app/models/team.py` +- Create: `backend/alembic/versions/0009_team_rationale.py` +- Test: `backend/tests/test_rationale_model.py` + +**Interfaces:** +- Produces: `Team.rationale` — nullable `JSON` column holding `{"title": str, "summary": str, "strengths": [str], "gaps": [str]}` or `None`. + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_rationale_model.py +import uuid +from app.models.allocation import Allocation +from app.models.team import Team + + +def test_team_rationale_persists_json(db): + alloc = Allocation(event_id=uuid.uuid4(), snapshot_hash="h", status="draft", + constraint_warnings={}) + db.add(alloc) + db.flush() + team = Team(allocation_id=alloc.id, name="Team 01", + rationale={"title": "Build squad", "summary": "Strong delivery.", + "strengths": ["2 advanced engineers"], "gaps": ["limited outreach"]}) + db.add(team) + db.commit() + db.refresh(team) + assert team.rationale["title"] == "Build squad" + assert team.rationale["gaps"] == ["limited outreach"] + + +def test_team_rationale_defaults_none(db): + alloc = Allocation(event_id=uuid.uuid4(), snapshot_hash="h2", status="draft", + constraint_warnings={}) + db.add(alloc) + db.flush() + team = Team(allocation_id=alloc.id, name="Team 02") + db.add(team) + db.commit() + db.refresh(team) + assert team.rationale is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && python -m pytest tests/test_rationale_model.py -v` +Expected: FAIL — `TypeError: 'rationale' is an invalid keyword argument for Team`. + +- [ ] **Step 3: Add the column to the model** + +In `backend/app/models/team.py`, update the import line and add the column to `Team`: + +```python +import uuid +from sqlalchemy import Column, String, Float, ForeignKey, Uuid, JSON + +from app.core.database import Base + + +class Team(Base): + __tablename__ = "teams" + + id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) + allocation_id = Column(Uuid(as_uuid=True), ForeignKey("allocations.id"), nullable=False, index=True) + name = Column(String, nullable=False) + fairness_score = Column(Float, nullable=True) + skill_score = Column(Float, nullable=True) + role_balance_score = Column(Float, nullable=True) + # Cached AI explanation: {"title","summary","strengths":[...],"gaps":[...]} or None. + rationale = Column(JSON, nullable=True) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && python -m pytest tests/test_rationale_model.py -v` +Expected: PASS (2 passed). The `conftest.py` `setup_database` fixture creates tables from the model, so no migration is needed for tests. + +- [ ] **Step 5: Write the Alembic migration (for Postgres prod)** + +```python +# backend/alembic/versions/0009_team_rationale.py +"""team rationale + +Adds teams.rationale (cached AI explanation per team). + +Revision ID: 0009_team_rationale +Revises: 0008_payout_team_unique +Create Date: 2026-06-20 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0009_team_rationale" +down_revision: Union[str, None] = "0008_payout_team_unique" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("teams") as b: + b.add_column(sa.Column("rationale", sa.JSON(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("teams") as b: + b.drop_column("rationale") +``` + +- [ ] **Step 6: Verify the migration applies and reverses** + +Run: `cd backend && DATABASE_URL="sqlite:///./_mig.db" SECRET_KEY=x python -m alembic upgrade head && DATABASE_URL="sqlite:///./_mig.db" SECRET_KEY=x python -m alembic downgrade -1 && rm -f _mig.db` +Expected: both run without error; `alembic heads` shows `0009_team_rationale`. + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/models/team.py backend/alembic/versions/0009_team_rationale.py backend/tests/test_rationale_model.py +git commit -m "feat(rationale): add teams.rationale JSON column + migration" +``` + +--- + +### Task 2: `rationale_service` pure helpers + config + +**Files:** +- Modify: `backend/app/core/config.py` +- Create: `backend/app/services/rationale_service.py` +- Test: `backend/tests/test_rationale_service.py` + +**Interfaces:** +- Consumes: `Event`, `Team`, `Participant` models; `settings.RATIONALE_MODEL`, `settings.ANTHROPIC_API_KEY`. +- Produces: + - `RationaleUnavailable(Exception)` + - `_team_payloads(db, allocation) -> list[dict]` — each `{"id": str(team.id), "name": str, "members": [{"role": str, "experience": str, "tech_stack": [...], "interests": [...]}]}`, **no names/emails**. + - `_build_request(event, payloads: list[dict]) -> dict` — Messages API kwargs. + - `_parse_rationales(content_blocks) -> dict[str, dict]` — `{team_id: {"title","summary","strengths","gaps"}}`. + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_rationale_service.py +from app.services import rationale_service as rat + + +def _payloads(): + return [{ + "id": "team-1", "name": "Team 01", + "members": [ + {"role": "technical", "experience": "advanced", "tech_stack": [], "interests": []}, + {"role": "design", "experience": "beginner", "tech_stack": [], "interests": []}, + ], + }] + + +class _Event: + title = "Hack Night" + description = "Build something" + + +def test_build_request_is_cacheable_and_pii_free(): + req = rat._build_request(_Event(), _payloads()) + # static instructions in a cacheable system block + assert req["system"][0]["cache_control"]["type"] == "ephemeral" + assert req["max_tokens"] >= 2048 + # the team id reaches the model; the tool returns the structured shape + blob = req["system"][0]["text"] + req["messages"][0]["content"] + assert "team-1" in blob + props = req["tools"][0]["input_schema"]["properties"]["rationales"]["items"]["properties"] + assert set(props) >= {"team_id", "title", "summary", "strengths", "gaps"} + + +def test_build_request_contains_no_pii(): + payloads = _payloads() + # composition only — names/emails never appear in the payload + req = rat._build_request(_Event(), payloads) + serialized = str(req) + for leaked in ("@", "Alice", "Bob"): + assert leaked not in serialized + + +def test_parse_rationales_keeps_valid_drops_malformed(): + class Block: + type = "tool_use" + input = {"rationales": [ + {"team_id": "a", "title": "T", "summary": "S", "strengths": ["x"], "gaps": []}, + {"team_id": "b", "title": "only-title"}, # missing required keys -> dropped + ]} + out = rat._parse_rationales([Block()]) + assert out["a"]["summary"] == "S" + assert "b" not in out + + +def test_parse_rationales_tolerates_omitted_team(): + class Block: + type = "tool_use" + input = {"rationales": []} + assert rat._parse_rationales([Block()]) == {} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && python -m pytest tests/test_rationale_service.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.services.rationale_service'`. + +- [ ] **Step 3: Add the config setting** + +In `backend/app/core/config.py`, directly under the `CATEGORIZATION_MODEL` line, add: + +```python + # Model for AI team rationales (separate from categorization so it can be tuned). + RATIONALE_MODEL: str = "claude-haiku-4-5-20251001" +``` + +- [ ] **Step 4: Create the service with the pure helpers** + +```python +# backend/app/services/rationale_service.py +"""Generate a short, PII-free "why this team works" rationale per team. + +Descriptive only — never reads into or changes the deterministic allocation +engine. Mirrors categorization_service: pure _build_request/_parse helpers plus a +monkeypatchable _classify. The AI input carries no participant names or emails. +""" +import logging + +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.models.allocation import Allocation +from app.models.event import Event +from app.models.participant import Participant +from app.models.team import Team, TeamMember + +logger = logging.getLogger(__name__) + +_MAX_TOKENS = 4096 + +_SYSTEM_INSTRUCTIONS = ( + "You explain why each already-formed team is balanced. You do NOT form or change " + "teams — you describe what exists. For each team return a short title (<=5 words), a " + "one-sentence summary, a list of strengths, and a list of coverage gaps, grounded in " + "the members' roles and experience. Never name or invent individuals; describe the " + "composition only. If a team is too sparse to assess, omit it from the response." +) + + +class RationaleUnavailable(Exception): + """Raised when AI rationale cannot run (no ANTHROPIC_API_KEY configured).""" + + +def _team_payloads(db: Session, allocation: Allocation) -> list[dict]: + """PII-free composition per team: roles, experience, and any tech_stack/interests.""" + payloads: list[dict] = [] + teams = db.query(Team).filter(Team.allocation_id == allocation.id).all() + for team in teams: + members = ( + db.query(Participant) + .join(TeamMember, Participant.id == TeamMember.participant_id) + .filter(TeamMember.team_id == team.id) + .all() + ) + payloads.append({ + "id": str(team.id), + "name": team.name, + "members": [{ + "role": m.normalized_strength or m.primary_strength, + "experience": m.experience_level, + "tech_stack": m.tech_stack or [], + "interests": m.interests or [], + } for m in members], + }) + return payloads + + +def _build_request(event: Event, payloads: list[dict]) -> dict: + import json + + tool = { + "name": "explain_teams", + "description": "Return a structured rationale for each team.", + "input_schema": { + "type": "object", + "properties": { + "rationales": { + "type": "array", + "items": { + "type": "object", + "properties": { + "team_id": {"type": "string"}, + "title": {"type": "string"}, + "summary": {"type": "string"}, + "strengths": {"type": "array", "items": {"type": "string"}}, + "gaps": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["team_id", "title", "summary", "strengths", "gaps"], + }, + } + }, + "required": ["rationales"], + }, + } + return { + "model": settings.RATIONALE_MODEL, + "max_tokens": _MAX_TOKENS, + "system": [{ + "type": "text", + "text": _SYSTEM_INSTRUCTIONS, + "cache_control": {"type": "ephemeral"}, + }], + "tools": [tool], + "tool_choice": {"type": "tool", "name": "explain_teams"}, + "messages": [{ + "role": "user", + "content": ( + f"Event: {event.title}\nDescription: {event.description or '(none)'}\n\n" + f"Explain each team from this composition (JSON):\n{json.dumps(payloads)}" + ), + }], + } + + +_REQUIRED_KEYS = {"team_id", "title", "summary", "strengths", "gaps"} + + +def _parse_rationales(content_blocks) -> dict[str, dict]: + out: dict[str, dict] = {} + for block in content_blocks: + if getattr(block, "type", None) == "tool_use": + for r in block.input.get("rationales", []): + if _REQUIRED_KEYS.issubset(r) and r["team_id"]: + out[r["team_id"]] = { + "title": r["title"], "summary": r["summary"], + "strengths": list(r["strengths"]), "gaps": list(r["gaps"]), + } + return out +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd backend && python -m pytest tests/test_rationale_service.py -v` +Expected: PASS (4 passed). + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/core/config.py backend/app/services/rationale_service.py backend/tests/test_rationale_service.py +git commit -m "feat(rationale): PII-free prompt build + structured parse helpers" +``` + +--- + +### Task 3: `rationale_service.generate` (persist) + +**Files:** +- Modify: `backend/app/services/rationale_service.py` +- Test: `backend/tests/test_rationale_service.py` + +**Interfaces:** +- Consumes: `_team_payloads`, `_build_request`, `_parse_rationales`. +- Produces: + - `_classify(event, payloads: list[dict]) -> dict[str, dict]` — does the Anthropic call (monkeypatched in tests). + - `generate(db, allocation) -> dict[str, dict]` — persists `Team.rationale`; raises `RationaleUnavailable` when no key. + +- [ ] **Step 1: Write the failing test** + +```python +# append to backend/tests/test_rationale_service.py +import uuid +import pytest +from app.models.allocation import Allocation +from app.models.event import Event +from app.models.participant import Participant +from app.models.team import Team, TeamMember +from app.models.user import User + + +def _alloc_with_team(db): + u = User(pubkey="c" * 64); db.add(u); db.commit() + e = Event(owner_id=u.id, title="Hack", description="d", team_count=1, + registration_slug=f"s{uuid.uuid4().hex[:6]}", status="allocated") + db.add(e); db.commit() + alloc = Allocation(event_id=e.id, snapshot_hash=uuid.uuid4().hex, status="draft", + constraint_warnings={}) + db.add(alloc); db.flush() + team = Team(allocation_id=alloc.id, name="Team 01"); db.add(team); db.flush() + p = Participant(event_id=e.id, name="Alice", email="a@t.com", primary_strength="technical", + experience_level="advanced", strength_source="preset", composite_score=3.0, + tech_stack=[], interests=[]); db.add(p); db.flush() + db.add(TeamMember(team_id=team.id, participant_id=p.id)); db.commit() + return e, alloc, team + + +def test_generate_persists_rationale(db, monkeypatch): + e, alloc, team = _alloc_with_team(db) + monkeypatch.setattr(rat.settings, "ANTHROPIC_API_KEY", "k") + monkeypatch.setattr(rat, "_classify", lambda event, payloads: { + str(team.id): {"title": "Builders", "summary": "Strong.", "strengths": ["x"], "gaps": []}, + }) + out = rat.generate(db, alloc) + assert out[str(team.id)]["title"] == "Builders" + db.refresh(team) + assert team.rationale["summary"] == "Strong." + + +def test_generate_without_key_raises(db, monkeypatch): + _e, alloc, _team = _alloc_with_team(db) + monkeypatch.setattr(rat.settings, "ANTHROPIC_API_KEY", None) + with pytest.raises(rat.RationaleUnavailable): + rat.generate(db, alloc) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && python -m pytest tests/test_rationale_service.py -k generate -v` +Expected: FAIL — `AttributeError: module 'app.services.rationale_service' has no attribute 'generate'`. + +- [ ] **Step 3: Implement `_classify` and `generate`** + +Append to `backend/app/services/rationale_service.py`: + +```python +def _classify(event: Event, payloads: list[dict]) -> dict[str, dict]: + """Call Claude for all teams; return {team_id: rationale}. Raises on failure.""" + import anthropic + + client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY) + msg = client.messages.create(**_build_request(event, payloads)) + return _parse_rationales(msg.content) + + +def generate(db: Session, allocation: Allocation) -> dict[str, dict]: + """Generate + persist a rationale per team. Raises RationaleUnavailable with no key.""" + if not settings.ANTHROPIC_API_KEY: + raise RationaleUnavailable("AI rationale requires ANTHROPIC_API_KEY") + event = db.query(Event).filter(Event.id == allocation.event_id).first() + payloads = _team_payloads(db, allocation) + + mapping: dict[str, dict] = {} + try: + mapping = _classify(event, payloads) + except Exception as exc: # noqa: BLE001 — best-effort; teams without a rationale just stay null + logger.warning("Rationale generation failed: %s", exc) + + teams = db.query(Team).filter(Team.allocation_id == allocation.id).all() + for team in teams: + r = mapping.get(str(team.id)) + if r: + team.rationale = r + db.commit() + return mapping +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && python -m pytest tests/test_rationale_service.py -v` +Expected: PASS (6 passed). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/rationale_service.py backend/tests/test_rationale_service.py +git commit -m "feat(rationale): generate + persist team rationales" +``` + +--- + +### Task 4: Rationale endpoint + +**Files:** +- Create: `backend/app/api/v1/rationale.py` +- Modify: `backend/app/main.py` +- Test: `backend/tests/test_rationale_endpoint.py` + +**Interfaces:** +- Consumes: `rationale_service.generate`, `RationaleUnavailable`, `assert_allocation_organizer`. +- Produces: `POST /api/v1/allocations/{allocation_id}/rationale` → `{team_id: {title,summary,strengths,gaps}}`. `400` when AI unconfigured; organizer-only. + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_rationale_endpoint.py +from tests.test_payout_endpoint import _setup_team + + +def test_rationale_requires_api_key_returns_400(client, auth_headers, monkeypatch): + from app.core.config import settings + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", None, raising=False) + _, allocation_id, _team_id, _members = _setup_team(client, auth_headers, all_have_addresses=False) + res = client.post(f"/api/v1/allocations/{allocation_id}/rationale", headers=auth_headers) + assert res.status_code == 400 + assert "anthropic" in res.text.lower() + + +def test_rationale_generates_and_persists(client, auth_headers, monkeypatch): + from app.core.config import settings + from app.services import rationale_service + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "k", raising=False) + monkeypatch.setattr(rationale_service, "_classify", lambda event, payloads: { + p["id"]: {"title": "Squad", "summary": "Balanced.", "strengths": ["a"], "gaps": ["b"]} + for p in payloads + }) + _, allocation_id, _team_id, _members = _setup_team(client, auth_headers, all_have_addresses=False) + + res = client.post(f"/api/v1/allocations/{allocation_id}/rationale", headers=auth_headers) + assert res.status_code == 200, res.text + body = res.json() + assert all(r["title"] == "Squad" for r in body.values()) + # persisted: the organizer teams view now carries the rationale + teams = client.get(f"/api/v1/allocations/{allocation_id}/teams", headers=auth_headers).json() + assert all(t["rationale"]["summary"] == "Balanced." for t in teams) + + +def test_rationale_requires_organizer(client, auth_headers, monkeypatch): + from app.core.config import settings + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "k", raising=False) + _, allocation_id, _team_id, _members = _setup_team(client, auth_headers, all_have_addresses=False) + from tests.conftest import make_nostr_event + from coincurve import PrivateKey + other = PrivateKey() + pubkey = other.public_key.format(compressed=True)[1:].hex() + token = client.post("/auth/nostr", json={"pubkey": pubkey, "event": make_nostr_event(other)}).json()["access_token"] + res = client.post(f"/api/v1/allocations/{allocation_id}/rationale", + headers={"Authorization": f"Bearer {token}"}) + assert res.status_code in (401, 403, 404) +``` + +> Note: `test_rationale_generates_and_persists` also exercises Task 5 (rationale in `TeamOut`). Implement Task 5 before expecting that assertion to pass; until then it will fail on the missing `rationale` key — that is expected and resolved in Task 5. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && python -m pytest tests/test_rationale_endpoint.py -v` +Expected: FAIL — 404 (route not mounted) on all three. + +- [ ] **Step 3: Create the router** + +```python +# backend/app/api/v1/rationale.py +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db +from app.models.user import User +from app.services.event_service import assert_allocation_organizer +from app.services import rationale_service +from app.services.rationale_service import RationaleUnavailable + +router = APIRouter() + + +@router.post("/{allocation_id}/rationale") +def generate_rationale( + allocation_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + allocation = assert_allocation_organizer(db, allocation_id, current_user.id) + try: + return rationale_service.generate(db, allocation) + except RationaleUnavailable as exc: + raise HTTPException(status_code=400, detail=str(exc)) +``` + +- [ ] **Step 4: Mount the router** + +In `backend/app/main.py`, add the import to the existing `from app.api.v1 import ...` line (append `rationale`) and add, after the `payouts` router include: + +```python +app.include_router(rationale.router, prefix="/api/v1/allocations", tags=["rationale"]) +``` + +- [ ] **Step 5: Run tests** + +Run: `cd backend && python -m pytest tests/test_rationale_endpoint.py -v` +Expected: `test_rationale_requires_api_key_returns_400` and `test_rationale_requires_organizer` PASS; `test_rationale_generates_and_persists` still FAILS on the `rationale` key (resolved in Task 5). + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/api/v1/rationale.py backend/app/main.py backend/tests/test_rationale_endpoint.py +git commit -m "feat(rationale): organizer endpoint to generate team rationales" +``` + +--- + +### Task 5: Surface rationale in organizer + public team views + +**Files:** +- Modify: `backend/app/schemas/allocation.py` +- Modify: `backend/app/api/v1/teams.py` +- Modify: `backend/app/api/v1/public.py` +- Test: `backend/tests/test_rationale_endpoint.py` (existing `test_rationale_generates_and_persists`), `backend/tests/test_rationale_public.py` + +**Interfaces:** +- Consumes: persisted `Team.rationale`. +- Produces: `TeamOut.rationale: Optional[dict]`, `PublicTeam.rationale: Optional[dict]`. + +- [ ] **Step 1: Write the failing public test** + +```python +# backend/tests/test_rationale_public.py +from tests.test_payout_endpoint import _setup_team + + +def test_public_results_include_rationale(client, auth_headers, monkeypatch): + from app.core.config import settings + from app.services import rationale_service + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "k", raising=False) + monkeypatch.setattr(rationale_service, "_classify", lambda event, payloads: { + p["id"]: {"title": "Squad", "summary": "Balanced.", "strengths": ["a"], "gaps": []} + for p in payloads + }) + event_id, allocation_id, _team_id, _members = _setup_team(client, auth_headers, all_have_addresses=False) + client.post(f"/api/v1/allocations/{allocation_id}/rationale", headers=auth_headers) + client.post(f"/api/v1/events/{event_id}/allocations/{allocation_id}/publish", headers=auth_headers) + + res = client.get(f"/api/v1/public/allocations/{allocation_id}") + assert res.status_code == 200, res.text + teams = res.json()["teams"] + assert any(t.get("rationale") and t["rationale"]["title"] == "Squad" for t in teams) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && python -m pytest tests/test_rationale_public.py tests/test_rationale_endpoint.py::test_rationale_generates_and_persists -v` +Expected: FAIL — responses have no `rationale` key. + +- [ ] **Step 3: Add `rationale` to the schemas** + +In `backend/app/schemas/allocation.py`, add `rationale: Optional[dict] = None` to **both** `TeamOut` (after `members`) and `PublicTeam` (after `members`): + +```python +class TeamOut(BaseModel): + id: UUID + allocation_id: UUID + name: str + fairness_score: Optional[float] + skill_score: Optional[float] + role_balance_score: Optional[float] + members: list[TeamMemberOut] = [] + rationale: Optional[dict] = None + + model_config = {"from_attributes": True} +``` + +```python +class PublicTeam(BaseModel): + id: UUID + name: str + fairness_score: Optional[float] + members: list[PublicTeamMember] = [] + rationale: Optional[dict] = None +``` + +- [ ] **Step 4: Populate `rationale` in the organizer team views** + +In `backend/app/api/v1/teams.py`, add `rationale=team.rationale,` to the `TeamOut(...)` constructions in **both** `list_teams` and `get_team` (right after the `members=[...]` argument). + +- [ ] **Step 5: Populate `rationale` in the public view** + +In `backend/app/api/v1/public.py`, in `public_allocation`, add `rationale=team.rationale,` to the `PublicTeam(...)` construction (after `members=[...]`). + +- [ ] **Step 6: Run tests** + +Run: `cd backend && python -m pytest tests/test_rationale_public.py tests/test_rationale_endpoint.py -v` +Expected: PASS (all rationale endpoint + public tests). + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/schemas/allocation.py backend/app/api/v1/teams.py backend/app/api/v1/public.py backend/tests/test_rationale_public.py +git commit -m "feat(rationale): expose rationale on organizer + public team views" +``` + +--- + +### Task 6: Clear rationale on manual edit + +**Files:** +- Modify: `backend/app/services/allocation_engine.py` (in `move_participant`, after the score recompute loop, before/with `db.commit()`) +- Test: `backend/tests/test_rationale_move.py` + +**Interfaces:** +- Consumes: `Team.rationale`. +- Produces: after `move_participant`, every team in the allocation has `rationale = None`. + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_rationale_move.py +from tests.test_payout_endpoint import _setup_team + + +def test_move_clears_rationale(client, auth_headers, monkeypatch): + from app.core.config import settings + from app.services import rationale_service + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "k", raising=False) + monkeypatch.setattr(rationale_service, "_classify", lambda event, payloads: { + p["id"]: {"title": "Squad", "summary": "Balanced.", "strengths": ["a"], "gaps": []} + for p in payloads + }) + _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=False) + client.post(f"/api/v1/allocations/{allocation_id}/rationale", headers=auth_headers) + + teams = client.get(f"/api/v1/allocations/{allocation_id}/teams", headers=auth_headers).json() + target = next(t["id"] for t in teams if t["id"] != team_id) + mover = members[0]["id"] + client.patch(f"/api/v1/allocations/{allocation_id}/members/{mover}", + headers=auth_headers, json={"team_id": target}) + + teams_after = client.get(f"/api/v1/allocations/{allocation_id}/teams", headers=auth_headers).json() + assert all(t["rationale"] is None for t in teams_after) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && python -m pytest tests/test_rationale_move.py -v` +Expected: FAIL — rationale survives the move (still `{"title": "Squad", ...}`). + +- [ ] **Step 3: Clear rationale in `move_participant`** + +In `backend/app/services/allocation_engine.py`, inside `move_participant`, in the existing loop that sets each team's scores (`for team in teams:` near the end), add a line so the loop also clears the stale rationale: + +```python + for team in teams: + team.skill_score = round(skill_score, 1) + team.role_balance_score = round(role_balance_score, 1) + team.fairness_score = round(fairness_score, 1) + team.rationale = None # composition changed; the cached explanation is stale + db.commit() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && python -m pytest tests/test_rationale_move.py -v` +Expected: PASS. + +- [ ] **Step 5: Run the full backend suite** + +Run: `cd backend && python -m pytest -q` +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/allocation_engine.py backend/tests/test_rationale_move.py +git commit -m "feat(rationale): clear cached rationale when a member is moved" +``` + +--- + +### Task 7: Frontend — types, API, engine UI + +**Files:** +- Modify: `frontend/hooks/use-allocation.ts` +- Modify: `frontend/components/engine/results-grid.tsx` +- Modify: `frontend/components/engine/team-card.tsx` +- Test: `frontend/tests/components/team-card.test.tsx` + +**Interfaces:** +- Consumes: `POST /api/v1/allocations/{id}/rationale`. +- Produces: + - `TeamRationale` type `{ title: string; summary: string; strengths: string[]; gaps: string[] }`. + - `Team.rationale?: TeamRationale | null`. + - `generateRationales(token, allocationId) -> Promise>`. + +- [ ] **Step 1: Write the failing component test** + +```tsx +// frontend/tests/components/team-card.test.tsx +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { TeamCard } from "@/components/engine/team-card"; +import type { Team } from "@/hooks/use-allocation"; + +const team: Team = { + id: "t1", name: "Team 01", fairness_score: 80, skill_score: 75, role_balance_score: 90, + members: [{ id: "m1", name: "Ada", email: "a@x.com", experience_level: "advanced", + normalized_strength: "technical" }], + rationale: { title: "Build squad", summary: "Strong delivery.", + strengths: ["2 advanced engineers"], gaps: ["limited outreach"] }, +}; + +describe("TeamCard rationale", () => { + it("renders the rationale summary and strengths/gaps when present", () => { + render(); + expect(screen.getByText("Strong delivery.")).toBeInTheDocument(); + expect(screen.getByText("2 advanced engineers")).toBeInTheDocument(); + expect(screen.getByText("limited outreach")).toBeInTheDocument(); + }); + + it("renders no rationale block when absent", () => { + render(); + expect(screen.queryByText("Strong delivery.")).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run tests/components/team-card.test.tsx` +Expected: FAIL — summary text not found (and `rationale` not on `Team` type → tsc/test error). + +- [ ] **Step 3: Add the type, the field, and the API call** + +In `frontend/hooks/use-allocation.ts`: add the type, extend `Team`, and add the API function. + +```typescript +export interface TeamRationale { + title: string; + summary: string; + strengths: string[]; + gaps: string[]; +} +``` + +In the `Team` interface, add: `rationale?: TeamRationale | null;` + +At the end of the file, add: + +```typescript +export async function generateRationales(token: string, allocationId: string) { + return fetchAPI>( + `/api/v1/allocations/${allocationId}/rationale`, + { method: "POST", token } + ); +} +``` + +- [ ] **Step 4: Render the rationale in `TeamCard`** + +In `frontend/components/engine/team-card.tsx`, add this block inside `` (after the strength-count badges `
...
`): + +```tsx + {team.rationale && ( +
+

{team.rationale.title}

+

{team.rationale.summary}

+ {team.rationale.strengths.length > 0 && ( +
    + {team.rationale.strengths.map((s, i) =>
  • {s}
  • )} +
+ )} + {team.rationale.gaps.length > 0 && ( +
    + {team.rationale.gaps.map((g, i) =>
  • {g}
  • )} +
+ )} +
+ )} +``` + +- [ ] **Step 5: Run the component test** + +Run: `cd frontend && npx vitest run tests/components/team-card.test.tsx` +Expected: PASS (2 passed). + +- [ ] **Step 6: Add the "Explain teams" button to `ResultsGrid`** + +In `frontend/components/engine/results-grid.tsx`: + +Update the import from `@/hooks/use-allocation` to also import `generateRationales`. Add a handler inside the component (next to `handleRegenerate`): + +```tsx + const [explaining, setExplaining] = useState(false); + + const handleExplain = async () => { + if (!session?.accessToken || explaining) return; + setExplaining(true); + try { + const map = await generateRationales(session.accessToken, allocation.id); + const teams = allocation.teams.map(t => ({ ...t, rationale: map[t.id] ?? t.rationale })); + onChanged({ ...allocation, teams }); + toast.success("Teams explained"); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "Could not explain teams"); + } finally { + setExplaining(false); + } + }; +``` + +In the action-button row (the `
`), add as the first button (always shown, draft or published): + +```tsx + +``` + +Add `Sparkles` to the `lucide-react` import at the top of the file. + +- [ ] **Step 7: Typecheck + lint + run the frontend unit suite** + +Run: `cd frontend && npx tsc --noEmit && npm run lint && npx vitest run` +Expected: tsc exit 0; lint 0 errors; all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add frontend/hooks/use-allocation.ts frontend/components/engine/results-grid.tsx frontend/components/engine/team-card.tsx frontend/tests/components/team-card.test.tsx +git commit -m "feat(rationale): Explain teams button + rationale on team cards" +``` + +--- + +### Task 8: Frontend — public results rendering + +**Files:** +- Modify: `frontend/app/results/[allocationId]/page.tsx` (render `team.rationale` per team) +- Test: `frontend/tests/components/...` (only if the page is decomposed into a testable component; otherwise verify via typecheck + manual) + +**Interfaces:** +- Consumes: `team.rationale` from the public allocation response (already typed via the shared `Team`/public shape). + +- [ ] **Step 1: Inspect the public results page** + +Run: `sed -n '1,200p' frontend/app/results/[allocationId]/page.tsx` (read it) to find where each team is rendered and what type it uses for teams. + +- [ ] **Step 2: Render the rationale block per team** + +Where each public team is rendered, add the same violet rationale block as in `TeamCard` (title + summary + strengths + gaps), guarded by `team.rationale &&`. If the public team type is local to the page, add an optional `rationale?: { title: string; summary: string; strengths: string[]; gaps: string[] } | null` to it. + +```tsx +{team.rationale && ( +
+

{team.rationale.title}

+

{team.rationale.summary}

+ {team.rationale.strengths.length > 0 && ( +
    + {team.rationale.strengths.map((s: string, i: number) =>
  • {s}
  • )} +
+ )} + {team.rationale.gaps.length > 0 && ( +
    + {team.rationale.gaps.map((g: string, i: number) =>
  • {g}
  • )} +
+ )} +
+)} +``` + +- [ ] **Step 3: Typecheck + lint + build** + +Run: `cd frontend && npx tsc --noEmit && npm run lint && NEXT_PUBLIC_API_URL=http://localhost:8000 AUTH_SECRET=x npm run build` +Expected: tsc exit 0; lint 0 errors; build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/app/results/[allocationId]/page.tsx +git commit -m "feat(rationale): show team rationale on public results" +``` + +--- + +## Self-Review + +**Spec coverage:** +- On-demand trigger → Task 4 endpoint + Task 7 button. ✓ +- Structured JSON `{title,summary,strengths,gaps}` → Tasks 1–3. ✓ +- Cached on team + cleared on re-run/edit → Task 1 column, Task 6 clears on move; regenerate makes new teams (no rationale) by existing behavior. ✓ +- PII-free input + public surface → Task 2 (`_team_payloads` excludes name/email; PII test), Task 5 public schema. ✓ +- No-key → 400 "AI not configured" → Task 3 raises, Task 4 maps to 400, Task 7 toast. ✓ +- Separate `RATIONALE_MODEL` → Task 2. ✓ +- Determinism untouched → rationale never read by `allocation_engine` generation; only cleared on edit. ✓ + +**Placeholder scan:** Task 8 Step 1 intentionally inspects an unread file before editing; the edit code is fully specified. No "TBD"/"handle errors" placeholders elsewhere. + +**Type consistency:** `TeamRationale {title, summary, strengths[], gaps[]}` matches the backend `_parse_rationales` output and `Team.rationale` JSON shape across all tasks. `generateRationales` returns `Record` matching the endpoint's `{team_id: rationale}`. diff --git a/docs/superpowers/specs/2026-06-20-team-rationale-design.md b/docs/superpowers/specs/2026-06-20-team-rationale-design.md new file mode 100644 index 0000000..4ec622b --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-team-rationale-design.md @@ -0,0 +1,123 @@ +# SquadSync 🧠 Team Rationale Design + +**Date:** 2026-06-20 +**Status:** Approved +**Branch:** `feat/team-rationale` (off the self-custody payout work) +**Builds on:** `categorization_service` (the batched, abstention-tolerant Claude pattern, +cacheable system block) and the deterministic allocation engine. +**Context:** Audit aim — capitalize on AI in depth. Today AI only normalizes "Other" +strengths. This adds an **explainability layer**: a short, human-readable "why this team +works" per team, so organizers and participants trust the (otherwise opaque) deterministic +allocation. It is the foundation for later AI features (pairing, NL queries). + +## Overview + +A descriptive, **post-hoc** explanation of an allocation. It never reads into or changes the +deterministic engine — it narrates the result that already happened. The organizer clicks +**"Explain teams"**; SquadSync generates a structured rationale per team in one batched Claude +call, caches it on each team, and shows it in the engine view and on the public results page. + +## Goals + +- One short, structured rationale per team: a title, a one-sentence summary, strengths, and gaps. +- Generated **on demand** (not on every draft run), cached, and shown publicly. +- Built only from composition data the app already has; **never** sends or shows participant PII. +- Zero effect on allocation — determinism and reproducibility are untouched. + +## Non-Goals + +- Influencing allocation, scoring, or ranking teams (that is later "complementary pairing"). +- Per-participant explanations ("why am I on this team") — team-level only for now. +- A deterministic non-AI fallback: when no API key is configured the feature is simply absent. +- Collecting `tech_stack`/`interests` (the form does not capture them yet; see Data reality). + +## Decisions (locked) + +- **Trigger:** on-demand `POST /allocations/{id}/rationale` from an "Explain teams" button. A fresh + allocate/regenerate produces new team rows (no rationale); a manual `move_member` edit **clears** + that allocation's rationales so a stale explanation never shows. +- **Storage:** a nullable `rationale` **JSON** column on `Team` holding + `{title, summary, strengths: [...], gaps: [...]}`. Structured (not a rendered string) so the UI + controls presentation and future features can read fields. +- **No-key behavior:** if `ANTHROPIC_API_KEY` is unset, the endpoint returns `400` "AI rationale + requires ANTHROPIC_API_KEY"; the button surfaces "AI not configured". No template fallback. +- **Privacy:** the AI input contains **no names or emails** — only role/normalized strength, + experience level, and `tech_stack`/`interests` when present. The prompt forbids naming + individuals, so the output is PII-free by construction and safe on public results. +- **Model:** reuse the Haiku model id via a `RATIONALE_MODEL` setting (default + `claude-haiku-4-5-20251001`). + +## Data reality (tech_stack / interests) + +The `participants.tech_stack` / `interests` columns exist and the API accepts them, but the +registration form never sets them, so they are empty for real participants. The rationale is +designed to lean on **roles, experience, and normalized strengths** (rich enough on their own) +and to fold in tech_stack/interests **only when non-empty**. Adding those form fields is a clean +follow-up (the "complementary pairing" track), out of scope here. + +## Data model + +- `teams.rationale` — new nullable `JSON` column (Alembic migration). `null` = not yet generated. + +## Backend components + +- **`app/services/rationale_service.py`** + - `_build_request(event, teams_with_members) -> dict` (pure): one batched Messages API request. + Static instructions + output contract in a **cacheable system block**; per-team composition + (PII-free) in the user message; a tool schema returning + `{rationales: [{team_id, title, summary, strengths[], gaps[]}]}`. + - `_parse_rationales(content_blocks) -> dict[team_id, dict]` (pure): extract + validate. + - `generate(db, allocation) -> dict[team_id, dict]`: gather each team's members (roles, + experience, normalized strength, optional tech_stack/interests), call Claude, persist + `Team.rationale`, return the map. Raises a typed `RationaleUnavailable` when no key is set. +- **`allocation_engine.move_participant`** — clear `rationale` on the allocation's teams after an + edit (it no longer describes the current composition). + +## API (organizer-authenticated, under `/api/v1`) + +- `POST /allocations/{allocation_id}/rationale` — generate + store + return + `{team_id: {title, summary, strengths, gaps}}`. `400` when AI is not configured; `404`/`403` + via the existing organizer guard. +- Public `GET /public/allocations/{allocation_id}` — extend `PublicTeam` with an optional + `rationale` object (PII-free) so published results show the blurb. + +## Frontend components + +- **Engine view (`run-panel` / `results-grid` / `team-card`)** — an "Explain teams" button that + calls the endpoint, then renders per team: title, one-sentence summary, and small + strengths / gaps lists. A toast surfaces "AI not configured" on `400`. +- **Public results page** — render the same per-team blurb when a rationale exists. + +## Data flow + +1. Organizer runs/edits an allocation, then clicks "Explain teams". +2. Backend gathers PII-free composition per team → one batched Claude call → structured rationales. +3. Each `Team.rationale` is persisted; the response updates the engine view live. +4. On publish, the public results page renders the stored rationales (no extra AI call). +5. Re-running allocation yields fresh teams (no rationale); editing a team clears stale rationales. + +## Error handling + +- **No API key** → `400` "AI rationale requires ANTHROPIC_API_KEY"; button shows "AI not configured". +- **Claude/tool failure or a team omitted from the response** → that team simply has no rationale + (the others still get one); never blocks the allocation or the page. +- **Malformed/oversized output** → batched + bounded `max_tokens`, mirroring categorization, so a + large allocation cannot truncate the whole response. + +## Testing + +Backend (SQLite, matching existing suite): +- `_build_request` — system block is cacheable; the payload contains **no name/email**; tool enum + shape; each team id present. +- `_parse_rationales` — keeps well-formed entries; drops malformed; tolerates an omitted team. +- `generate` — persists `Team.rationale`; raises `RationaleUnavailable` with no key. +- endpoint — organizer-only; `400` without a key; stores on teams; public surface includes it. +- `move_participant` clears the allocation's rationales. + +Frontend: unit-test the rationale API call + the card rendering of title/summary/strengths/gaps; +the "AI not configured" toast path. + +## Scope guardrails (YAGNI) + +Team-level, descriptive, on-demand, cached, PII-free. No allocation influence, no per-person +explanations, no new data collection, no non-AI fallback. diff --git a/frontend/app/results/[allocationId]/page.tsx b/frontend/app/results/[allocationId]/page.tsx index feb353a..6a6c803 100644 --- a/frontend/app/results/[allocationId]/page.tsx +++ b/frontend/app/results/[allocationId]/page.tsx @@ -10,6 +10,7 @@ interface Team { name: string; fairness_score?: number; members: TeamMember[]; + rationale?: { title: string; summary: string; strengths: string[]; gaps: string[] } | null; } interface PayoutSummary { team_label: string; total_sats: number; status: string; paid_count: number; member_count: number; } interface AllocationResult { id: string; status: string; teams: Team[]; payouts?: PayoutSummary[]; } @@ -87,6 +88,22 @@ export default async function ResultsPage({ params }: { params: Promise<{ alloca ))} + {team.rationale && ( +
+

{team.rationale.title}

+

{team.rationale.summary}

+ {team.rationale.strengths.length > 0 && ( +
    + {team.rationale.strengths.map((s: string, i: number) =>
  • {s}
  • )} +
+ )} + {team.rationale.gaps.length > 0 && ( +
    + {team.rationale.gaps.map((g: string, i: number) =>
  • {g}
  • )} +
+ )} +
+ )} ))} diff --git a/frontend/components/engine/results-grid.tsx b/frontend/components/engine/results-grid.tsx index a979fe0..4191ae1 100644 --- a/frontend/components/engine/results-grid.tsx +++ b/frontend/components/engine/results-grid.tsx @@ -3,10 +3,10 @@ import { useState } from "react"; import { useSession } from "next-auth/react"; import { toast } from "sonner"; -import { AlertTriangle, Download, Link2, CheckCircle2, RefreshCw } from "lucide-react"; +import { AlertTriangle, Download, Link2, CheckCircle2, RefreshCw, Sparkles } from "lucide-react"; import { TeamCard } from "./team-card"; import { PayoutModal } from "./payout-modal"; -import { publishAllocation, moveMember, regenerateAllocation } from "@/hooks/use-allocation"; +import { publishAllocation, moveMember, regenerateAllocation, generateRationales } from "@/hooks/use-allocation"; import { Button } from "@/components/ui/button"; import type { Allocation, Team } from "@/hooks/use-allocation"; import { normalizationNote } from "@/lib/allocation-notes"; @@ -23,6 +23,7 @@ export function ResultsGrid({ allocation, eventId, onPublished, onChanged }: Res const [publishing, setPublishing] = useState(false); const [working, setWorking] = useState(false); // a move or regenerate is in flight const [payoutTeam, setPayoutTeam] = useState(null); + const [explaining, setExplaining] = useState(false); const warningEntries = Object.entries(allocation.constraint_warnings); const note = normalizationNote(allocation.ai_normalized, allocation.auto_normalized); const isDraft = allocation.status === "draft"; @@ -69,6 +70,21 @@ export function ResultsGrid({ allocation, eventId, onPublished, onChanged }: Res } }; + const handleExplain = async () => { + if (!session?.accessToken || explaining) return; + setExplaining(true); + try { + const map = await generateRationales(session.accessToken, allocation.id); + const teams = allocation.teams.map(t => ({ ...t, rationale: map[t.id] ?? t.rationale })); + onChanged({ ...allocation, teams }); + toast.success("Teams explained"); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "Could not explain teams"); + } finally { + setExplaining(false); + } + }; + const handleCSV = async () => { if (!session?.accessToken) return; try { @@ -143,6 +159,9 @@ export function ResultsGrid({ allocation, eventId, onPublished, onChanged }: Res )}
+ {isDraft && (
+ {team.rationale && ( +
+

{team.rationale.title}

+

{team.rationale.summary}

+ {team.rationale.strengths.length > 0 && ( +
    + {team.rationale.strengths.map((s, i) =>
  • {s}
  • )} +
+ )} + {team.rationale.gaps.length > 0 && ( +
    + {team.rationale.gaps.map((g, i) =>
  • {g}
  • )} +
+ )} +
+ )} +
{[ { label: "Skill", value: team.skill_score }, diff --git a/frontend/hooks/use-allocation.ts b/frontend/hooks/use-allocation.ts index 9203500..551c5eb 100644 --- a/frontend/hooks/use-allocation.ts +++ b/frontend/hooks/use-allocation.ts @@ -19,6 +19,13 @@ export interface TeamMember { composite_score?: number; } +export interface TeamRationale { + title: string; + summary: string; + strengths: string[]; + gaps: string[]; +} + export interface Team { id: string; name: string; @@ -26,6 +33,7 @@ export interface Team { skill_score?: number; role_balance_score?: number; members: TeamMember[]; + rationale?: TeamRationale | null; } export interface Allocation { @@ -136,3 +144,10 @@ export async function reportPayoutItemFailed( { method: "POST", body: { error }, token } ); } + +export async function generateRationales(token: string, allocationId: string) { + return fetchAPI>( + `/api/v1/allocations/${allocationId}/rationale`, + { method: "POST", token } + ); +} diff --git a/frontend/tests/components/team-card.test.tsx b/frontend/tests/components/team-card.test.tsx new file mode 100644 index 0000000..a0b5363 --- /dev/null +++ b/frontend/tests/components/team-card.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { TeamCard } from "@/components/engine/team-card"; +import type { Team } from "@/hooks/use-allocation"; + +const team: Team = { + id: "t1", name: "Team 01", fairness_score: 80, skill_score: 75, role_balance_score: 90, + members: [{ id: "m1", name: "Ada", email: "a@x.com", experience_level: "advanced", + normalized_strength: "technical" }], + rationale: { title: "Build squad", summary: "Strong delivery.", + strengths: ["2 advanced engineers"], gaps: ["limited outreach"] }, +}; + +describe("TeamCard rationale", () => { + it("renders the rationale summary and strengths/gaps when present", () => { + render(); + expect(screen.getByText("Build squad")).toBeInTheDocument(); + expect(screen.getByText("Strong delivery.")).toBeInTheDocument(); + expect(screen.getByText("2 advanced engineers")).toBeInTheDocument(); + expect(screen.getByText("limited outreach")).toBeInTheDocument(); + }); + + it("renders no rationale block when absent", () => { + render(); + expect(screen.queryByText("Strong delivery.")).not.toBeInTheDocument(); + }); +});