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
# 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"

View File

@@ -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))

View File

@@ -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",
]

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:
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)

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 רק אחרי אישור דפנה) |
| `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 של הצמצום) |
### פסיקה, קורפוס ויומונים

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) ────────────────
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" },

View File

@@ -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" },

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 (

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 { 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;

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}
# 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"],