feat(status): single source of truth for the case-status model + canonicalize intermediates
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) <noreply@anthropic.com>
This commit is contained in:
118
mcp-server/src/legal_mcp/case_status_model.py
Normal file
118
mcp-server/src/legal_mcp/case_status_model.py
Normal file
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -17,6 +17,8 @@ const STATUS_ICONS: Record<CaseStatus, LucideIcon> = {
|
||||
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<CaseStatus, string> = {
|
||||
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",
|
||||
|
||||
@@ -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<string, PhaseKey> = {
|
||||
analyst_verified: "thinking",
|
||||
research_complete: "thinking",
|
||||
analysis_enriched: "thinking",
|
||||
ready_for_writing: "writing",
|
||||
drafting: "writing",
|
||||
@@ -80,6 +81,8 @@ export const STATUS_LABELS: Record<CaseStatus, string> = {
|
||||
new: "חדש",
|
||||
processing: "בעיבוד",
|
||||
documents_ready: "מסמכים מוכנים",
|
||||
analyst_verified: "ניתוח אומת",
|
||||
research_complete: "מחקר הושלם",
|
||||
outcome_set: "תוצאה נקבעה",
|
||||
direction_approved: "כיוון אושר",
|
||||
qa_review: "בדיקת איכות",
|
||||
@@ -97,8 +100,6 @@ export const STATUS_LABELS: Record<CaseStatus, string> = {
|
||||
const LEGACY_STATUS_LABELS: Record<string, string> = {
|
||||
in_progress: "בעבודה",
|
||||
uploading: "מעלה",
|
||||
analyst_verified: "ניתוח אומת",
|
||||
research_complete: "מחקר הושלם",
|
||||
brainstorming: "סיעור מוחות",
|
||||
analysis_enriched: "ניתוח הועמק",
|
||||
ready_for_writing: "מוכן לכתיבה",
|
||||
@@ -124,6 +125,8 @@ export const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
|
||||
new: "התיק נוצר וממתין להעלאת מסמכים",
|
||||
processing: "המערכת מעבדת ומנתחת את המסמכים",
|
||||
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
|
||||
analyst_verified: "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה",
|
||||
research_complete: "חקר התקדימים הושלם (מסלול נפרד מהמנתח)",
|
||||
outcome_set: "נקבעה תוצאה צפויה לערר",
|
||||
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה",
|
||||
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
|
||||
|
||||
10
web/app.py
10
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,
|
||||
|
||||
Reference in New Issue
Block a user