Merge pull request 'refactor(cases): צמצום תפריט-סטטוס 17→10 + מקור-אמת יחיד (UI-B1/G2)' (#287) from worktree-status-trim into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s

This commit was merged in pull request #287.
This commit is contained in:
2026-06-17 10:15:41 +00:00
16 changed files with 316 additions and 190 deletions

View File

@@ -9,10 +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
# 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): class CaseStatus(str, enum.Enum):
NEW = "new" 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" DRAFTED = "drafted"
EXPORTED = "exported"
REVIEWED = "reviewed" REVIEWED = "reviewed"
FINAL = "final" FINAL = "final"

View File

@@ -103,7 +103,7 @@ def _clamp_limit(limit: int, hard_max: int = _MAX_LIMIT) -> int:
@mcp.tool() @mcp.tool()
async def case_list(status: str = "", limit: int = 50) -> str: 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)) return await cases.case_list(status, _clamp_limit(limit))

View File

@@ -271,10 +271,8 @@ async def case_list(status: str = "", limit: int = 50) -> str:
"""רשימת תיקי ערר עם אפשרות סינון לפי סטטוס. """רשימת תיקי ערר עם אפשרות סינון לפי סטטוס.
Args: Args:
status: סינון לפי סטטוס (new, processing, proofread, documents_ready, analyst_verified, status: סינון לפי סטטוס (new, processing, documents_ready, outcome_set,
research_complete, outcome_set, direction_pending, direction_approved, direction_approved, qa_review, drafted, exported, reviewed, final). ריק = הכל
analysis_enriched, ready_for_writing, drafted, qa_passed, qa_failed,
exported, done). ריק = הכל
limit: מספר תוצאות מקסימלי limit: מספר תוצאות מקסימלי
""" """
cases = await db.list_cases(status=status or None, limit=limit) cases = await db.list_cases(status=status or None, limit=limit)
@@ -327,7 +325,7 @@ async def case_update(
Args: Args:
case_number: מספר תיק הערר 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: כותרת חדשה title: כותרת חדשה
subject: נושא חדש subject: נושא חדש
notes: הערות חדשות notes: הערות חדשות
@@ -343,12 +341,13 @@ async def case_update(
""" """
from datetime import date as date_type 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 = [ STATUS_ORDER = [
"new", "uploading", "processing", "documents_ready", "new", "processing", "documents_ready",
"analyst_verified", "research_complete", "outcome_set", "outcome_set", "direction_approved",
"brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing", "qa_review", "drafted",
"drafting", "qa_review", "drafted",
"exported", "reviewed", "final", "exported", "reviewed", "final",
] ]

View File

@@ -91,7 +91,7 @@ def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]:
if docs and not has_draft: if docs and not has_draft:
steps.append("התחל ניסוח טיוטת החלטה (/draft-decision)") 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("סקור ועדכן את הטיוטה")
steps.append("עדכן סטטוס ל-drafted") steps.append("עדכן סטטוס ל-drafted")
@@ -191,8 +191,10 @@ async def set_outcome(
outcome_reasoning=reasoning, outcome_reasoning=reasoning,
) )
# Update case status # Update case status — aligned with the web /set-outcome endpoint:
await db.update_case(case_id, status="in_progress", expected_outcome=outcome) # 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) outcome_hebrew = OUTCOME_LABELS_HE.get(outcome, outcome)

View File

@@ -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 <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) | | `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 <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (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) | | `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 | ידני (חד-פעמי לתיק) | | `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 <c> python /tmp/backfill_case_status_trim.py --apply`. | חד-פעמי (אחרי deploy של הצמצום) |
### פסיקה, קורפוס ויומונים ### פסיקה, קורפוס ויומונים

View File

@@ -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 <c>:/tmp/
docker exec <c> python /tmp/backfill_case_status_trim.py # dry-run
docker exec <c> 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())

View File

