refactor(cases): צמצום תפריט-סטטוס 17→10 + מקור-אמת יחיד (UI-B1/G2)
תפריט הסטטוס-הידני הכיל 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 רק אחרי אישור דפנה) |
|
||||
| `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 <c> python /tmp/backfill_case_status_trim.py --apply`. | חד-פעמי (אחרי deploy של הצמצום) |
|
||||
|
||||
### פסיקה, קורפוס ויומונים
|
||||
|
||||
|
||||
115
scripts/backfill_case_status_trim.py
Normal file
115
scripts/backfill_case_status_trim.py
Normal 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())
|
||||
@@ -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<string, { label: string; cls: string }> = {
|
||||
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" },
|
||||
|
||||
@@ -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<Bucket["tone"], string> = {
|
||||
|
||||
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" },
|
||||
|
||||
@@ -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<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: "יוצא",
|
||||
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<CaseStatus, LucideIcon> = {
|
||||
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<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:
|
||||
* 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<CaseStatus, string> = {
|
||||
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 && <Icon className="w-3 h-3 shrink-0" />}
|
||||
{STATUS_LABELS[status] ?? status}
|
||||
{statusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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",
|
||||
};
|
||||
type GroupKey = PhaseKey;
|
||||
|
||||
const GROUP_META: Record<GroupKey, { label: string; color: string }> = {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<PhaseKey, LucideIcon> = {
|
||||
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 (
|
||||
|
||||
103
web-ui/src/lib/api/case-status.ts
Normal file
103
web-ui/src/lib/api/case-status.ts
Normal 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: "החלטה סופית — מוכנה להגשה",
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
15
web/app.py
15
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"],
|
||||
|
||||
Reference in New Issue
Block a user