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>
138 lines
5.7 KiB
TypeScript
138 lines
5.7 KiB
TypeScript
/**
|
||
* Frontend mirror of the canonical case-status lifecycle.
|
||
*
|
||
* 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).
|
||
*
|
||
* 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. */
|
||
export const CASE_STATUSES = [
|
||
"new",
|
||
"processing",
|
||
"documents_ready",
|
||
"analyst_verified",
|
||
"research_complete",
|
||
"outcome_set",
|
||
"direction_approved",
|
||
"qa_review",
|
||
"drafted",
|
||
"exported",
|
||
"reviewed",
|
||
"final",
|
||
] as const;
|
||
|
||
export type CaseStatus = (typeof CASE_STATUSES)[number];
|
||
|
||
export type PhaseKey = "intake" | "prep" | "thinking" | "writing" | "done";
|
||
|
||
/** The 5 visual phases the lifecycle collapses into across the app. */
|
||
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: ["analyst_verified", "research_complete", "outcome_set", "direction_approved"] },
|
||
{ key: "writing", label: "כתיבת טיוטה", statuses: ["qa_review", "drafted"] },
|
||
{ key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] },
|
||
];
|
||
|
||
/**
|
||
* 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> = {
|
||
analysis_enriched: "thinking",
|
||
ready_for_writing: "writing",
|
||
drafting: "writing",
|
||
in_progress: "thinking",
|
||
};
|
||
|
||
/** Which phase a status belongs to (undefined only for truly unknown values). */
|
||
export function phaseOf(status?: CaseStatus | string): PhaseKey | undefined {
|
||
if (!status) return undefined;
|
||
return (
|
||
PHASES.find((p) => (p.statuses as string[]).includes(status))?.key ??
|
||
LEGACY_STATUS_PHASE[status]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Index of a status within the 5-phase pipeline (−1 only for unknown values).
|
||
* Single source of truth for the stepper — handles legacy statuses via phaseOf,
|
||
* so a case on `analyst_verified` lands on "ניתוח וכיוון" instead of blank.
|
||
*/
|
||
export function phaseIndexOf(status?: CaseStatus | string): number {
|
||
const key = phaseOf(status);
|
||
return key ? PHASES.findIndex((p) => p.key === key) : -1;
|
||
}
|
||
|
||
export const STATUS_LABELS: Record<CaseStatus, string> = {
|
||
new: "חדש",
|
||
processing: "בעיבוד",
|
||
documents_ready: "מסמכים מוכנים",
|
||
analyst_verified: "ניתוח אומת",
|
||
research_complete: "מחקר הושלם",
|
||
outcome_set: "תוצאה נקבעה",
|
||
direction_approved: "כיוון אושר",
|
||
qa_review: "בדיקת איכות",
|
||
drafted: "טיוטה",
|
||
exported: "יוצא",
|
||
reviewed: "נבדק",
|
||
final: "סופי",
|
||
};
|
||
|
||
/**
|
||
* Hebrew labels for the removed/legacy statuses — display-only fallback so a
|
||
* row that hasn't been migrated yet (or arrives from an old client) never
|
||
* renders a raw English slug as a label. These are NOT selectable statuses.
|
||
*/
|
||
const LEGACY_STATUS_LABELS: Record<string, string> = {
|
||
in_progress: "בעבודה",
|
||
uploading: "מעלה",
|
||
brainstorming: "סיעור מוחות",
|
||
analysis_enriched: "ניתוח הועמק",
|
||
ready_for_writing: "מוכן לכתיבה",
|
||
drafting: "בכתיבה",
|
||
qa_failed: "בדיקת איכות נכשלה",
|
||
qa_passed: "טיוטה",
|
||
};
|
||
|
||
/**
|
||
* Always-Hebrew label for any status string. Use this everywhere a status is
|
||
* rendered as a label so an unknown/legacy value never leaks an English slug.
|
||
*/
|
||
export function statusLabel(status?: string): string {
|
||
if (!status) return "";
|
||
return (
|
||
STATUS_LABELS[status as CaseStatus] ??
|
||
LEGACY_STATUS_LABELS[status] ??
|
||
"לא ידוע"
|
||
);
|
||
}
|
||
|
||
export const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
|
||
new: "התיק נוצר וממתין להעלאת מסמכים",
|
||
processing: "המערכת מעבדת ומנתחת את המסמכים",
|
||
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
|
||
analyst_verified: "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה",
|
||
research_complete: "חקר התקדימים הושלם (מסלול נפרד מהמנתח)",
|
||
outcome_set: "נקבעה תוצאה צפויה לערר",
|
||
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה",
|
||
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
|
||
drafted: "טיוטה מוכנה לעיון",
|
||
exported: "ההחלטה יוצאה לקובץ DOCX",
|
||
reviewed: 'ההחלטה נבדקה ע"י היו"ר',
|
||
final: "החלטה סופית — מוכנה להגשה",
|
||
};
|