From e8bcb9c1eafb45b7a4a752bc5585b8e11d971773 Mon Sep 17 00:00:00 2001 From: Chaim Date: Fri, 12 Jun 2026 06:16:42 +0000 Subject: [PATCH] =?UTF-8?q?fix(cases):=20=D7=9E=D7=A1=D7=A4=D7=95=D7=A8=20?= =?UTF-8?q?5-=D7=A1=D7=A4=D7=A8=D7=AA=D7=99=20=D7=9C=D7=91=D7=9C"=D7=9E=20?= =?UTF-8?q?=E2=80=94=20=D7=A1=D7=99=D7=95=D7=95=D7=92,=20=D7=95=D7=9C?= =?UTF-8?q?=D7=99=D7=93=D7=A6=D7=99=D7=94,=20=D7=95=D7=97=D7=99=D7=A4?= =?UTF-8?q?=D7=95=D7=A9=20=D7=A4=D7=A1=D7=99=D7=A7=D7=94-=D7=97=D7=A1?= =?UTF-8?q?=D7=A8=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit נוהל-יו"ר (2026-06-11): מבנה מספר-תיק = <סידורי>-<חודש>-<שנה>, ואורך הסידורי מקודד את סוג-ההליך — 4 ספרות = ערר, 5 ספרות = בל"מ. הספרה הראשונה ממשיכה לקבוע תחום בשני האורכים (1→רישוי, 8→היטל, 9→פיצויים). הכלל חד-כיווני: 5-ספרתי הוא תמיד בל"מ; 4-ספרתי אינו מחייב ערר (בל"מ-מורשת מזוהה מהנושא). הבאג שדיווח עליו היו"ר: חיפוש פסיקה-חסרה לפי מספר-תיק החזיר 404 על כל ערך שאינו תיק קיים — שבר את הטבלה תוך כדי הקלדה ועל מספרי 5-ספרות. תיקונים: - web/app.py: GET /api/missing-precedents — מסנן case_number שלא תאם תיק מחזיר רשימה ריקה (200), לא 404. סמנטיקה תקינה ל-collection-filter. - missing-precedents/page.tsx: debounce (350ms) על שדות-הסינון — קוורי אחד אחרי שמפסיקים להקליד, לא אחד לכל הקשה. - practice_area.py: regex סידורי \d{4}→\d{4,5}; case_serial_digits() + is_blam_by_number() (5⇒בל"מ); derive_subtype_with_blam ו-derive_proceeding_type מזהים בל"מ גם מ-5-ספרות (בנוסף לנושא). callers: cases.py, internal_decisions.py. - proofreader.py: דפוסי חילוץ-שם-קובץ \d{3,4}→\d{3,5}. - web-ui: practice-area.ts (מראָה ל-backend), schemas/case.ts (regex serial-month-year, 4-or-5 ספרות, superRefine 5⇒בל"מ), placeholder בוויזרד. - תיעוד: docs/spec/X1-identifiers.md §1א + legal-ai/CLAUDE.md. Invariants: מקיים G1 (נרמול-במקור — ספרה ראשונה כמקור-אמת יחיד לתחום), G2 (מסלול-סיווג יחיד, אין כפילות), INV-DM/X1 (מפתח קנוני + proceeding_type). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 + docs/spec/X1-identifiers.md | 20 ++++++++ .../legal_mcp/services/internal_decisions.py | 1 + .../src/legal_mcp/services/practice_area.py | 47 ++++++++++++++++--- .../src/legal_mcp/services/proofreader.py | 9 ++-- mcp-server/src/legal_mcp/tools/cases.py | 5 +- web-ui/src/app/missing-precedents/page.tsx | 20 ++++++-- web-ui/src/components/wizard/case-wizard.tsx | 2 +- web-ui/src/lib/practice-area.ts | 26 ++++++++-- web-ui/src/lib/schemas/case.ts | 35 +++++++++++--- web/app.py | 16 ++++++- 11 files changed, 157 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 97a29ac..c68c357 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,8 @@ | היטל השבחה | 8xxx | קר ומקצועי | יבש, ללא רגשות | | פיצויים (ס' 197) | 9xxx | קר ומקצועי | דומה להיטל השבחה | +> **מבנה מספר-תיק (נוהל-יו"ר 2026-06-11):** `<סידורי>-<חודש>-<שנה>`. **אורך הסידורי = סוג-הליך:** 4 ספרות → **ערר**, 5 ספרות → **בל"מ** (`85074-09-24`). הספרה הראשונה עדיין קובעת תחום בשני האורכים. כלל חד-כיווני: 5-ספרתי הוא תמיד בל"מ; 4-ספרתי אינו מחייב ערר (בל"מ-מורשת מזוהה מהנושא). מקור-אמת: [`docs/spec/X1-identifiers.md`](docs/spec/X1-identifiers.md) §1א. + ### מטרת המערכת כלי עבודה שמסייע ליו"ר הוועדה: **ניהול תיקים** (ייבוא, סיווג, מעקב סטטוס) · **בסיס ידע** (פסיקה, ביטויי מעבר, לקחים, חקיקה) · **חיפוש סמנטי (RAG)** · **סיוע בכתיבה** (טיוטות לפי 12 בלוקים בסגנון דפנה) · **ייצוא DOCX**. diff --git a/docs/spec/X1-identifiers.md b/docs/spec/X1-identifiers.md index 8970fb4..6b4ad74 100644 --- a/docs/spec/X1-identifiers.md +++ b/docs/spec/X1-identifiers.md @@ -37,6 +37,26 @@ > (`cases.proceeding_type`, `db.py:912`). כך "ערר 8137/24" ו-"בל\"מ 8137/24" הם שתי > רשומות מובחנות בעלות אותו `case_number=8137-24` ו-`proceeding_type` שונה. +### 1א. אורך-הסידורי — אות לסוג-ההליך (נוהל-יו"ר, 2026-06-11) + +מבנה ה-`case_number` הוא `<סידורי>-<חודש>-<שנה>` (serial-month-year). **אורך הסידורי מקודד +את סוג-ההליך:** + +| אורך סידורי | סוג-הליך | דוגמה | הערה | +|-------------|----------|-------|------| +| 4 ספרות | **ערר** | `1230-04-26` | הליך עיקרי | +| 5 ספרות | **בל"מ** | `85074-09-24` | בקשה להארכת מועד | + +- **הספרה הראשונה ממשיכה לקודד את התחום בשני האורכים** — `1→רישוי`, `8→היטל השבחה`, + `9→פיצויים ס'197` (INV-DM/practice_area). תיק בל"מ `85074` → תחום היטל-השבחה. +- **הכלל חד-כיווני:** סידורי בן 5 ספרות **הוא** בל"מ (אות אוטומטי, `is_blam_by_number`, + `practice_area.py`). סידורי בן 4 ספרות **אינו** מחייב ערר — בל"מ-מורשת בן 4 ספרות עדיין + מזוהה מהנושא (`is_blam_subject`). הרקע: ירושלים אימצה מספור 5-ספרתי לבל"מ רק עכשיו; ת"א + מזה זמן (למשל `81002-01-21`). +- **פתיחת תיק חדש מחייבת את צורת serial-month-year המלאה** (כולל חודש) — ולידציית-הכתיבה + (`web-ui/src/lib/schemas/case.ts`) דוחה את צורת המורשת ללא-חודש. ההתאמה-הסלחנית-בקריאה + (§3) עדיין בולעת רשומות-מורשת בנות שתי-חוליות לצורך *חיפוש*, לא לצורך *יצירה*. + **נרמול-בכתיבה הוא המנגנון הראשי; התאמה-סלחנית-בקריאה היא נוחות משנית בלבד.** כלל-ההנדסה "נרמול לא תיקון-תסמין" (חוקה §6) קובע: מתקנים את הנתון במקור, לא מטליאים בקריאה. אם רשומה נשמרה בצורה לא-קנונית — היעד הוא לנרמל אותה במיגרציה/בכתיבה, **לא** לסמוך על מנוע-קריאה diff --git a/mcp-server/src/legal_mcp/services/internal_decisions.py b/mcp-server/src/legal_mcp/services/internal_decisions.py index 898de30..3816fb7 100644 --- a/mcp-server/src/legal_mcp/services/internal_decisions.py +++ b/mcp-server/src/legal_mcp/services/internal_decisions.py @@ -58,6 +58,7 @@ def _internal_validate(inputs: dict) -> None: def _internal_derive(inputs: dict) -> dict: district = (inputs.get("district") or "").strip() or _district_from_court(inputs.get("court") or "") proc = (inputs.get("proceeding_type") or "").strip() or derive_proceeding_type( + case_number=inputs.get("case_number") or "", appeal_subtype=inputs.get("appeal_subtype") or "", subject=inputs.get("case_name") or "", ) return {"district": district, "proceeding_type": proc} diff --git a/mcp-server/src/legal_mcp/services/practice_area.py b/mcp-server/src/legal_mcp/services/practice_area.py index 40673e1..fbf4112 100644 --- a/mcp-server/src/legal_mcp/services/practice_area.py +++ b/mcp-server/src/legal_mcp/services/practice_area.py @@ -176,8 +176,12 @@ _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE = { # Match the case number (last numeric group) in formats like: # ARAR-25-8126, ARAR-24-01-8007-33, 8126/25, 1170, ערר 1024-25 -_CASE_NUM = re.compile(r"(?:ARAR[-\s]*\d{2}[-\s]*(?:\d{2}[-\s]*)?)(\d{4})", re.IGNORECASE) -_PLAIN_NUM = re.compile(r"(\d{4})") +# Serial is 4 OR 5 digits: 4 = ערר (appeal), 5 = בל"מ (extension-of-time) per +# the post-reform numbering convention (Jerusalem adopted 5-digit בל"מ; Tel Aviv +# long predates it — e.g. 81002-01-21). The leading digit still encodes the +# domain (1→רישוי, 8→היטל, 9→פיצויים) in BOTH widths — see is_blam_by_number(). +_CASE_NUM = re.compile(r"(?:ARAR[-\s]*\d{2}[-\s]*(?:\d{2}[-\s]*)?)(\d{4,5})", re.IGNORECASE) +_PLAIN_NUM = re.compile(r"(\d{4,5})") _DOMAIN_TO_SUBTYPE: dict[str, str] = { @@ -216,6 +220,29 @@ def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit, "unknown") +def case_serial_digits(case_number: str) -> int | None: + """Return the digit-count of the case serial, or None if unparseable. + + The serial is the leading numeric group of the case number (the part + before month/year): ``8126-03-25`` → 4, ``81002-01-21`` → 5. + """ + cn = case_number or "" + m = _CASE_NUM.search(cn) or _PLAIN_NUM.search(cn) + return len(m.group(1)) if m else None + + +def is_blam_by_number(case_number: str) -> bool: + """True iff the case serial has 5 digits. + + Post-reform numbering convention: a 4-digit serial is an ערר (appeal), + a 5-digit serial is a בל"מ (בקשה להארכת מועד). This is the authoritative + signal going forward; legacy 4-digit בל"מ cases are still detected from + the subject via ``is_blam_subject``. The rule is **one-directional** — a + 5-digit serial implies בל"מ, but a 4-digit serial does NOT imply ערר. + """ + return case_serial_digits(case_number) == 5 + + def derive_subtype_with_blam( case_number: str, subject: str = "", @@ -236,9 +263,11 @@ def derive_subtype_with_blam( 'building_permit' """ base = derive_subtype(case_number, practice_area) - if not is_blam_subject(subject): + # בל"מ is signalled either by the subject text (legacy 4-digit cases) or by + # a 5-digit serial (post-reform convention). + if not (is_blam_subject(subject) or is_blam_by_number(case_number)): return base - # subject says it's בל"מ — return the matching extension_request_* variant. + # it's a בל"מ — return the matching extension_request_* variant. # For domain practice_area (axis B), use the direct mapping. if practice_area in DOMAIN_PRACTICE_AREAS: return _DOMAIN_TO_BLAM_SUBTYPE.get(practice_area, base) @@ -263,15 +292,21 @@ def is_blam_subtype(appeal_subtype: str) -> bool: return appeal_subtype in BLAM_SUBTYPES -def derive_proceeding_type(*, appeal_subtype: str = "", subject: str = "") -> str: +def derive_proceeding_type( + *, case_number: str = "", appeal_subtype: str = "", subject: str = "", +) -> str: """Return 'בל"מ' / 'ערר' for appeals-committee decisions/cases. - Priority: explicit subtype prefix → subject regex → default 'ערר'. + Priority: explicit subtype prefix → subject regex → 5-digit serial → + default 'ערר'. The 5-digit signal is one-directional (a 4-digit serial + does not force 'ערר' — a legacy 4-digit בל"מ is caught by the subject). """ if appeal_subtype and appeal_subtype.startswith("extension_request_"): return 'בל"מ' if subject and is_blam_subject(subject): return 'בל"מ' + if case_number and is_blam_by_number(case_number): + return 'בל"מ' return "ערר" diff --git a/mcp-server/src/legal_mcp/services/proofreader.py b/mcp-server/src/legal_mcp/services/proofreader.py index e1db884..999c841 100644 --- a/mcp-server/src/legal_mcp/services/proofreader.py +++ b/mcp-server/src/legal_mcp/services/proofreader.py @@ -268,12 +268,13 @@ async def proofread(path: Path) -> tuple[str, dict]: # ── Metadata extraction ────────────────────────────────────────── +# Serial is 3–5 digits: 4 = ערר, 5 = בל"מ (post-reform). 3 tolerates legacy short serials. FILENAME_NUMBER_PATTERNS = [ - re.compile(r"^ARAR-(\d{2})-(\d{3,4})"), - re.compile(r"^ערר\s+(\d{3,4})-(\d{2})"), - re.compile(r"^ערר\s+(\d{3,4})\s*-"), + re.compile(r"^ARAR-(\d{2})-(\d{3,5})"), + re.compile(r"^ערר\s+(\d{3,5})-(\d{2})"), + re.compile(r"^ערר\s+(\d{3,5})\s*-"), ] -LEGACY_MULTI_PATTERN = re.compile(r"(\d{3,4})\+(\d{3,4})") +LEGACY_MULTI_PATTERN = re.compile(r"(\d{3,5})\+(\d{3,5})") def decision_number_from_filename(stem: str) -> str | None: diff --git a/mcp-server/src/legal_mcp/tools/cases.py b/mcp-server/src/legal_mcp/tools/cases.py index 85f762c..365fd3e 100644 --- a/mcp-server/src/legal_mcp/tools/cases.py +++ b/mcp-server/src/legal_mcp/tools/cases.py @@ -183,9 +183,10 @@ async def case_create( appeal_subtype = derived_subtype pa.validate(practice_area, appeal_subtype) - # proceeding_type: explicit override > derived from subtype/subject > 'ערר' + # proceeding_type: explicit override > derived from subtype/subject/number > 'ערר' + # (a 5-digit serial signals בל"מ per the post-reform numbering convention). resolved_proc = proceeding_type.strip() or pa.derive_proceeding_type( - appeal_subtype=appeal_subtype, subject=subject, + case_number=case_number, appeal_subtype=appeal_subtype, subject=subject, ) case = await db.create_case( diff --git a/web-ui/src/app/missing-precedents/page.tsx b/web-ui/src/app/missing-precedents/page.tsx index c3e4d09..6099b21 100644 --- a/web-ui/src/app/missing-precedents/page.tsx +++ b/web-ui/src/app/missing-precedents/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { AppShell } from "@/components/app-shell"; import { Input } from "@/components/ui/input"; @@ -35,6 +35,20 @@ export default function MissingPrecedentsPage() { const [legalTopic, setLegalTopic] = useState(""); const [filter, setFilter] = useState("open"); + /* Debounce the filters so the table fires one query after the user stops + * typing — not one per keystroke. Each intermediate value used to + * round-trip to the API (and a non-existent case number errored mid-typing). */ + const [caseNumberQ, setCaseNumberQ] = useState(""); + const [legalTopicQ, setLegalTopicQ] = useState(""); + useEffect(() => { + const t = setTimeout(() => setCaseNumberQ(caseNumber.trim()), 350); + return () => clearTimeout(t); + }, [caseNumber]); + useEffect(() => { + const t = setTimeout(() => setLegalTopicQ(legalTopic.trim()), 350); + return () => clearTimeout(t); + }, [legalTopic]); + const counts = useMissingPrecedents({ limit: 1 }); const byStatus = counts.data?.by_status ?? {}; @@ -123,8 +137,8 @@ export default function MissingPrecedentsPage() { {/* lifecycle note (mockup 09 `.lifecycle`) */} diff --git a/web-ui/src/components/wizard/case-wizard.tsx b/web-ui/src/components/wizard/case-wizard.tsx index ca5af04..aa39236 100644 --- a/web-ui/src/components/wizard/case-wizard.tsx +++ b/web-ui/src/components/wizard/case-wizard.tsx @@ -166,7 +166,7 @@ export function CaseWizard() { diff --git a/web-ui/src/lib/practice-area.ts b/web-ui/src/lib/practice-area.ts index 6ad9858..d333935 100644 --- a/web-ui/src/lib/practice-area.ts +++ b/web-ui/src/lib/practice-area.ts @@ -104,8 +104,28 @@ export function isBlamSubject(subject: string): boolean { } /* - * Like deriveSubtype() but also detects בל"מ from the subject. Mirrors - * `derive_subtype_with_blam()` in practice_area.py. + * Digit-count of the case serial (the leading numeric group, before + * month/year): "8126-03-25" → 4, "81002-01-21" → 5. Returns null when no + * serial is present. Mirrors `case_serial_digits()` in practice_area.py. + */ +export function caseSerialDigits(caseNumber: string): number | null { + const m = caseNumber.trim().match(/(\d{4,5})/); + return m ? m[1].length : null; +} + +/* + * True iff the case serial has 5 digits — the post-reform convention for + * בל"מ (4 digits = ערר). One-directional: 5 ⇒ בל"מ, but 4 does NOT imply + * ערר (a legacy 4-digit בל"מ is caught via the subject). Mirrors + * `is_blam_by_number()` in practice_area.py. + */ +export function isBlamByNumber(caseNumber: string): boolean { + return caseSerialDigits(caseNumber) === 5; +} + +/* + * Like deriveSubtype() but also detects בל"מ from the subject OR a 5-digit + * serial. Mirrors `derive_subtype_with_blam()` in practice_area.py. */ export function deriveSubtypeWithBlam( caseNumber: string, @@ -113,7 +133,7 @@ export function deriveSubtypeWithBlam( practiceArea: PracticeArea = "appeals_committee", ): AppealSubtype { const base = deriveSubtype(caseNumber, practiceArea); - if (!isBlamSubject(subject)) return base; + if (!isBlamSubject(subject) && !isBlamByNumber(caseNumber)) return base; if (base === "building_permit") return "extension_request_building_permit"; if (base === "betterment_levy") return "extension_request_betterment_levy"; if (base === "compensation_197") return "extension_request_compensation"; diff --git a/web-ui/src/lib/schemas/case.ts b/web-ui/src/lib/schemas/case.ts index 1eea3b5..7654b7e 100644 --- a/web-ui/src/lib/schemas/case.ts +++ b/web-ui/src/lib/schemas/case.ts @@ -12,16 +12,26 @@ import { z } from "zod"; import type { PracticeArea, AppealSubtype } from "@/lib/practice-area"; -/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md. - * Two accepted formats, both hyphen-separated: - * NNNN-YY → "1033-25" (case sequence + 2-digit year) - * NNNN-MM-YY → "1000-04-26" (case sequence + 2-digit month + year) +/* Appeal numbers follow the 1xxx / 8xxx / 9xxx domain convention (leading + * digit) from CLAUDE.md, plus the post-reform serial-length rule: + * 4-digit serial → ערר (appeal) e.g. "1230-04-26" + * 5-digit serial → בל"מ (extension-of-time) e.g. "85074-09-24" + * + * New cases MUST carry the month — the canonical serial-month-year form + * NNNN[N]-MM-YY (X1-identifiers spec §1; chair procedure 2026-06-11). The + * legacy NNNN-YY (no-month) form is still tolerated by backend lookup for + * historical records, but is no longer accepted when opening a new case. * * Slashes are deliberately forbidden: FastAPI path routing can't capture * a `/` inside a {case_number} segment even when URL-encoded as %2F, so * any case with a slash becomes unreachable at * GET /api/cases/{case_number}/details. */ -const caseNumberRe = /^[1-9]\d{3}(?:-\d{2}){1,2}$/; +const caseNumberRe = /^[1-9]\d{3,4}-\d{2}-\d{2}$/; + +/* Serial digit-count: leading numeric group before month/year (4 = ערר, + * 5 = בל"מ). Returns 0 when no serial is present. */ +const caseSerialLen = (n: string): number => + n.trim().match(/^(\d{4,5})/)?.[1].length ?? 0; const hebrewPartyRe = /[\u0590-\u05FFA-Za-z]/; @@ -55,7 +65,10 @@ export const caseCreateSchema = z.object({ .string() .trim() .min(1, "שדה חובה") - .regex(caseNumberRe, "פורמט: NNNN-YY או NNNN-MM-YY (למשל 1033-25 או 1000-04-26)"), + .regex( + caseNumberRe, + 'פורמט: מספר-חודש-שנה. 4 ספרות = ערר (1230-04-26), 5 ספרות = בל"מ (85074-09-24)', + ), title: z.string().trim().min(3, "כותרת קצרה מדי").max(200, "כותרת ארוכה מדי"), appellants: z .array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין")) @@ -86,6 +99,16 @@ export const caseCreateSchema = z.object({ "unknown", ] as const satisfies readonly AppealSubtype[]), proceeding_type: z.enum(["ערר", 'בל"מ'] as const), +}).superRefine((d, ctx) => { + /* Post-reform rule: a 5-digit serial IS a בל"מ. One-directional — a + * 4-digit serial may still be a legacy בל"מ, so we don't force ערר. */ + if (caseSerialLen(d.case_number) === 5 && d.proceeding_type !== 'בל"מ') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["proceeding_type"], + message: 'מספר בן 5 ספרות הוא תיק בל"מ — סוג התיק חייב להיות בל"מ', + }); + } }); export type CaseCreateInput = z.infer; diff --git a/web/app.py b/web/app.py index bda56a8..90ce0d3 100644 --- a/web/app.py +++ b/web/app.py @@ -7429,7 +7429,21 @@ async def missing_precedents_list( elif case_number.strip(): c = await db.get_case_by_number(case_number.strip()) if not c: - raise HTTPException(404, f"תיק לא נמצא: {case_number}") + # A list-filter that matches no case yields an empty list — NOT a + # 404. The UI field updates per-keystroke, so 404-on-no-match broke + # the table mid-typing (and on any non-existent number). + pool = await db.get_pool() + async with pool.acquire() as conn: + counts = await conn.fetch( + "SELECT status, COUNT(*) AS n FROM missing_precedents GROUP BY status" + ) + by_status = {r["status"]: r["n"] for r in counts} + return { + "items": [], + "count": 0, + "by_status": by_status, + "total_open": by_status.get("open", 0), + } case_uuid = UUID(c["id"]) rows = await db.list_missing_precedents( -- 2.49.1