fix(cases): מספור 5-ספרתי לבל"מ — סיווג, ולידציה, וחיפוש פסיקה-חסרה
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s

נוהל-יו"ר (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) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 06:16:42 +00:00
parent 9cd290e08e
commit e8bcb9c1ea
11 changed files with 157 additions and 26 deletions

View File

@@ -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`) */}

View File

@@ -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"
/>

View File

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

View File

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