feat(proceeding-type): explicit ערר/בל"מ field for cases + corpus
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m40s

Same case_number can exist as both a regular appeal (ערר) and an
extension-of-time request (בל"מ), and we were inferring the difference
from appeal_subtype prefixes — fragile, and case-number lookups
weren't disambiguated. Now stored as a first-class field on both
case_law (corpus) and cases (live cases), with partial unique indexes
on (case_number, proceeding_type).

- SCHEMA_V15: column + CHECK constraints + backfill from
  appeal_subtype LIKE 'extension_request_%' + partial unique indexes
  replace the old global UNIQUE(case_number).
- derive_proceeding_type() centralizes the inference rule
  (extension_request_* → בל"מ; subject regex fallback; default ערר).
- Metadata extractor prompt asks Claude to populate the new field
  explicitly; apply_to_record writes it for internal_committee rows.
- internal_decision_upload, case_create, case_update accept an
  optional proceeding_type; FastAPI request models expose it.
- Wizard + edit dialog get a sided Select; case header renders the
  resolved label (ערר / בל"מ).
- Uploaded the 2 staged בל"מ decisions on betterment levy:
  8126/24 (סופר נוח, 13 chunks), 8047/23 (הרנון, 48 chunks).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 09:17:33 +00:00
parent 1645653ba9
commit d359ab9884
15 changed files with 308 additions and 14 deletions

View File

@@ -823,6 +823,58 @@ CREATE TABLE IF NOT EXISTS legal_argument_propositions (
"""
# proceeding_type — מבחין בין הליך ערר עיקרי לבל"מ (בקשה להארכת מועד).
# חל גם על case_law (קורפוס) וגם על cases (תיקים חיים). שני הסוגים
# יכולים לחלוק אותו case_number, ולכן ה-uniqueness עוברת ל-(case_number,
# proceeding_type). בקורפוס: רק internal_committee מקבלים ערך מאוכלס;
# פסיקה חיצונית נשארת עם ''.
SCHEMA_V15_SQL = """
-- ------- case_law (קורפוס) -------
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS proceeding_type TEXT NOT NULL DEFAULT '';
ALTER TABLE case_law DROP CONSTRAINT IF EXISTS case_law_proceeding_type_check;
ALTER TABLE case_law ADD CONSTRAINT case_law_proceeding_type_check
CHECK (proceeding_type IN ('', 'ערר', 'בל"מ'));
-- Backfill לפי appeal_subtype הקיים
UPDATE case_law SET proceeding_type = 'בל"מ'
WHERE source_kind = 'internal_committee' AND proceeding_type = ''
AND appeal_subtype LIKE 'extension_request_%';
UPDATE case_law SET proceeding_type = 'ערר'
WHERE source_kind = 'internal_committee' AND proceeding_type = '';
ALTER TABLE case_law DROP CONSTRAINT IF EXISTS case_law_internal_proceeding_check;
ALTER TABLE case_law ADD CONSTRAINT case_law_internal_proceeding_check
CHECK (source_kind != 'internal_committee' OR proceeding_type IN ('ערר', 'בל"מ'));
-- החלפת UNIQUE(case_number) ב-partial unique לפי source_kind
ALTER TABLE case_law DROP CONSTRAINT IF EXISTS case_law_case_number_key;
DROP INDEX IF EXISTS case_law_case_number_key;
CREATE UNIQUE INDEX IF NOT EXISTS uq_case_law_internal_number_proc
ON case_law (case_number, proceeding_type)
WHERE source_kind = 'internal_committee';
CREATE UNIQUE INDEX IF NOT EXISTS uq_case_law_external_number
ON case_law (case_number)
WHERE source_kind <> 'internal_committee';
-- ------- cases (תיקים חיים) -------
ALTER TABLE cases ADD COLUMN IF NOT EXISTS proceeding_type TEXT NOT NULL DEFAULT 'ערר';
ALTER TABLE cases DROP CONSTRAINT IF EXISTS cases_proceeding_type_check;
ALTER TABLE cases ADD CONSTRAINT cases_proceeding_type_check
CHECK (proceeding_type IN ('ערר', 'בל"מ'));
UPDATE cases SET proceeding_type = 'בל"מ'
WHERE proceeding_type = 'ערר' AND appeal_subtype LIKE 'extension_request_%';
ALTER TABLE cases DROP CONSTRAINT IF EXISTS cases_case_number_key;
DROP INDEX IF EXISTS cases_case_number_key;
CREATE UNIQUE INDEX IF NOT EXISTS uq_cases_number_proc
ON cases (case_number, proceeding_type);
"""
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
async with pool.acquire() as conn:
await conn.execute(SCHEMA_SQL)
@@ -840,7 +892,8 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
await conn.execute(SCHEMA_V12_SQL)
await conn.execute(SCHEMA_V13_SQL)
await conn.execute(SCHEMA_V14_SQL)
logger.info("Database schema initialized (v1-v14)")
await conn.execute(SCHEMA_V15_SQL)
logger.info("Database schema initialized (v1-v15)")
async def init_schema() -> None:
@@ -867,6 +920,7 @@ async def create_case(
# from the case_number prefix before calling here.
practice_area: str = "",
appeal_subtype: str = "",
proceeding_type: str = "ערר",
) -> dict:
pool = await get_pool()
case_id = uuid4()
@@ -875,14 +929,14 @@ async def create_case(
"""INSERT INTO cases (id, case_number, title, appellants, respondents,
subject, property_address, permit_number, committee_type,
hearing_date, notes, expected_outcome,
practice_area, appeal_subtype)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)""",
practice_area, appeal_subtype, proceeding_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)""",
case_id, case_number, title,
json.dumps(appellants or []),
json.dumps(respondents or []),
subject, property_address, permit_number, committee_type,
hearing_date, notes, expected_outcome,
practice_area, appeal_subtype,
practice_area, appeal_subtype, proceeding_type,
)
return await get_case(case_id)
@@ -2024,18 +2078,22 @@ async def create_internal_committee_decision(
summary: str = "",
is_binding: bool = True,
document_id: UUID | None = None,
proceeding_type: str = "ערר",
) -> dict:
"""Upsert an appeals-committee decision as source_kind='internal_committee'.
If a row with this case_number already exists as cited_only, promotes it.
Idempotent: calling again updates metadata in-place.
Idempotency key: (case_number, proceeding_type) — the same number can
exist as both 'ערר' and 'בל"מ' (an extension-of-time request can be
filed against an existing appeal with the same number).
"""
pool = await get_pool()
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
async with pool.acquire() as conn:
existing = await conn.fetchrow(
"SELECT id FROM case_law WHERE case_number = $1",
case_number,
"SELECT id FROM case_law "
"WHERE case_number = $1 AND proceeding_type = $2 "
" AND source_kind = 'internal_committee'",
case_number, proceeding_type,
)
if existing:
row = await conn.fetchrow(
@@ -2072,19 +2130,20 @@ async def create_internal_committee_decision(
subject_tags, summary, full_text,
source_kind, source_type, document_id,
extraction_status, halacha_extraction_status,
practice_area, appeal_subtype, is_binding
practice_area, appeal_subtype, is_binding, proceeding_type
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
'internal_committee', 'appeals_committee', $10,
'processing', 'pending',
$11, $12, $13
$11, $12, $13, $14
)
RETURNING *
""",
case_number, case_name, court, decision_date, chair_name, district,
tags_json, summary, full_text,
document_id, practice_area, appeal_subtype, is_binding,
proceeding_type,
)
return _row_to_case_law(row)
@@ -2100,6 +2159,7 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
"case_number", "case_name", "court", "date", "practice_area", "appeal_subtype",
"subject_tags", "summary", "headnote", "key_quote", "source_url",
"source_type", "precedent_level", "is_binding", "district", "chair_name",
"proceeding_type",
}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:

View File

@@ -24,6 +24,7 @@ from uuid import UUID, uuid4
from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor
from legal_mcp.services.practice_area import derive_proceeding_type
logger = logging.getLogger(__name__)
@@ -86,11 +87,13 @@ async def ingest_internal_decision(
text: str | None = None,
document_id: UUID | None = None,
queue_halachot: bool = True,
proceeding_type: str = "",
) -> dict:
"""Ingest an appeals-committee decision into the internal corpus.
Either file_path or text must be provided.
If district is empty, it is inferred from court.
If proceeding_type is empty, it is derived from appeal_subtype/case_name.
Returns: {"status": "completed", "case_law_id": "...", "chunks": N}
"""
if not file_path and not text:
@@ -99,6 +102,9 @@ async def ingest_internal_decision(
raise ValueError("case_number is required")
resolved_district = district.strip() or _district_from_court(court)
resolved_proc = proceeding_type.strip() or derive_proceeding_type(
appeal_subtype=appeal_subtype, subject=case_name,
)
if file_path:
src = Path(file_path)
@@ -133,6 +139,7 @@ async def ingest_internal_decision(
summary=summary.strip(),
is_binding=is_binding,
document_id=document_id,
proceeding_type=resolved_proc,
)
case_law_id = UUID(str(record["id"]))

View File

@@ -263,6 +263,18 @@ def is_blam_subtype(appeal_subtype: str) -> bool:
return appeal_subtype in BLAM_SUBTYPES
def derive_proceeding_type(*, appeal_subtype: str = "", subject: str = "") -> str:
"""Return 'בל"מ' / 'ערר' for appeals-committee decisions/cases.
Priority: explicit subtype prefix → subject regex → default 'ערר'.
"""
if appeal_subtype and appeal_subtype.startswith("extension_request_"):
return 'בל"מ'
if subject and is_blam_subject(subject):
return 'בל"מ'
return "ערר"
def derive_domain_practice_area(case_number: str) -> str:
"""Map a case_number prefix to a domain practice_area (axis B).

View File

@@ -50,6 +50,7 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
"decision_date_iso": "YYYY-MM-DD — תאריך מתן ההחלטה כפי שמופיע בטקסט (בכותרת או בחתימה הסופית). אם לא ניתן לזהות במדויק — מחרוזת ריקה.",
"precedent_level": "אחד מ-4: 'עליון' / 'מנהלי' / 'ועדת_ערר_ארצית' / 'ועדת_ערר_מחוזית'. בחר לפי הערכאה שמסומנת בכותרת הפסק. אם לא ברור — מחרוזת ריקה.",
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
"proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY''ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר''בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.",
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות."
}
@@ -65,6 +66,7 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
8. **precedent_level** — קבע לפי הערכאה: בית המשפט העליון = "עליון"; בית משפט מחוזי בשבתו כבית משפט לעניינים מנהליים = "מנהלי"; ועדת ערר ארצית = "ועדת_ערר_ארצית"; ועדת ערר מחוזית (כמו ועדות תכנון ובניה ירושלים/מחוז המרכז וכד') = "ועדת_ערר_מחוזית". השתמש ב-underscore כפי שמופיע — לא ברווח.
9. **source_type** — שני ערכים בלבד: "court_ruling" כשהמסמך הוא פסק דין/החלטה של בית משפט (עליון/בג"ץ/מנהלי/מחוזי); "appeals_committee" כשהמסמך הוא החלטה של ועדת ערר (ארצית או מחוזית). זה משלים את `precedent_level` — שני השדות צריכים להיות תואמים.
10. **court** — מהכותרת הראשית של הפסק. ניסוח מלא (לא קיצור). מחרוזת ריקה אם לא ניתן לזהות.
11. **proceeding_type** — חובה לזהות עבור החלטות ועדת ערר; ריק עבור פסיקת בית משפט. הסימן הברור: בכותרת הראשונה של המסמך כתוב "ערר (ועדות ערר ...) NNNN/YY"'ערר'; "בל\"מ NNNN/YY" או הנושא "בקשה להארכת מועד להגשת ערר"'בל\"מ'. שני הסוגים יכולים לחלוק אותו מספר תיק — לכן חשוב להבחין מפורשות.
"""
@@ -160,6 +162,10 @@ async def extract_metadata(case_law_id: UUID | str) -> dict:
st = result["source_type"].strip()
if st in {"court_ruling", "appeals_committee"}:
out["source_type"] = st
if isinstance(result.get("proceeding_type"), str):
pt = result["proceeding_type"].strip()
if pt in {"ערר", 'בל"מ', ""}:
out["proceeding_type"] = pt
if isinstance(result.get("court"), str):
out["court"] = result["court"].strip()
if isinstance(result.get("case_number_clean"), str):
@@ -267,6 +273,13 @@ async def apply_to_record(
if c:
fields_to_update["court"] = c
# proceeding_type — only fill for internal_committee rows (the field is
# meaningless for court rulings, which we keep as '').
if not (record.get("proceeding_type") or "").strip():
pt = (suggested.get("proceeding_type") or "").strip()
if pt and (record.get("source_kind") == "internal_committee"):
fields_to_update["proceeding_type"] = pt
if overwrite_case_number:
cn = (suggested.get("case_number_clean") or "").strip()
if cn:

View File

@@ -130,6 +130,7 @@ async def case_create(
expected_outcome: str = "",
practice_area: str = "",
appeal_subtype: str = "",
proceeding_type: str = "",
) -> str:
"""יצירת תיק ערר חדש.
@@ -150,6 +151,7 @@ async def case_create(
אוטומטית ממספר התיק (1xxx→רישוי, 8xxx→השבחה, 9xxx→197)
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
ריק = יוסק אוטומטית ממספר התיק
proceeding_type: 'ערר' / 'בל"מ'. ריק = יוסק מ-appeal_subtype/subject.
"""
from datetime import date as date_type
@@ -173,6 +175,11 @@ async def case_create(
appeal_subtype = derived_subtype
pa.validate(practice_area, appeal_subtype)
# proceeding_type: explicit override > derived from subtype/subject > 'ערר'
resolved_proc = proceeding_type.strip() or pa.derive_proceeding_type(
appeal_subtype=appeal_subtype, subject=subject,
)
case = await db.create_case(
case_number=case_number,
title=title,
@@ -187,6 +194,7 @@ async def case_create(
expected_outcome=expected_outcome,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
proceeding_type=resolved_proc,
)
# If the user overrode the case-number convention (e.g. case 8500 marked
@@ -290,6 +298,7 @@ async def case_update(
respondents: list[str] | None = None,
property_address: str = "",
permit_number: str = "",
proceeding_type: str = "",
) -> str:
"""עדכון פרטי תיק.
@@ -307,6 +316,7 @@ async def case_update(
respondents: רשימת משיבים חדשה
property_address: כתובת נכס חדשה
permit_number: מספר תכנית/בקשה חדש
proceeding_type: 'ערר' / 'בל"מ' — ריק = ללא שינוי
"""
from datetime import date as date_type
@@ -359,6 +369,12 @@ async def case_update(
fields["property_address"] = property_address
if permit_number:
fields["permit_number"] = permit_number
if proceeding_type:
if proceeding_type not in {"ערר", 'בל"מ'}:
raise ValueError(
f"proceeding_type לא תקין: {proceeding_type!r}. ערכים תקפים: ערר / בל\"מ"
)
fields["proceeding_type"] = proceeding_type
updated = await db.update_case(UUID(case["id"]), **fields)

View File

@@ -21,6 +21,10 @@ from legal_mcp.services import internal_decisions as int_svc
# Valid Hebrew district names (matches _COURT_TO_DISTRICT in service)
VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב", "צפון", "דרום", "חיפה", "ארצי"}
# proceeding_type — ערר vs בל"מ. The service can derive it from
# appeal_subtype/subject if left empty, so this stays optional at the API.
VALID_PROCEEDING_TYPES = {"ערר", 'בל"מ'}
def _ok(payload) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
@@ -43,6 +47,7 @@ async def internal_decision_upload(
subject_tags: list[str] | None = None,
summary: str = "",
is_binding: bool = False,
proceeding_type: str = "",
) -> str:
"""העלאת החלטה של ועדת ערר (internal_committee) לקורפוס הסמכותי.
@@ -62,6 +67,7 @@ async def internal_decision_upload(
appeal_subtype: building_permit / וכו'.
subject_tags: תגיות נושא.
is_binding: בד"כ False (ועדת ערר לא מחייבת ועדה אחרת — שכנוע אופקי).
proceeding_type: 'ערר' או 'בל"מ'. אם ריק — נגזר מ-appeal_subtype/case_name.
Returns: JSON עם case_law_id, מספר chunks, halachot_pending.
"""
@@ -83,6 +89,11 @@ async def internal_decision_upload(
f"district לא תקין: {district!r}. ערכים תקפים: "
+ ", ".join(sorted(VALID_DISTRICTS))
)
if proceeding_type.strip() and proceeding_type.strip() not in VALID_PROCEEDING_TYPES:
return _err(
f"proceeding_type לא תקין: {proceeding_type!r}. ערכים תקפים: "
+ ", ".join(sorted(VALID_PROCEEDING_TYPES))
)
try:
result = await int_svc.ingest_internal_decision(
@@ -98,6 +109,7 @@ async def internal_decision_upload(
summary=summary,
is_binding=is_binding,
file_path=file_path,
proceeding_type=proceeding_type,
)
except Exception as e:
return _err(str(e))