From ba542f9c214a8621f1093c40f3362e89eecebc8d Mon Sep 17 00:00:00 2001 From: Chaim Date: Wed, 17 Jun 2026 09:47:13 +0000 Subject: [PATCH] =?UTF-8?q?refactor(cases):=20=D7=A6=D7=9E=D7=A6=D7=95?= =?UTF-8?q?=D7=9D=20=D7=AA=D7=A4=D7=A8=D7=99=D7=98-=D7=A1=D7=98=D7=98?= =?UTF-8?q?=D7=95=D7=A1=2017=E2=86=9210=20+=20=D7=9E=D7=A7=D7=95=D7=A8-?= =?UTF-8?q?=D7=90=D7=9E=D7=AA=20=D7=99=D7=97=D7=99=D7=93=20(UI-B1/G2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit תפריט הסטטוס-הידני הכיל 17 סטטוסים שמתוכם ~9 דקורציה טהורה — שלבי-ביניים שאף קוד בפייפליין לא קבע ושום לוגיקה לא הסתעפה לפיהם, עם רשימות כפולות לא-עקביות ב-6+ קבצים (UI-B1) ו-exported כסטטוס-רפאים (באג agent-audit). הליבה (10): new, processing, documents_ready, outcome_set, direction_approved, qa_review, drafted, exported, reviewed, final. - SSoT חדש web-ui/src/lib/api/case-status.ts (רשימה/שלבים/תוויות/statusLabel); כל הצרכנים (badge/changer/timeline/guide/donut/kpi/compose) מייבאים משם. - statusLabel() מבטיח תווית עברית תמיד — גם לערך-מורשת (נפילה עברית, לא סלאג). - בקאנד: STATUS_ORDER 10, models.CaseStatus מיושר, set_outcome קובע outcome_set/direction_approved (במקום in_progress) כמו endpoint ה-web. - exported מוקשח אחרי export-DOCX מוצלח (forward-only); widget "נכשל ב-QA" עודכן ל-qa_review (הסטטוס שנקבע בפועל בכשל-QA). - scripts/backfill_case_status_trim.py: מיפוי שורות-מורשת לסטטוס-הליבה הקודם. Invariants: UI-B1 (מקור-אמת יחיד) ✅ · G2 (אין מסלול מקביל) ✅ · GAP-42 (חלקי). Co-Authored-By: Claude Opus 4.8 (1M context) --- mcp-server/src/legal_mcp/models.py | 10 +- mcp-server/src/legal_mcp/server.py | 2 +- mcp-server/src/legal_mcp/tools/cases.py | 19 ++- mcp-server/src/legal_mcp/tools/workflow.py | 8 +- scripts/SCRIPTS.md | 1 + scripts/backfill_case_status_trim.py | 115 ++++++++++++++++++ .../app/cases/[caseNumber]/compose/page.tsx | 7 -- web-ui/src/components/cases/kpi-cards.tsx | 18 +-- web-ui/src/components/cases/status-badge.tsx | 105 ++++------------ .../src/components/cases/status-changer.tsx | 13 +- web-ui/src/components/cases/status-donut.tsx | 17 +-- web-ui/src/components/cases/status-guide.tsx | 18 +-- .../components/cases/workflow-timeline.tsx | 30 ++--- web-ui/src/lib/api/case-status.ts | 103 ++++++++++++++++ web-ui/src/lib/api/cases.ts | 25 +--- web/app.py | 15 ++- 16 files changed, 316 insertions(+), 190 deletions(-) create mode 100644 scripts/backfill_case_status_trim.py create mode 100644 web-ui/src/lib/api/case-status.ts diff --git a/mcp-server/src/legal_mcp/models.py b/mcp-server/src/legal_mcp/models.py index d7d4b03..e853645 100644 --- a/mcp-server/src/legal_mcp/models.py +++ b/mcp-server/src/legal_mcp/models.py @@ -9,10 +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). class CaseStatus(str, enum.Enum): NEW = "new" - IN_PROGRESS = "in_progress" + PROCESSING = "processing" + DOCUMENTS_READY = "documents_ready" + OUTCOME_SET = "outcome_set" + DIRECTION_APPROVED = "direction_approved" + QA_REVIEW = "qa_review" DRAFTED = "drafted" + EXPORTED = "exported" REVIEWED = "reviewed" FINAL = "final" diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index f414aab..4a446e0 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -103,7 +103,7 @@ def _clamp_limit(limit: int, hard_max: int = _MAX_LIMIT) -> int: @mcp.tool() async def case_list(status: str = "", limit: int = 50) -> str: - """רשימת תיקי ערר. סינון אופציונלי לפי סטטוס (new/in_progress/drafted/reviewed/final).""" + """רשימת תיקי ערר. סינון אופציונלי לפי סטטוס (new/processing/documents_ready/outcome_set/direction_approved/qa_review/drafted/exported/reviewed/final).""" return await cases.case_list(status, _clamp_limit(limit)) diff --git a/mcp-server/src/legal_mcp/tools/cases.py b/mcp-server/src/legal_mcp/tools/cases.py index 4b90b88..7a2e6fd 100644 --- a/mcp-server/src/legal_mcp/tools/cases.py +++ b/mcp-server/src/legal_mcp/tools/cases.py @@ -271,10 +271,8 @@ async def case_list(status: str = "", limit: int = 50) -> str: """רשימת תיקי ערר עם אפשרות סינון לפי סטטוס. Args: - status: סינון לפי סטטוס (new, processing, proofread, documents_ready, analyst_verified, - research_complete, outcome_set, direction_pending, direction_approved, - analysis_enriched, ready_for_writing, drafted, qa_passed, qa_failed, - exported, done). ריק = הכל + status: סינון לפי סטטוס (new, processing, documents_ready, outcome_set, + direction_approved, qa_review, drafted, exported, reviewed, final). ריק = הכל limit: מספר תוצאות מקסימלי """ cases = await db.list_cases(status=status or None, limit=limit) @@ -327,7 +325,7 @@ async def case_update( Args: case_number: מספר תיק הערר - status: סטטוס חדש (new, in_progress, drafted, reviewed, final) + status: סטטוס חדש (new, processing, documents_ready, outcome_set, direction_approved, qa_review, drafted, exported, reviewed, final) title: כותרת חדשה subject: נושא חדש notes: הערות חדשות @@ -343,12 +341,13 @@ async def case_update( """ from datetime import date as date_type - # Ordered workflow statuses — regression protection + # 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", "uploading", "processing", "documents_ready", - "analyst_verified", "research_complete", "outcome_set", - "brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing", - "drafting", "qa_review", "drafted", + "new", "processing", "documents_ready", + "outcome_set", "direction_approved", + "qa_review", "drafted", "exported", "reviewed", "final", ] diff --git a/mcp-server/src/legal_mcp/tools/workflow.py b/mcp-server/src/legal_mcp/tools/workflow.py index 1526d81..589c28c 100644 --- a/mcp-server/src/legal_mcp/tools/workflow.py +++ b/mcp-server/src/legal_mcp/tools/workflow.py @@ -91,7 +91,7 @@ def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]: if docs and not has_draft: steps.append("התחל ניסוח טיוטת החלטה (/draft-decision)") - elif has_draft and case["status"] in ("new", "in_progress"): + elif has_draft and case["status"] == "new": steps.append("סקור ועדכן את הטיוטה") steps.append("עדכן סטטוס ל-drafted") @@ -191,8 +191,10 @@ async def set_outcome( outcome_reasoning=reasoning, ) - # Update case status - await db.update_case(case_id, status="in_progress", expected_outcome=outcome) + # Update case status — aligned with the web /set-outcome endpoint: + # reasoning given → direction is set; no reasoning → outcome only (brainstorm next). + new_status = "direction_approved" if reasoning else "outcome_set" + await db.update_case(case_id, status=new_status, expected_outcome=outcome) outcome_hebrew = OUTCOME_LABELS_HE.get(outcome, outcome) diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index d0ba72b..3b3d767 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -137,6 +137,7 @@ | `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved ` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides ` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) | | `fix_137_committee_case_number.py` | python | **#137 — תיקון-נתון חד-פעמי**: רשומת `internal_committee` בודדת (1bf0bae0) שבה ציטוט-מלא זיהם את שדה-המזהה (case_number=`85074/0425`, case_name=ציטוט שלם) — הפרת INV-ID2 ממסלול `missing_precedent_upload` (לפני תיקון-הקוד ב-#137). מתקן `case_number`→`85074-04-25`, `case_name`→צדדים, ו-token ב-`citation_formatted`. אומת היחיד עם `_canonical_case_number(num)≠num` ב-internal_committee (138 ה"מזוהמים" האחרים = מקור-חיצוני/cited_only מקודמים-קידומת, X1 §5 — מחוץ-לתחום). `document_id=NULL`, 0 ציטוטים-נכנסים → ללא נתיב/קובץ לשנות. guard-התנגשות על `(case_number,proceeding_type)`. אידמפוטנטי, dry-run כברירת-מחדל / `--apply`. הרצה: `HOME=/home/chaim PYTHONPATH=mcp-server/src mcp-server/.venv/bin/python scripts/fix_137_committee_case_number.py --apply`. | חד-פעמי (בוצע 2026-06-15) | | `retrofit_case.py` | python | retrofit רטרואקטיבי — מזריק bookmarks לקובץ קיים של תיק ספציפי ומגדיר אותו כ-active_draft | ידני (חד-פעמי לתיק) | +| `backfill_case_status_trim.py` | python | **צמצום תפריט-הסטטוס 17→10** — ממפה כל `cases.status` שהוסר (uploading/in_progress/analyst_verified/research_complete/brainstorming/analysis_enriched/ready_for_writing/drafting/qa_failed) לסטטוס-הליבה הקודם-ביותר ברצף (→processing/outcome_set/documents_ready/direction_approved/qa_review), כך ששורות קיימות לא נתקעות על ערך מחוץ ל-SSoT. מציג קודם את פילוח-הסטטוסים המלא. אידמפוטנטי, dry-run כברירת-מחדל / `--apply`. רץ בקונטיינר (Postgres :5433 משותף): `docker exec python /tmp/backfill_case_status_trim.py --apply`. | חד-פעמי (אחרי deploy של הצמצום) | ### פסיקה, קורפוס ויומונים diff --git a/scripts/backfill_case_status_trim.py b/scripts/backfill_case_status_trim.py new file mode 100644 index 0000000..677421a --- /dev/null +++ b/scripts/backfill_case_status_trim.py @@ -0,0 +1,115 @@ +"""Backfill case.status after the 17 → 10 status-menu trim. + +Why this exists: the manual status menu was trimmed from 17 to 10 core +statuses (decorative mid-stage markers that no pipeline code ever set were +removed). Existing rows that currently hold a removed status would otherwise +be "stuck" on a value no longer in the dropdown / SSoT, rendering via the +Hebrew legacy fallback. This maps each removed status to the nearest +*preceding* kept status in the lifecycle order, so a case keeps the closest +truthful position. + +Mapping (removed → kept): + + uploading → processing + in_progress → outcome_set + analyst_verified → documents_ready + research_complete → documents_ready + brainstorming → outcome_set + analysis_enriched → direction_approved + ready_for_writing → direction_approved + drafting → direction_approved + qa_failed → qa_review + +Idempotent: a second run is a no-op (no rows match the removed statuses). +Dry-run by default — prints the affected counts; pass --apply to write. + +Usage (runs inside the legal-ai container — shared Postgres on :5433): + docker cp scripts/backfill_case_status_trim.py :/tmp/ + docker exec python /tmp/backfill_case_status_trim.py # dry-run + docker exec python /tmp/backfill_case_status_trim.py --apply # write +""" +from __future__ import annotations + +import argparse +import asyncio +import logging +import sys +from pathlib import Path + + +def _setup_paths(): + here = Path(__file__).resolve().parent + mcp_src = here.parent / "mcp-server" / "src" + if mcp_src.is_dir() and str(mcp_src) not in sys.path: + sys.path.insert(0, str(mcp_src)) + + +_setup_paths() +from legal_mcp.services import db # noqa: E402 + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +log = logging.getLogger("status-trim") + +# removed status → nearest preceding kept status +STATUS_MAP = { + "uploading": "processing", + "in_progress": "outcome_set", + "analyst_verified": "documents_ready", + "research_complete": "documents_ready", + "brainstorming": "outcome_set", + "analysis_enriched": "direction_approved", + "ready_for_writing": "direction_approved", + "drafting": "direction_approved", + "qa_failed": "qa_review", +} + + +async def backfill(apply: bool) -> int: + pool = await db.get_pool() + + # Show the full current distribution for context. + dist = await pool.fetch("SELECT status, count(*) AS n FROM cases GROUP BY status ORDER BY n DESC") + log.info("Current status distribution:") + for r in dist: + log.info(" %-22s %d", r["status"], r["n"]) + + affected = {r["status"]: r["n"] for r in dist if r["status"] in STATUS_MAP} + total = sum(affected.values()) + if not total: + log.info("Nothing to migrate — no rows hold a removed status. ✓") + return 0 + + log.info("Rows to migrate (%d total):", total) + for old, n in affected.items(): + log.info(" %-22s → %-20s (%d)", old, STATUS_MAP[old], n) + + if not apply: + log.info("DRY-RUN — no changes written. Re-run with --apply to migrate.") + return total + + migrated = 0 + for old, new in STATUS_MAP.items(): + if old not in affected: + continue + res = await pool.execute( + "UPDATE cases SET status = $1, updated_at = now() WHERE status = $2", + new, old, + ) + # res like "UPDATE 3" + n = int(res.split()[-1]) if res and res.split()[-1].isdigit() else 0 + migrated += n + log.info(" migrated %-22s → %-20s (%d)", old, new, n) + + log.info("Done — migrated %d rows.", migrated) + return migrated + + +def main() -> int: + parser = argparse.ArgumentParser(description="Backfill case.status after the 17→10 status trim") + parser.add_argument("--apply", action="store_true", help="Write changes (default: dry-run)") + args = parser.parse_args() + return 0 if asyncio.run(backfill(args.apply)) >= 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web-ui/src/app/cases/[caseNumber]/compose/page.tsx b/web-ui/src/app/cases/[caseNumber]/compose/page.tsx index 3d73766..0fd2f28 100644 --- a/web-ui/src/app/cases/[caseNumber]/compose/page.tsx +++ b/web-ui/src/app/cases/[caseNumber]/compose/page.tsx @@ -21,17 +21,10 @@ import { DOC_TYPE_LABELS, type DocType } from "@/lib/doc-types"; // ── Case-status → Hebrew label + tone (mockup 03 status chip) ──────────────── const STATUS_CHIP: Record = { new: { label: "חדש", cls: "bg-rule-soft text-ink-muted border-rule" }, - uploading: { label: "בהעלאה", cls: "bg-info-bg text-info border-info/30" }, processing: { label: "בעיבוד", cls: "bg-info-bg text-info border-info/30" }, documents_ready: { label: "מסמכים מוכנים", cls: "bg-info-bg text-info border-info/30" }, - analyst_verified: { label: "אומת ע״י אנליסט", cls: "bg-info-bg text-info border-info/30" }, - research_complete: { label: "מחקר הושלם", cls: "bg-info-bg text-info border-info/30" }, outcome_set: { label: "תוצאה נקבעה", cls: "bg-info-bg text-info border-info/30" }, - brainstorming: { label: "סיעור-מוחות", cls: "bg-info-bg text-info border-info/30" }, direction_approved: { label: "כיוון אושר", cls: "bg-info-bg text-info border-info/30" }, - analysis_enriched: { label: "ניתוח הועשר", cls: "bg-info-bg text-info border-info/30" }, - ready_for_writing: { label: "מוכן לכתיבה", cls: "bg-gold-wash text-gold-deep border-gold/40" }, - drafting: { label: "בעריכה", cls: "bg-info-bg text-info border-info/30" }, qa_review: { label: "בדיקת-איכות", cls: "bg-gold-wash text-gold-deep border-gold/40" }, drafted: { label: "טיוטה", cls: "bg-gold-wash text-gold-deep border-gold/40" }, exported: { label: "יוצא", cls: "bg-success-bg text-success border-success/40" }, diff --git a/web-ui/src/components/cases/kpi-cards.tsx b/web-ui/src/components/cases/kpi-cards.tsx index 80402f4..be36a43 100644 --- a/web-ui/src/components/cases/kpi-cards.tsx +++ b/web-ui/src/components/cases/kpi-cards.tsx @@ -1,5 +1,6 @@ import { Card, CardContent } from "@/components/ui/card"; import type { Case } from "@/lib/api/cases"; +import { phaseOf } from "@/lib/api/case-status"; type Bucket = { label: string; @@ -17,15 +18,14 @@ const TONE_STYLES: Record = { function bucketize(cases: Case[] | undefined): Bucket[] { const c = cases ?? []; - const inProgress = c.filter((x) => - ["processing", "documents_ready", "analyst_verified", "research_complete", "outcome_set", "brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"].includes(x.status), - ).length; - const drafting = c.filter((x) => - ["drafting", "qa_review", "drafted"].includes(x.status), - ).length; - const done = c.filter((x) => - ["exported", "reviewed", "final"].includes(x.status), - ).length; + /* Buckets derive from the SSoT phases: "בהכנה" = everything from processing + * through direction-approval (intake-after-new + prep + thinking). */ + const inProgress = c.filter((x) => { + const p = phaseOf(x.status); + return x.status === "processing" || p === "prep" || p === "thinking"; + }).length; + const drafting = c.filter((x) => phaseOf(x.status) === "writing").length; + const done = c.filter((x) => phaseOf(x.status) === "done").length; return [ { label: "סה״כ תיקי ערר", caption: "בכל הסטטוסים", value: c.length, tone: "navy" }, diff --git a/web-ui/src/components/cases/status-badge.tsx b/web-ui/src/components/cases/status-badge.tsx index 4a0e8c1..5dbb3ce 100644 --- a/web-ui/src/components/cases/status-badge.tsx +++ b/web-ui/src/components/cases/status-badge.tsx @@ -1,105 +1,48 @@ import { Badge } from "@/components/ui/badge"; import { - FilePlus2, Upload, Loader2, FileCheck, Target, - Lightbulb, Compass, PenLine, SearchCheck, FileText, - FileOutput, CheckCircle2, Award, ShieldCheck, BookOpen, - Microscope, PlayCircle, Hammer, AlertTriangle, + FilePlus2, Loader2, FileCheck, Target, + Compass, SearchCheck, FileText, + FileOutput, CheckCircle2, Award, } from "lucide-react"; -import type { CaseStatus } from "@/lib/api/cases"; +import { + STATUS_LABELS, STATUS_DESCRIPTIONS, statusLabel, type CaseStatus, +} from "@/lib/api/case-status"; import type { LucideIcon } from "lucide-react"; -const STATUS_LABELS: Record = { - new: "חדש", - in_progress: "בעבודה", - uploading: "מעלה", - processing: "בעיבוד", - documents_ready: "מסמכים מוכנים", - analyst_verified: "ניתוח אומת", - research_complete: "מחקר הושלם", - outcome_set: "תוצאה נקבעה", - brainstorming: "סיעור מוחות", - direction_approved: "כיוון אושר", - analysis_enriched: "ניתוח הועמק", - ready_for_writing: "מוכן לכתיבה", - drafting: "בכתיבה", - qa_review: "בדיקת איכות", - qa_failed: "בדיקת איכות נכשלה", - drafted: "טיוטה", - exported: "יוצא", - reviewed: "נבדק", - final: "סופי", -}; +/* Labels + descriptions come from the SSoT (@/lib/api/case-status). + * Icons + color tones are view-layer concerns and live here, keyed off the + * same 10 core statuses. */ const STATUS_ICONS: Record = { new: FilePlus2, - in_progress: Hammer, - uploading: Upload, processing: Loader2, documents_ready: FileCheck, - analyst_verified: ShieldCheck, - research_complete: BookOpen, outcome_set: Target, - brainstorming: Lightbulb, direction_approved: Compass, - analysis_enriched: Microscope, - ready_for_writing: PlayCircle, - drafting: PenLine, qa_review: SearchCheck, - qa_failed: AlertTriangle, drafted: FileText, exported: FileOutput, reviewed: CheckCircle2, final: Award, }; -const STATUS_DESCRIPTIONS: Record = { - new: "התיק נוצר וממתין להעלאת מסמכים", - in_progress: "התיק בעבודה", - uploading: "מסמכים בתהליך העלאה לשרת", - processing: "המערכת מעבדת ומנתחת את המסמכים", - documents_ready: "כל המסמכים עובדו ומוכנים לעבודה", - analyst_verified: "ניתוח ראשוני אומת — ממתין למחקר תקדימים", - research_complete: "מחקר תקדימים הושלם — ממתין לבחירת תוצאה", - outcome_set: "נקבעה תוצאה צפויה לערר", - brainstorming: "ניתוח כיוונים אפשריים להחלטה", - direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח", - analysis_enriched: "ניתוח הועמק ופסיקה אומתה — מוכן לכתיבה", - ready_for_writing: "הכל מוכן — ממתין לכותב ההחלטה", - drafting: "טיוטת ההחלטה בתהליך כתיבה", - qa_review: "הטיוטה בבדיקת איכות אוטומטית", - qa_failed: "בדיקת האיכות נכשלה — נדרש תיקון", - drafted: "טיוטה מוכנה לעיון", - exported: "ההחלטה יוצאה לקובץ DOCX", - reviewed: "ההחלטה נבדקה ע\"י היו\"ר", - final: "החלטה סופית — מוכנה להגשה", -}; - /* Status color groups: - * intake → new, uploading, processing (muted parchment) - * prep → documents_ready, outcome_set (info blue) - * thinking→ brainstorming, direction_approved (gold) - * writing → drafting, qa_review, drafted (warn amber) - * done → exported, reviewed, final (success green) */ + * intake → new, processing (muted parchment / info) + * prep → documents_ready (info blue) + * thinking→ outcome_set, direction_approved (gold) + * writing → qa_review, drafted (warn amber) + * done → exported, reviewed, final (success green) */ const STATUS_TONE: Record = { - new: "bg-rule-soft text-ink-muted border-rule", - in_progress: "bg-warn-bg text-warn border-warn/40", - uploading: "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-info-bg text-info border-info/40", - research_complete:"bg-info-bg text-info border-info/40", - outcome_set: "bg-info-bg text-info border-info/40", - brainstorming: "bg-gold-wash text-gold-deep border-gold/40", + 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", + outcome_set: "bg-gold-wash text-gold-deep border-gold/40", direction_approved:"bg-gold-wash text-gold-deep border-gold/50", - analysis_enriched:"bg-gold-wash text-gold-deep border-gold/50", - ready_for_writing:"bg-gold-wash text-gold-deep border-gold/50", - drafting: "bg-warn-bg text-warn border-warn/40", - qa_review: "bg-warn-bg text-warn border-warn/40", - qa_failed: "bg-danger-bg text-danger border-danger/40", - drafted: "bg-warn-bg text-warn border-warn/50", - exported: "bg-success-bg text-success border-success/40", - reviewed: "bg-success-bg text-success border-success/50", - final: "bg-success-bg text-success border-success/60 font-semibold", + qa_review: "bg-warn-bg text-warn border-warn/40", + drafted: "bg-warn-bg text-warn border-warn/50", + exported: "bg-success-bg text-success border-success/40", + reviewed: "bg-success-bg text-success border-success/50", + final: "bg-success-bg text-success border-success/60 font-semibold", }; export function StatusBadge({ status }: { status: CaseStatus }) { @@ -110,7 +53,7 @@ export function StatusBadge({ status }: { status: CaseStatus }) { className={`rounded-full px-2.5 py-0.5 text-[0.72rem] font-medium inline-flex items-center gap-1 ${STATUS_TONE[status] ?? ""}`} > {Icon && } - {STATUS_LABELS[status] ?? status} + {statusLabel(status)} ); } diff --git a/web-ui/src/components/cases/status-changer.tsx b/web-ui/src/components/cases/status-changer.tsx index ed5530c..c3741cc 100644 --- a/web-ui/src/components/cases/status-changer.tsx +++ b/web-ui/src/components/cases/status-changer.tsx @@ -7,21 +7,16 @@ import { } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { STATUS_LABELS, STATUS_ICONS } from "@/components/cases/status-badge"; -import { useUpdateCase, type CaseStatus } from "@/lib/api/cases"; +import { useUpdateCase } from "@/lib/api/cases"; +import { CASE_STATUSES, type CaseStatus } from "@/lib/api/case-status"; /* * Dropdown for manually overriding the case status — skip a step * or revert to an earlier stage. Calls PUT /api/cases/:caseNumber - * with { status: newValue }. + * with { status: newValue }. The option list is the SSoT lifecycle. */ -const ALL_STATUSES: CaseStatus[] = [ - "new", "uploading", "processing", - "documents_ready", "analyst_verified", "research_complete", "outcome_set", - "brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing", - "drafting", "qa_review", "drafted", - "exported", "reviewed", "final", -]; +const ALL_STATUSES: readonly CaseStatus[] = CASE_STATUSES; export function StatusChanger({ caseNumber, diff --git a/web-ui/src/components/cases/status-donut.tsx b/web-ui/src/components/cases/status-donut.tsx index 0ef4823..b016cf8 100644 --- a/web-ui/src/components/cases/status-donut.tsx +++ b/web-ui/src/components/cases/status-donut.tsx @@ -1,23 +1,16 @@ "use client"; -import type { Case, CaseStatus } from "@/lib/api/cases"; +import type { Case } from "@/lib/api/cases"; +import { phaseOf, type PhaseKey } from "@/lib/api/case-status"; import { STATUS_LABELS } from "@/components/cases/status-badge"; /* * Conic-gradient donut — ported from legal-ai/web/static/index.html renderHero(). * Kept deliberately dependency-free (no D3/recharts) — a single background-image. - * Five status groups map onto the navy/gold/info/warn/success palette. + * Five status groups (= the SSoT phases) map onto navy/gold/info/warn/success. */ -type GroupKey = "intake" | "prep" | "thinking" | "writing" | "done"; - -const GROUP_OF: Record = { - new: "intake", in_progress: "intake", uploading: "intake", processing: "intake", - documents_ready: "prep", analyst_verified: "prep", research_complete: "prep", outcome_set: "prep", - brainstorming: "thinking", direction_approved: "thinking", analysis_enriched: "thinking", ready_for_writing: "thinking", - drafting: "writing", qa_review: "writing", qa_failed: "writing", drafted: "writing", - exported: "done", reviewed: "done", final: "done", -}; +type GroupKey = PhaseKey; const GROUP_META: Record = { intake: { label: "חדש / בעיבוד", color: "var(--color-ink-muted)" }, @@ -32,7 +25,7 @@ export function StatusDonut({ cases }: { cases?: Case[] }) { intake: 0, prep: 0, thinking: 0, writing: 0, done: 0, }; (cases ?? []).forEach((c) => { - const g = GROUP_OF[c.status]; + const g = phaseOf(c.status); if (g) counts[g] += 1; }); const total = Object.values(counts).reduce((a, b) => a + b, 0); diff --git a/web-ui/src/components/cases/status-guide.tsx b/web-ui/src/components/cases/status-guide.tsx index 1c8f4e8..aa845c4 100644 --- a/web-ui/src/components/cases/status-guide.tsx +++ b/web-ui/src/components/cases/status-guide.tsx @@ -2,28 +2,16 @@ import { useState } from "react"; import { ChevronDown, ChevronUp } from "lucide-react"; -import type { CaseStatus } from "@/lib/api/cases"; +import { PHASES as PHASE_GROUPS } from "@/lib/api/case-status"; import { STATUS_LABELS, STATUS_ICONS, STATUS_DESCRIPTIONS, STATUS_TONE } from "@/components/cases/status-badge"; /* - * Collapsible guide showing all 13 statuses grouped by phase, + * Collapsible guide showing the core statuses grouped by phase, * each with its icon, Hebrew label, color badge, and description. + * Phases come from the SSoT (@/lib/api/case-status). * Intended to sit below the WorkflowTimeline in the case sidebar. */ -type PhaseGroup = { - label: string; - statuses: CaseStatus[]; -}; - -const PHASE_GROUPS: PhaseGroup[] = [ - { label: "קליטה ועיבוד", statuses: ["new", "uploading", "processing"] }, - { label: "הכנת תיק", statuses: ["documents_ready", "analyst_verified", "research_complete", "outcome_set"] }, - { label: "ניתוח וכיוון", statuses: ["brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"] }, - { label: "כתיבת טיוטה", statuses: ["drafting", "qa_review", "drafted"] }, - { label: "סגירה", statuses: ["exported", "reviewed", "final"] }, -]; - export function StatusGuide() { const [open, setOpen] = useState(false); diff --git a/web-ui/src/components/cases/workflow-timeline.tsx b/web-ui/src/components/cases/workflow-timeline.tsx index 5ede7c8..4d887d4 100644 --- a/web-ui/src/components/cases/workflow-timeline.tsx +++ b/web-ui/src/components/cases/workflow-timeline.tsx @@ -1,6 +1,6 @@ "use client"; -import type { CaseStatus } from "@/lib/api/cases"; +import { PHASES, type CaseStatus, type PhaseKey } from "@/lib/api/case-status"; import { STATUS_LABELS, STATUS_ICONS, STATUS_DESCRIPTIONS } from "@/components/cases/status-badge"; import { FolderInput, ClipboardList, Brain, PenLine, CheckCircle2, @@ -8,27 +8,19 @@ import { import type { LucideIcon } from "lucide-react"; /* - * Vertical RTL workflow timeline showing the 13-status case pipeline. - * Groups the raw statuses into the 5 visual phases used across the app - * (intake → prep → thinking → writing → done) so the user sees - * "where am I in the process" rather than 13 micro-steps. + * Vertical RTL workflow timeline. Phases + their statuses come from the SSoT + * (@/lib/api/case-status); only the per-phase icon is a view-layer concern and + * stays here. The user sees "where am I in the process" rather than micro-steps. */ -type Phase = { - key: string; - label: string; - icon: LucideIcon; - statuses: CaseStatus[]; +const PHASE_ICONS: Record = { + intake: FolderInput, + prep: ClipboardList, + thinking: Brain, + writing: PenLine, + done: CheckCircle2, }; -const PHASES: Phase[] = [ - { key: "intake", label: "קליטה ועיבוד", icon: FolderInput, statuses: ["new", "uploading", "processing"] }, - { key: "prep", label: "הכנת תיק", icon: ClipboardList, statuses: ["documents_ready", "analyst_verified", "research_complete", "outcome_set"] }, - { key: "thinking", label: "ניתוח וכיוון", icon: Brain, statuses: ["brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"] }, - { key: "writing", label: "כתיבת טיוטה", icon: PenLine, statuses: ["drafting", "qa_review", "drafted"] }, - { key: "done", label: "סגירה", icon: CheckCircle2, statuses: ["exported", "reviewed", "final"] }, -]; - function phaseIndexOf(status?: CaseStatus): number { if (!status) return -1; return PHASES.findIndex((p) => p.statuses.includes(status)); @@ -61,7 +53,7 @@ export function WorkflowTimeline({ status }: { status?: CaseStatus }) { : state === "current" ? "text-gold-deep" : "text-ink-muted/50"; - const PhaseIcon = phase.icon; + const PhaseIcon = PHASE_ICONS[phase.key]; const isLast = i === PHASES.length - 1; return ( diff --git a/web-ui/src/lib/api/case-status.ts b/web-ui/src/lib/api/case-status.ts new file mode 100644 index 0000000..9829851 --- /dev/null +++ b/web-ui/src/lib/api/case-status.ts @@ -0,0 +1,103 @@ +/** + * Single source of truth for the case-status lifecycle (UI-B1 / G2). + * + * 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. + * + * 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`. + */ + +/** Ordered lifecycle — also the order shown in the manual status dropdown. */ +export const CASE_STATUSES = [ + "new", + "processing", + "documents_ready", + "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: ["outcome_set", "direction_approved"] }, + { key: "writing", label: "כתיבת טיוטה", statuses: ["qa_review", "drafted"] }, + { key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] }, +]; + +/** Which phase a status belongs to (undefined for unknown/legacy values). */ +export function phaseOf(status?: CaseStatus | string): PhaseKey | undefined { + if (!status) return undefined; + return PHASES.find((p) => (p.statuses as string[]).includes(status))?.key; +} + +export const STATUS_LABELS: Record = { + new: "חדש", + processing: "בעיבוד", + documents_ready: "מסמכים מוכנים", + 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 = { + in_progress: "בעבודה", + uploading: "מעלה", + analyst_verified: "ניתוח אומת", + research_complete: "מחקר הושלם", + brainstorming: "סיעור מוחות", + analysis_enriched: "ניתוח הועמק", + ready_for_writing: "מוכן לכתיבה", + drafting: "בכתיבה", + qa_failed: "בדיקת איכות נכשלה", +}; + +/** + * 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 = { + new: "התיק נוצר וממתין להעלאת מסמכים", + processing: "המערכת מעבדת ומנתחת את המסמכים", + documents_ready: "כל המסמכים עובדו ומוכנים לעבודה", + outcome_set: "נקבעה תוצאה צפויה לערר", + direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה", + qa_review: "הטיוטה בבדיקת איכות אוטומטית", + drafted: "טיוטה מוכנה לעיון", + exported: "ההחלטה יוצאה לקובץ DOCX", + reviewed: 'ההחלטה נבדקה ע"י היו"ר', + final: "החלטה סופית — מוכנה להגשה", +}; diff --git a/web-ui/src/lib/api/cases.ts b/web-ui/src/lib/api/cases.ts index 132d9bb..b817550 100644 --- a/web-ui/src/lib/api/cases.ts +++ b/web-ui/src/lib/api/cases.ts @@ -13,26 +13,11 @@ import { apiRequest } from "./client"; import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case"; import type { PracticeArea, AppealSubtype } from "@/lib/practice-area"; -export type CaseStatus = - | "new" - | "in_progress" - | "uploading" - | "processing" - | "documents_ready" - | "analyst_verified" - | "research_complete" - | "outcome_set" - | "brainstorming" - | "direction_approved" - | "analysis_enriched" - | "ready_for_writing" - | "drafting" - | "qa_review" - | "qa_failed" - | "drafted" - | "exported" - | "reviewed" - | "final"; +/* CaseStatus + the status list/labels/phases are defined once in ./case-status + * (single source of truth, UI-B1). Re-exported here so existing + * `import { CaseStatus } from "@/lib/api/cases"` sites keep working. */ +export type { CaseStatus } from "./case-status"; +import type { CaseStatus } from "./case-status"; export type Case = { case_number: string; diff --git a/web/app.py b/web/app.py index f84be1f..fc7d7e4 100644 --- a/web/app.py +++ b/web/app.py @@ -3806,6 +3806,14 @@ async def api_export_docx(case_number: str, background_tasks: BackgroundTasks): data = envelope_unwrap(parsed) # success payload: {path, active_draft_path, message} + # Mark the case 'exported' (forward-only — case_update's STATUS_ORDER won't + # override a later 'reviewed'/'final'). Previously the status stayed at + # drafted/reviewed after a successful export (agent-audit-2026-05-17 bug). + try: + await cases_tools.case_update(case_number, status="exported") + except Exception: + logger.warning("export-docx: failed to set status=exported for %s", case_number, exc_info=True) + # Notify the Paperclip plugin to attach the final-decision document. docx_filename = ( data.get("filename") @@ -5741,14 +5749,15 @@ async def api_chair_pending(): "href": "/feedback", "sample": [{"text": (r["feedback_text"] or "")[:120], "source": r["case_number"]} for r in cf_sample], }) - # 4) תיקים שנכשלו ב-QA + # 4) תיקים שנכשלו ב-QA — בכשל-QA הסטטוס שנקבע בפועל הוא qa_review + # (api_run_qa: pass→drafted, fail→qa_review). הסטטוס qa_failed הוסר. qa_rows = await conn.fetch( - "SELECT case_number, coalesce(title,'') AS title FROM cases WHERE status='qa_failed' ORDER BY updated_at DESC") + "SELECT case_number, coalesce(title,'') AS title FROM cases WHERE status='qa_review' ORDER BY updated_at DESC") # Single failed case → link straight to it; multiple → home dashboard # (the donut/table surface them). Each sample row links to its own case. qa_href = f"/cases/{qa_rows[0]['case_number']}" if len(qa_rows) == 1 else "/" categories.append({ - "key": "qa_failed", "label": "תיקים שנכשלו ב-QA", + "key": "qa_review", "label": "תיקים שנכשלו ב-QA", "description": "תיקים שבדיקת-האיכות חסמה — דורשים התייחסותך לפני המשך.", "count": len(qa_rows), "severity": "high" if qa_rows else "ok", "href": qa_href, "sample": [{"text": r["case_number"], "source": r["title"], -- 2.49.1