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

@@ -124,6 +124,7 @@ mcp__legal-ai__internal_decision_upload(
decision_date="2020-11-15",
practice_area="rishuy_uvniya", # Axis B
appeal_subtype="building_permit",
proceeding_type="ערר", # 'ערר' / 'בל"מ' — ראה מטה
subject_tags=["שימוש חורג"],
is_binding=False, # תמיד False — שכנוע אופקי, לא חוב
)
@@ -135,6 +136,13 @@ mcp__legal-ai__internal_decision_upload(
- `chair_name` — בלעדיו אי-אפשר לחפש סלקטיבית לפי הרכב
- `district` — ערכים תקפים: **ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי** (גם "תל-אביב" עם מקף נקלט)
**שדה מומלץ — `proceeding_type`:**
- `"ערר"` — הליך ערר עיקרי (כותרת ב-PDF: "ערר (ועדות ערר ...) NNNN/YY")
- `'בל"מ'` — בקשה להארכת מועד להגשת ערר (כותרת: "בל\"מ NNNN/YY" או נושא "בקשה להארכת מועד להגשת ערר")
- שני הסוגים יכולים לחלוק אותו מספר תיק (למשל 8047/23 קיים גם כערר וגם כבל"מ).
- בכותרת הראשית של ה-PDF זה תמיד מפורש — לקרוא משם ולא לנחש.
- אם תשאיר ריק — הכלי גוזר אוטומטית מ-appeal_subtype (`extension_request_*` → 'בל"מ') או מתבנית הטקסט. עדיף מפורש.
**הכלי שומר `source_kind='internal_committee'`.** DB constraint `case_law_internal_district_check` אוכף ש-`district NOT NULL` כשמדובר ב-internal_committee.
### אם chair_name או district חסר ב-PDF

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

View File

@@ -29,6 +29,7 @@
| `multimodal_backfill.py` | python | Backfill voyage-multimodal-3 page embeddings על מסמכי תיקים קיימים. idempotent (skips by default), forces `MULTIMODAL_ENABLED=true` ל-run, רץ מהקונטיינר. שלב C — ראה `docs/voyage-upgrades-plan.md` | ידני per-case (`python multimodal_backfill.py 8174-24 8137-24`) |
| `backfill_chunk_pages.py` | python | Backfill `page_number` ב-`document_chunks` קיימים. legacy chunker לא tracked עמודים → `page_number=NULL` חוסם boost של multimodal hybrid (text+image join על אותו עמוד). re-extracts כל PDF (re-OCR אם צריך, ~$0.0015/page), מחשב page_offsets, ומעדכן chunks. idempotent | ידני per-case (`python backfill_chunk_pages.py 8174-24 8137-24`) |
| `backfill_legal_arguments.py` | python | Backfill `legal_arguments` לתיקים עם `claims` קיימים (TaskMaster #36). מקבץ פרופוזיציות גולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד) דרך `argument_aggregator.aggregate_claims_to_arguments` (Claude CLI). תומך `--dry-run`/`--apply`/`--force`/`--case <num>...`. **חייב לרוץ מהמכונה המקומית** (לא קונטיינר) — `claude_session` דורש Claude CLI | ידני per-case (`python scripts/backfill_legal_arguments.py --apply --case 1017-03-26`) |
| `upload_blam_decisions.py` | python | חד-פעמי (2026-05-26) — העלאת 2 החלטות בל"מ ל-`case_law` (8126/24 סופר נוח, 8047/23 הרנון) דרך `ingest_internal_decision` ישיר, עוקף MCP server שטרם נטען מחדש אחרי הוספת `proceeding_type`. **לא להריץ שוב** | חד-פעמי — להעביר ל-`.archive/` בהזדמנות |
## תיקיית `.archive/` — סקריפטים שהושלמו

View File

@@ -0,0 +1,60 @@
"""One-shot uploader for the 2 new בל"מ decisions Chaim staged in
data/precedents/incoming/. Bypasses MCP because the running MCP server
was started before SCHEMA_V15 + proceeding_type wiring landed.
Run from /home/chaim/legal-ai with the venv:
POSTGRES_URL=... .venv/bin/python scripts/upload_blam_decisions.py
"""
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp.services import internal_decisions as svc
DECISIONS = [
{
"file_path": "/home/chaim/legal-ai/data/precedents/incoming/ARAR-24-8126.pdf",
"case_number": "8126/24",
"chair_name": "דפנה תמיר",
"district": "ירושלים",
"case_name": "הוועדה המקומית ירושלים נ' סופר נוח",
"court": "ועדת הערר לתכנון ובנייה — מחוז ירושלים",
"decision_date": "2024-07-07",
"practice_area": "betterment_levy",
"appeal_subtype": "extension_request_betterment_levy",
"proceeding_type": 'בל"מ',
"subject_tags": ["בקשה_להארכת_מועד", "היטל_השבחה"],
"summary": "",
"is_binding": False,
},
{
"file_path": "/home/chaim/legal-ai/data/precedents/incoming/ARAR-23-8047-3.docx",
"case_number": "8047/23",
"chair_name": "דפנה תמיר",
"district": "ירושלים",
"case_name": 'עזבון אליהו הרנון ז"ל נ\' הוועדה המקומית ירושלים',
"court": "ועדת הערר לתכנון ובנייה — מחוז ירושלים",
"decision_date": "2025-09-29",
"practice_area": "betterment_levy",
"appeal_subtype": "extension_request_betterment_levy",
"proceeding_type": 'בל"מ',
"subject_tags": ["בקשה_להארכת_מועד", "היטל_השבחה"],
"summary": "",
"is_binding": False,
},
]
async def main():
for d in DECISIONS:
print(f"→ uploading {d['case_number']} ({d['proceeding_type']})")
result = await svc.ingest_internal_decision(**d)
print(f" ✓ case_law_id={result.get('case_law_id')} chunks={result.get('chunks')}")
print("done.")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -17,7 +17,10 @@ import {
} from "@/components/ui/select";
import { PartiesField } from "@/components/wizard/parties-field";
import { useUpdateCase } from "@/lib/api/cases";
import { caseUpdateSchema, expectedOutcomes, type CaseUpdateInput } from "@/lib/schemas/case";
import {
caseUpdateSchema, expectedOutcomes, proceedingTypes,
type CaseUpdateInput,
} from "@/lib/schemas/case";
import type { CaseDetail } from "@/lib/api/cases";
/*
@@ -47,6 +50,7 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
respondents: data.respondents ?? [],
property_address: data.property_address ?? "",
permit_number: data.permit_number ?? "",
proceeding_type: data.proceeding_type ?? "ערר",
},
});
@@ -63,6 +67,7 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
respondents: data.respondents ?? [],
property_address: data.property_address ?? "",
permit_number: data.permit_number ?? "",
proceeding_type: data.proceeding_type ?? "ערר",
});
}, [open, data, form]);
@@ -104,6 +109,37 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
<FieldError message={form.formState.errors.subject?.message} />
</div>
<div>
<Label className="text-navy">סוג תיק</Label>
<Controller
control={form.control}
name="proceeding_type"
render={({ field }) => (
<Select
value={field.value ?? "ערר"}
onValueChange={(v) =>
field.onChange(v as CaseUpdateInput["proceeding_type"])
}
dir="rtl"
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{proceedingTypes.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-[0.7rem] text-ink-muted mt-1">
ערר = הליך עיקרי; בל&quot;מ = בקשה להארכת מועד להגשת ערר
</p>
</div>
<div className="h-px bg-rule" />
<Controller

View File

@@ -41,7 +41,7 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
<div className="space-y-2">
<div className="flex items-center gap-3 flex-wrap">
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
ערר {data?.case_number ?? "—"}
{data?.proceeding_type ?? "ערר"} {data?.case_number ?? "—"}
</span>
{data?.status && <StatusBadge status={data.status} />}
{data?.archived_at && (

View File

@@ -16,7 +16,7 @@ import {
import { PartiesField } from "@/components/wizard/parties-field";
import { useCreateCase } from "@/lib/api/cases";
import {
caseCreateSchema, expectedOutcomes,
caseCreateSchema, expectedOutcomes, proceedingTypes,
type CaseCreateInput,
} from "@/lib/schemas/case";
import {
@@ -35,7 +35,7 @@ type StepKey = (typeof STEPS)[number]["key"];
/* Fields validated at each step — lets the user fix just what's on screen
* before moving forward, instead of surfacing all errors from page 1. */
const STEP_FIELDS: Record<StepKey, (keyof CaseCreateInput)[]> = {
basics: ["case_number", "title", "practice_area", "appeal_subtype"],
basics: ["case_number", "title", "proceeding_type", "practice_area", "appeal_subtype"],
parties: ["appellants", "respondents"],
details: ["subject", "hearing_date", "expected_outcome", "notes", "property_address", "permit_number"],
};
@@ -66,6 +66,7 @@ export function CaseWizard() {
expected_outcome: "",
practice_area: "appeals_committee",
appeal_subtype: "unknown",
proceeding_type: "ערר",
},
});
@@ -74,11 +75,17 @@ export function CaseWizard() {
* stop the moment they manually pick a value from the dropdown. Mirrors
* the wireSubtypeAutofill() behaviour of the vanilla UI
* (legal-ai/web/static/index.html around line 2770).
*
* proceeding_type follows the same pattern: if the user hasn't picked
* a value yet, we derive 'בל"מ' whenever the subtype lands on an
* extension_request_* value.
*/
const userTouchedSubtype = useRef(false);
const userTouchedProceeding = useRef(false);
const caseNumber = form.watch("case_number");
const practiceArea = form.watch("practice_area");
const subject = form.watch("subject");
const appealSubtype = form.watch("appeal_subtype");
useEffect(() => {
if (userTouchedSubtype.current) return;
/* derive_subtype_with_blam picks extension_request_* when subject
@@ -89,6 +96,16 @@ export function CaseWizard() {
}
}, [caseNumber, practiceArea, subject, form]);
/* proceeding_type follows appeal_subtype when the user hasn't picked
* one explicitly — extension_request_* always implies 'בל"מ'. */
useEffect(() => {
if (userTouchedProceeding.current) return;
const proc = appealSubtype.startsWith("extension_request_") ? 'בל"מ' : "ערר";
if (proc !== form.getValues("proceeding_type")) {
form.setValue("proceeding_type", proc, { shouldValidate: false });
}
}, [appealSubtype, form]);
const stepIndex = STEPS.findIndex((s) => s.key === step);
const isLast = stepIndex === STEPS.length - 1;
@@ -162,6 +179,39 @@ export function CaseWizard() {
<Input id="title" {...form.register("title")} className="mt-1" />
<FieldError message={form.formState.errors.title?.message} />
</div>
<div>
<Label className="text-navy">
סוג תיק <span className="text-danger">*</span>
</Label>
<Controller
control={form.control}
name="proceeding_type"
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v) => {
userTouchedProceeding.current = true;
field.onChange(v as CaseCreateInput["proceeding_type"]);
}}
dir="rtl"
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{proceedingTypes.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-[0.7rem] text-ink-muted mt-1">
ערר = הליך עיקרי; בל&quot;מ = בקשה להארכת מועד להגשת ערר
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-navy">תחום משפטי</Label>

View File

@@ -54,6 +54,8 @@ export type Case = {
respondents?: string[] | null;
property_address?: string | null;
permit_number?: string | null;
/* 'ערר' = regular appeal, 'בל"מ' = extension-of-time request */
proceeding_type?: "ערר" | 'בל"מ';
};
export type CaseDocument = {

View File

@@ -40,6 +40,15 @@ export const expectedOutcomes = [
{ value: "betterment_levy", label: "היטל השבחה" },
] as const;
/* proceeding_type — distinguishes a regular appeal (ערר) from an
* extension-of-time request (בל"מ). The same case_number can exist as
* both, so this is a separate axis from appeal_subtype/practice_area. */
export const proceedingTypes = [
{ value: "ערר", label: "ערר" },
{ value: 'בל"מ', label: 'בל"מ' },
] as const;
export type ProceedingType = (typeof proceedingTypes)[number]["value"];
export const caseCreateSchema = z.object({
case_number: z
.string()
@@ -75,6 +84,7 @@ export const caseCreateSchema = z.object({
"extension_request_compensation",
"unknown",
] as const satisfies readonly AppealSubtype[]),
proceeding_type: z.enum(["ערר", 'בל"מ'] as const),
});
export type CaseCreateInput = z.infer<typeof caseCreateSchema>;
@@ -100,6 +110,7 @@ export const caseUpdateSchema = z.object({
.optional(),
property_address: z.string().trim().max(200).optional(),
permit_number: z.string().trim().max(100).optional(),
proceeding_type: z.enum(["ערר", 'בל"מ'] as const).optional(),
});
export type CaseUpdateInput = z.infer<typeof caseUpdateSchema>;

View File

@@ -1234,6 +1234,9 @@ class CaseCreateRequest(BaseModel):
# send a domain value explicitly.
practice_area: str = ""
appeal_subtype: str = ""
# proceeding_type: 'ערר' / 'בל"מ'. Empty → auto-derived from
# appeal_subtype / subject downstream.
proceeding_type: str = ""
class CaseUpdateRequest(BaseModel):
@@ -1249,6 +1252,7 @@ class CaseUpdateRequest(BaseModel):
respondents: list[str] | None = None
property_address: str = ""
permit_number: str = ""
proceeding_type: str = ""
@app.post("/api/cases/create")
@@ -1268,6 +1272,7 @@ async def api_case_create(req: CaseCreateRequest):
expected_outcome=req.expected_outcome,
practice_area=req.practice_area,
appeal_subtype=req.appeal_subtype,
proceeding_type=req.proceeding_type,
)
parsed = json.loads(result)
@@ -1399,6 +1404,7 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest, background_t
respondents=req.respondents,
property_address=req.property_address,
permit_number=req.permit_number,
proceeding_type=req.proceeding_type,
)
except ValueError as exc:
raise HTTPException(422, str(exc))