feat(status): single source of truth for case-status model + canonicalize intermediates #382
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
|
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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "הטיוטה בבדיקת איכות אוטומטית",
|
||||||
|
|||||||
10
web/app.py
10
web/app.py
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user