@@ -21,17 +21,10 @@ import { DOC_TYPE_LABELS, type DocType } from "@/lib/doc-types";
// ── Case-status → Hebrew label + tone (mockup 03 status chip) ──────────────── // ── Case-status → Hebrew label + tone (mockup 03 status chip) ────────────────
const STATUS_CHIP: Record<string, { label: string; cls: string }> = { const STATUS_CHIP: Record<string, { label: string; cls: string }> = {
new: { label: "חדש", cls: "bg-rule-soft text-ink-muted border-rule" }, 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" }, processing: { label: "בעיבוד", cls: "bg-info-bg text-info border-info/30" },
documents_ready: { 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" }, 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" }, 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" }, 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" }, drafted: { label: "טיוטה", cls: "bg-gold-wash text-gold-deep border-gold/40" },
exported: { label: "יוצא", cls: "bg-success-bg text-success border-success/40" }, exported: { label: "יוצא", cls: "bg-success-bg text-success border-success/40" },

View File

@@ -1,5 +1,6 @@
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import type { Case } from "@/lib/api/cases"; import type { Case } from "@/lib/api/cases";
import { phaseOf } from "@/lib/api/case-status";
type Bucket = { type Bucket = {
label: string; label: string;
@@ -17,15 +18,14 @@ const TONE_STYLES: Record<Bucket["tone"], string> = {
function bucketize(cases: Case[] | undefined): Bucket[] { function bucketize(cases: Case[] | undefined): Bucket[] {
const c = cases ?? []; const c = cases ?? [];
const inProgress = c.filter((x) => /* Buckets derive from the SSoT phases: "בהכנה" = everything from processing
["processing", "documents_ready", "analyst_verified", "research_complete", "outcome_set", "brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"].includes(x.status), * through direction-approval (intake-after-new + prep + thinking). */
).length; const inProgress = c.filter((x) => {
const drafting = c.filter((x) => const p = phaseOf(x.status);
["drafting", "qa_review", "drafted"].includes(x.status), return x.status === "processing" || p === "prep" || p === "thinking";
).length; }).length;
const done = c.filter((x) => const drafting = c.filter((x) => phaseOf(x.status) === "writing").length;
["exported", "reviewed", "final"].includes(x.status), const done = c.filter((x) => phaseOf(x.status) === "done").length;
).length;
return [ return [
{ label: "סה״כ תיקי ערר", caption: "בכל הסטטוסים", value: c.length, tone: "navy" }, { label: "סה״כ תיקי ערר", caption: "בכל הסטטוסים", value: c.length, tone: "navy" },

View File

@@ -1,105 +1,48 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
FilePlus2, Upload, Loader2, FileCheck, Target, FilePlus2, Loader2, FileCheck, Target,
Lightbulb, Compass, PenLine, SearchCheck, FileText, Compass, SearchCheck, FileText,
FileOutput, CheckCircle2, Award, ShieldCheck, BookOpen, FileOutput, CheckCircle2, Award,
Microscope, PlayCircle, Hammer, AlertTriangle,
} from "lucide-react"; } 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"; import type { LucideIcon } from "lucide-react";
const STATUS_LABELS: Record<CaseStatus, string> = { /* Labels + descriptions come from the SSoT (@/lib/api/case-status).
new: "חדש", * Icons + color tones are view-layer concerns and live here, keyed off the
in_progress: "בעבודה", * same 10 core statuses. */
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: "סופי",
};
const STATUS_ICONS: Record<CaseStatus, LucideIcon> = { const STATUS_ICONS: Record<CaseStatus, LucideIcon> = {
new: FilePlus2, new: FilePlus2,
in_progress: Hammer,
uploading: Upload,
processing: Loader2, processing: Loader2,
documents_ready: FileCheck, documents_ready: FileCheck,
analyst_verified: ShieldCheck,
research_complete: BookOpen,
outcome_set: Target, outcome_set: Target,
brainstorming: Lightbulb,
direction_approved: Compass, direction_approved: Compass,
analysis_enriched: Microscope,
ready_for_writing: PlayCircle,
drafting: PenLine,
qa_review: SearchCheck, qa_review: SearchCheck,
qa_failed: AlertTriangle,
drafted: FileText, drafted: FileText,
exported: FileOutput, exported: FileOutput,
reviewed: CheckCircle2, reviewed: CheckCircle2,
final: Award, final: Award,
}; };
const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
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: /* Status color groups:
* intake → new, uploading, processing (muted parchment) * intake → new, processing (muted parchment / info)
* prep → documents_ready, outcome_set (info blue) * prep → documents_ready (info blue)
* thinking→ brainstorming, direction_approved (gold) * thinking→ outcome_set, direction_approved (gold)
* writing → drafting, qa_review, drafted (warn amber) * writing → qa_review, drafted (warn amber)
* done → exported, reviewed, final (success green) */ * done → exported, reviewed, final (success green) */
const STATUS_TONE: Record<CaseStatus, string> = { const STATUS_TONE: Record<CaseStatus, string> = {
new: "bg-rule-soft text-ink-muted border-rule", new: "bg-rule-soft text-ink-muted border-rule",
in_progress: "bg-warn-bg text-warn border-warn/40", processing: "bg-info-bg text-info border-info/30",
uploading: "bg-rule-soft text-ink-muted border-rule", documents_ready: "bg-info-bg text-info border-info/40",
processing: "bg-info-bg text-info border-info/30", outcome_set: "bg-gold-wash text-gold-deep border-gold/40",
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",
direction_approved:"bg-gold-wash text-gold-deep border-gold/50", direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
analysis_enriched:"bg-gold-wash text-gold-deep border-gold/50", qa_review: "bg-warn-bg text-warn border-warn/40",
ready_for_writing:"bg-gold-wash text-gold-deep border-gold/50", drafted: "bg-warn-bg text-warn border-warn/50",
drafting: "bg-warn-bg text-warn border-warn/40", exported: "bg-success-bg text-success border-success/40",
qa_review: "bg-warn-bg text-warn border-warn/40", reviewed: "bg-success-bg text-success border-success/50",
qa_failed: "bg-danger-bg text-danger border-danger/40", final: "bg-success-bg text-success border-success/60 font-semibold",
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 }) { 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] ?? ""}`} className={`rounded-full px-2.5 py-0.5 text-[0.72rem] font-medium inline-flex items-center gap-1 ${STATUS_TONE[status] ?? ""}`}
> >
{Icon && <Icon className="w-3 h-3 shrink-0" />} {Icon && <Icon className="w-3 h-3 shrink-0" />}
{STATUS_LABELS[status] ?? status} {statusLabel(status)}
</Badge> </Badge>
); );
} }

View File

@@ -7,21 +7,16 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { STATUS_LABELS, STATUS_ICONS } from "@/components/cases/status-badge"; 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 * Dropdown for manually overriding the case status — skip a step
* or revert to an earlier stage. Calls PUT /api/cases/:caseNumber * 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[] = [ const ALL_STATUSES: readonly CaseStatus[] = CASE_STATUSES;
"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",
];
export function StatusChanger({ export function StatusChanger({
caseNumber, caseNumber,

View File

@@ -1,23 +1,16 @@
"use client"; "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"; import { STATUS_LABELS } from "@/components/cases/status-badge";
/* /*
* Conic-gradient donut — ported from legal-ai/web/static/index.html renderHero(). * Conic-gradient donut — ported from legal-ai/web/static/index.html renderHero().
* Kept deliberately dependency-free (no D3/recharts) — a single background-image. * 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"; type GroupKey = PhaseKey;
const GROUP_OF: Record<CaseStatus, GroupKey> = {
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",
};
const GROUP_META: Record<GroupKey, { label: string; color: string }> = { const GROUP_META: Record<GroupKey, { label: string; color: string }> = {
intake: { label: "חדש / בעיבוד", color: "var(--color-ink-muted)" }, 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, intake: 0, prep: 0, thinking: 0, writing: 0, done: 0,
}; };
(cases ?? []).forEach((c) => { (cases ?? []).forEach((c) => {
const g = GROUP_OF[c.status]; const g = phaseOf(c.status);
if (g) counts[g] += 1; if (g) counts[g] += 1;
}); });
const total = Object.values(counts).reduce((a, b) => a + b, 0); const total = Object.values(counts).reduce((a, b) => a + b, 0);

View File

@@ -2,28 +2,16 @@
import { useState } from "react"; import { useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-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"; 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. * 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. * 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() { export function StatusGuide() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);

View File

@@ -1,6 +1,6 @@
"use client"; "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 { STATUS_LABELS, STATUS_ICONS, STATUS_DESCRIPTIONS } from "@/components/cases/status-badge";
import { import {
FolderInput, ClipboardList, Brain, PenLine, CheckCircle2, FolderInput, ClipboardList, Brain, PenLine, CheckCircle2,
@@ -8,27 +8,19 @@ import {
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
/* /*
* Vertical RTL workflow timeline showing the 13-status case pipeline. * Vertical RTL workflow timeline. Phases + their statuses come from the SSoT
* Groups the raw statuses into the 5 visual phases used across the app * (@/lib/api/case-status); only the per-phase icon is a view-layer concern and
* (intake → prep → thinking → writing → done) so the user sees * stays here. The user sees "where am I in the process" rather than micro-steps.
* "where am I in the process" rather than 13 micro-steps.
*/ */
type Phase = { const PHASE_ICONS: Record<PhaseKey, LucideIcon> = {
key: string; intake: FolderInput,
label: string; prep: ClipboardList,
icon: LucideIcon; thinking: Brain,
statuses: CaseStatus[]; 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 { function phaseIndexOf(status?: CaseStatus): number {
if (!status) return -1; if (!status) return -1;
return PHASES.findIndex((p) => p.statuses.includes(status)); return PHASES.findIndex((p) => p.statuses.includes(status));
@@ -61,7 +53,7 @@ export function WorkflowTimeline({ status }: { status?: CaseStatus }) {
: state === "current" ? "text-gold-deep" : state === "current" ? "text-gold-deep"
: "text-ink-muted/50"; : "text-ink-muted/50";
const PhaseIcon = phase.icon; const PhaseIcon = PHASE_ICONS[phase.key];
const isLast = i === PHASES.length - 1; const isLast = i === PHASES.length - 1;
return ( return (

View File

@@ -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<CaseStatus, string> = {
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<string, string> = {
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<CaseStatus, string> = {
new: "התיק נוצר וממתין להעלאת מסמכים",
processing: "המערכת מעבדת ומנתחת את המסמכים",
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
outcome_set: "נקבעה תוצאה צפויה לערר",
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה",
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
drafted: "טיוטה מוכנה לעיון",
exported: "ההחלטה יוצאה לקובץ DOCX",
reviewed: 'ההחלטה נבדקה ע"י היו"ר',
final: "החלטה סופית — מוכנה להגשה",
};

View File

@@ -13,26 +13,11 @@ import { apiRequest } from "./client";
import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case"; import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case";
import type { PracticeArea, AppealSubtype } from "@/lib/practice-area"; import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
export type CaseStatus = /* CaseStatus + the status list/labels/phases are defined once in ./case-status
| "new" * (single source of truth, UI-B1). Re-exported here so existing
| "in_progress" * `import { CaseStatus } from "@/lib/api/cases"` sites keep working. */
| "uploading" export type { CaseStatus } from "./case-status";
| "processing" import type { CaseStatus } from "./case-status";
| "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";
export type Case = { export type Case = {
case_number: string; case_number: string;

View File

@@ -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} 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. # Notify the Paperclip plugin to attach the final-decision document.
docx_filename = ( docx_filename = (
data.get("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], "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( 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 # Single failed case → link straight to it; multiple → home dashboard
# (the donut/table surface them). Each sample row links to its own case. # (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 "/" qa_href = f"/cases/{qa_rows[0]['case_number']}" if len(qa_rows) == 1 else "/"
categories.append({ categories.append({
"key": "qa_failed", "label": "תיקים שנכשלו ב-QA", "key": "qa_review", "label": "תיקים שנכשלו ב-QA",
"description": "תיקים שבדיקת-האיכות חסמה — דורשים התייחסותך לפני המשך.", "description": "תיקים שבדיקת-האיכות חסמה — דורשים התייחסותך לפני המשך.",
"count": len(qa_rows), "severity": "high" if qa_rows else "ok", "href": qa_href, "count": len(qa_rows), "severity": "high" if qa_rows else "ok", "href": qa_href,
"sample": [{"text": r["case_number"], "source": r["title"], "sample": [{"text": r["case_number"], "source": r["title"],