feat(milestone): merge phase/02 cohort-dashboard → milestone/v0.4-operator-tier

Phase 2 complete — Cohort Dashboard + Aggregation:
- Cohort aggregation pipeline (k-anon ≥10 write-time suppression, async hook, nightly 03:00 CT reconcile)
- 4 auth-gated operator API endpoints (cohort, mastery, failure-patterns, credentials)
- React cohort dashboard (BrowserRouter, login, 3 views, inline SVG sparklines, auth gate)
- SPA fallback via SpaStaticFiles subclass (G-041 — NOT catch-all route)
- G-038 differencing-attack test (unit + API e2e)
- 317 pytest pass, 36 skip, 0 fail; 17/17 vitest pass; npm build + typecheck clean

---ci---
project: praxis
phase: 2
milestone: v0.4
status: complete
requirements:
  covered: [REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-MT-02]
  partial: []
---/ci---
This commit is contained in:
Praxis CI
2026-08-04 11:43:34 +00:00
parent d3a67511e5
commit ec6fcc64bc
33 changed files with 6102 additions and 191 deletions
+52
View File
@@ -16,6 +16,7 @@ No auth — learner_id is the hardcoded 'learner-1' (D-007).
from __future__ import annotations
import asyncio
import datetime as _dt
import json
import logging
import uuid
@@ -27,6 +28,10 @@ from server.cost import CostBreakdown, derive_cost
log = logging.getLogger(__name__)
def _now_iso() -> str:
return _dt.datetime.now(_dt.timezone.utc).isoformat()
class SessionRecorder:
"""Records a voice session to SQLite (TASK-04-03)."""
@@ -35,10 +40,12 @@ class SessionRecorder:
store: PraxisStore,
learner_id: str = HARDCODED_LEARNER_ID,
scenario_id: str = "cs_refund_ca_v01",
pg_store: Any = None,
) -> None:
self.store = store
self.learner_id = learner_id
self.scenario_id = scenario_id
self.pg_store = pg_store
self.session_id: str | None = None
self._turn_seq = 0
# Cost inputs accumulated over the session.
@@ -143,8 +150,53 @@ class SessionRecorder:
asyncio.create_task(
self._run_mastery_flow_guarded(mastery_deps)
)
# v0.4 P2 (D-054): fire-and-forget cohort aggregation hook. Runs in
# parallel with the mastery flow — aggregation only needs the session
# outcome (available after session end), not the mastery scoring
# result. Rubric-dependent metrics are reconciled by the nightly job.
# Off the voice path (C-8, D-054). No-op if pg_store is None.
if self.pg_store is not None:
session_outcome = self._build_session_outcome(outcome)
asyncio.create_task(self._run_cohort_aggregation(session_outcome))
return breakdown
def _build_session_outcome(self, outcome: str) -> dict[str, Any]:
"""Construct the session_outcome dict for the aggregation hook."""
rubric_scores: list[dict[str, Any]] = []
if self.mastery_result and isinstance(self.mastery_result, dict):
rubric_scores = list(self.mastery_result.get("rubric_scores") or [])
return {
"learner_ref": self.learner_id,
"path": self._path_slug(),
"scenario_id": self.scenario_id,
"outcome": outcome,
"rubric_scores": rubric_scores,
"failure_mode": self._failure_mode(),
"branch_path": list(self._branch_path),
"timestamp": _now_iso(),
}
def _path_slug(self) -> str:
# The scenario_id encodes the path loosely; default to customer_service.
if self.scenario_id and self.scenario_id.startswith("cs_"):
return "customer_service"
return "default"
def _failure_mode(self) -> str | None:
if self.mastery_result and isinstance(self.mastery_result, dict):
return self.mastery_result.get("failure_mode")
return None
async def _run_cohort_aggregation(self, session_outcome: dict[str, Any]) -> None:
"""Fire-and-forget wrapper around the cohort aggregation hook (D-054)."""
try:
from server.cohort.hook import on_session_end
await on_session_end(self.pg_store, session_outcome)
except Exception:
log.exception("cohort aggregation dispatch failed for session %s", self.session_id)
async def _run_mastery_flow_guarded(self, deps: "MasteryFlowDeps") -> None:
try:
await self.run_mastery_flow(deps)