Merge pull request 'fix(cases): מספור 5-ספרתי לבל"מ — סיווג, ולידציה, וחיפוש פסיקה-חסרה' (#219) from worktree-case-numbering-blam into main
This commit was merged in pull request #219.
This commit is contained in:
@@ -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**.
|
||||
|
||||
|
||||
@@ -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) קובע: מתקנים את הנתון במקור, לא מטליאים בקריאה. אם רשומה
|
||||
נשמרה בצורה לא-קנונית — היעד הוא לנרמל אותה במיגרציה/בכתיבה, **לא** לסמוך על מנוע-קריאה
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 "ערר"
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<StatusFilter>("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() {
|
||||
|
||||
<MissingPrecedentsTable
|
||||
status={filter === "all" ? "" : filter}
|
||||
caseNumber={caseNumber.trim() || undefined}
|
||||
legalTopic={legalTopic.trim() || undefined}
|
||||
caseNumber={caseNumberQ || undefined}
|
||||
legalTopic={legalTopicQ || undefined}
|
||||
/>
|
||||
|
||||
{/* lifecycle note (mockup 09 `.lifecycle`) */}
|
||||
|
||||
@@ -166,7 +166,7 @@ export function CaseWizard() {
|
||||
</Label>
|
||||
<Input
|
||||
id="case_number"
|
||||
placeholder="1033-25 או 1000-04-26"
|
||||
placeholder='1230-04-26 (ערר) · 85074-09-24 (בל"מ)'
|
||||
{...form.register("case_number")}
|
||||
className="mt-1 tabular-nums"
|
||||
/>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof caseCreateSchema>;
|
||||
|
||||
16
web/app.py
16
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(
|
||||
|
||||
Reference in New Issue
Block a user