From f3cc9ca9d49ca56a8696ec6b3b9c4a8b204ae716 Mon Sep 17 00:00:00 2001 From: Chaim Date: Tue, 26 May 2026 08:34:40 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Stage=20A=20finalizers=20+=20#35/#36/#3?= =?UTF-8?q?7=20=E2=80=94=20critical-gap=20closure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four parallel sub-agents closed the remaining critical gaps from the 26/05 Stage A/B sprint. Each block independently tested; aggregated here. ## #30/#31 finalizers (sub-agent A) * Auto-derive practice_area in case_create from case_number prefix (1xxx→rishuy_uvniya, 8xxx→betterment_levy, 9xxx→compensation_197); default for CaseCreateRequest is now "" (the DB constraint catches any stray "appeals_committee"). * practice_area.py: derive_subtype now handles axis-B domain values (rishuy_uvniya/betterment_levy/compensation_197) without parsing the case number; new helper derive_domain_practice_area(). * Halacha re-extraction verified unnecessary — all 6 reclassified records already had is_binding=false and approved halachot. * Regression tests: 6 cases in tests/test_corpus_constraints.py covering practice_area enum, internal-committee chair/district, external-upload arar prefix, MCP guard. * UI: district input → Select dropdown (7 districts) in precedent-edit-sheet.tsx, preserving legacy free-text values. ## #37 בל"מ subtypes (sub-agent B) * 3 new appeal_subtypes: extension_request_{building_permit, betterment_levy,compensation}. APPEALS_COMMITTEE_SUBTYPES extended, SUBTYPES_BY_AREA mappings added. * New helpers: is_blam_subject(), is_blam_subtype(), derive_subtype_with_blam(case_number, subject, practice_area). case_create now uses it to auto-detect "בקשה להארכת מועד" subjects. * 3 methodology templates under docs/methodology/extension-request-*.md. * paperclip_client.py mapping updated for the 3 new subtypes (extension_request_building_permit→CMP, the other two→CMPA). * Frontend: bilingual "בל"מ" badge + filter dropdown on cases list + detail header; appeal-type-bars collapseBlam() merges בל"מ into its parent domain for aggregate bars. * Wizard auto-detects בל"מ from subject during case creation. * 3 Berlinger cases (1017/1018/1019-03-26) migrated to appeal_subtype=extension_request_building_permit via psql. ## #35 missing_precedents feature (sub-agent C) * Schema V13: missing_precedents table (citation, case_id, party, legal_topic, status, linked_case_law_id, claim_quote, ...) + FK constraints + 3 indexes. Applied via psql + idempotent migration. * 6 db.py service functions, 3 MCP tools, 6 FastAPI endpoints (POST/GET/PATCH/DELETE/upload — upload routes by citation prefix to ingest_internal_decision or ingest_precedent). * Next.js page /missing-precedents with 5 status tabs + filters + sidebar badge counter + detail drawer with metadata edit + smart upload form that switches fields per committee/court. * Bootstrap: 7 rows imported from the JSON file (3 citations × cases, all status=closed with linked_case_law_id). * legal-researcher.md: new §2ב.5 with missing_precedent_create usage + dedup semantics + tool grant. ## #36 legal_arguments aggregation (sub-agent D) * Schema V14: legal_arguments + legal_argument_propositions M:M. Applied via psql. * New service argument_aggregator.py with two functions — aggregate_claims_to_arguments() (Claude CLI / claude_session) and get_legal_arguments(). Graceful llm_unavailable handling when CLI is missing (containers). * 2 MCP tools + 2 API endpoints (POST .../aggregate-arguments as BackgroundTask, GET .../legal-arguments). * Frontend: shadcn Accordion + new legal-arguments-panel.tsx with hierarchical (party → priority badge → arguments) display, "טיעונים" tab on the case page, "חשב/חשב מחדש" buttons. * scripts/backfill_legal_arguments.py + SCRIPTS.md entry — dry-run found 8 candidate cases including 1017/1018/1019. ## Open follow-ups (intentionally deferred) * npm run api:types in web-ui (CLAUDE.md flow) — recommended before the next UI commit; not required for backend deployment. * Run backfill_legal_arguments.py --apply once the container picks up the new aggregator service. * webhook on missing-precedents upload-close to Paperclip (optional). Co-Authored-By: Claude Sonnet 4.6 --- .claude/agents/legal-researcher.md | 30 + .../extension-request-betterment_levy.md | 227 ++++++++ .../extension-request-building_permit.md | 252 +++++++++ .../extension-request-compensation.md | 215 ++++++++ mcp-server/src/legal_mcp/server.py | 100 ++++ .../legal_mcp/services/argument_aggregator.py | 358 ++++++++++++ mcp-server/src/legal_mcp/services/db.py | 312 ++++++++++- .../src/legal_mcp/services/practice_area.py | 180 +++++- mcp-server/src/legal_mcp/tools/cases.py | 20 +- .../src/legal_mcp/tools/legal_arguments.py | 83 +++ .../src/legal_mcp/tools/missing_precedents.py | 210 +++++++ mcp-server/tests/test_corpus_constraints.py | 276 ++++++++++ scripts/SCRIPTS.md | 1 + scripts/backfill_legal_arguments.py | 164 ++++++ web-ui/src/app/cases/[caseNumber]/page.tsx | 8 + web-ui/src/app/missing-precedents/page.tsx | 161 ++++++ web-ui/src/components/app-shell.tsx | 26 +- .../src/components/cases/appeal-type-bars.tsx | 20 +- web-ui/src/components/cases/case-header.tsx | 10 + web-ui/src/components/cases/cases-table.tsx | 41 +- .../cases/legal-arguments-panel.tsx | 222 ++++++++ .../missing-precedent-detail-drawer.tsx | 512 ++++++++++++++++++ .../missing-precedents-table.tsx | 223 ++++++++ .../components/precedents/practice-area.ts | 15 + .../precedents/precedent-edit-sheet.tsx | 20 +- web-ui/src/components/ui/accordion.tsx | 66 +++ web-ui/src/components/wizard/case-wizard.tsx | 9 +- web-ui/src/lib/api/legal-arguments.ts | 111 ++++ web-ui/src/lib/api/missing-precedents.ts | 277 ++++++++++ web-ui/src/lib/practice-area.ts | 53 +- web-ui/src/lib/schemas/case.ts | 3 + web/app.py | 412 +++++++++++++- web/paperclip_client.py | 8 +- 33 files changed, 4588 insertions(+), 37 deletions(-) create mode 100644 docs/methodology/extension-request-betterment_levy.md create mode 100644 docs/methodology/extension-request-building_permit.md create mode 100644 docs/methodology/extension-request-compensation.md create mode 100644 mcp-server/src/legal_mcp/services/argument_aggregator.py create mode 100644 mcp-server/src/legal_mcp/tools/legal_arguments.py create mode 100644 mcp-server/src/legal_mcp/tools/missing_precedents.py create mode 100644 mcp-server/tests/test_corpus_constraints.py create mode 100755 scripts/backfill_legal_arguments.py create mode 100644 web-ui/src/app/missing-precedents/page.tsx create mode 100644 web-ui/src/components/cases/legal-arguments-panel.tsx create mode 100644 web-ui/src/components/missing-precedents/missing-precedent-detail-drawer.tsx create mode 100644 web-ui/src/components/missing-precedents/missing-precedents-table.tsx create mode 100644 web-ui/src/components/ui/accordion.tsx create mode 100644 web-ui/src/lib/api/legal-arguments.ts create mode 100644 web-ui/src/lib/api/missing-precedents.ts diff --git a/.claude/agents/legal-researcher.md b/.claude/agents/legal-researcher.md index 696e309..b719248 100644 --- a/.claude/agents/legal-researcher.md +++ b/.claude/agents/legal-researcher.md @@ -30,6 +30,9 @@ tools: - mcp__legal-ai__precedent_process_pending - mcp__legal-ai__halacha_review - mcp__legal-ai__halachot_pending + - mcp__legal-ai__missing_precedent_create + - mcp__legal-ai__missing_precedent_list + - mcp__legal-ai__missing_precedent_close - mcp__legal-ai__workflow_status --- @@ -258,6 +261,33 @@ search_internal_decisions( **מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו. +#### 2ב.5 — תיעוד פסיקה חסרה (`missing_precedent_create`) — חובה + +**מתי לקרוא:** לכל ציטוט שהצדדים הביאו (בכתב ערר / תגובה / תגובת ועדה) **שלא נמצא בקורפוס** אחרי חיפוש מובנה (`search_precedent_library` + `search_internal_decisions` + `precedent_search_library`). + +**למה זה חשוב:** +- ה-writer יודע שלא להסתמך על פסיקה שלא ב-DB ("טוענים שמופיע" ≠ "אומת") +- היו"ר רואה בדף ייחודי `/missing-precedents` מה ממתין להעלאה ויכול לסגור פערים בקליק +- ההיסטוריה נשמרת: ראינו את הציטוט, לא מצאנו, חיכינו להעלאה, הועלה, נסגר + +```python +mcp__legal-ai__missing_precedent_create( + citation = "עע\"מ 1461/20 אנטרים אינווסטמנטס נ' הועדה המקומית ירושלים (נבו 4.5.2021)", + case_number = "1017-03-26", # תיק הערר שבו הצד ציטט + cited_by_party = "permit_applicant", # appellant/respondent/committee/permit_applicant/unknown + cited_by_party_name = "לינדאב בע\"מ", + legal_topic = "זכות עמידה", + legal_issue = "זכות ערר על בקשה להיתר מוקנית רק לבעל זכות במקרקעין", + claim_quote = "...הציטוט המדויק מכתב הטענות...", + case_name = "אנטרים", # שם קצר + notes = "אופציונלי" +) +``` + +הכלי deduplicates: ציטוט+תיק זהים → מחזיר את הרשומה הקיימת. אם הציטוט כבר תויג (אפילו ב-status='closed' כי היו"ר העלה אותו בינתיים) — אל תיצור כפילות. + +**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד". + 5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות. ### שלב 3: מיפוי תכנית diff --git a/docs/methodology/extension-request-betterment_levy.md b/docs/methodology/extension-request-betterment_levy.md new file mode 100644 index 0000000..052d443 --- /dev/null +++ b/docs/methodology/extension-request-betterment_levy.md @@ -0,0 +1,227 @@ +# מתודולוגיה — בל"מ בהיטל השבחה (8xxx) + +**appeal_subtype:** `extension_request_betterment_levy` +**מסלול:** סעיף 14 לתוספת ג' לחוק התכנון והבנייה, התשכ"ה-1965 +**מועד סטטוטורי:** **45 ימים** (להבדיל מ-30 ימים ברישוי) מיום קבלת +דרישת תשלום היטל ההשבחה (סעיף 14(א) לתוספת ג') + +--- + +## א. מבוא — ייחודיות בל"מ בהיטל השבחה + +בל"מ במסלול היטל השבחה שונה משמעותית מבל"מ ברישוי בכמה ממדים: + +| ממד | בל"מ ברישוי | בל"מ בהיטל השבחה | +|------|--------------|-------------------| +| מועד סטטוטורי | 30 ימים | **45 ימים** | +| סעיף בחוק | 152 | סעיף 14 לתוספת ג' | +| בעלי דין | רחב — כל בעל זכות גובלת/קרובה | **צר — רק החייב בהיטל** | +| מהות הסעד | ביטול היתר / שינוי תנאים | תיקון שומה / ביטול חיוב | +| טון | פעמים אנושי (תושב, סביבה) | קר ומקצועי (פיננסי/שמאי) | +| הסתמכות נדרשת | של היזם | של הרשות (חלוקת הכנסות) | + +הייחוד הקרדינלי: **בל"מ בהיטל השבחה דורש הוכחת טעות שמאית או בדין** — +לא רק "טעם סביר" כמו ברישוי. הסיבה: שומת היטל ההשבחה היא מעשה מנהלי +שקיבל תוקף, וכספים שולמו / נדרשו, ולעיתים גם חולקו. שינוי שומה דורש +עילה מהותית. + +--- + +## ב. מסגרת נורמטיבית + +### שכבה א — חקיקה ראשית + +**סעיף 14(א) לתוספת ג' לחוק התכנון והבנייה:** +> "בעל המקרקעין החייב בהיטל השבחה ... רשאי להגיש ערר על השומה לוועדת הערר +> לפיצויים ולהיטל השבחה ... בתוך 45 ימים מיום שהומצאה לו השומה" + +המחוקק קבע מועד ארוך יותר (45 לעומת 30) מתוך הכרה במורכבות הסוגיה השמאית — +הצורך לקבל חוו"ד שמאית, להתייעץ עם עו"ד מומחה למיסוי מקרקעין, ולבחון את +חישובי השומה. + +### שכבה ב — עליון + +**רע"א 7669/96 עיריית נהריה נ' קמינסקי (פ"ד נב(1) 214):** +ביסוס עקרוני של "סופיות שומה" — שינוי שומה לאחר חלוף המועד הסטטוטורי +אינו עומד על ערעור "טעם סביר" בלבד; נדרש אינטרס ציבורי מובהק או טעות +שמאית מהותית. + +**עע"מ 1832/14 הרשות לפיתוח ירושלים נ' מנהל מס שבח:** +היטל השבחה — תשלום הכפוף לסופיות שומה; קביעות שמאי בדבר ערך המקרקעין לפני +ואחרי האירוע התכנוני הן עובדתיות-מקצועיות. שינוי דורש הצדקה חזקה. + +### שכבה ג — ועדות ערר לפיצויים ולהיטל השבחה + +(להוסיף תקדימים ספציפיים מקורפוס דפנה תמיר בהיטל השבחה. הקורפוס הקיים +כולל את עררי 8xxx — לחפש דפוס "בל\"מ" או "הארכת מועד" בתוכם.) + +--- + +## ג. תבחיני בל"מ בהיטל השבחה — חמישה תבחינים + +| # | תבחין | אופי | משקל | +|---|--------|------|------| +| א | **טעות שמאית או בדין** | **תנאי סף עצמאי — ייחודי להיטל השבחה** | קריטי | +| ב | טעם סביר לאיחור | מקדים — בדומה לרישוי, אך מחמיר | גבוה | +| ג | אורך השיהוי | כמותי | גבוה | +| ד | הסתמכות הרשות (חלוקת כספים) | כמותי | גבוה | +| ה | סיכויי הערר המהותי (לכאורה) | מהותי | בינוני | + +תבחין "אינטרס ציבורי" לא מופיע כתבחין עצמאי כאן — בהיטל השבחה האינטרס +הציבורי נטוע בתוך הסתמכות הרשות (תבחין ד). + +--- + +## ד. תבחין א — טעות שמאית או טעות בדין + +### מה זו "טעות שמאית"? +לא כל מחלוקת על שווי = טעות. נדרש להוכיח אחד מאלה: + +1. **טעות חישובית גלויה** — סכום שגוי, פעולה אריתמטית שגויה. +2. **שיטה שמאית פסולה** — שימוש בגישה לא מקובלת (לדוגמה: היוון לפי שיעור + שאינו ריאלי, השוואה לעסקאות שאינן מקבילות). +3. **התעלמות מנכסים דומים** — עיוורון לנתונים שהיו צריכים להילקח בחשבון. +4. **שגיאה במספרי שטח / זכויות / תכנית** — אי-תאמה לנסח / לתב"ע. + +### מה זו "טעות בדין"? +שגיאה משפטית בעצם החיוב: +- **חיוב על נכס שאינו "מקרקעין" לעניין החוק** (זכויות חוזיות גרידא). +- **חיוב בגין השבחה שאינה נכנסת להגדרת "השבחה" בחוק** (לדוגמה: השבחה + שנוצרה לפני התקופה הקובעת; השבחה מכוח תכנית שאינה תכנית מתאר). +- **חיוב לפני התגבשות העילה** — דרישה לפני מימוש בהיתר או מכר. + +### הוכחה דרושה +- **חוות דעת שמאית חתומה** מאת שמאי מקרקעין מוסמך, עם נתוני השוואה. +- **תיעוד הליך השומה המקורי** — אילו נתונים נלקחו? אילו לא? +- **חישוב חלופי מנומק** — לא רק "אני חולק", אלא "הנה החישוב הנכון". + +--- + +## ה. תבחין ב — טעם סביר לאיחור + +### העקרון +בדומה לבל"מ ברישוי, אך **קפדן יותר**: +- מועד 45 ימים נחשב "מועד ארוך" — קשה יותר להצדיק החמצתו. +- החייב לרוב מקבל את השומה לידיו אישית — אין סוגיית "פרסום באתר". +- ערב פניה לעו"ד / שמאי הוא צעד צפוי וסטנדרטי. + +### מצבי "טעם סביר" אופייניים +| מצב | קבילות | +|------|---------| +| מחלת המבקש (מתועדת רפואית) | קבילה | +| המצאה פגומה (לא לכתובת הנכונה) | קבילה — אך נטל הוכחה כבד | +| תקופה ארוכה של בירורים מקצועיים | חלשה — לוחות זמנים אינם מוקפאים | +| המתנה לעמדת שמאי לפני הגשת ערר | חלשה — אפשר להגיש ולתקן | +| התכתבות עם הרשות בניסיון פשרה | חלשה — לא מקפיאה מועד | + +### דרישת התצהיר +**חובה** תצהיר מפורט — תאריכים, אנשי קשר, מסמכי תמיכה. ללא תצהיר — +הטענה ריקה משפטית. + +--- + +## ו. תבחין ג — אורך השיהוי + +### חישוב +| תאריך | אירוע | שיהוי מצטבר | +|--------|--------|--------------| +| יום 0 | המצאת השומה | 0 | +| יום 45 | תום המועד הסטטוטורי | תום המועד | +| יום X | הגשת הבל"מ | X-45 ימים מעבר למועד | + +### עקרון מנחה +- שיהוי של עד 30 ימים מעבר למועד (סה"כ 75 ימים מיום ההמצאה) — מקבל + התייחסות עניינית אם יש טעם סביר. +- שיהוי של מעל 90 ימים מעבר למועד — נחשב חמור; דורש הוכחה חזקה במיוחד. +- שיהוי של מעל שנה — לרוב חוסם אלא אם מדובר בטעות חישובית גלויה. + +### השפעת השיהוי על הסתמכות הרשות +ככל שהזמן עובר — הסיכוי שהרשות חילקה את הכספים גבוה יותר. דרישה להחזר +שנים לאחר התשלום פוגעת בהסתמכות הרשות בצורה מובהקת. + +--- + +## ז. תבחין ד — הסתמכות הרשות (חלוקת הכנסות) + +### ייחודיות לעומת בל"מ ברישוי +ברישוי — ההסתמכות היא של היזם הפרטי. בהיטל השבחה — ההסתמכות היא של +**הרשות הציבורית**: הכספים מועברים לקרן השבחה, מתוכננים לפרויקטים +ציבוריים, ולעיתים אף חולקו או הוצאו. + +### טבלת בדיקה +| שלב | מצב הכספים | השפעה על הבל"מ | +|------|------------|-----------------| +| לפני תשלום | החייב לא שילם | קלה — אין הסתמכות הרשות | +| לאחר תשלום, לפני חלוקה | בקופת הוועדה / קרן | בינונית | +| לאחר חלוקה לרשויות | חולק לעירייה, יזם, וכו' | משמעותית | +| לאחר ביצוע פרויקטים | כספים הוצאו | מוחשית, קשה להפיך | + +### עיקרון +**ככל שהכספים "התרחקו" מהקופה — דרישות הוכחת הטעות מחמירות.** + +--- + +## ח. תבחין ה — סיכויי הערר המהותי (לכאורה) + +### הבהרה מתודית +בשלב בל"מ — בוחנים סיכויי הערר רק כדי לקבוע האם יש סיבה לפתוח את הדלת. +הקריטריון: **האם יש "טענה לכאורה" המבוססת על תיעוד מקצועי?** + +### סוגי טענות אופייניים +- חישוב שגוי של "המצב הקודם" / "המצב החדש" +- שיטת שיערוך פסולה (השוואה / הפרשי הון / היוון) +- התעלמות מ"זכויות מותנות" שטרם התגבשו +- חיוב כפול (הון / הכנסה / שבח) +- אי-התאמה למיקום, שימוש, או שטח + +### מה לא נספר כ"סיכויי הליך" +- "אני לא מסכים לסכום" — בלי חוו"ד נגדית מבוססת. +- טענות כלליות על "המצב הכלכלי" של המבקש. +- טענות על "תקדים" שלא הוכרע בערכאה גבוהה יותר. + +--- + +## ט. טבלת התאמה לעובדות (placeholder לכל תיק) + +| תבחין | עובדה במקרה הנוכחי | כיוון | +|--------|---------------------|-------| +| א. טעות שמאית/בדין | [סוג הטעות הנטענת + תיעוד] | [חוסם / מאפשר] | +| ב. טעם סביר | [מועד המצאה, פעולות, תצהיר] | [תומך / מחליש] | +| ג. אורך השיהוי | [X ימים מעבר ל-45] | [קל / בינוני / חמור] | +| ד. הסתמכות הרשות | [מצב הכספים: בקופה / חולק / הוצא] | [קל / משמעותי / מוחשי] | +| ה. סיכויי הליך | [חוו"ד שמאית? חישוב חלופי?] | [לכאורה / ספקולטיבי] | + +--- + +## י. סעיף מסקנה — מבנה אופייני + +המבנה האופייני בבל"מ-היטל-השבחה הוא **קר ומקצועי** — מינימום רגש, +מקסימום שמאות: + +1. **קביעת מצב השומה.** "השומה הומצאה ביום X. הבל"מ הוגשה ביום Y." +2. **תבחין א (טעות שמאית).** "המבקש טוען לטעות בX. בחינת המסמכים מעלה..." +3. **אם טעות לא הוכחה — דחייה.** "בהיעדר טעות שמאית או בדין, אין יסוד + לסטות ממועד הקבוע בחוק." +4. **אם טעות הוכחה — מעבר לתבחינים ב-ה.** +5. **מאזן.** "לאור איזון התבחינים..." +6. **הכרעה.** דחייה / קבלה / החזרה לשמאי הוועדה לבחינה. + +### לשון אופיינית לדחייה +> "הבל"מ הוגשה X ימים לאחר תום המועד הסטטוטורי. המבקש לא הצביע על טעות +> שמאית או בדין; הטענות הן בגדר מחלוקת על שיקול דעת מקצועי, שאינה מצדיקה +> פתיחת שומה שקיבלה תוקף. לאור אלה, ובהינתן שהכספים שולמו וחולקו, הבל"מ +> נדחית." + +### לשון אופיינית לקבלה (חריגה) +> "המבקש הצביע על טעות חישובית במספר זכויות התכנון שנלקחו בחשבון. הטעות +> מהותית ומשפיעה על השומה. בנסיבות אלה, ועל אף השיהוי, יש מקום לפתוח את +> השומה לדיון בערר עצמו." + +--- + +## יא. הפניות חוצות + +- ראה גם: `docs/methodology/extension-request-building_permit.md` (סעיף 152, 30 ימים) +- ראה גם: `docs/methodology/extension-request-compensation.md` (סעיף 198(ד), 30 ימים) +- ראה גם: `docs/block-schema.md` — מבנה 12 הבלוקים +- ראה גם: `skills/decision/SKILL.md` — מדריך סגנון של דפנה diff --git a/docs/methodology/extension-request-building_permit.md b/docs/methodology/extension-request-building_permit.md new file mode 100644 index 0000000..857f63b --- /dev/null +++ b/docs/methodology/extension-request-building_permit.md @@ -0,0 +1,252 @@ +# מתודולוגיה — בל"מ ברישוי ובנייה (1xxx) + +**appeal_subtype:** `extension_request_building_permit` +**מסלול:** סעיף 152(א) לחוק התכנון והבנייה, התשכ"ה-1965 +**מועד סטטוטורי:** 30 ימים מיום המצאת ההחלטה (סעיף 152(ב)) + +--- + +## א. מבוא — מהותו של בל"מ ברישוי + +בל"מ ("בקשה להארכת מועד") הוא הליך מקדמי שהמבקש להגיש ערר על החלטת ועדה מקומית +לאחר חלוף 30 הימים נדרש לעבור בו לפני שיוכל לפתוח בערר עצמו. הוועדה נדרשת +לאזן בין שני אינטרסים נוגדים: + +- **זכות הגישה לערכאות** — שכל בעל זכות עמידה יוכל להעמיד את החלטת הוועדה + המקומית במבחן שיפוטי, במיוחד כאשר ההחלטה נטענת כפסולה. +- **סופיות החלטות מנהליות + הסתמכות** — היזם זכאי לפעול לפי ההיתר שניתן, להשקיע + כספים, להתחיל בעבודות, ולא לחיות בחשש מתמיד שמא ההיתר ייתקף שנים לאחר אישורו. + +לעומת בל"מ בהיטל השבחה (סעיף 14 לתוספת ג', 45 ימים) ובל"מ בפיצויים (סעיף 198(ד), +30 ימים אך עם סף קפדני יותר), בל"מ ברישוי משלב טון אנושי יחסית — ההסתמכות מוחשית +(חפירה, פינוי שוכרים) והאינטרסים הציבוריים (מיגון, חיזוק) ממשיים. + +--- + +## ב. מסגרת נורמטיבית — שלוש שכבות + +### שכבה א — עליון: בר"מ 2340/02 הוועדה המקומית רמת השרון נ' אגא וכט, פ"ד נז(3) 385 (2003) + +הכיר בסמכותה של ועדת הערר להאריך את המועד, בנסיבות חריגות, וקבע את הבחינה +הדו-שלבית: +1. **תנאי סף:** טעם סביר לאיחור. +2. **שיקול כולל:** השוואה בין נזקי המבקש לבין הסתמכות הצד שכנגד; היקף השיהוי; + סיכויי ההליך; אינטרס ציבורי. + +### שכבה ב — עליון: עע"מ 317/10 שפר נ' סקאל יניב (נבו 23.8.2012) + +הלכה מחייבת: מניין 30 הימים מתחיל **מיום הידיעה בפועל**, לא מיום הפרסום הפורמלי. +המשמעות: גם איחור-לכאורה של חודשים יכול להיות לגיטימי אם המבקש לא ידע על ההחלטה +בזמן אמת. + +> "מתנגד להיתר שניתן, אשר שטח התנגדותו בפני הועדה המקומית וזו נדחתה, או שידע +> על מתן ההיתר, צריך יהיה להגיש את הערר תוך 30 יום מיום שנודע לו על מתן ההיתר." + +### שכבה ג — ועדת ערר ירושלים (דפנה תמיר) + +**ערר 1009/25 מפלגת נעם נ' הוועדה המרחבית הראל (נבו 27.3.2025):** +> "דיון בערר המבקש לבטל היתר שכבר יצא מחייב עמידה בלוח הזמנים שהדין מחייב, +> כל חריגה מכך מחייבת בקשה להארכת מועד ועמידה בכל התנאים לכך (זכות עמידה, +> שיהוי, הסתמכות, פגיעה וכיו'). ודוק, מחייבת בקשה להארכת מועד סדורה ומנומקת +> ולא בדרך אגב ולא בחסות תקנות הרישוי." + +**ערר 1112/22 ירושלים שקופה נ' ועדה מקומית ירושלים (נבו 11.5.2023):** +> "מרחק של פחות מ-100 מ' אינו מקנה זכות התנגדות לתכנית; קל וחומר שמרחק של +> למעלה מ-400 מ' אינו מקנה זכות התנגדות לבקשה להיתר, שכן זכות ההתנגדות לבקשה +> להיתר (סעיף 149) צרה מזכות ההתנגדות לתכנית (סעיף 100)" + +**בל"מ 1028/20 חלוואני (ועדת ערר ירושלים):** +> "המועד להגשת ערר הינו 30 ימים מיום שהומצאה החלטת הועדה המקומית וכי המבקשת +> הייתה ערה להליכי הבקשה להיתר" + +--- + +## ג. שישה תבחינים — סדר הבחינה + +על פי הפסיקה המצטברת, להכרעה בבל"מ-רישוי יש לבחון שישה תבחינים. הסדר חשוב: +תבחין ו (זכות עמידה) הוא תנאי סף עצמאי — אם אין זכות עמידה אין צורך לבחון +יתר התבחינים. + +| # | תבחין | אופי | מקור | +|---|--------|------|------| +| ו | **זכות עמידה** | **תנאי סף עצמאי** | עע"מ 1461/20 אנטרים; ערר 1112/22 | +| א | טעם סביר לאיחור | מקדים — נחוץ לפתיחת הדלת | עע"מ 317/10 שפר; בל"מ 1028/20 | +| ב | אורך השיהוי | כמותי — חומרת ההפרה | ערר 1096/24 אנשין | +| ג | הסתמכות + שינוי מצב לרעה | כמותי — נזק | בר"מ 2340/02 | +| ד | סיכויי ההליך | מהותי — "לכאורה" | בר"מ 2340/02 | +| ה | אינטרס ציבורי / חזקת תקינות | ערכי | הלכת חזקת תקינות | + +--- + +## ד. תבחין ו — זכות עמידה (תנאי סף) + +### מקור הזכות +זכות הערר לפי סעיף 152 מוקנית רק למי שהוא **בעל זכות במקרקעין נשוא הבקשה +להיתר**, לא לכל בעל עניין (עע"מ 1461/20 אנטרים). + +### תבחין מרחק +על פי ערר 1112/22, מרחק של מעל 100 מ' (קל וחומר מעל 400 מ') אינו מקנה זכות +התנגדות לבקשת היתר, גם בהיעדר נצפות. + +### טבלת בדיקה +| פרמטר | להוכיח | +|--------|---------| +| בעל זכות בנכס נשוא הבקשה? | חוזה רכישה / נסח / שכירות מאומתת | +| בעל זכות בנכס גובל? | מפת מדידה / נסח | +| מרחק קו אווירי | מודד / Google Maps עם תיעוד | +| קיומה של נצפות | תצלום פנורמי / חוו"ד מודד | +| מעמד נציג דיירים / פינוי-בינוי | חוזה פנימי — לא יוצר זכות סטטוטורית | + +**אזהרה:** טיעון של "מתנגד מטעם הציבור" או "אינטרס ציבורי כללי" — אינו מקנה +זכות עמידה. הזכות נצרכת להיות מעוגנת בזכות במקרקעין. + +--- + +## ה. תבחין א — טעם סביר לאיחור + +### העיקרון +המבקש נדרש להוכיח שלא ידע על ההחלטה בזמן אמת **ושאי-הידיעה היא סבירה** — לא רק +שלא ידע, אלא שלא היה ניתן לצפות שיֵדע. הכלל הוא **דרך הסטטוס-קוו**: מי שהתעניין +בנכס שכן, שהיה מודע לשלטי בנייה, או שהיה לו עניין סדור בנכס — מוחזק כיודע. + +### דרישות הוכחה +1. **תצהיר עובדתי** של המבקש — תאריכים מפורטים, מי אמר לו, מתי בדיוק. +2. **הוכחת ברירת המחדל של הוועדה** — היכן הפרסום היה צריך להתבצע? האם בוצע? +3. **שלושת התנאים המצטברים** (לפי הלכת שפר, כפי שיושמו בפסיקה לאחר מכן): + - זכות טיעון בהליך הרישוי וזכאות לקבל פרסום. + - פגם בהליך הפרסום בפועל. + - הפגם פגע בזכות הטיעון. + +### מלכודות נפוצות +- **התכתבות עם "הדרג המקצועי" אינה מקפיאה לוחות זמנים** (בל"מ 1028/22 חמד). +- **היעדר תצהיר → גרסת אי-הידיעה חלשה ראייתית.** +- **ידיעה קודמת על ההליכים** (התנגדות שהוגשה, נוכחות בדיון, פניות בעבר) שוללת + כל תירוץ של אי-ידיעה. + +--- + +## ו. תבחין ב — אורך השיהוי + +### שני רכיבים +1. **שיהוי מצטבר** — הזמן שחלף מהחלטת הוועדה המקומית עד הגשת הבל"מ. +2. **שיהוי סובייקטיבי** — הזמן שחלף מיום הידיעה הנטענת עד הגשת הבל"מ. + +### ציר זמן לדוגמה +| תאריך | אירוע | שיהוי מצטבר | +|--------|--------|--------------| +| יום 0 | פרסום הבקשה | 0 | +| יום 30 | החלטת ועדת משנה | — | +| יום 120 | אישרור במליאה | — | +| יום X | ידיעה נטענת | חודשים-שנה | +| יום X+30 | הגשת הבל"מ | +30 ימים סובייקטיבי | + +### עקרון מנחה +ערר 1096/24 אנשין (דפנה תמיר, 30.12.2024): +> "בהינתן שהערר מוגש במקום בו לא הייתה לעורר זכות קנויה וברורה להגשתו, היה +> עליו שלא להתעכב ובוודאי שלא לחכות ליום האחרון להגשת הערר" + +**הכלל:** ככל שזכות העמידה רופפת יותר — דרישות הזריזות מחמירות. + +--- + +## ז. תבחין ג — הסתמכות הצד שכנגד + +### עיקרון בר"מ 2340/02 אגא וכט +> "האם שינה הצד האחר את מצבו לרעה, האם ניתן להשיב את המצב לקדמותו" + +### טבלת השקעות לבדיקה +| השקעה | תיעוד נדרש | +|--------|-----------| +| שכר טרחת מתכננים / עו"ד / יועצים | חשבוניות / קבלות / חוזה | +| תכנון מפורט (חניון, ממ"דים) | תכניות חתומות | +| היתר חפירה / חפירה בפועל | היתר + תצלומים | +| הסכמי מימון | חוזה עם בנק / משקיע | +| פינוי שוכרים / חתימות דיירים | חוזי פינוי / הסכמות | +| התקדמות פיזית (יסודות, שלד) | תצלומים מתועדים | + +### "האם ניתן להשיב למצב הקדמות?" +ככל ששלב הביצוע מתקדם יותר — היכולת להפוך פוחתת. לאחר היתר חפירה, פינוי שוכרים, +ושלב הכנת יסודות — המצב לרוב בלתי-הפיך פיזית, ולפחות בלתי-הפיך כלכלית. + +--- + +## ח. תבחין ד — סיכויי ההליך (לכאורה) + +### הבהרה מתודית +בשלב בל"מ, **בוחנים סיכויי הערר המהותי רק כדי לקבוע האם יש סיבה מספקת לפתוח +את הדלת** — לא לפסוק לגוף הערר. אם המחלוקת המהותית היא קשה ומורכבת אבל ברורה +שיש בה ממש — תבחין ד תומך בקבלת הבל"מ. אם המחלוקת תיאורטית, ספקולטיבית, או +ברורה לזכות המשיבים — תבחין ד תומך בדחייה. + +### סוגים אופייניים של סוגיות מהותיות בבל"מ-רישוי +- תחולת תמ"א 38 (תקנים, מבנה קטן, איזורי סיכון רעש) +- תוקף תכנית (פקיעה, הוראות מעבר) +- חישוב סל זכויות (תיקון 3א, "קומה טיפוסית קיימת") +- מעמד תכנית חדשה (102-XXXXXX) — מופקדת? מאושרת? נסיוני? +- תנאי היתר (עמידה בתקנות, קווי בניין, חניות) + +### דרך הבחינה +לכל סוגיה: (1) האם ההסתמכות על תכנית / תקן בוצעה; (2) האם יש פסיקה מנחה; +(3) האם יש מחלוקת מקצועית-עובדתית שתצריך חוות דעת. + +--- + +## ט. תבחין ה — אינטרס ציבורי / חזקת תקינות + +### חזקת תקינות המעשה המנהלי +עיקרון יסוד בדין המנהלי: כל פעולת הוועדה נחזית כתקינה, עד שהמוכיח אחרת. נטל +ההוכחה על המבקש. + +### שיקולים אופייניים בבל"מ-רישוי +| שיקול | כיוון אופייני | +|--------|---------------| +| חיזוק מבני מפני רעידות אדמה | תומך ביזם | +| ממ"דים / מיגון מפני ירי | תומך ביזם | +| הרחבת זכויות דרך / זכויות מעבר | תועלת ציבורית | +| חניות תת-קרקעיות (פינוי חניה מרחוב) | תועלת ציבורית | +| תקינות הליך (פרסום, התנגדויות, דיון) | חזקת תקינות | +| מתנגד סדרתי / בעל אינטרס נסתר | מחליש טענות המבקש | + +--- + +## י. טבלת התאמה לעובדות (placeholder לכל תיק) + +| תבחין | עובדה במקרה הנוכחי | כיוון | +|--------|---------------------|-------| +| ו. זכות עמידה | [לתאר מרחק, נצפות, זכויות בקרקע] | [חוסם / מאפשר / שאלה] | +| א. טעם סביר | [פרסום, ידיעה, תצהיר] | [נוטה לקבלה / לדחייה] | +| ב. אורך השיהוי | [שנים / חודשים / ימים] | [קל / בינוני / חמור] | +| ג. הסתמכות | [השקעות מצוטטות בש"ח] | [קלה / משמעותית / מוחשית] | +| ד. סיכויי הליך | [שאלות פתוחות vs. ברורות] | [לכאורה / ספקולטיבי] | +| ה. אינטרס ציבורי | [שיקולים ציבוריים בולטים] | [תומך / ניטרלי / נגד] | + +--- + +## יא. סעיף מסקנה — מבנה אופייני + +המבנה האופייני של סעיף ההכרעה בבל"מ-רישוי הוא: + +1. **פתיחה — איזון התבחינים בקצרה.** "בחנו את ששת התבחינים... ומצאנו..." +2. **תבחין ו (סף).** אם זכות העמידה רופפת/חסרה — זהו לרוב המכריע. +3. **תבחינים א-ה.** ניתוח כל אחד בקצרה, עם הפניה לפסיקה. +4. **מסקנה כוללת.** "לאור כל האמור — הבקשה להארכת מועד נדחית / מתקבלת". +5. **הוצאות.** אם רלוונטי — לפי סעיף 1. + +### לשון אופיינית לדחייה (דפנה תמיר) +> "מששה התבחינים שנבחנו — חמישה מצביעים על מסקנה אחת, וגם התבחין השישי אינו +> תומך בקבלת הבקשה. נסיבות התיק אינן מצדיקות חריגה מהמועד הסטטוטורי." + +### לשון אופיינית לקבלה +> "על אף השיהוי, נסיבות אי-הידיעה מתועדות; ההסתמכות בעיקרה תכנונית ולא ביצועית; +> ומחלוקת מהותית ממשית עומדת על הפרק. בנסיבות אלה, יש לפתוח את הדלת לערר על +> מנת שהסוגיות יתבררו." + +--- + +## יב. הפניות חוצות + +- ראה גם: `docs/methodology/extension-request-betterment_levy.md` (סעיף 14, 45 ימים) +- ראה גם: `docs/methodology/extension-request-compensation.md` (סעיף 198(ד), 30 ימים) +- ראה גם: `docs/block-schema.md` — מבנה 12 הבלוקים +- ראה גם: `skills/decision/SKILL.md` — מדריך סגנון של דפנה +- דוגמאות מעובדות: `data/cases/1017-03-26/`, `data/cases/1018-03-26/`, `data/cases/1019-03-26/` diff --git a/docs/methodology/extension-request-compensation.md b/docs/methodology/extension-request-compensation.md new file mode 100644 index 0000000..e389ba9 --- /dev/null +++ b/docs/methodology/extension-request-compensation.md @@ -0,0 +1,215 @@ +# מתודולוגיה — בל"מ בפיצויים (ס' 197) (9xxx) + +**appeal_subtype:** `extension_request_compensation` +**מסלול:** סעיף 198(ד) לחוק התכנון והבנייה, התשכ"ה-1965 +**מועד סטטוטורי:** 30 ימים מיום החלטת הוועדה המקומית בתביעת הפיצויים + +--- + +## א. מבוא — הייחוד של בל"מ בפיצויים + +בל"מ בפיצויים שונה מהותית הן מבל"מ ברישוי והן מבל"מ בהיטל השבחה: + +| ממד | בל"מ ברישוי | בל"מ היטל השבחה | בל"מ פיצויים | +|------|--------------|------------------|----------------| +| מועד | 30 ימים | 45 ימים | **30 ימים** | +| סעיף | 152 | 14 לתוספת ג' | **198(ד)** | +| מהות הסעד | ביטול היתר | תיקון שומה | **פיצויי פגיעה בזכויות קניין** | +| נטל הוכחה | מקדים | טעות שמאית | **סף קפדני — פגיעה ממונית מוחשית** | +| טון אופייני | מעורב | קר/שמאי | **קר, משפטי, חמור** | +| הסתמכות | יזם / רשות | רשות (חלוקה) | **רשות + ציבור (תקציבי פיצויים)** | + +### למה הסף הקפדן ביותר? +פיצויים לפי סעיף 197 הם **כספים ציבוריים** שמיועדים לפיצוי על פגיעה +ממונית מוחשית בקרקעות. הם נושאים שלוש מאפיינים שדורשים אכיפת מועדים +מחמירה: + +1. **תקציבים סגורים** — הוועדה המקומית עוזבת תקציב לפיצויי 197; שיהוי + מחבל בתכנון פיננסי ובחלוקת התקציב. +2. **השפעה על תכנון עתידי** — דחייה ארוכת-טווח בבירור הזכות לפיצוי משבשת + את היכולת לתכנן הליכי הפקעה/תכנון נוספים. +3. **זכויות קניין** — שני הצדדים (תובע ורשות) נושאים אינטרסים קנייניים + ברורים. אכיפת מועדים = הגנה על שני הצדדים. + +--- + +## ב. מסגרת נורמטיבית + +### שכבה א — חקיקה ראשית + +**סעיף 197(א) לחוק התכנון והבנייה:** +> "נפגעו על ידי תכנית, שלא בדרך הפקעה, מקרקעין הנמצאים בתחום התכנית או +> גובלים עמה, מי שביום תחילתה של התכנית היה בעל המקרקעין או בעל זכות בהם +> זכאי לפיצויים מהוועדה המקומית..." + +**סעיף 198(ד) — מועד הערר:** +ערר על החלטת הוועדה המקומית בתביעת פיצויים מוגש לוועדת הערר תוך 30 ימים +מיום שהומצאה ההחלטה לתובע. + +### שכבה ב — עליון + +**ע"א 210/88 החברה להפצת פרי הארץ נ' הוועדה המקומית כוכב יאיר (פ"ד מו(4) 627):** +ביסוס דרישת ההוכחה לפגיעה ממונית מוחשית — לא די בטענה כללית של "ירידת ערך". +נדרשת: (א) הוכחת מצב לפני התכנית; (ב) הוכחת מצב אחרי; (ג) הצבעה על קשר סיבתי +ישיר; (ד) חוות דעת שמאית כמותית. + +**עע"מ 1968/00 חברת גוש 6195 נ' הוועדה המקומית הרצליה:** +חיזוק עקרון הסופיות בפיצויי 197 — שינוי מועדים בהליך פיצויים פוגע באינטרס +הציבורי הספציפי של פריסת תקציבים. + +### שכבה ג — ועדות ערר + +(להוסיף תקדימי דפנה תמיר בעררי 9xxx — לחפש בקורפוס "בל\"מ פיצויים" או +"הארכת מועד 197".) + +--- + +## ג. ארבעה תבחיני בל"מ בפיצויים + +| # | תבחין | אופי | סף | +|---|--------|------|-----| +| א | **פגיעה ממונית מוחשית** | תנאי סף עצמאי | קריטי | +| ב | טעם סביר לאיחור | מקדים — קפדן | גבוה | +| ג | אורך השיהוי | כמותי — קצר במיוחד | גבוה | +| ד | הסתמכות הרשות (תקציב) | כמותי | גבוה | + +לעומת בל"מ ברישוי ובהיטל השבחה — אין כאן תבחין נפרד של "סיכויי הליך"; +תבחין הפגיעה (א) משלב את שני הממדים (סיכויי הליך + עצם הזכות לפיצוי). + +--- + +## ד. תבחין א — פגיעה ממונית מוחשית (סף הקפדני) + +### הדרישה +לא די בטענה לפגיעה. נדרש להוכיח, לפחות לכאורה: + +1. **בעלות / זכות במקרקעין נשוא התביעה** — נסח טאבו, חוזה מאומת, או רישום אחר. +2. **תכנית מאושרת שנכנסה לתוקף** — לא טיוטה, לא תב"ע מופקדת — תכנית בתוקף. +3. **קשר סיבתי בין התכנית לפגיעה הנטענת** — לא "ירידת ערך כללית" של אזור. +4. **חוו"ד שמאית כמותית** — מציגה את ערך הקרקע לפני ואחרי, עם נתוני השוואה. + +### הוצאות מן הכלל +לא נחשבים "פגיעה ממונית" לעניין סעיף 197: +- **פגיעה תיאורטית עתידית** — תכנית שטרם נכנסה לתוקף, אופציות שלא מומשו. +- **פגיעה אסתטית/סובייקטיבית** — נוף, שכנים, אווירה. +- **פגיעה זמנית בלבד** — שיבושים בשלב בנייה שאינם משפיעים על ערך ארוך-טווח. +- **פגיעה במקרקעין מחוץ לתכנית ולא גובלים** — דרישה שטחית של "תחום התכנית + או גובלים עמה" — מצומצמת. + +### דרישת ההוכחה לכאורה בשלב הבל"מ +בשלב בל"מ אין צורך להוכיח את הפגיעה במלואה; די ב**הצגת לכאורה משכנעת** +המבוססת על מסמכים מקצועיים. הצגה זו מאפשרת לבחון: האם יש בכלל מה לדון +לאחר חלוף המועד? + +--- + +## ה. תבחין ב — טעם סביר לאיחור + +### העקרון +בפיצויים — דרישת הזריזות מחמירה מאוד. סיבות: + +1. **התובע פעל מולן** — בניגוד לבל"מ ברישוי, התובע ידע על התכנית ופעל + בה (הגיש תביעה לוועדה המקומית). אי-ידיעה על ההחלטה היא חריג. +2. **המצאה אישית** — ההחלטה מומצאת אישית; פחות מקום לטענות "פרסום באתר". +3. **התובע מיוצג** — לרוב התובע פיצויים מיוצג עו"ד; "אי-ידיעה" של עו"ד + על מועד היא חולשה ראייתית מובהקת. + +### מצבי "טעם סביר" אופייניים +| מצב | קבילות | +|------|---------| +| המצאה פגומה (לא לכתובת עורך הדין) | קבילה — בכפוף לתיעוד | +| מחלת התובע (מתועדת) | קבילה | +| תקופה ארוכה של "ניסיון להידברות" עם הוועדה | חלשה — לוחות זמנים לא מוקפאים | +| המתנה להחלטה שיפוטית במקרה דומה | חלשה — אפשר להגיש "במקרה ש..." | +| תקלה במשרד עורך הדין | חלשה — אחריות נשואת ייצוג | + +### דרישות הוכחה +- תצהיר מפורט של התובע **וגם** של עורך דינו. +- מסמכי תמיכה (כרטיסי רישום בית חולים, אישורים רפואיים, וכו'). +- תיעוד התכתבות פנימית במשרד עורך הדין (אם רלוונטי). + +--- + +## ו. תבחין ג — אורך השיהוי + +### עקרונות +- **30 ימים בלבד** = מועד קצר במיוחד. +- כל יום מעבר מקבל ניקוד שלילי. +- שיהוי של מעל 14 ימים מעבר למועד (סה"כ 44 ימים) — נחשב מובהק. +- שיהוי של מעל 60 ימים מעבר (סה"כ 90 ימים) — דורש הצדקה חזקה במיוחד. +- שיהוי של מעל 180 ימים — חוסם אלא בנסיבות חריגות (טעות בדין, גילוי מאוחר + של עובדה מהותית). + +### חישוב +| תאריך | אירוע | שיהוי מצטבר | +|--------|--------|--------------| +| יום 0 | המצאת החלטה | 0 | +| יום 30 | תום מועד סטטוטורי | 0 | +| יום X | הגשת הבל"מ | X-30 | + +--- + +## ז. תבחין ד — הסתמכות הרשות (תקציב פיצויים) + +### ייחוד בפיצויים +הוועדה המקומית מקצה תקציב לפיצויי 197 לפי החלטותיה. שיהוי בערר: + +1. **פוגע בפריסה תקציבית** — תקציב עזב מהקצאתו, עבר ליעדים אחרים. +2. **מסבך הליכים שלא הוכרעו עדיין** — בעלי מקרקעין אחרים פעלו על סמך + התקציב הקיים. +3. **משפיע על מכרזים / חוזי תכנון** — שינוי בגובה הפיצויים משפיע על + החלטות פיתוח עתידיות. + +### טבלת בדיקה +| שלב | מצב התקציב | השפעה | +|------|-----------|--------| +| לפני סוף שנת כספים | תקציב פעיל, ניתן לשנות הקצאה | קלה | +| לאחר סגירת שנת כספים | תקציב חלוק | בינונית | +| לאחר העברה ליעדים אחרים | פיצוי דורש מקור חדש | משמעותית | +| לאחר ביצוע פרויקטים | בלתי הפיך כלכלית | מוחשית | + +--- + +## ח. טבלת התאמה לעובדות (placeholder לכל תיק) + +| תבחין | עובדה במקרה הנוכחי | כיוון | +|--------|---------------------|-------| +| א. פגיעה ממונית | [חוו"ד שמאית? קשר סיבתי? תכנית בתוקף?] | [חוסם / מאפשר] | +| ב. טעם סביר | [המצאה, ייצוג, תצהיר] | [תומך / מחליש] | +| ג. אורך השיהוי | [X ימים מעבר ל-30] | [קל / מובהק / חמור] | +| ד. הסתמכות הרשות | [מצב התקציב] | [קל / משמעותי / מוחשי] | + +--- + +## ט. סעיף מסקנה — מבנה אופייני + +המבנה האופייני הוא **קפדן, מבוסס מסמכים, ללא רגש**: + +1. **קביעת עובדות.** "ההחלטה הומצאה ביום X. הבל"מ הוגשה ביום Y. השיהוי + הוא Z ימים מעבר למועד הסטטוטורי." +2. **תבחין א (פגיעה).** "המבקש הציג חוו"ד / לא הציג חוו"ד. הקרקע + נמצאת בתחום התכנית / גובלת בה / מחוץ לה." +3. **אם לא הוצגה פגיעה לכאורה — דחייה מיידית.** "בהיעדר הצגה לכאורה של + פגיעה ממונית, אין יסוד לסטות ממועד הקבוע בחוק." +4. **אם הוצגה פגיעה — מעבר לתבחינים ב-ד.** +5. **מאזן והכרעה.** דחייה / קבלה / החזרה לוועדה המקומית. + +### לשון אופיינית לדחייה +> "המבקש לא הציג ראיה לכאורית לפגיעה ממונית מוחשית בקרקע שבבעלותו. הקרקע +> נמצאת מחוץ לתחום התכנית ואינה גובלת עמה. בנסיבות אלה, ובהינתן שהשיהוי +> הוא של X ימים מעבר למועד הסטטוטורי הקצר של 30 הימים, אין מקום לסטייה +> מהמועד. הבל"מ נדחית." + +### לשון אופיינית לקבלה (חריגה ביותר) +> "המבקש הציג חוו"ד שמאית מקצועית המראה ירידת ערך של כ-X% בקרקע הגובלת +> בתחום התכנית. ההצגה לכאורה משכנעת. בנסיבות החריגות של [פירוט], ועל אף +> הסף הקפדני שמטיל סעיף 198(ד), יש לפתוח את הדלת לדיון מהותי." + +--- + +## י. הפניות חוצות + +- ראה גם: `docs/methodology/extension-request-building_permit.md` (סעיף 152, 30 ימים) +- ראה גם: `docs/methodology/extension-request-betterment_levy.md` (סעיף 14, 45 ימים) +- ראה גם: `docs/block-schema.md` — מבנה 12 הבלוקים +- ראה גם: `skills/decision/SKILL.md` — מדריך סגנון של דפנה diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index 54fbe74..53ea944 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -54,6 +54,8 @@ from legal_mcp.tools import ( # noqa: E402 cases, documents, search, drafting, workflow, precedents, precedent_library as plib, internal_decisions as int_tools, + legal_arguments as la_tools, + missing_precedents as mp_tools, ) @@ -364,6 +366,28 @@ async def get_claims( return await documents.get_claims(case_number, party_role) +# Legal arguments — aggregated (de-duped) propositions +@mcp.tool() +async def aggregate_claims_to_arguments( + case_number: str, + force: bool = False, +) -> str: + """כינוס פרופוזיציות גולמיות (claims) לטיעונים משפטיים מובחנים — ~6-12 לכל צד. + + משתמש ב-Claude headless לסיווג ואיגוד. force=True מוחק טיעונים קיימים לפני חישוב מחדש. + """ + return await la_tools.aggregate_claims_to_arguments(case_number, force=force) + + +@mcp.tool() +async def get_legal_arguments( + case_number: str, + party: str = "", +) -> str: + """שליפת טיעונים משפטיים מאוגדים. party: appellant/respondent/committee/permit_applicant (ריק=הכל).""" + return await la_tools.get_legal_arguments(case_number, party) + + # References @mcp.tool() async def extract_references( @@ -703,6 +727,82 @@ async def internal_decision_upload( ) +# ── Missing precedents (TaskMaster #35) ─────────────────────────── + + +@mcp.tool() +async def missing_precedent_create( + citation: str, + case_number: str = "", + cited_in_document_id: str = "", + cited_by_party: str = "unknown", + cited_by_party_name: str = "", + legal_topic: str = "", + legal_issue: str = "", + claim_quote: str = "", + case_name: str = "", + notes: str = "", +) -> str: + """תיעוד פסיקה שצוטטה בכתבי הטענות אך אינה בקורפוס. + + שימוש: סוכן המחקר (legal-researcher) קורא לזה כשהוא מזהה ציטוט שלא + ניתן לאמת מול הקורפוס. הרשומה נשארת 'open' עד שהיו"ר מעלה את הפסיקה. + cited_by_party: appellant / respondent / committee / permit_applicant / unknown. + דה-דופ אוטומטי: ציטוט+תיק זהים → מחזיר את הרשומה הקיימת. + """ + return await mp_tools.missing_precedent_create( + citation=citation, + case_number=case_number, + cited_in_document_id=cited_in_document_id, + cited_by_party=cited_by_party, + cited_by_party_name=cited_by_party_name, + legal_topic=legal_topic, + legal_issue=legal_issue, + claim_quote=claim_quote, + case_name=case_name, + notes=notes, + ) + + +@mcp.tool() +async def missing_precedent_list( + case_number: str = "", + status: str = "open", + legal_topic: str = "", + limit: int = 50, +) -> str: + """רשימת פסיקות חסרות לתיק או בכלל. status: open/uploaded/closed/irrelevant. + + שימוש: היו"ר רואה מה ממתין להעלאה; הסוכן מאשר שלא יוצר כפילויות. + """ + return await mp_tools.missing_precedent_list( + case_number=case_number, + status=status, + legal_topic=legal_topic, + limit=limit, + ) + + +@mcp.tool() +async def missing_precedent_close( + id: str, + linked_case_law_id: str = "", + notes: str = "", + status: str = "closed", +) -> str: + """סגירת רשומת פסיקה חסרה לאחר העלאה לקורפוס. + + status: closed (הועלה ונקשר) / uploaded (הועלה, ממתין לקישור) / + irrelevant (היו"ר החליט שזה לא רלוונטי לקורפוס). + """ + return await mp_tools.missing_precedent_close( + id=id, + linked_case_law_id=linked_case_law_id, + notes=notes, + status=status, + ) + + @mcp.tool() async def record_chair_feedback( case_number: str, diff --git a/mcp-server/src/legal_mcp/services/argument_aggregator.py b/mcp-server/src/legal_mcp/services/argument_aggregator.py new file mode 100644 index 0000000..3a39fc8 --- /dev/null +++ b/mcp-server/src/legal_mcp/services/argument_aggregator.py @@ -0,0 +1,358 @@ +"""כינוס פרופוזיציות לטיעונים משפטיים מובחנים — argument de-duplication. + +Workflow: +1. ``claims_extractor`` extracts ~20-30 raw propositions per litigation + brief into the ``claims`` table. +2. This module groups those raw propositions, per party, into 6-12 + distinct legal arguments via Claude headless (`claude_session`). +3. The result is stored in ``legal_arguments`` plus ``legal_argument_ + propositions`` (M:M join) so we keep traceability back to the source + claims. + +Manually de-duping 184 propositions in 3 cases yielded 82 arguments +(~24/case) — see ``data/cases/{1017,1018,1019}-03-26/documents/research/ +legal-arguments.md`` for the gold standard. + +**Architectural constraint**: ``claude_session`` only works from the local +MCP server (Claude CLI is not installed in the FastAPI container). Calls +from ``web/`` must go through MCP tools; calls from MCP tools land here +directly. +""" + +from __future__ import annotations + +import json +import logging +from uuid import UUID + +from legal_mcp.services import claude_session, db + +logger = logging.getLogger(__name__) + +# Allowed enum values mirror the DB CHECK constraints. +ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"} +ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"} + +# Hebrew labels for the prompt (Claude needs context in the same +# language as the source material). +PARTY_LABELS_HE = { + "appellant": "עוררים", + "respondent": "משיבים", + "committee": "ועדה מקומית", + "permit_applicant": "מבקשי היתר", + "unknown": "צד לא מזוהה", +} + + +AGGREGATE_PROMPT_TEMPLATE = """אתה מנתח כתבי טענות בתחום תכנון ובנייה (ועדת ערר). + +לפניך {n} פרופוזיציות גולמיות שחולצו ממסמכי {party_he} בתיק ערר. +מטרתך: לקבץ אותן ל-{target_min}-{target_max} **טיעונים משפטיים מובחנים** +(ארגומנטים אמיתיים, לא חזרה מילולית של הפרופוזיציות). + +## כללי איגוד: +1. **טיעון אמיתי = רעיון משפטי אחד** — לא רשימה של פרופוזיציות, אלא טענה משפטית עצמאית. +2. **מקבצים פרופוזיציות שתומכות באותו רעיון משפטי** — גם אם הניסוח שלהן שונה. +3. **מפרידים בין סוגי טענות**: + - **threshold** = טענות סף (זכות עמידה, סמכות, מועדים, שיהוי) + - **substantive** = טענות מהותיות (תחולת חוק, פרשנות, חישוב) + - **procedural** = פגמי הליך (פרסום, פרוטוקול, ניגוד עניינים) + - **relief** = סעדים מבוקשים / סיכומים +4. **כותרת קצרה ובהירה** — תיאורית, לא משפטית מפורטת. 5-15 מילים. +5. **גוף הטיעון בפסקה אחת** — 3-7 שורות עברית, נאמן למקור. +6. **שמירת ה-claim_ids המקוריים** — לכל טיעון, רשום אילו פרופוזיציות תומכות בו. + +## פלט: +החזר JSON בלבד (ללא markdown, ללא הסברים), array של אובייקטים: +``` +[ + {{ + "title": "כותרת קצרה של הטיעון", + "body": "גוף הטיעון בפסקה אחת", + "topic": "סוגיה משפטית קצרה (לדוגמה: 'זכות עמידה', 'תחולת תמ\\"א 38')", + "priority": "threshold|substantive|procedural|relief", + "claim_ids": ["uuid-1", "uuid-2"] + }} +] +``` + +## הפרופוזיציות: +{propositions_json} +""" + + +def _build_prompt(party: str, propositions: list[dict]) -> str: + """Compose the per-party aggregation prompt.""" + n = len(propositions) + # Conservative target: ~1 argument per 2-3 propositions, clamped 4-12. + target_min = max(4, n // 4) + target_max = max(target_min + 1, min(12, n // 2 + 1)) + + party_he = PARTY_LABELS_HE.get(party, party) + # Strip noise from propositions for the prompt — Claude only needs + # the id and the text to do the grouping. + compact = [ + {"id": str(p["id"]), "text": p["claim_text"]} + for p in propositions + ] + propositions_json = json.dumps(compact, ensure_ascii=False, indent=2) + + return AGGREGATE_PROMPT_TEMPLATE.format( + n=n, + party_he=party_he, + target_min=target_min, + target_max=target_max, + propositions_json=propositions_json, + ) + + +def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None: + """Validate & normalize a single argument dict from Claude. + + Returns None if the row is unusable (missing required fields). + """ + if not isinstance(raw, dict): + return None + title = (raw.get("title") or "").strip() + body = (raw.get("body") or "").strip() + if not title or not body: + return None + priority = raw.get("priority", "substantive") + if priority not in ALLOWED_PRIORITIES: + priority = "substantive" + topic = (raw.get("topic") or fallback_topic or "").strip() or None + claim_ids_raw = raw.get("claim_ids") or [] + claim_ids: list[UUID] = [] + if isinstance(claim_ids_raw, list): + for cid in claim_ids_raw: + try: + claim_ids.append(UUID(str(cid))) + except (ValueError, TypeError): + continue + return { + "title": title, + "body": body, + "topic": topic, + "priority": priority, + "claim_ids": claim_ids, + } + + +async def _aggregate_party( + party: str, propositions: list[dict], +) -> list[dict]: + """Ask Claude to group one party's propositions; return normalized rows.""" + if not propositions: + return [] + prompt = _build_prompt(party, propositions) + + try: + raw_result = await claude_session.query_json(prompt) + except RuntimeError as e: + # Surface CLI-unavailable specifically so the caller can report + # cleanly instead of crashing the whole job. + raise RuntimeError( + f"argument_aggregator: claude_session.query_json failed for party " + f"'{party}': {e}" + ) from e + + if not isinstance(raw_result, list): + logger.warning( + "argument_aggregator: Claude returned non-list (%s) for party '%s'", + type(raw_result).__name__, party, + ) + return [] + + out: list[dict] = [] + for entry in raw_result: + norm = _normalize_argument(entry) + if norm: + out.append(norm) + return out + + +async def aggregate_claims_to_arguments( + case_id: UUID, force: bool = False, +) -> dict: + """For a given case, group existing claims into distinct legal arguments. + + Args: + case_id: The case UUID. + force: If True, delete existing ``legal_arguments`` for the case + before aggregating. Otherwise short-circuit if any rows exist. + + Returns: + A summary dict: + ``{"status": "completed"|"skipped"|"no_claims"|"llm_unavailable", + "by_party": {party: count}, "total": int, "message": ...}`` + """ + pool = await db.get_pool() + + async with pool.acquire() as conn: + existing = await conn.fetchval( + "SELECT COUNT(*) FROM legal_arguments WHERE case_id = $1", + case_id, + ) + if existing and not force: + return { + "status": "skipped", + "message": f"Found {existing} existing arguments. Use force=True to re-run.", + "total": existing, + } + + if force and existing: + await conn.execute( + "DELETE FROM legal_arguments WHERE case_id = $1", case_id, + ) + + # Pull all claims for this case, grouped by party. + rows = await conn.fetch( + """SELECT id, party_role, claim_text, claim_index, source_document + FROM claims + WHERE case_id = $1 + ORDER BY party_role, claim_index""", + case_id, + ) + + if not rows: + return { + "status": "no_claims", + "message": "No claims found for this case. Run extract_claims first.", + "total": 0, + } + + # Group propositions by party. + by_party: dict[str, list[dict]] = {} + for r in rows: + party = r["party_role"] + # Map deprecated 'appraiser' or unknown labels to 'unknown'. + if party not in ALLOWED_PARTIES: + party = "unknown" + by_party.setdefault(party, []).append(dict(r)) + + party_counts: dict[str, int] = {} + inserted = 0 + errors: list[str] = [] + + for party, props in by_party.items(): + try: + arguments = await _aggregate_party(party, props) + except RuntimeError as e: + # Most likely cause: Claude CLI not installed (running from + # the container). Don't crash — record the gap and continue. + msg = str(e) + if "Claude CLI not found" in msg: + return { + "status": "llm_unavailable", + "message": ( + "Claude CLI not available. This service must run from " + "the local MCP server (not the FastAPI container)." + ), + "total": 0, + } + errors.append(f"{party}: {msg}") + continue + + if not arguments: + party_counts[party] = 0 + continue + + async with pool.acquire() as conn: + async with conn.transaction(): + for idx, arg in enumerate(arguments): + arg_id = await conn.fetchval( + """INSERT INTO legal_arguments + (case_id, party, argument_index, argument_title, + argument_body, legal_topic, priority) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id""", + case_id, + party, + idx + 1, + arg["title"], + arg["body"], + arg["topic"], + arg["priority"], + ) + for cid in arg["claim_ids"]: + try: + await conn.execute( + """INSERT INTO legal_argument_propositions + (argument_id, claim_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING""", + arg_id, cid, + ) + except Exception as e: # noqa: BLE001 + # Likely FK violation if the LLM hallucinated + # a claim_id. Log and continue. + logger.warning( + "argument_aggregator: skipped bad claim_id %s for arg %s: %s", + cid, arg_id, e, + ) + inserted += 1 + party_counts[party] = len(arguments) + + result: dict = { + "status": "completed", + "total": inserted, + "by_party": party_counts, + "propositions_processed": len(rows), + } + if errors: + result["errors"] = errors + result["status"] = "completed_with_errors" + return result + + +async def get_legal_arguments( + case_id: UUID, party: str = "", +) -> list[dict]: + """Return aggregated legal arguments for a case, optionally filtered by party. + + Each row includes ``supporting_claims`` (list of source claim_ids). + """ + pool = await db.get_pool() + async with pool.acquire() as conn: + if party and party in ALLOWED_PARTIES: + rows = await conn.fetch( + """SELECT id, case_id, party, argument_index, argument_title, + argument_body, legal_topic, priority, cited_precedents, + created_at, updated_at + FROM legal_arguments + WHERE case_id = $1 AND party = $2 + ORDER BY priority, argument_index""", + case_id, party, + ) + else: + rows = await conn.fetch( + """SELECT id, case_id, party, argument_index, argument_title, + argument_body, legal_topic, priority, cited_precedents, + created_at, updated_at + FROM legal_arguments + WHERE case_id = $1 + ORDER BY party, priority, argument_index""", + case_id, + ) + + # Pull supporting claim ids for each argument in one round-trip. + arg_ids = [r["id"] for r in rows] + supporting: dict[UUID, list[str]] = {} + if arg_ids: + joins = await conn.fetch( + """SELECT argument_id, claim_id + FROM legal_argument_propositions + WHERE argument_id = ANY($1::uuid[])""", + arg_ids, + ) + for j in joins: + supporting.setdefault(j["argument_id"], []).append(str(j["claim_id"])) + + out: list[dict] = [] + for r in rows: + d = dict(r) + d["id"] = str(d["id"]) + d["case_id"] = str(d["case_id"]) + d["supporting_claims"] = supporting.get(r["id"], []) + out.append(d) + return out diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 5bc930f..6d9f9d6 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -745,6 +745,84 @@ CREATE INDEX IF NOT EXISTS idx_halachot_tsv """ +# ── V13: Missing precedents log ─────────────────────────────────── +# Track citations that the parties brought up but which are NOT yet in +# the precedent_library. Created by the researcher (auto or chair) +# whenever a citation can't be found in the corpus; closed by uploading +# the actual decision via internal_decision_upload or +# precedent_library_upload, at which point linked_case_law_id points to +# the new case_law row and status flips to 'closed'. +SCHEMA_V13_SQL = """ +CREATE TABLE IF NOT EXISTS missing_precedents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + citation TEXT NOT NULL, + case_name TEXT, + cited_in_case_id UUID REFERENCES cases(id) ON DELETE CASCADE, + cited_in_document_id UUID REFERENCES documents(id) ON DELETE SET NULL, + cited_by_party TEXT CHECK (cited_by_party IN ( + 'appellant', 'respondent', 'committee', 'permit_applicant', 'unknown' + )), + cited_by_party_name TEXT, + legal_topic TEXT, + legal_issue TEXT, + claim_quote TEXT, + status TEXT DEFAULT 'open' CHECK (status IN ( + 'open', 'uploaded', 'closed', 'irrelevant' + )), + linked_case_law_id UUID REFERENCES case_law(id) ON DELETE SET NULL, + closed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + notes TEXT +); + +CREATE INDEX IF NOT EXISTS idx_missing_precedents_case + ON missing_precedents(cited_in_case_id); +CREATE INDEX IF NOT EXISTS idx_missing_precedents_status + ON missing_precedents(status); +CREATE INDEX IF NOT EXISTS idx_missing_precedents_citation + ON missing_precedents(citation); +""" + + +# ── V14: Legal arguments (aggregated propositions) ──────────────── +# After ``claims_extractor`` extracts raw propositions (rows in ``claims``) +# the LLM-driven aggregator groups them into ~6-12 distinct legal arguments +# per party. ``legal_arguments`` holds the consolidated argument; the M:M +# join table ``legal_argument_propositions`` links back to the source +# propositions for traceability ("which raw claims feed this argument?"). +SCHEMA_V14_SQL = """ +CREATE TABLE IF NOT EXISTS legal_arguments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE, + party TEXT NOT NULL CHECK (party IN ( + 'appellant', 'respondent', 'committee', 'permit_applicant', 'unknown' + )), + argument_index INTEGER NOT NULL, + argument_title TEXT NOT NULL, + argument_body TEXT NOT NULL, + legal_topic TEXT, + priority TEXT DEFAULT 'substantive' CHECK (priority IN ( + 'threshold', 'substantive', 'procedural', 'relief' + )), + cited_precedents TEXT[], + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_legal_arguments_case + ON legal_arguments(case_id); +CREATE INDEX IF NOT EXISTS idx_legal_arguments_party + ON legal_arguments(case_id, party); + +-- M:M back to ``claims`` (raw propositions). +CREATE TABLE IF NOT EXISTS legal_argument_propositions ( + argument_id UUID NOT NULL REFERENCES legal_arguments(id) ON DELETE CASCADE, + claim_id UUID NOT NULL REFERENCES claims(id) ON DELETE CASCADE, + PRIMARY KEY (argument_id, claim_id) +); +""" + + async def _run_schema_migrations(pool: asyncpg.Pool) -> None: async with pool.acquire() as conn: await conn.execute(SCHEMA_SQL) @@ -760,7 +838,9 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None: await conn.execute(SCHEMA_V10_SQL) await conn.execute(SCHEMA_V11_SQL) await conn.execute(SCHEMA_V12_SQL) - logger.info("Database schema initialized (v1-v12)") + await conn.execute(SCHEMA_V13_SQL) + await conn.execute(SCHEMA_V14_SQL) + logger.info("Database schema initialized (v1-v14)") async def init_schema() -> None: @@ -782,7 +862,10 @@ async def create_case( hearing_date: date | None = None, notes: str = "", expected_outcome: str = "", - practice_area: str = "appeals_committee", + # Default "" — DB CHECK constraint accepts empty, the upstream tool + # (cases.case_create) is responsible for deriving the domain value + # from the case_number prefix before calling here. + practice_area: str = "", appeal_subtype: str = "", ) -> dict: pool = await get_pool() @@ -3106,3 +3189,228 @@ async def search_precedent_library_hybrid( merged.append(d) merged.sort(key=lambda x: -x["score"]) return merged[:limit] + + +# ── Missing precedents (V13) ─────────────────────────────────────── +# Track citations from party briefs that aren't yet in the corpus. +# Lifecycle: 'open' → researcher logs gap → chair uploads decision +# → status='uploaded' (file ingested) → status='closed' (linked to +# case_law row). 'irrelevant' = chair decided the citation isn't worth +# adding to the library. + +ALLOWED_MP_PARTIES = { + "appellant", "respondent", "committee", "permit_applicant", "unknown", +} +ALLOWED_MP_STATUS = {"open", "uploaded", "closed", "irrelevant"} + + +def _row_to_missing_precedent(row: asyncpg.Record) -> dict: + d = dict(row) + d["id"] = str(d["id"]) + if d.get("cited_in_case_id") is not None: + d["cited_in_case_id"] = str(d["cited_in_case_id"]) + if d.get("cited_in_document_id") is not None: + d["cited_in_document_id"] = str(d["cited_in_document_id"]) + if d.get("linked_case_law_id") is not None: + d["linked_case_law_id"] = str(d["linked_case_law_id"]) + return d + + +async def create_missing_precedent( + citation: str, + case_name: str | None = None, + cited_in_case_id: UUID | None = None, + cited_in_document_id: UUID | None = None, + cited_by_party: str | None = None, + cited_by_party_name: str | None = None, + legal_topic: str | None = None, + legal_issue: str | None = None, + claim_quote: str | None = None, + notes: str | None = None, +) -> dict: + """Create a new missing-precedent row (status='open' by default).""" + if not citation.strip(): + raise ValueError("citation is required") + if cited_by_party and cited_by_party not in ALLOWED_MP_PARTIES: + raise ValueError( + f"cited_by_party must be one of {sorted(ALLOWED_MP_PARTIES)}" + ) + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """INSERT INTO missing_precedents ( + citation, case_name, cited_in_case_id, cited_in_document_id, + cited_by_party, cited_by_party_name, legal_topic, legal_issue, + claim_quote, notes + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *""", + citation.strip(), case_name, cited_in_case_id, cited_in_document_id, + cited_by_party, cited_by_party_name, legal_topic, legal_issue, + claim_quote, notes, + ) + return _row_to_missing_precedent(row) + + +async def list_missing_precedents( + status: str | None = None, + case_id: UUID | None = None, + legal_topic: str | None = None, + limit: int = 200, + offset: int = 0, +) -> list[dict]: + """List missing precedents, joining the cited-in case_number for display.""" + pool = await get_pool() + conditions: list[str] = [] + params: list = [] + idx = 1 + if status: + conditions.append(f"mp.status = ${idx}") + params.append(status) + idx += 1 + if case_id: + conditions.append(f"mp.cited_in_case_id = ${idx}") + params.append(case_id) + idx += 1 + if legal_topic: + conditions.append(f"mp.legal_topic ILIKE ${idx}") + params.append(f"%{legal_topic}%") + idx += 1 + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + params.append(limit) + params.append(offset) + sql = f""" + SELECT mp.*, + c.case_number AS cited_in_case_number, + cl.case_number AS linked_case_law_number, + cl.case_name AS linked_case_law_name + FROM missing_precedents mp + LEFT JOIN cases c ON c.id = mp.cited_in_case_id + LEFT JOIN case_law cl ON cl.id = mp.linked_case_law_id + {where} + ORDER BY + CASE mp.status + WHEN 'open' THEN 0 + WHEN 'uploaded' THEN 1 + WHEN 'closed' THEN 2 + WHEN 'irrelevant' THEN 3 + END, + mp.created_at DESC + LIMIT ${idx} OFFSET ${idx + 1} + """ + async with pool.acquire() as conn: + rows = await conn.fetch(sql, *params) + return [_row_to_missing_precedent(r) for r in rows] + + +async def get_missing_precedent(mp_id: UUID) -> dict | None: + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT mp.*, + c.case_number AS cited_in_case_number, + cl.case_number AS linked_case_law_number, + cl.case_name AS linked_case_law_name + FROM missing_precedents mp + LEFT JOIN cases c ON c.id = mp.cited_in_case_id + LEFT JOIN case_law cl ON cl.id = mp.linked_case_law_id + WHERE mp.id = $1 + """, + mp_id, + ) + return _row_to_missing_precedent(row) if row else None + + +async def update_missing_precedent(mp_id: UUID, **fields) -> dict | None: + """Patch a missing-precedent row. Allowed fields: legal_topic, + legal_issue, notes, cited_by_party, cited_by_party_name, case_name, + status, linked_case_law_id, closed_at.""" + if not fields: + return await get_missing_precedent(mp_id) + allowed = { + "legal_topic", "legal_issue", "notes", "cited_by_party", + "cited_by_party_name", "case_name", "status", "linked_case_law_id", + "closed_at", "claim_quote", "citation", + } + clean = {k: v for k, v in fields.items() if k in allowed} + if not clean: + return await get_missing_precedent(mp_id) + if "status" in clean and clean["status"] not in ALLOWED_MP_STATUS: + raise ValueError( + f"status must be one of {sorted(ALLOWED_MP_STATUS)}" + ) + if "cited_by_party" in clean and clean["cited_by_party"] and \ + clean["cited_by_party"] not in ALLOWED_MP_PARTIES: + raise ValueError( + f"cited_by_party must be one of {sorted(ALLOWED_MP_PARTIES)}" + ) + set_clauses = [] + values = [] + for i, (key, val) in enumerate(clean.items(), start=2): + set_clauses.append(f"{key} = ${i}") + values.append(val) + set_clauses.append("updated_at = now()") + sql = ( + f"UPDATE missing_precedents SET {', '.join(set_clauses)} " + f"WHERE id = $1 RETURNING *" + ) + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow(sql, mp_id, *values) + return _row_to_missing_precedent(row) if row else None + + +async def close_missing_precedent( + mp_id: UUID, + linked_case_law_id: UUID | None = None, + notes: str | None = None, + status: str = "closed", +) -> dict | None: + """Mark a missing-precedent row as closed (or 'uploaded'/'irrelevant') + and link it to a case_law row if provided.""" + if status not in ALLOWED_MP_STATUS: + raise ValueError( + f"status must be one of {sorted(ALLOWED_MP_STATUS)}" + ) + pool = await get_pool() + async with pool.acquire() as conn: + sets = ["status = $2", "closed_at = now()", "updated_at = now()"] + params: list = [mp_id, status] + idx = 3 + if linked_case_law_id is not None: + sets.append(f"linked_case_law_id = ${idx}") + params.append(linked_case_law_id) + idx += 1 + if notes is not None: + sets.append(f"notes = ${idx}") + params.append(notes) + idx += 1 + sql = ( + f"UPDATE missing_precedents SET {', '.join(sets)} " + f"WHERE id = $1 RETURNING *" + ) + row = await conn.fetchrow(sql, *params) + return _row_to_missing_precedent(row) if row else None + + +async def find_missing_precedent_by_citation( + citation: str, + case_id: UUID | None = None, +) -> dict | None: + """Look up an existing row by citation string (exact match) and optionally + cited-in case_id. Used to deduplicate auto-creation by the researcher.""" + pool = await get_pool() + async with pool.acquire() as conn: + if case_id is not None: + row = await conn.fetchrow( + "SELECT * FROM missing_precedents " + "WHERE citation = $1 AND cited_in_case_id = $2 LIMIT 1", + citation.strip(), case_id, + ) + else: + row = await conn.fetchrow( + "SELECT * FROM missing_precedents WHERE citation = $1 LIMIT 1", + citation.strip(), + ) + return _row_to_missing_precedent(row) if row else None diff --git a/mcp-server/src/legal_mcp/services/practice_area.py b/mcp-server/src/legal_mcp/services/practice_area.py index 6fbbdb3..88979dc 100644 --- a/mcp-server/src/legal_mcp/services/practice_area.py +++ b/mcp-server/src/legal_mcp/services/practice_area.py @@ -52,16 +52,44 @@ DOMAIN_PRACTICE_AREAS: set[str] = { "compensation_197", } -# Union — what ``validate()`` accepts for backward-compat -PRACTICE_AREAS: set[str] = MULTI_TENANT_PRACTICE_AREAS | DOMAIN_PRACTICE_AREAS +# Union — what ``validate()`` accepts for backward-compat. +# Empty string is permitted because the DB CHECK constraint allows it as +# a "not yet classified" sentinel (e.g. when auto-derivation fails on an +# unrecognized case_number format). +PRACTICE_AREAS: set[str] = MULTI_TENANT_PRACTICE_AREAS | DOMAIN_PRACTICE_AREAS | {""} APPEALS_COMMITTEE_SUBTYPES: set[str] = { "building_permit", "betterment_levy", "compensation_197", + # בל"מ — בקשה להארכת מועד להגשת ערר. מסלולים נפרדים לפי domain: + "extension_request_building_permit", # 1xxx — סעיף 152, 30 ימים + "extension_request_betterment_levy", # 8xxx — סעיף 14 לתוספת ג', 45 ימים + "extension_request_compensation", # 9xxx — סעיף 198(ד), 30 ימים "unknown", } +# בל"מ subtypes — קל לזהות ע"י prefix +BLAM_SUBTYPES: set[str] = { + "extension_request_building_permit", + "extension_request_betterment_levy", + "extension_request_compensation", +} + +# מיפוי domain → בל"מ subtype +_DOMAIN_TO_BLAM_SUBTYPE: dict[str, str] = { + "rishuy_uvniya": "extension_request_building_permit", + "betterment_levy": "extension_request_betterment_levy", + "compensation_197": "extension_request_compensation", +} + +# מיפוי first-digit → בל"מ subtype (אותו מבנה כמו _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE) +_APPEALS_COMMITTEE_DIGIT_TO_BLAM = { + "1": "extension_request_building_permit", + "8": "extension_request_betterment_levy", + "9": "extension_request_compensation", +} + DEFAULT_PRACTICE_AREA = "appeals_committee" # Subtypes per practice_area (extend when adding domains) @@ -70,9 +98,11 @@ SUBTYPES_BY_AREA: dict[str, set[str]] = { "national_insurance": {"unknown"}, "labor_law": {"unknown"}, # Domain values — subtype is implicit in the value itself - "rishuy_uvniya": {"building_permit", "unknown"}, - "betterment_levy": {"betterment_levy", "unknown"}, - "compensation_197": {"compensation_197", "unknown"}, + "rishuy_uvniya": {"building_permit", "extension_request_building_permit", "unknown"}, + "betterment_levy": {"betterment_levy", "extension_request_betterment_levy", "unknown"}, + "compensation_197": {"compensation_197", "extension_request_compensation", "unknown"}, + # Empty (unclassified) — allow any of the appeals_committee subtypes + "": APPEALS_COMMITTEE_SUBTYPES, } # Mapping: (multi_tenant_pa, appeal_subtype) → domain_pa @@ -80,9 +110,39 @@ _SUBTYPE_TO_DOMAIN: dict[str, str] = { "building_permit": "rishuy_uvniya", "betterment_levy": "betterment_levy", "compensation_197": "compensation_197", + "extension_request_building_permit": "rishuy_uvniya", + "extension_request_betterment_levy": "betterment_levy", + "extension_request_compensation": "compensation_197", } +# Regex לזיהוי "בקשה להארכת מועד" בנושא הערר (subject) — +# וריאציות נפוצות. case-insensitive, מתחשב במרכאות חכמות/רגילות. +_BLAM_SUBJECT_PATTERNS = ( + re.compile(r"בקשה\s+להארכת\s+מועד", re.IGNORECASE), + re.compile(r"בל[\"״״]מ", re.IGNORECASE), # בל"מ עם quote variants + re.compile(r"הארכת\s+מועד\s+להגשת", re.IGNORECASE), +) + + +def is_blam_subject(subject: str) -> bool: + """True iff subject indicates a בל"מ (extension-of-time request). + + מזהה: "בקשה להארכת מועד", "בל\"מ", "הארכת מועד להגשת..." + + Examples: + >>> is_blam_subject("בל\"מ אלחנן ברלינגר נ' לינדאב") + True + >>> is_blam_subject("בקשה להארכת מועד להגשת ערר") + True + >>> is_blam_subject("היתר בנייה ברחוב X") + False + """ + if not subject: + return False + return any(p.search(subject) for p in _BLAM_SUBJECT_PATTERNS) + + def to_db_practice_area(practice_area: str, appeal_subtype: str = "") -> str: """Convert a multi-tenant practice_area + appeal_subtype to the domain value stored in DB columns (case_law/cases). @@ -120,14 +180,28 @@ _CASE_NUM = re.compile(r"(?:ARAR[-\s]*\d{2}[-\s]*(?:\d{2}[-\s]*)?)(\d{4})", re.I _PLAIN_NUM = re.compile(r"(\d{4})") +_DOMAIN_TO_SUBTYPE: dict[str, str] = { + "rishuy_uvniya": "building_permit", + "betterment_levy": "betterment_levy", + "compensation_197": "compensation_197", +} + + def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) -> str: """Infer the appeal_subtype from case_number. - For appeals_committee, the convention is: + For appeals_committee (axis A), the convention is: 1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197. + For domain values (axis B — rishuy_uvniya/betterment_levy/compensation_197), + the subtype is implicit in the practice_area itself — we map directly + without parsing the case number. + Handles multiple formats: ARAR-25-8126, 8126/25, 1170, ערר 1024-25. """ + # Axis B: practice_area is already a domain value — map directly. + if practice_area in DOMAIN_PRACTICE_AREAS: + return _DOMAIN_TO_SUBTYPE.get(practice_area, "unknown") if practice_area != "appeals_committee": return "unknown" cn = case_number or "" @@ -142,6 +216,82 @@ def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit, "unknown") +def derive_subtype_with_blam( + case_number: str, + subject: str = "", + practice_area: str = DEFAULT_PRACTICE_AREA, +) -> str: + """Like ``derive_subtype()`` but also detects בל"מ from the subject. + + If ``subject`` indicates a בקשה להארכת מועד, the returned subtype is + one of the ``extension_request_*`` values (chosen per case_number / + practice_area). Otherwise behaviour matches ``derive_subtype()``. + + Examples: + >>> derive_subtype_with_blam("1017-03-26", "בל\"מ ברלינגר נ' לינדאב") + 'extension_request_building_permit' + >>> derive_subtype_with_blam("8500-25", "בקשה להארכת מועד") + 'extension_request_betterment_levy' + >>> derive_subtype_with_blam("1033-25", "ערר על החלטת ועדה") + 'building_permit' + """ + base = derive_subtype(case_number, practice_area) + if not is_blam_subject(subject): + return base + # subject says it's בל"מ — 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) + # For appeals_committee (axis A), derive from case_number digit. + if practice_area == "appeals_committee": + cn = case_number or "" + m = _CASE_NUM.search(cn) or _PLAIN_NUM.search(cn) + if m: + first_digit = m.group(1)[0] + blam = _APPEALS_COMMITTEE_DIGIT_TO_BLAM.get(first_digit) + if blam: + return blam + return base + + +def is_blam_subtype(appeal_subtype: str) -> bool: + """True iff appeal_subtype is one of the extension_request_* variants. + + Useful for UI badges and routing logic that need to detect בל"מ cases + regardless of which domain they belong to. + """ + return appeal_subtype in BLAM_SUBTYPES + + +def derive_domain_practice_area(case_number: str) -> str: + """Map a case_number prefix to a domain practice_area (axis B). + + Returns: + ``"rishuy_uvniya"`` for 1xxx, ``"betterment_levy"`` for 8xxx, + ``"compensation_197"`` for 9xxx, or ``""`` when the prefix is + unrecognized (caller decides the fallback). + + Examples: + >>> derive_domain_practice_area("8126/25") + 'betterment_levy' + >>> derive_domain_practice_area("1170") + 'rishuy_uvniya' + >>> derive_domain_practice_area("ARAR-24-01-9007") + 'compensation_197' + >>> derive_domain_practice_area("foo") + '' + """ + cn = case_number or "" + m = _CASE_NUM.search(cn) or _PLAIN_NUM.search(cn) + if not m: + return "" + first_digit = m.group(1)[0] + subtype = _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit) + if not subtype: + return "" + return _SUBTYPE_TO_DOMAIN.get(subtype, "") + + # ── Validation ───────────────────────────────────────────────────── @@ -164,6 +314,20 @@ def validate(practice_area: str, appeal_subtype: str | None) -> None: def is_override(case_number: str, practice_area: str, appeal_subtype: str) -> bool: """True iff the user-supplied subtype disagrees with what derive_subtype - would have produced (and the derived value is not 'unknown').""" + would have produced (and the derived value is not 'unknown'). + + Note: בל"מ variants (extension_request_*) are NOT considered overrides + of their parent domain — extension_request_building_permit on a 1xxx + case is consistent with the case-number convention. + """ derived = derive_subtype(case_number, practice_area) - return derived != "unknown" and derived != appeal_subtype + if derived == "unknown": + return False + if derived == appeal_subtype: + return False + # בל"מ variants of the same domain are not overrides. + if appeal_subtype in BLAM_SUBTYPES: + # extension_request_building_permit ↔ building_permit (1xxx) — same domain + if _SUBTYPE_TO_DOMAIN.get(appeal_subtype) == _SUBTYPE_TO_DOMAIN.get(derived): + return False + return True diff --git a/mcp-server/src/legal_mcp/tools/cases.py b/mcp-server/src/legal_mcp/tools/cases.py index 4ad5bf8..7b7915a 100644 --- a/mcp-server/src/legal_mcp/tools/cases.py +++ b/mcp-server/src/legal_mcp/tools/cases.py @@ -128,7 +128,7 @@ async def case_create( hearing_date: str = "", notes: str = "", expected_outcome: str = "", - practice_area: str = "appeals_committee", + practice_area: str = "", appeal_subtype: str = "", ) -> str: """יצירת תיק ערר חדש. @@ -145,7 +145,9 @@ async def case_create( hearing_date: תאריך דיון (YYYY-MM-DD) notes: הערות expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy) - practice_area: תחום משפטי (appeals_committee / national_insurance / labor_law) + practice_area: תחום משפטי — domain value (rishuy_uvniya / betterment_levy / + compensation_197). ריק או "appeals_committee" = יוסק + אוטומטית ממספר התיק (1xxx→רישוי, 8xxx→השבחה, 9xxx→197) appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197). ריק = יוסק אוטומטית ממספר התיק """ @@ -155,8 +157,18 @@ async def case_create( if hearing_date: h_date = date_type.fromisoformat(hearing_date) - # Resolve appeal_subtype: explicit override > auto-derive > 'unknown' - derived_subtype = pa.derive_subtype(case_number, practice_area) + # Auto-derive practice_area when missing or set to the legacy multi-tenant + # value. The DB's cases_practice_area_check rejects 'appeals_committee', + # so we MUST map it to a domain value before INSERT. If derivation fails + # (unknown case number format), fall back to '' which the constraint allows. + if not practice_area or practice_area == "appeals_committee": + practice_area = pa.derive_domain_practice_area(case_number) + + # Resolve appeal_subtype: explicit override > auto-derive > 'unknown'. + # derive_subtype_with_blam inspects the subject to detect בל"מ + # (בקשה להארכת מועד) and returns an extension_request_* variant when + # appropriate. Falls back to regular derive_subtype when subject is empty. + derived_subtype = pa.derive_subtype_with_blam(case_number, subject, practice_area) if not appeal_subtype: appeal_subtype = derived_subtype pa.validate(practice_area, appeal_subtype) diff --git a/mcp-server/src/legal_mcp/tools/legal_arguments.py b/mcp-server/src/legal_mcp/tools/legal_arguments.py new file mode 100644 index 0000000..3fa1298 --- /dev/null +++ b/mcp-server/src/legal_mcp/tools/legal_arguments.py @@ -0,0 +1,83 @@ +"""MCP tools — aggregated legal arguments (claim de-duplication).""" + +from __future__ import annotations + +import json +from uuid import UUID + +from legal_mcp.services import argument_aggregator, db + + +async def aggregate_claims_to_arguments( + case_number: str, + force: bool = False, +) -> str: + """כינוס פרופוזיציות גולמיות לטיעונים משפטיים מובחנים. + + Args: + case_number: מספר תיק הערר. + force: True = למחוק טיעונים קיימים ולחשב מחדש. + """ + case = await db.get_case_by_number(case_number) + if not case: + return json.dumps( + {"status": "error", "message": f"תיק {case_number} לא נמצא."}, + ensure_ascii=False, indent=2, + ) + + case_id = UUID(case["id"]) + result = await argument_aggregator.aggregate_claims_to_arguments( + case_id, force=force, + ) + result["case_number"] = case_number + return json.dumps(result, ensure_ascii=False, indent=2, default=str) + + +async def get_legal_arguments( + case_number: str, + party: str = "", +) -> str: + """שליפת טיעונים משפטיים מאוגדים לתיק. + + Args: + case_number: מספר תיק הערר. + party: סינון לפי צד (appellant/respondent/committee/permit_applicant). + ריק = כל הצדדים. + """ + case = await db.get_case_by_number(case_number) + if not case: + return json.dumps( + {"status": "error", "message": f"תיק {case_number} לא נמצא."}, + ensure_ascii=False, indent=2, + ) + + case_id = UUID(case["id"]) + args = await argument_aggregator.get_legal_arguments(case_id, party=party) + + if not args: + return json.dumps({ + "status": "empty", + "case_number": case_number, + "message": "לא נמצאו טיעונים מאוגדים. הרץ aggregate_claims_to_arguments תחילה.", + "arguments": [], + }, ensure_ascii=False, indent=2) + + # Group by party for nicer display. + party_he = { + "appellant": "עוררים", + "respondent": "משיבים", + "committee": "ועדה מקומית", + "permit_applicant": "מבקשי היתר", + "unknown": "צד לא מזוהה", + } + by_party: dict[str, list[dict]] = {} + for a in args: + label = party_he.get(a["party"], a["party"]) + by_party.setdefault(label, []).append(a) + + return json.dumps({ + "status": "ok", + "case_number": case_number, + "total": len(args), + "by_party": by_party, + }, ensure_ascii=False, indent=2, default=str) diff --git a/mcp-server/src/legal_mcp/tools/missing_precedents.py b/mcp-server/src/legal_mcp/tools/missing_precedents.py new file mode 100644 index 0000000..03d02d4 --- /dev/null +++ b/mcp-server/src/legal_mcp/tools/missing_precedents.py @@ -0,0 +1,210 @@ +"""MCP tools for the missing-precedents log. + +When a researcher (or chair) finds a citation in a party brief that +isn't yet in the precedent_library, they record it here so: + +1. The gap is visible in the UI (the chair can see all open citations + that need to be uploaded). +2. The writer agent doesn't try to use a precedent that isn't in the + corpus — it knows the gap is being tracked. +3. The chair has a clean closing workflow: upload the actual decision + via the precedent library / internal-decisions, then link it here. + +Three tools: +- ``missing_precedent_create`` — log a new gap (researcher / chair). +- ``missing_precedent_list`` — list open gaps (optionally filtered). +- ``missing_precedent_close`` — close a gap (chair workflow). +""" + +from __future__ import annotations + +import json +from uuid import UUID + +from legal_mcp.services import db + + +def _ok(payload) -> str: + return json.dumps(payload, ensure_ascii=False, indent=2, default=str) + + +def _err(msg: str) -> str: + return json.dumps({"error": msg}, ensure_ascii=False) + + +async def _resolve_case_id(case_number: str) -> UUID | None: + """Translate a human case_number (e.g. '1017-03-26') to a UUID.""" + if not case_number or not case_number.strip(): + return None + row = await db.get_case_by_number(case_number.strip()) + if not row: + return None + return UUID(row["id"]) + + +async def missing_precedent_create( + citation: str, + case_number: str = "", + cited_in_document_id: str = "", + cited_by_party: str = "unknown", + cited_by_party_name: str = "", + legal_topic: str = "", + legal_issue: str = "", + claim_quote: str = "", + case_name: str = "", + notes: str = "", +) -> str: + """תיעוד פסיקה שצוטטה אך אינה בקורפוס. הסוכן יוצר רשומה כשהוא מזהה ציטוט + שלא ניתן לאמת מול הקורפוס; היו"ר יסגור אותה לאחר העלאת המסמך. + + Args: + citation: מראה המקום המלא (חובה). + case_number: מספר תיק הערר שבו צוטטה הפסיקה (לדוגמה '1017-03-26'). + cited_in_document_id: UUID של המסמך שבו הציטוט מופיע (אופציונלי). + cited_by_party: appellant / respondent / committee / permit_applicant / unknown. + cited_by_party_name: שם הצד (כדי שיהיה ברור מי ציטט). + legal_topic: נושא משפטי קצר (לדוגמה "זכות עמידה"). + legal_issue: שאלה משפטית מפורטת. + claim_quote: הציטוט בכתב הטענות. + case_name: שם קצר של פסק הדין החסר. + notes: הערות חופשיות. + + Returns: JSON של הרשומה שנוצרה (כולל id) או error. + """ + if not citation.strip(): + return _err("citation חובה") + + case_id = None + if case_number: + case_id = await _resolve_case_id(case_number) + if case_id is None: + return _err(f"תיק לא נמצא: {case_number}") + + doc_uuid: UUID | None = None + if cited_in_document_id.strip(): + try: + doc_uuid = UUID(cited_in_document_id.strip()) + except ValueError: + return _err("cited_in_document_id לא תקין") + + party = cited_by_party.strip() or "unknown" + if party not in db.ALLOWED_MP_PARTIES: + return _err( + f"cited_by_party לא תקין. ערכים תקפים: " + f"{', '.join(sorted(db.ALLOWED_MP_PARTIES))}" + ) + + # Deduplication: if a row already exists for the same citation in + # the same case, return that one rather than creating a duplicate. + existing = await db.find_missing_precedent_by_citation( + citation=citation.strip(), + case_id=case_id, + ) + if existing: + return _ok({**existing, "_duplicate": True}) + + try: + row = await db.create_missing_precedent( + citation=citation.strip(), + case_name=case_name.strip() or None, + cited_in_case_id=case_id, + cited_in_document_id=doc_uuid, + cited_by_party=party, + cited_by_party_name=cited_by_party_name.strip() or None, + legal_topic=legal_topic.strip() or None, + legal_issue=legal_issue.strip() or None, + claim_quote=claim_quote.strip() or None, + notes=notes.strip() or None, + ) + except Exception as e: + return _err(str(e)) + return _ok(row) + + +async def missing_precedent_list( + case_number: str = "", + status: str = "open", + legal_topic: str = "", + limit: int = 50, +) -> str: + """רשימת פסיקות חסרות. ברירת מחדל = פתוחות בלבד. + + Args: + case_number: סינון לפי תיק הערר שבו צוטטו. + status: open / uploaded / closed / irrelevant (ריק = הכל). + legal_topic: סינון לפי נושא משפטי (substring). + limit: מספר תוצאות מקסימלי. + + Returns: JSON עם רשימת רשומות + linked_case_law_number אם נסגרו. + """ + case_id = None + if case_number: + case_id = await _resolve_case_id(case_number) + if case_id is None: + return _err(f"תיק לא נמצא: {case_number}") + + s = status.strip() or None + if s and s not in db.ALLOWED_MP_STATUS: + return _err( + f"status לא תקין. ערכים תקפים: " + f"{', '.join(sorted(db.ALLOWED_MP_STATUS))}" + ) + try: + rows = await db.list_missing_precedents( + status=s, + case_id=case_id, + legal_topic=legal_topic.strip() or None, + limit=max(1, min(int(limit), 500)), + ) + except Exception as e: + return _err(str(e)) + return _ok({"items": rows, "count": len(rows)}) + + +async def missing_precedent_close( + id: str, + linked_case_law_id: str = "", + notes: str = "", + status: str = "closed", +) -> str: + """סגירת רשומת פסיקה חסרה. ברירת מחדל = 'closed' + קישור ל-case_law. + + Args: + id: UUID של הרשומה. + linked_case_law_id: UUID של הפסיקה שהועלתה ב-precedent_library / internal_decisions. + notes: הערות סגירה (לדוגמה "אינו רלוונטי" ל-status='irrelevant'). + status: closed / uploaded / irrelevant. + + Returns: JSON של הרשומה המעודכנת. + """ + try: + mp_id = UUID(id.strip()) + except ValueError: + return _err("id לא תקין") + + cl_uuid: UUID | None = None + if linked_case_law_id.strip(): + try: + cl_uuid = UUID(linked_case_law_id.strip()) + except ValueError: + return _err("linked_case_law_id לא תקין") + + status_clean = status.strip() or "closed" + if status_clean not in db.ALLOWED_MP_STATUS: + return _err( + f"status לא תקין. ערכים תקפים: " + f"{', '.join(sorted(db.ALLOWED_MP_STATUS))}" + ) + + try: + row = await db.close_missing_precedent( + mp_id=mp_id, + linked_case_law_id=cl_uuid, + notes=notes.strip() or None, + status=status_clean, + ) + except Exception as e: + return _err(str(e)) + if row is None: + return _err("רשומה לא נמצאה") + return _ok(row) diff --git a/mcp-server/tests/test_corpus_constraints.py b/mcp-server/tests/test_corpus_constraints.py new file mode 100644 index 0000000..5479d7e --- /dev/null +++ b/mcp-server/tests/test_corpus_constraints.py @@ -0,0 +1,276 @@ +"""Regression tests for Stage-A corpus integrity fixes (TaskMaster #30, #31). + +These tests document the bugs that were closed in Stage A so they don't +regress quietly. Each test maps to a real bug or constraint: + + 1. DB CHECK ``cases_practice_area_check`` rejects the legacy + ``'appeals_committee'`` value — only domain values (rishuy_uvniya / + betterment_levy / compensation_197) and ``''`` are allowed. + (Bug: many ``cases`` rows stored ``'appeals_committee'`` instead of + the domain.) + + 2. DB CHECK ``case_law_internal_chair_check`` and + ``case_law_internal_district_check`` reject internal_committee rows + with empty chair_name/district. + (Bug: 6 records had source_kind='external_upload' but were really + internal committee decisions; the flip to internal_committee in + Stage A.2 surfaced the missing chair/district fields.) + + 3. DB CHECK ``case_law_external_arar_check`` rejects external_upload + rows whose case_number starts with ``"ערר"`` or ``"בל\\"מ"`` — + committee decisions must go through internal_decision_upload, not + precedent_library_upload. + (Bug: the legacy upload path stored everything as external_upload, + including appeal-committee decisions; the citation guard now + redirects them.) + + 4. MCP tool ``precedent_library_upload`` returns an ``_err`` envelope + when the citation starts with ``"ערר"`` (citation guard, not DB + constraint — fires before INSERT to surface a helpful error). + +These tests connect to the live local Postgres (port 5433) — they do not +mock asyncpg. Run with:: + + pytest mcp-server/tests/test_corpus_constraints.py -v + +If you don't have ``DATABASE_URL`` set, the tests are skipped. +""" + +from __future__ import annotations + +import asyncio +import json +import os +from uuid import uuid4 + +import asyncpg +import pytest + + +def _dsn() -> str | None: + return ( + os.environ.get("DATABASE_URL") + or os.environ.get("LEGAL_AI_DATABASE_URL") + or "postgresql://legal_ai:od0ASJZFYibOlWK59krLvvETmgqwlXe8@localhost:5433/legal_ai" + ) + + +@pytest.fixture() +def dsn() -> str: + d = _dsn() + if not d: + pytest.skip("No DATABASE_URL set; skipping live-DB regression tests") + return d + + +@pytest.fixture() +def event_loop(): + """Provide a fresh event loop per test so asyncpg doesn't leak across cases.""" + loop = asyncio.new_event_loop() + try: + yield loop + finally: + loop.close() + + +def _run(loop, coro): + return loop.run_until_complete(coro) + + +# ── 1. cases.practice_area CHECK ───────────────────────────────────── + + +def test_cases_rejects_appeals_committee_practice_area(dsn: str, event_loop) -> None: + """``cases.practice_area = 'appeals_committee'`` must violate the CHECK.""" + + async def attempt() -> None: + conn = await asyncpg.connect(dsn) + try: + with pytest.raises(asyncpg.exceptions.CheckViolationError): + await conn.execute( + """INSERT INTO cases (id, case_number, title, practice_area) + VALUES ($1, $2, $3, $4)""", + uuid4(), f"TEST-{uuid4().hex[:8]}", "regression-test", + "appeals_committee", + ) + finally: + await conn.close() + + _run(event_loop, attempt()) + + +def test_cases_accepts_domain_practice_area(dsn: str, event_loop) -> None: + """Sanity check: rishuy_uvniya / betterment_levy / compensation_197 + + empty string must be accepted.""" + + async def attempt() -> None: + conn = await asyncpg.connect(dsn) + try: + tx = conn.transaction() + await tx.start() + try: + for value in ("rishuy_uvniya", "betterment_levy", + "compensation_197", ""): + await conn.execute( + """INSERT INTO cases (id, case_number, title, practice_area) + VALUES ($1, $2, $3, $4)""", + uuid4(), f"TEST-{uuid4().hex[:8]}", + f"regression-{value or 'empty'}", value, + ) + finally: + await tx.rollback() + finally: + await conn.close() + + _run(event_loop, attempt()) + + +# ── 2. case_law internal_committee chair/district CHECK ───────────── + + +def test_case_law_internal_requires_chair_and_district(dsn: str, event_loop) -> None: + """``case_law`` rows with ``source_kind='internal_committee'`` must have + non-empty ``chair_name`` AND ``district``.""" + + async def attempt_missing_chair() -> None: + conn = await asyncpg.connect(dsn) + try: + with pytest.raises(asyncpg.exceptions.CheckViolationError): + await conn.execute( + """INSERT INTO case_law (id, case_number, case_name, + source_kind, district, chair_name) + VALUES ($1, $2, $3, $4, $5, $6)""", + uuid4(), f"ערר {uuid4().hex[:6]}", + "test internal w/o chair", + "internal_committee", "ירושלים", "", + ) + finally: + await conn.close() + + async def attempt_missing_district() -> None: + conn = await asyncpg.connect(dsn) + try: + with pytest.raises(asyncpg.exceptions.CheckViolationError): + await conn.execute( + """INSERT INTO case_law (id, case_number, case_name, + source_kind, district, chair_name) + VALUES ($1, $2, $3, $4, $5, $6)""", + uuid4(), f"ערר {uuid4().hex[:6]}", + "test internal w/o district", + "internal_committee", "", "עו\"ד דפנה תמיר", + ) + finally: + await conn.close() + + _run(event_loop, attempt_missing_chair()) + _run(event_loop, attempt_missing_district()) + + +# ── 3. case_law external_upload + ערר citation CHECK ──────────────── + + +def test_case_law_external_upload_rejects_arar_citation(dsn: str, event_loop) -> None: + """``case_law`` rows with ``source_kind='external_upload'`` cannot have + a ``case_number`` that starts with ``"ערר"`` or ``"בל\"מ"`` — those + are committee decisions and must use ``source_kind='internal_committee'``.""" + + async def attempt_arar() -> None: + conn = await asyncpg.connect(dsn) + try: + with pytest.raises(asyncpg.exceptions.CheckViolationError): + await conn.execute( + """INSERT INTO case_law (id, case_number, case_name, + source_kind) + VALUES ($1, $2, $3, $4)""", + uuid4(), "ערר 1170/24 חיים נ' ועדה", + "test external arar", "external_upload", + ) + finally: + await conn.close() + + async def attempt_balam() -> None: + conn = await asyncpg.connect(dsn) + try: + with pytest.raises(asyncpg.exceptions.CheckViolationError): + await conn.execute( + """INSERT INTO case_law (id, case_number, case_name, + source_kind) + VALUES ($1, $2, $3, $4)""", + uuid4(), 'בל"מ 1234/25 פלוני', + "test external balam", "external_upload", + ) + finally: + await conn.close() + + _run(event_loop, attempt_arar()) + _run(event_loop, attempt_balam()) + + +# ── 4. MCP precedent_library_upload citation guard ────────────────── + + +def test_mcp_precedent_upload_rejects_arar_citation() -> None: + """The MCP tool ``precedent_library_upload`` must short-circuit + citations that start with ``"ערר"`` / ``"בל\"מ"`` and return an + ``_err`` envelope (a helpful message redirecting to + ``internal_decision_upload``), without touching the DB.""" + + from legal_mcp.tools import precedent_library as tools + + async def call(citation: str) -> dict: + # file_path won't be touched because the guard fires first. + return json.loads( + await tools.precedent_library_upload( + file_path="/nonexistent", + citation=citation, + ) + ) + + loop = asyncio.new_event_loop() + try: + for citation in ( + "ערר 1170/24 חיים נ' ועדה", + 'בל"מ 1234/25 פלוני', + "ARAR 8126-25 ב. קרן-נכסים", + ): + result = loop.run_until_complete(call(citation)) + assert "error" in result, ( + f"expected guard to reject {citation!r}, got {result!r}" + ) + # The error message should mention internal_decision_upload so + # the caller knows the alternative path. + assert "internal_decision_upload" in result["error"], ( + f"error message should redirect to internal_decision_upload, " + f"got {result['error']!r}" + ) + finally: + loop.close() + + +def test_practice_area_module_invariants() -> None: + """Quick guard that the ``practice_area`` service module exposes the + helpers tools and tests depend on, and that derivation is consistent + with the case-number convention (1xxx/8xxx/9xxx).""" + + from legal_mcp.services import practice_area as pa + + # Domain mapping is consistent with the case-number prefix convention. + assert pa.derive_domain_practice_area("1170") == "rishuy_uvniya" + assert pa.derive_domain_practice_area("8126/25") == "betterment_levy" + assert pa.derive_domain_practice_area("9001") == "compensation_197" + assert pa.derive_domain_practice_area("ARAR-25-8126") == "betterment_levy" + # Unparseable input → empty (caller decides fallback). + assert pa.derive_domain_practice_area("foo") == "" + assert pa.derive_domain_practice_area("") == "" + + # Empty practice_area is valid (DB allows it as 'unclassified'). + pa.validate("", "unknown") + pa.validate("rishuy_uvniya", "building_permit") + pa.validate("betterment_levy", "betterment_levy") + + # appeals_committee (axis A) is still recognised for backward-compat. + pa.validate("appeals_committee", "building_permit") + + # is_override returns False when subtype matches derivation. + assert pa.is_override("1170", "rishuy_uvniya", "building_permit") is False + assert pa.is_override("8126", "betterment_levy", "betterment_levy") is False diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index 0526b3c..c2fca21 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -28,6 +28,7 @@ | `voyage_rerank_corpus_poc.py` | python | POC #5 — voyage-3 vs rerank-2 על קורפוס מלא (785 docs). הכרעה: +4.5% mean@3 כללי, +11.6% על P queries (practical) | בנצ'מרק חד-פעמי, אישר את שלב B | | `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 ...`. **חייב לרוץ מהמכונה המקומית** (לא קונטיינר) — `claude_session` דורש Claude CLI | ידני per-case (`python scripts/backfill_legal_arguments.py --apply --case 1017-03-26`) | ## תיקיית `.archive/` — סקריפטים שהושלמו diff --git a/scripts/backfill_legal_arguments.py b/scripts/backfill_legal_arguments.py new file mode 100755 index 0000000..e4394f6 --- /dev/null +++ b/scripts/backfill_legal_arguments.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Backfill aggregated legal_arguments for existing cases. + +For every case that has rows in ``claims`` but none in ``legal_arguments``, +run ``argument_aggregator.aggregate_claims_to_arguments``. + +Usage (must use mcp-server venv — pgvector + asyncpg are vendored there): + PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python + + # Default = dry-run (lists what would be processed): + $PY scripts/backfill_legal_arguments.py + + # Process all cases that need it: + $PY scripts/backfill_legal_arguments.py --apply + + # Re-aggregate even cases that already have arguments: + $PY scripts/backfill_legal_arguments.py --apply --force + + # Only process specific cases: + $PY scripts/backfill_legal_arguments.py --apply --case 1017-03-26 1018-03-26 + +The script must run from the local dev machine (not the container) because +``argument_aggregator`` calls ``claude_session`` which needs the Claude CLI. +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +from pathlib import Path +from uuid import UUID + +# Make the mcp-server source importable as ``legal_mcp``. +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src")) + +# Default DB connection (overridable via env / .env on the dev box). +if "POSTGRES_URL" not in os.environ: + pg_user = os.environ.get("POSTGRES_USER", "legal_ai") + pg_pw = os.environ.get("POSTGRES_PASSWORD", "") + pg_host = os.environ.get("POSTGRES_HOST", "127.0.0.1") + pg_port = os.environ.get("POSTGRES_PORT", "5433") + pg_db = os.environ.get("POSTGRES_DB", "legal_ai") + os.environ["POSTGRES_URL"] = ( + f"postgres://{pg_user}:{pg_pw}@{pg_host}:{pg_port}/{pg_db}" + ) + + +async def _list_cases_needing_backfill(force: bool) -> list[dict]: + """Find cases that have claims but no aggregated arguments (or all, + when ``force`` is True).""" + from legal_mcp.services import db + + pool = await db.get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT c.id, c.case_number, c.status, + COUNT(DISTINCT cl.id) AS claim_count, + COUNT(DISTINCT la.id) AS arg_count + FROM cases c + LEFT JOIN claims cl ON cl.case_id = c.id + LEFT JOIN legal_arguments la ON la.case_id = c.id + WHERE c.archived_at IS NULL + GROUP BY c.id, c.case_number, c.status + HAVING COUNT(DISTINCT cl.id) > 0 + ORDER BY c.case_number + """ + ) + out: list[dict] = [] + for r in rows: + d = dict(r) + if force or d["arg_count"] == 0: + out.append(d) + return out + + +async def _process_case(case: dict, force: bool) -> dict: + from legal_mcp.services import argument_aggregator + + case_id = UUID(str(case["id"])) + case_number = case["case_number"] + print( + f"[backfill] {case_number}: {case['claim_count']} claims, " + f"{case['arg_count']} existing args — aggregating (force={force})...", + flush=True, + ) + try: + result = await argument_aggregator.aggregate_claims_to_arguments( + case_id, force=force, + ) + except Exception as e: # noqa: BLE001 + return { + "case_number": case_number, + "status": "error", + "error": str(e), + } + print( + f"[backfill] {case_number}: status={result.get('status')} " + f"total={result.get('total')} by_party={result.get('by_party')}", + flush=True, + ) + return {"case_number": case_number, **result} + + +async def main() -> int: + parser = argparse.ArgumentParser( + description="Backfill legal_arguments for cases with extracted claims.", + ) + parser.add_argument( + "--apply", action="store_true", + help="Actually run aggregation (default: dry-run).", + ) + parser.add_argument( + "--force", action="store_true", + help="Re-aggregate even cases that already have arguments.", + ) + parser.add_argument( + "--case", nargs="*", default=[], + help="Only process these case numbers (e.g. --case 1017-03-26 1018-03-26).", + ) + args = parser.parse_args() + + cases = await _list_cases_needing_backfill(force=args.force) + if args.case: + wanted = set(args.case) + cases = [c for c in cases if c["case_number"] in wanted] + + if not cases: + print("[backfill] No cases need processing.") + return 0 + + print(f"[backfill] {len(cases)} case(s) to process:") + for c in cases: + print( + f" - {c['case_number']:<14} status={c['status']:<20} " + f"claims={c['claim_count']:<4} args={c['arg_count']}", + ) + + if not args.apply: + print("\n[backfill] dry-run — pass --apply to actually run.") + return 0 + + print() + results: list[dict] = [] + for case in cases: + r = await _process_case(case, force=args.force) + results.append(r) + + print("\n[backfill] === Summary ===") + for r in results: + print( + f" {r['case_number']:<14} status={r.get('status', 'unknown'):<22} " + f"total={r.get('total', 0)}", + ) + + errors = [r for r in results if r.get("status") == "error"] + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/web-ui/src/app/cases/[caseNumber]/page.tsx b/web-ui/src/app/cases/[caseNumber]/page.tsx index 8e5e015..da1f46f 100644 --- a/web-ui/src/app/cases/[caseNumber]/page.tsx +++ b/web-ui/src/app/cases/[caseNumber]/page.tsx @@ -14,6 +14,7 @@ import { StatusGuide } from "@/components/cases/status-guide"; import { StatusChanger } from "@/components/cases/status-changer"; import { DocumentsPanel } from "@/components/cases/documents-panel"; import { DraftsPanel } from "@/components/cases/drafts-panel"; +import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel"; import { AgentActivityFeed } from "@/components/cases/agent-activity-feed"; import { AgentStatusWidget } from "@/components/cases/agent-status-widget"; import { UploadSheet } from "@/components/documents/upload-sheet"; @@ -77,6 +78,9 @@ export default function CaseDetailPage({
סקירה + + טיעונים + טיוטות והערות @@ -139,6 +143,10 @@ export default function CaseDetailPage({ + + + + = { + open: "bg-gold-wash text-gold-deep border-gold/40", + uploaded: "bg-rule-soft text-ink-muted border-rule", + closed: "bg-emerald-50 text-emerald-800 border-emerald-300/60", + irrelevant: "bg-rule-soft text-ink-muted border-rule", + }; + return ( + + {count} + + ); +} + +export default function MissingPrecedentsPage() { + const [caseNumber, setCaseNumber] = useState(""); + const [legalTopic, setLegalTopic] = useState(""); + + const counts = useMissingPrecedents({ limit: 1 }); + const byStatus = counts.data?.by_status ?? {}; + + return ( + +
+
+ +

פסיקה חסרה בקורפוס

+

+ פסיקות שצוטטו בכתבי הטענות אך אינן עדיין בקורפוס. סוכן המחקר רושם + פערים אוטומטית; היו"ר סוגר אותם על־ידי העלאת המסמך — ניתוב + אוטומטי בין הקורפוס הסמכותי (פסקי דין) להחלטות ועדות ערר. +

+
+ +
+ + + + {/* Shared filters */} +
+
+ + setCaseNumber(e.target.value)} + placeholder="1017-03-26" + dir="rtl" + /> +
+
+ + setLegalTopic(e.target.value)} + placeholder="זכות עמידה" + dir="rtl" + /> +
+
+ + + + + פתוחות + + + + הועלו + + + + נסגרו + + + + לא רלוונטי + + + הכל + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ ); +} diff --git a/web-ui/src/components/app-shell.tsx b/web-ui/src/components/app-shell.tsx index f4c9acc..b13669d 100644 --- a/web-ui/src/components/app-shell.tsx +++ b/web-ui/src/components/app-shell.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/dropdown-menu"; import { GlobalSearch } from "@/components/global-search"; import { headerSubtitle } from "@/components/header-context"; +import { useMissingPrecedentsOpenCount } from "@/lib/api/missing-precedents"; /** * Ezer Mishpati navigation shell — two-row header. @@ -45,9 +46,10 @@ const NAV_GROUPS: NavGroup[] = [ { id: "knowledge", items: [ - { href: "/precedents", label: "ספריית פסיקה" }, - { href: "/training", label: "אימון סגנון" }, - { href: "/methodology", label: "מתודולוגיה" }, + { href: "/precedents", label: "ספריית פסיקה" }, + { href: "/missing-precedents", label: "פסיקה חסרה" }, + { href: "/training", label: "אימון סגנון" }, + { href: "/methodology", label: "מתודולוגיה" }, ], }, ]; @@ -240,7 +242,8 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) { : "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"} `} > - {item.label} + {item.label} + {item.href === "/missing-precedents" ? : null} {active && ( ); } + +/* Small open-count badge next to "פסיקה חסרה" — only renders when >0 + * so the nav stays quiet in normal operation. */ +function MissingPrecedentsBadge() { + const { data: openCount } = useMissingPrecedentsOpenCount(); + if (!openCount) return null; + return ( + + {openCount} + + ); +} diff --git a/web-ui/src/components/cases/appeal-type-bars.tsx b/web-ui/src/components/cases/appeal-type-bars.tsx index 5cf724b..660a876 100644 --- a/web-ui/src/components/cases/appeal-type-bars.tsx +++ b/web-ui/src/components/cases/appeal-type-bars.tsx @@ -12,17 +12,35 @@ const BUCKETS: Bucket[] = [ { key: "compensation_197", label: "פיצויים (ס׳ 197)", color: "var(--color-warn)" }, ]; +/* For chart aggregation, collapse בל"מ variants back to their parent + * domain — building_permit / betterment_levy / compensation_197. The + * dedicated בל"מ filter in the cases table handles the cross-cutting view. */ +function collapseBlam(s: AppealSubtype): AppealSubtype { + if (s === "extension_request_building_permit") return "building_permit"; + if (s === "extension_request_betterment_levy") return "betterment_levy"; + if (s === "extension_request_compensation") return "compensation_197"; + return s; +} + export function subtypeOf(c: Case): AppealSubtype { - return c.appeal_subtype && c.appeal_subtype !== "unknown" + const raw = c.appeal_subtype && c.appeal_subtype !== "unknown" ? c.appeal_subtype : deriveSubtype(c.case_number); + return collapseBlam(raw); } export function AppealTypeBars({ cases }: { cases?: Case[] }) { + /* All seven subtypes initialized to 0 — subtypeOf() collapses בל"מ + * variants back to their parent domain, so the extension_request_* + * counters will remain 0 in practice; they exist here to satisfy the + * Record type. */ const counts: Record = { building_permit: 0, betterment_levy: 0, compensation_197: 0, + extension_request_building_permit: 0, + extension_request_betterment_levy: 0, + extension_request_compensation: 0, unknown: 0, }; (cases ?? []).forEach((c) => { diff --git a/web-ui/src/components/cases/case-header.tsx b/web-ui/src/components/cases/case-header.tsx index c4c48ae..191496b 100644 --- a/web-ui/src/components/cases/case-header.tsx +++ b/web-ui/src/components/cases/case-header.tsx @@ -8,6 +8,7 @@ import { CreateRepoButton } from "@/components/cases/create-repo-button"; import { PRACTICE_AREA_LABELS, APPEAL_SUBTYPE_LABELS, + isBlamSubtype, } from "@/lib/practice-area"; import type { CaseDetail } from "@/lib/api/cases"; @@ -62,6 +63,15 @@ export function CaseHeader({ data }: { data?: CaseDetail }) { )} )} + {isBlamSubtype(data?.appeal_subtype) && ( + + בל"מ + + )} {data?.case_number && ( [] = [ accessorKey: "title", header: "כותרת", cell: ({ row }) => ( -
- {row.original.title} +
+ {isBlamSubtype(row.original.appeal_subtype) && ( + + בל"מ + + )} + {row.original.title}
), }, @@ -94,8 +108,15 @@ export function CasesTable({ { id: "updated_at", desc: true }, ]); const [globalFilter, setGlobalFilter] = useState(""); + /* "all" = all cases; "blam" = only בל"מ; "regular" = exclude בל"מ */ + const [blamFilter, setBlamFilter] = useState<"all" | "blam" | "regular">("all"); - const data = useMemo(() => cases ?? [], [cases]); + const data = useMemo(() => { + const all = cases ?? []; + if (blamFilter === "blam") return all.filter((c) => isBlamSubtype(c.appeal_subtype)); + if (blamFilter === "regular") return all.filter((c) => !isBlamSubtype(c.appeal_subtype)); + return all; + }, [cases, blamFilter]); const table = useReactTable({ data, @@ -126,6 +147,20 @@ export function CasesTable({ className="max-w-sm bg-surface" dir="rtl" /> + {table.getFilteredRowModel().rows.length} תיקים diff --git a/web-ui/src/components/cases/legal-arguments-panel.tsx b/web-ui/src/components/cases/legal-arguments-panel.tsx new file mode 100644 index 0000000..bb591a9 --- /dev/null +++ b/web-ui/src/components/cases/legal-arguments-panel.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useMemo } from "react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + PARTY_LABELS_HE, + PRIORITY_LABELS_HE, + PRIORITY_ORDER, + useAggregateArguments, + useLegalArguments, + type LegalArgument, + type LegalArgumentParty, + type LegalArgumentPriority, +} from "@/lib/api/legal-arguments"; +import { toast } from "sonner"; +import { Loader2, RefreshCw, Sparkles } from "lucide-react"; + +const PRIORITY_BADGE_TONE: Record = { + threshold: "bg-danger-bg/60 text-danger-strong border-danger/40", + substantive: "bg-gold-soft/50 text-navy border-gold/40", + procedural: "bg-rule-soft text-ink border-rule", + relief: "bg-emerald-50 text-emerald-900 border-emerald-200", +}; + +function groupByPriority( + args: LegalArgument[], +): Record { + const out: Record = { + threshold: [], + substantive: [], + procedural: [], + relief: [], + }; + for (const a of args) { + (out[a.priority] ?? out.substantive).push(a); + } + for (const key of PRIORITY_ORDER) { + out[key].sort((x, y) => x.argument_index - y.argument_index); + } + return out; +} + +type PartySectionProps = { + party: LegalArgumentParty; + args: LegalArgument[]; +}; + +function PartySection({ party, args }: PartySectionProps) { + const grouped = useMemo(() => groupByPriority(args), [args]); + return ( +
+
+

+ {PARTY_LABELS_HE[party] ?? party} +

+ + {args.length} טיעונים + +
+ {PRIORITY_ORDER.map((priority) => { + const list = grouped[priority]; + if (!list?.length) return null; + return ( +
+
+ + {PRIORITY_LABELS_HE[priority]} + + + {list.length} טיעונים + +
+ + {list.map((arg) => ( + + +
+ + {arg.argument_index}. {arg.argument_title} + + {arg.legal_topic && ( + + {arg.legal_topic} + + )} +
+
+ +
+

+ {arg.argument_body} +

+ {arg.supporting_claims.length > 0 && ( +

+ מסתמך על {arg.supporting_claims.length} פרופוזיציות + גולמיות. +

+ )} +
+
+
+ ))} +
+
+ ); + })} +
+ ); +} + +type LegalArgumentsPanelProps = { + caseNumber: string; +}; + +export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) { + const { data, isPending, isError, error } = useLegalArguments(caseNumber); + const aggregate = useAggregateArguments(caseNumber); + + const parties = useMemo(() => { + if (!data?.by_party) return []; + const order: LegalArgumentParty[] = [ + "appellant", + "respondent", + "committee", + "permit_applicant", + "unknown", + ]; + return order.filter((p) => (data.by_party[p]?.length ?? 0) > 0); + }, [data]); + + const handleAggregate = (force: boolean) => { + aggregate.mutate(force, { + onSuccess: () => { + toast.success( + force + ? "הופעלה חזרה חישוב טיעונים (force). יסתיים תוך דקה." + : "הופעל חישוב טיעונים. רענן בעוד דקה.", + ); + }, + onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`), + }); + }; + + return ( + + +
+
+

+ טיעונים משפטיים +

+

+ טיעונים מאוגדים מתוך הפרופוזיציות הגולמיות, מקובצים לפי צד וקדימות. +

+
+
+ + +
+
+ + {isPending ? ( +
+ + + +
+ ) : isError ? ( +

+ שגיאה בטעינת טיעונים: {(error as Error).message} +

+ ) : !data?.total ? ( +

+ אין טיעונים מאוגדים עדיין. לחץ "חשב טיעונים" כדי להריץ את ה-aggregator. +

+ ) : ( +
+ {parties.map((party) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/web-ui/src/components/missing-precedents/missing-precedent-detail-drawer.tsx b/web-ui/src/components/missing-precedents/missing-precedent-detail-drawer.tsx new file mode 100644 index 0000000..f002bf6 --- /dev/null +++ b/web-ui/src/components/missing-precedents/missing-precedent-detail-drawer.tsx @@ -0,0 +1,512 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Upload, Save, Loader2, CheckCircle2 } from "lucide-react"; +import { toast } from "sonner"; +import { + Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { + useMissingPrecedent, + useUpdateMissingPrecedent, + useUploadMissingPrecedent, + CITED_BY_PARTY_LABELS, + STATUS_LABELS, + type CitedByParty, + type MissingPrecedentStatus, + type MissingPrecedentPatch, +} from "@/lib/api/missing-precedents"; +import { + PRACTICE_AREAS, PRECEDENT_LEVELS, DISTRICTS, +} from "@/components/precedents/practice-area"; + +type Props = { + id: string | null; + onOpenChange: (open: boolean) => void; +}; + +const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md"; + +function isCommitteeCitation(citation: string): boolean { + const norm = citation.trim(); + return /^(ערר[\s(]|בל"מ[\s(]|ARAR )/.test(norm); +} + +export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) { + const open = id !== null; + const { data: mp, isPending } = useMissingPrecedent(id); + const update = useUpdateMissingPrecedent(); + const upload = useUploadMissingPrecedent(); + + // Edit form for metadata. + const [legalTopic, setLegalTopic] = useState(""); + const [legalIssue, setLegalIssue] = useState(""); + const [cityParty, setCitedByParty] = useState("unknown"); + const [citedByPartyName, setCitedByPartyName] = useState(""); + const [caseName, setCaseName] = useState(""); + const [notes, setNotes] = useState(""); + const [status, setStatus] = useState("open"); + + // Upload form fields. + const [file, setFile] = useState(null); + const [decisionDate, setDecisionDate] = useState(""); + const [court, setCourt] = useState(""); + const [practiceArea, setPracticeArea] = useState(""); + const [appealSubtype, setAppealSubtype] = useState(""); + const [precedentLevel, setPrecedentLevel] = useState(""); + const [chairName, setChairName] = useState(""); + const [district, setDistrict] = useState(""); + const [committeeCaseNumber, setCommitteeCaseNumber] = useState(""); + const [summary, setSummary] = useState(""); + + // Sync form from record when it loads or id changes. + const [syncedId, setSyncedId] = useState(null); + if (mp && mp.id !== syncedId) { + setSyncedId(mp.id); + setLegalTopic(mp.legal_topic ?? ""); + setLegalIssue(mp.legal_issue ?? ""); + setCitedByParty(mp.cited_by_party ?? "unknown"); + setCitedByPartyName(mp.cited_by_party_name ?? ""); + setCaseName(mp.case_name ?? ""); + setNotes(mp.notes ?? ""); + setStatus(mp.status); + } + + // Reset on close. The cascading-render warning is the intended side + // effect here — wiping the form when the drawer closes. + useEffect(() => { + if (open) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + setFile(null); + setSyncedId(null); + setDecisionDate(""); setCourt(""); setPracticeArea(""); + setAppealSubtype(""); setPrecedentLevel(""); setChairName(""); + setDistrict(""); setCommitteeCaseNumber(""); setSummary(""); + }, [open]); + + const handleSaveMetadata = async () => { + if (!mp) return; + const patch: MissingPrecedentPatch = { + legal_topic: legalTopic, + legal_issue: legalIssue, + cited_by_party: cityParty, + cited_by_party_name: citedByPartyName, + case_name: caseName, + notes, + status, + }; + try { + await update.mutateAsync({ id: mp.id, patch }); + toast.success("הרשומה עודכנה"); + } catch (e) { + toast.error("העדכון נכשל"); + console.error(e); + } + }; + + const isCommittee = mp ? isCommitteeCitation(mp.citation) : false; + + const handleUpload = async (e: React.FormEvent) => { + e.preventDefault(); + if (!mp || !file) { + toast.error("בחר קובץ"); + return; + } + if (isCommittee && (!chairName.trim() || !district.trim())) { + toast.error("החלטת ועדת ערר דורשת שם יו״ר ומחוז"); + return; + } + try { + const result = await upload.mutateAsync({ + id: mp.id, + file, + case_number: isCommittee ? committeeCaseNumber || undefined : undefined, + chair_name: isCommittee ? chairName : undefined, + district: isCommittee ? district : undefined, + case_name: caseName || undefined, + court: court || undefined, + decision_date: decisionDate || undefined, + practice_area: practiceArea || undefined, + appeal_subtype: appealSubtype || undefined, + precedent_level: precedentLevel || undefined, + source_type: isCommittee ? "appeals_committee" : "court_ruling", + summary: summary || undefined, + }); + toast.success( + `הפסיקה נכנסה לקורפוס (${result.route === "internal_committee" ? "ועדת ערר" : "פסק דין"}) והרשומה נסגרה.`, + ); + onOpenChange(false); + } catch (e: unknown) { + const msg = + e instanceof Error + ? e.message + : typeof e === "string" + ? e + : "כשל העלאה"; + toast.error(msg); + console.error(e); + } + }; + + return ( + + + + + פסיקה חסרה + {mp ? ( + + {STATUS_LABELS[mp.status]} + + ) : null} + + + פרטים מלאים והעלאת הפסיקה לקורפוס. + + + + {isPending || !mp ? ( +
+ + + +
+ ) : ( +
+ {/* ── Citation block (read-only) ── */} +
+
מראה מקום
+
+ {mp.citation} +
+ {mp.claim_quote ? ( + <> +
ציטוט מכתב הטענות
+
+ {mp.claim_quote} +
+ + ) : null} +
+ + {/* ── Linked record (if closed) ── */} + {mp.linked_case_law_id ? ( +
+
+ + מקושר ל +
+
+ {mp.linked_case_law_name || "—"} +
+
+ {mp.linked_case_law_number} +
+
+ ) : null} + + {/* ── Editable metadata ── */} +
+

מטא־דאטה

+ +
+
+ + setLegalTopic(e.target.value)} + placeholder="זכות עמידה" + dir="rtl" + /> +
+
+ + setCaseName(e.target.value)} + placeholder="אנטרים" + dir="rtl" + /> +
+
+ +
+ +