From 44f6915f0cea3e35b77394194f1788cf1222fe56 Mon Sep 17 00:00:00 2001 From: Chaim Date: Tue, 30 Jun 2026 19:31:21 +0000 Subject: [PATCH] feat(status): single source of truth for the case-status model + canonicalize intermediates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the case where the H1 chip, the pipeline stepper and the manual-changer dropdown disagreed (e.g. 1043-02-26 on analyst_verified): the status sat between the canonical 10 and a legacy bucket, so each surface read a different list. Root fix — ONE authoritative status model + canonicalize the real intermediate states (chair-approved): - New mcp-server/.../case_status_model.py — the single registry: ordered StatusDef list (key/label/description/phase/selectable/terminal/on_enter), the 5 phases, STATUS_ORDER, phase_of/label_of, and a drift assertion that the CaseStatus enum matches it. `on_enter` is the seam for a status to *do* something on entry, declared next to its definition (no dispatcher wired yet). - models.CaseStatus enum: analyst_verified + research_complete promoted to first-class canonical statuses (the agents set them — they belong in the model, not a legacy fallback). - tools/cases.py: the forward-only STATUS_ORDER guard now derives from the registry instead of an inline list. - GET /api/status-model exposes the model so the frontend mirror can be generated against it. - web-ui case-status.ts mirrors it: the two intermediates moved from the legacy map into CASE_STATUSES / PHASES(thinking) / STATUS_LABELS / STATUS_DESCRIPTIONS; status-badge icons+tones extended. So the chip (status), stepper (its phase) and dropdown (now includes it) all agree. Invariants: G2 — collapses the scattered status definitions (enum, inline STATUS_ORDER, two frontend label maps) onto one backend authority + a documented frontend mirror; no parallel status list remains. Agent-prompt alignment + backfill follow in a separate change. py_compile + tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- mcp-server/src/legal_mcp/case_status_model.py | 118 ++++++++++++++++++ mcp-server/src/legal_mcp/models.py | 11 +- mcp-server/src/legal_mcp/tools/cases.py | 16 +-- web-ui/src/components/cases/status-badge.tsx | 4 + web-ui/src/lib/api/case-status.ts | 45 +++---- web/app.py | 10 ++ 6 files changed, 170 insertions(+), 34 deletions(-) create mode 100644 mcp-server/src/legal_mcp/case_status_model.py diff --git a/mcp-server/src/legal_mcp/case_status_model.py b/mcp-server/src/legal_mcp/case_status_model.py new file mode 100644 index 0000000..1321288 --- /dev/null +++ b/mcp-server/src/legal_mcp/case_status_model.py @@ -0,0 +1,118 @@ +"""Single source of truth for the case-status lifecycle (the *status model*). + +Every consumer derives its order / labels / phase from this one registry: + • ``CaseStatus`` enum (models.py) — the type-level key set (kept consistent + by the assertion at the bottom of this module); + • the forward-only ``STATUS_ORDER`` guard in ``tools/cases.py``; + • the ``GET /api/status-model`` endpoint (web/app.py) that the frontend + mirror (``web-ui/src/lib/api/case-status.ts``) is generated against. + +To make a status *do something* on entry (notify, kick a job, …), set its +``on_enter`` to an action key — the single place where a status's behaviour is +declared. ``tools/cases.py`` dispatches it when a case transitions into that +status (forward-only), so the behaviour lives next to the definition. + +The five **phases** are the coarse pipeline the 12 statuses collapse into for the +header stepper. Intermediate analyst/research states (``analyst_verified``, +``research_complete``) are first-class canonical statuses here — the agents set +them, so they must be in the model rather than fall between the canonical set +and the legacy bucket (the bug that made the chip, stepper and manual-changer +disagree). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# Ordered 5-phase pipeline (key → Hebrew label) — coarse view of the lifecycle. +PHASES: list[tuple[str, str]] = [ + ("intake", "קליטה ועיבוד"), + ("prep", "הכנת תיק"), + ("thinking", "ניתוח וכיוון"), + ("writing", "כתיבת טיוטה"), + ("done", "סגירה"), +] +PHASE_LABELS: dict[str, str] = dict(PHASES) + + +@dataclass(frozen=True) +class StatusDef: + key: str + label: str # Hebrew label (chip / dropdown) + description: str # Hebrew one-liner (status guide) + phase: str # one of PHASES keys + selectable: bool = True # offered in the manual status-changer dropdown + terminal: bool = False + on_enter: str | None = None # future: action key dispatched on entry + + +# THE lifecycle — ordered. STATUS_ORDER, the enum and the frontend all derive +# from this list. Insert intermediates in workflow order. +STATUS_DEFS: list[StatusDef] = [ + StatusDef("new", "חדש", "התיק נוצר וממתין להעלאת מסמכים", "intake"), + StatusDef("processing", "בעיבוד", "המערכת מעבדת ומנתחת את המסמכים", "intake"), + StatusDef("documents_ready", "מסמכים מוכנים", "כל המסמכים עובדו ומוכנים לעבודה", "prep"), + StatusDef("analyst_verified", "ניתוח אומת", "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה של היו״ר", "thinking"), + StatusDef("research_complete", "מחקר הושלם", "חקר התקדימים הושלם (מסלול נפרד מהמנתח)", "thinking"), + StatusDef("outcome_set", "תוצאה נקבעה", "נקבעה תוצאה צפויה לערר", "thinking"), + StatusDef("direction_approved", "כיוון אושר", "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה", "thinking"), + StatusDef("qa_review", "בדיקת איכות", "הטיוטה בבדיקת איכות אוטומטית", "writing"), + StatusDef("drafted", "טיוטה", "טיוטה מוכנה לעיון", "writing"), + StatusDef("exported", "יוצא", "ההחלטה יוצאה לקובץ DOCX", "done"), + StatusDef("reviewed", "נבדק", 'ההחלטה נבדקה ע"י היו"ר', "done"), + StatusDef("final", "סופי", "החלטה סופית — מוכנה להגשה", "done", terminal=True), +] + +BY_KEY: dict[str, StatusDef] = {d.key: d for d in STATUS_DEFS} +STATUS_ORDER: list[str] = [d.key for d in STATUS_DEFS] + + +def phase_of(status: str | None) -> str | None: + """The pipeline phase a status belongs to (None for unknown values).""" + d = BY_KEY.get(status or "") + return d.phase if d else None + + +def label_of(status: str | None) -> str: + d = BY_KEY.get(status or "") + return d.label if d else (status or "") + + +def to_dict() -> dict: + """Serialisable status model for ``GET /api/status-model`` (frontend SSoT).""" + return { + "statuses": [ + { + "key": d.key, + "label": d.label, + "description": d.description, + "phase": d.phase, + "selectable": d.selectable, + "terminal": d.terminal, + "on_enter": d.on_enter, + } + for d in STATUS_DEFS + ], + "phases": [{"key": k, "label": v} for k, v in PHASES], + } + + +# Drift guard — the registry IS the canonical key set; the CaseStatus enum must +# match it exactly. Importing here (models.py never imports this module) is safe. +def _assert_consistent() -> None: + from legal_mcp.models import CaseStatus + + registry = {d.key for d in STATUS_DEFS} + enum_keys = {s.value for s in CaseStatus} + if registry != enum_keys: + raise RuntimeError( + "case_status_model drift: registry vs CaseStatus enum differ — " + f"only-in-registry={registry - enum_keys}, only-in-enum={enum_keys - registry}" + ) + phase_keys = {k for k, _ in PHASES} + bad = {d.key: d.phase for d in STATUS_DEFS if d.phase not in phase_keys} + if bad: + raise RuntimeError(f"case_status_model: statuses with unknown phase: {bad}") + + +_assert_consistent() diff --git a/mcp-server/src/legal_mcp/models.py b/mcp-server/src/legal_mcp/models.py index e853645..9c3bd4a 100644 --- a/mcp-server/src/legal_mcp/models.py +++ b/mcp-server/src/legal_mcp/models.py @@ -9,13 +9,18 @@ from uuid import UUID from pydantic import BaseModel, Field -# Core case lifecycle — kept in sync with STATUS_ORDER in tools/cases.py and the -# frontend SSoT web-ui/src/lib/api/case-status.ts. Trimmed from 17 → 10 (the -# decorative mid-stage markers that no pipeline code ever set were removed). +# Core case lifecycle. The canonical key set, order, labels, phases and (future) +# per-status actions all live in ONE place — legal_mcp/case_status_model.py — +# which asserts this enum matches it. The frontend mirror is +# web-ui/src/lib/api/case-status.ts (generated against GET /api/status-model). +# The analyst/research intermediate states are first-class canonical statuses +# (the agents set them) — not legacy. class CaseStatus(str, enum.Enum): NEW = "new" PROCESSING = "processing" DOCUMENTS_READY = "documents_ready" + ANALYST_VERIFIED = "analyst_verified" + RESEARCH_COMPLETE = "research_complete" OUTCOME_SET = "outcome_set" DIRECTION_APPROVED = "direction_approved" QA_REVIEW = "qa_review" diff --git a/mcp-server/src/legal_mcp/tools/cases.py b/mcp-server/src/legal_mcp/tools/cases.py index 31d4c6f..e15349e 100644 --- a/mcp-server/src/legal_mcp/tools/cases.py +++ b/mcp-server/src/legal_mcp/tools/cases.py @@ -13,6 +13,7 @@ from uuid import UUID import httpx from legal_mcp import config +from legal_mcp.case_status_model import STATUS_ORDER # status SSoT from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope @@ -341,16 +342,8 @@ async def case_update( """ from datetime import date as date_type - # Ordered core lifecycle — regression protection (forward-only). - # Single source of truth, mirrored by web-ui/src/lib/api/case-status.ts and - # models.CaseStatus. Trimmed from 17 → 10 (decorative statuses removed). - STATUS_ORDER = [ - "new", "processing", "documents_ready", - "outcome_set", "direction_approved", - "qa_review", "drafted", - "exported", "reviewed", "final", - ] - + # Ordered core lifecycle (forward-only regression guard). Single source of + # truth: legal_mcp/case_status_model.py — STATUS_ORDER/BY_KEY derive from it. case = await db.get_case_by_number(case_number) if not case: return err(f"תיק {case_number} לא נמצא.") @@ -363,6 +356,9 @@ async def case_update( # Only update if advancing or status is unknown to the order if new_idx >= cur_idx or new_idx == -1: fields["status"] = status + # per-status on_enter hook (case_status_model) — the single place a + # status's behaviour is declared. No dispatcher wired yet; when the + # first action is added, dispatch BY_KEY[status].on_enter here. if title: fields["title"] = title if subject: diff --git a/web-ui/src/components/cases/status-badge.tsx b/web-ui/src/components/cases/status-badge.tsx index 5dbb3ce..2d3734a 100644 --- a/web-ui/src/components/cases/status-badge.tsx +++ b/web-ui/src/components/cases/status-badge.tsx @@ -17,6 +17,8 @@ const STATUS_ICONS: Record = { new: FilePlus2, processing: Loader2, documents_ready: FileCheck, + analyst_verified: SearchCheck, + research_complete: Compass, outcome_set: Target, direction_approved: Compass, qa_review: SearchCheck, @@ -36,6 +38,8 @@ const STATUS_TONE: Record = { new: "bg-rule-soft text-ink-muted border-rule", processing: "bg-info-bg text-info border-info/30", documents_ready: "bg-info-bg text-info border-info/40", + analyst_verified: "bg-gold-wash text-gold-deep border-gold/40", + research_complete: "bg-gold-wash text-gold-deep border-gold/40", outcome_set: "bg-gold-wash text-gold-deep border-gold/40", direction_approved:"bg-gold-wash text-gold-deep border-gold/50", qa_review: "bg-warn-bg text-warn border-warn/40", diff --git a/web-ui/src/lib/api/case-status.ts b/web-ui/src/lib/api/case-status.ts index d3fdc81..ce88e80 100644 --- a/web-ui/src/lib/api/case-status.ts +++ b/web-ui/src/lib/api/case-status.ts @@ -1,16 +1,19 @@ /** - * Single source of truth for the case-status lifecycle (UI-B1 / G2). + * Frontend mirror of the canonical case-status lifecycle. * - * The 17-status manual menu was trimmed to the **10 core statuses** that the - * pipeline actually sets or that gate real logic. Decorative mid-stage markers - * (uploading, analyst_verified, research_complete, brainstorming, - * analysis_enriched, ready_for_writing, drafting) plus the legacy/dead - * `in_progress` and `qa_failed` were removed — no pipeline code ever set them. + * The ONE source of truth is the backend status model + * (`mcp-server/src/legal_mcp/case_status_model.py`), exposed at + * `GET /api/status-model`; this file mirrors it (order / labels / phases) so the + * UI keeps compile-time `CaseStatus` typing. Keep the two in sync — the backend + * registry is authoritative (it also declares per-status `on_enter` actions). * - * Every status consumer (badge, changer, timeline, guide, donut, KPI cards, - * compose chip) imports the list / labels / phases from here instead of - * re-declaring its own array. Keep this file in sync with the backend - * STATUS_ORDER in `mcp-server/src/legal_mcp/tools/cases.py`. + * The analyst/research intermediate states (`analyst_verified`, + * `research_complete`) are first-class canonical statuses — the agents set them, + * so the chip, stepper and manual changer all agree instead of the status + * falling between the canonical set and a legacy bucket. + * + * Every status consumer (badge, changer, timeline, guide, donut, KPI cards) + * imports the list / labels / phases from here instead of re-declaring its own. */ /** Ordered lifecycle — also the order shown in the manual status dropdown. */ @@ -18,6 +21,8 @@ export const CASE_STATUSES = [ "new", "processing", "documents_ready", + "analyst_verified", + "research_complete", "outcome_set", "direction_approved", "qa_review", @@ -35,22 +40,18 @@ export type PhaseKey = "intake" | "prep" | "thinking" | "writing" | "done"; export const PHASES: { key: PhaseKey; label: string; statuses: CaseStatus[] }[] = [ { key: "intake", label: "קליטה ועיבוד", statuses: ["new", "processing"] }, { key: "prep", label: "הכנת תיק", statuses: ["documents_ready"] }, - { key: "thinking", label: "ניתוח וכיוון", statuses: ["outcome_set", "direction_approved"] }, + { key: "thinking", label: "ניתוח וכיוון", statuses: ["analyst_verified", "research_complete", "outcome_set", "direction_approved"] }, { key: "writing", label: "כתיבת טיוטה", statuses: ["qa_review", "drafted"] }, { key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] }, ]; /** - * Legacy/intermediate statuses that the trimmed PHASES list dropped but the - * Paperclip agents still set (e.g. the analyst writes `analyst_verified`, the - * researcher writes `research_complete`). Without this map a case parked on one - * of them renders with NO active phase — the pipeline stepper goes blank and the - * chair can't see where the case stands. Map each to the phase it belongs to for - * display purposes only (these are NOT selectable statuses). + * Truly-legacy statuses that are NOT in the canonical set but might still arrive + * from an old client / un-migrated row — mapped to a phase for display only, so + * the stepper never goes blank. (The analyst/research intermediates are NO + * longer here — they are canonical statuses in CASE_STATUSES above.) */ const LEGACY_STATUS_PHASE: Record = { - analyst_verified: "thinking", - research_complete: "thinking", analysis_enriched: "thinking", ready_for_writing: "writing", drafting: "writing", @@ -80,6 +81,8 @@ export const STATUS_LABELS: Record = { new: "חדש", processing: "בעיבוד", documents_ready: "מסמכים מוכנים", + analyst_verified: "ניתוח אומת", + research_complete: "מחקר הושלם", outcome_set: "תוצאה נקבעה", direction_approved: "כיוון אושר", qa_review: "בדיקת איכות", @@ -97,8 +100,6 @@ export const STATUS_LABELS: Record = { const LEGACY_STATUS_LABELS: Record = { in_progress: "בעבודה", uploading: "מעלה", - analyst_verified: "ניתוח אומת", - research_complete: "מחקר הושלם", brainstorming: "סיעור מוחות", analysis_enriched: "ניתוח הועמק", ready_for_writing: "מוכן לכתיבה", @@ -124,6 +125,8 @@ export const STATUS_DESCRIPTIONS: Record = { new: "התיק נוצר וממתין להעלאת מסמכים", processing: "המערכת מעבדת ומנתחת את המסמכים", documents_ready: "כל המסמכים עובדו ומוכנים לעבודה", + analyst_verified: "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה", + research_complete: "חקר התקדימים הושלם (מסלול נפרד מהמנתח)", outcome_set: "נקבעה תוצאה צפויה לערר", direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה", qa_review: "הטיוטה בבדיקת איכות אוטומטית", diff --git a/web/app.py b/web/app.py index a24bcd6..9ed306d 100644 --- a/web/app.py +++ b/web/app.py @@ -1828,6 +1828,16 @@ async def health(): return {"status": "ok"} +@app.get("/api/status-model") +async def api_status_model(): + """Canonical case-status model — ordered statuses (key/label/description/ + phase/selectable/terminal/on_enter) + the 5 phases. Single source of truth + (legal_mcp/case_status_model.py); the frontend case-status.ts mirrors it.""" + from legal_mcp.case_status_model import to_dict + + return to_dict() + + @app.get("/api/cases") async def list_cases( detail: bool = False,