Merge pull request 'feat(status): single source of truth for case-status model + canonicalize intermediates' (#382) from worktree-status-single-source into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m40s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s

This commit was merged in pull request #382.
This commit is contained in:
2026-06-30 19:32:00 +00:00
6 changed files with 170 additions and 34 deletions

View 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()

View File

@@ -9,13 +9,18 @@ from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# Core case lifecycle — kept in sync with STATUS_ORDER in tools/cases.py and the # Core case lifecycle. The canonical key set, order, labels, phases and (future)
# frontend SSoT web-ui/src/lib/api/case-status.ts. Trimmed from 17 → 10 (the # per-status actions all live in ONE place — legal_mcp/case_status_model.py —
# decorative mid-stage markers that no pipeline code ever set were removed). # 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): class CaseStatus(str, enum.Enum):
NEW = "new" NEW = "new"
PROCESSING = "processing" PROCESSING = "processing"
DOCUMENTS_READY = "documents_ready" DOCUMENTS_READY = "documents_ready"
ANALYST_VERIFIED = "analyst_verified"
RESEARCH_COMPLETE = "research_complete"
OUTCOME_SET = "outcome_set" OUTCOME_SET = "outcome_set"
DIRECTION_APPROVED = "direction_approved" DIRECTION_APPROVED = "direction_approved"
QA_REVIEW = "qa_review" QA_REVIEW = "qa_review"

View File

@@ -13,6 +13,7 @@ from uuid import UUID
import httpx import httpx
from legal_mcp import config 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.services import audit, db, extractor, git_sync, practice_area as pa
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope 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 from datetime import date as date_type
# Ordered core lifecycle — regression protection (forward-only). # Ordered core lifecycle (forward-only regression guard). Single source of
# Single source of truth, mirrored by web-ui/src/lib/api/case-status.ts and # truth: legal_mcp/case_status_model.py — STATUS_ORDER/BY_KEY derive from it.
# 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",
]
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return err(f"תיק {case_number} לא נמצא.") return err(f"תיק {case_number} לא נמצא.")
@@ -363,6 +356,9 @@ async def case_update(
# Only update if advancing or status is unknown to the order # Only update if advancing or status is unknown to the order
if new_idx >= cur_idx or new_idx == -1: if new_idx >= cur_idx or new_idx == -1:
fields["status"] = status 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: if title:
fields["title"] = title fields["title"] = title
if subject: if subject:

View File

@@ -17,6 +17,8 @@ const STATUS_ICONS: Record<CaseStatus, LucideIcon> = {
new: FilePlus2, new: FilePlus2,
processing: Loader2, processing: Loader2,
documents_ready: FileCheck, documents_ready: FileCheck,
analyst_verified: SearchCheck,
research_complete: Compass,
outcome_set: Target, outcome_set: Target,
direction_approved: Compass, direction_approved: Compass,
qa_review: SearchCheck, qa_review: SearchCheck,
@@ -36,6 +38,8 @@ const STATUS_TONE: Record<CaseStatus, string> = {
new: "bg-rule-soft text-ink-muted border-rule", new: "bg-rule-soft text-ink-muted border-rule",
processing: "bg-info-bg text-info border-info/30", processing: "bg-info-bg text-info border-info/30",
documents_ready: "bg-info-bg text-info border-info/40", 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", outcome_set: "bg-gold-wash text-gold-deep border-gold/40",
direction_approved:"bg-gold-wash text-gold-deep border-gold/50", direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
qa_review: "bg-warn-bg text-warn border-warn/40", qa_review: "bg-warn-bg text-warn border-warn/40",

View File

@@ -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 * The ONE source of truth is the backend status model
* pipeline actually sets or that gate real logic. Decorative mid-stage markers * (`mcp-server/src/legal_mcp/case_status_model.py`), exposed at
* (uploading, analyst_verified, research_complete, brainstorming, * `GET /api/status-model`; this file mirrors it (order / labels / phases) so the
* analysis_enriched, ready_for_writing, drafting) plus the legacy/dead * UI keeps compile-time `CaseStatus` typing. Keep the two in sync — the backend
* `in_progress` and `qa_failed` were removed — no pipeline code ever set them. * registry is authoritative (it also declares per-status `on_enter` actions).
* *
* Every status consumer (badge, changer, timeline, guide, donut, KPI cards, * The analyst/research intermediate states (`analyst_verified`,
* compose chip) imports the list / labels / phases from here instead of * `research_complete`) are first-class canonical statuses — the agents set them,
* re-declaring its own array. Keep this file in sync with the backend * so the chip, stepper and manual changer all agree instead of the status
* STATUS_ORDER in `mcp-server/src/legal_mcp/tools/cases.py`. * 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. */ /** Ordered lifecycle — also the order shown in the manual status dropdown. */
@@ -18,6 +21,8 @@ export const CASE_STATUSES = [
"new", "new",
"processing", "processing",
"documents_ready", "documents_ready",
"analyst_verified",
"research_complete",
"outcome_set", "outcome_set",
"direction_approved", "direction_approved",
"qa_review", "qa_review",
@@ -35,22 +40,18 @@ export type PhaseKey = "intake" | "prep" | "thinking" | "writing" | "done";
export const PHASES: { key: PhaseKey; label: string; statuses: CaseStatus[] }[] = [ export const PHASES: { key: PhaseKey; label: string; statuses: CaseStatus[] }[] = [
{ key: "intake", label: "קליטה ועיבוד", statuses: ["new", "processing"] }, { key: "intake", label: "קליטה ועיבוד", statuses: ["new", "processing"] },
{ key: "prep", label: "הכנת תיק", statuses: ["documents_ready"] }, { 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: "writing", label: "כתיבת טיוטה", statuses: ["qa_review", "drafted"] },
{ key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] }, { key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] },
]; ];
/** /**
* Legacy/intermediate statuses that the trimmed PHASES list dropped but the * Truly-legacy statuses that are NOT in the canonical set but might still arrive
* Paperclip agents still set (e.g. the analyst writes `analyst_verified`, the * from an old client / un-migrated row — mapped to a phase for display only, so
* researcher writes `research_complete`). Without this map a case parked on one * the stepper never goes blank. (The analyst/research intermediates are NO
* of them renders with NO active phase — the pipeline stepper goes blank and the * longer here — they are canonical statuses in CASE_STATUSES above.)
* 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).
*/ */
const LEGACY_STATUS_PHASE: Record<string, PhaseKey> = { const LEGACY_STATUS_PHASE: Record<string, PhaseKey> = {
analyst_verified: "thinking",
research_complete: "thinking",
analysis_enriched: "thinking", analysis_enriched: "thinking",
ready_for_writing: "writing", ready_for_writing: "writing",
drafting: "writing", drafting: "writing",
@@ -80,6 +81,8 @@ export const STATUS_LABELS: Record<CaseStatus, string> = {
new: "חדש", new: "חדש",
processing: "בעיבוד", processing: "בעיבוד",
documents_ready: "מסמכים מוכנים", documents_ready: "מסמכים מוכנים",
analyst_verified: "ניתוח אומת",
research_complete: "מחקר הושלם",
outcome_set: "תוצאה נקבעה", outcome_set: "תוצאה נקבעה",
direction_approved: "כיוון אושר", direction_approved: "כיוון אושר",
qa_review: "בדיקת איכות", qa_review: "בדיקת איכות",
@@ -97,8 +100,6 @@ export const STATUS_LABELS: Record<CaseStatus, string> = {
const LEGACY_STATUS_LABELS: Record<string, string> = { const LEGACY_STATUS_LABELS: Record<string, string> = {
in_progress: "בעבודה", in_progress: "בעבודה",
uploading: "מעלה", uploading: "מעלה",
analyst_verified: "ניתוח אומת",
research_complete: "מחקר הושלם",
brainstorming: "סיעור מוחות", brainstorming: "סיעור מוחות",
analysis_enriched: "ניתוח הועמק", analysis_enriched: "ניתוח הועמק",
ready_for_writing: "מוכן לכתיבה", ready_for_writing: "מוכן לכתיבה",
@@ -124,6 +125,8 @@ export const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
new: "התיק נוצר וממתין להעלאת מסמכים", new: "התיק נוצר וממתין להעלאת מסמכים",
processing: "המערכת מעבדת ומנתחת את המסמכים", processing: "המערכת מעבדת ומנתחת את המסמכים",
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה", documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
analyst_verified: "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה",
research_complete: "חקר התקדימים הושלם (מסלול נפרד מהמנתח)",
outcome_set: "נקבעה תוצאה צפויה לערר", outcome_set: "נקבעה תוצאה צפויה לערר",
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה", direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה",
qa_review: "הטיוטה בבדיקת איכות אוטומטית", qa_review: "הטיוטה בבדיקת איכות אוטומטית",

View File

@@ -1828,6 +1828,16 @@ async def health():
return {"status": "ok"} 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") @app.get("/api/cases")
async def list_cases( async def list_cases(
detail: bool = False, detail: bool = False,