feat: Stage A finalizers + #35/#36/#37 — critical-gap closure
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,9 @@ tools:
|
|||||||
- mcp__legal-ai__precedent_process_pending
|
- mcp__legal-ai__precedent_process_pending
|
||||||
- mcp__legal-ai__halacha_review
|
- mcp__legal-ai__halacha_review
|
||||||
- mcp__legal-ai__halachot_pending
|
- 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
|
- mcp__legal-ai__workflow_status
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -258,6 +261,33 @@ search_internal_decisions(
|
|||||||
|
|
||||||
**מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו.
|
**מינימום:** 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. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||||
|
|
||||||
### שלב 3: מיפוי תכנית
|
### שלב 3: מיפוי תכנית
|
||||||
|
|||||||
227
docs/methodology/extension-request-betterment_levy.md
Normal file
227
docs/methodology/extension-request-betterment_levy.md
Normal file
@@ -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` — מדריך סגנון של דפנה
|
||||||
252
docs/methodology/extension-request-building_permit.md
Normal file
252
docs/methodology/extension-request-building_permit.md
Normal file
@@ -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/`
|
||||||
215
docs/methodology/extension-request-compensation.md
Normal file
215
docs/methodology/extension-request-compensation.md
Normal file
@@ -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` — מדריך סגנון של דפנה
|
||||||
@@ -54,6 +54,8 @@ from legal_mcp.tools import ( # noqa: E402
|
|||||||
cases, documents, search, drafting, workflow, precedents,
|
cases, documents, search, drafting, workflow, precedents,
|
||||||
precedent_library as plib,
|
precedent_library as plib,
|
||||||
internal_decisions as int_tools,
|
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)
|
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
|
# References
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def extract_references(
|
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()
|
@mcp.tool()
|
||||||
async def record_chair_feedback(
|
async def record_chair_feedback(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
|
|||||||
358
mcp-server/src/legal_mcp/services/argument_aggregator.py
Normal file
358
mcp-server/src/legal_mcp/services/argument_aggregator.py
Normal file
@@ -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
|
||||||
@@ -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 def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute(SCHEMA_SQL)
|
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_V10_SQL)
|
||||||
await conn.execute(SCHEMA_V11_SQL)
|
await conn.execute(SCHEMA_V11_SQL)
|
||||||
await conn.execute(SCHEMA_V12_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:
|
async def init_schema() -> None:
|
||||||
@@ -782,7 +862,10 @@ async def create_case(
|
|||||||
hearing_date: date | None = None,
|
hearing_date: date | None = None,
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
expected_outcome: 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 = "",
|
appeal_subtype: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
@@ -3106,3 +3189,228 @@ async def search_precedent_library_hybrid(
|
|||||||
merged.append(d)
|
merged.append(d)
|
||||||
merged.sort(key=lambda x: -x["score"])
|
merged.sort(key=lambda x: -x["score"])
|
||||||
return merged[:limit]
|
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
|
||||||
|
|||||||
@@ -52,16 +52,44 @@ DOMAIN_PRACTICE_AREAS: set[str] = {
|
|||||||
"compensation_197",
|
"compensation_197",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Union — what ``validate()`` accepts for backward-compat
|
# Union — what ``validate()`` accepts for backward-compat.
|
||||||
PRACTICE_AREAS: set[str] = MULTI_TENANT_PRACTICE_AREAS | DOMAIN_PRACTICE_AREAS
|
# 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] = {
|
APPEALS_COMMITTEE_SUBTYPES: set[str] = {
|
||||||
"building_permit",
|
"building_permit",
|
||||||
"betterment_levy",
|
"betterment_levy",
|
||||||
"compensation_197",
|
"compensation_197",
|
||||||
|
# בל"מ — בקשה להארכת מועד להגשת ערר. מסלולים נפרדים לפי domain:
|
||||||
|
"extension_request_building_permit", # 1xxx — סעיף 152, 30 ימים
|
||||||
|
"extension_request_betterment_levy", # 8xxx — סעיף 14 לתוספת ג', 45 ימים
|
||||||
|
"extension_request_compensation", # 9xxx — סעיף 198(ד), 30 ימים
|
||||||
"unknown",
|
"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"
|
DEFAULT_PRACTICE_AREA = "appeals_committee"
|
||||||
|
|
||||||
# Subtypes per practice_area (extend when adding domains)
|
# Subtypes per practice_area (extend when adding domains)
|
||||||
@@ -70,9 +98,11 @@ SUBTYPES_BY_AREA: dict[str, set[str]] = {
|
|||||||
"national_insurance": {"unknown"},
|
"national_insurance": {"unknown"},
|
||||||
"labor_law": {"unknown"},
|
"labor_law": {"unknown"},
|
||||||
# Domain values — subtype is implicit in the value itself
|
# Domain values — subtype is implicit in the value itself
|
||||||
"rishuy_uvniya": {"building_permit", "unknown"},
|
"rishuy_uvniya": {"building_permit", "extension_request_building_permit", "unknown"},
|
||||||
"betterment_levy": {"betterment_levy", "unknown"},
|
"betterment_levy": {"betterment_levy", "extension_request_betterment_levy", "unknown"},
|
||||||
"compensation_197": {"compensation_197", "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
|
# Mapping: (multi_tenant_pa, appeal_subtype) → domain_pa
|
||||||
@@ -80,9 +110,39 @@ _SUBTYPE_TO_DOMAIN: dict[str, str] = {
|
|||||||
"building_permit": "rishuy_uvniya",
|
"building_permit": "rishuy_uvniya",
|
||||||
"betterment_levy": "betterment_levy",
|
"betterment_levy": "betterment_levy",
|
||||||
"compensation_197": "compensation_197",
|
"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:
|
def to_db_practice_area(practice_area: str, appeal_subtype: str = "") -> str:
|
||||||
"""Convert a multi-tenant practice_area + appeal_subtype to the
|
"""Convert a multi-tenant practice_area + appeal_subtype to the
|
||||||
domain value stored in DB columns (case_law/cases).
|
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})")
|
_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:
|
def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) -> str:
|
||||||
"""Infer the appeal_subtype from case_number.
|
"""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.
|
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.
|
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":
|
if practice_area != "appeals_committee":
|
||||||
return "unknown"
|
return "unknown"
|
||||||
cn = case_number or ""
|
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")
|
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 ─────────────────────────────────────────────────────
|
# ── 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:
|
def is_override(case_number: str, practice_area: str, appeal_subtype: str) -> bool:
|
||||||
"""True iff the user-supplied subtype disagrees with what derive_subtype
|
"""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)
|
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
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ async def case_create(
|
|||||||
hearing_date: str = "",
|
hearing_date: str = "",
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
expected_outcome: str = "",
|
expected_outcome: str = "",
|
||||||
practice_area: str = "appeals_committee",
|
practice_area: str = "",
|
||||||
appeal_subtype: str = "",
|
appeal_subtype: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""יצירת תיק ערר חדש.
|
"""יצירת תיק ערר חדש.
|
||||||
@@ -145,7 +145,9 @@ async def case_create(
|
|||||||
hearing_date: תאריך דיון (YYYY-MM-DD)
|
hearing_date: תאריך דיון (YYYY-MM-DD)
|
||||||
notes: הערות
|
notes: הערות
|
||||||
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
|
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).
|
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
|
||||||
ריק = יוסק אוטומטית ממספר התיק
|
ריק = יוסק אוטומטית ממספר התיק
|
||||||
"""
|
"""
|
||||||
@@ -155,8 +157,18 @@ async def case_create(
|
|||||||
if hearing_date:
|
if hearing_date:
|
||||||
h_date = date_type.fromisoformat(hearing_date)
|
h_date = date_type.fromisoformat(hearing_date)
|
||||||
|
|
||||||
# Resolve appeal_subtype: explicit override > auto-derive > 'unknown'
|
# Auto-derive practice_area when missing or set to the legacy multi-tenant
|
||||||
derived_subtype = pa.derive_subtype(case_number, practice_area)
|
# 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:
|
if not appeal_subtype:
|
||||||
appeal_subtype = derived_subtype
|
appeal_subtype = derived_subtype
|
||||||
pa.validate(practice_area, appeal_subtype)
|
pa.validate(practice_area, appeal_subtype)
|
||||||
|
|||||||
83
mcp-server/src/legal_mcp/tools/legal_arguments.py
Normal file
83
mcp-server/src/legal_mcp/tools/legal_arguments.py
Normal file
@@ -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)
|
||||||
210
mcp-server/src/legal_mcp/tools/missing_precedents.py
Normal file
210
mcp-server/src/legal_mcp/tools/missing_precedents.py
Normal file
@@ -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)
|
||||||
276
mcp-server/tests/test_corpus_constraints.py
Normal file
276
mcp-server/tests/test_corpus_constraints.py
Normal file
@@ -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
|
||||||
@@ -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 |
|
| `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`) |
|
| `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_chunk_pages.py` | python | Backfill `page_number` ב-`document_chunks` קיימים. legacy chunker לא tracked עמודים → `page_number=NULL` חוסם boost של multimodal hybrid (text+image join על אותו עמוד). re-extracts כל PDF (re-OCR אם צריך, ~$0.0015/page), מחשב page_offsets, ומעדכן chunks. idempotent | ידני per-case (`python backfill_chunk_pages.py 8174-24 8137-24`) |
|
||||||
|
| `backfill_legal_arguments.py` | python | Backfill `legal_arguments` לתיקים עם `claims` קיימים (TaskMaster #36). מקבץ פרופוזיציות גולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד) דרך `argument_aggregator.aggregate_claims_to_arguments` (Claude CLI). תומך `--dry-run`/`--apply`/`--force`/`--case <num>...`. **חייב לרוץ מהמכונה המקומית** (לא קונטיינר) — `claude_session` דורש Claude CLI | ידני per-case (`python scripts/backfill_legal_arguments.py --apply --case 1017-03-26`) |
|
||||||
|
|
||||||
## תיקיית `.archive/` — סקריפטים שהושלמו
|
## תיקיית `.archive/` — סקריפטים שהושלמו
|
||||||
|
|
||||||
|
|||||||
164
scripts/backfill_legal_arguments.py
Executable file
164
scripts/backfill_legal_arguments.py
Executable file
@@ -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()))
|
||||||
@@ -14,6 +14,7 @@ import { StatusGuide } from "@/components/cases/status-guide";
|
|||||||
import { StatusChanger } from "@/components/cases/status-changer";
|
import { StatusChanger } from "@/components/cases/status-changer";
|
||||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
import { DraftsPanel } from "@/components/cases/drafts-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 { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||||
@@ -77,6 +78,9 @@ export default function CaseDetailPage({
|
|||||||
<div className="flex items-center justify-between gap-3 mb-1 flex-wrap">
|
<div className="flex items-center justify-between gap-3 mb-1 flex-wrap">
|
||||||
<TabsList className="bg-rule-soft/60">
|
<TabsList className="bg-rule-soft/60">
|
||||||
<TabsTrigger value="overview">סקירה</TabsTrigger>
|
<TabsTrigger value="overview">סקירה</TabsTrigger>
|
||||||
|
<TabsTrigger value="arguments">
|
||||||
|
טיעונים
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="drafts">
|
<TabsTrigger value="drafts">
|
||||||
טיוטות והערות
|
טיוטות והערות
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -139,6 +143,10 @@ export default function CaseDetailPage({
|
|||||||
<DocumentsPanel data={data} />
|
<DocumentsPanel data={data} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="arguments" className="mt-5">
|
||||||
|
<LegalArgumentsPanel caseNumber={caseNumber} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="drafts" className="mt-5">
|
<TabsContent value="drafts" className="mt-5">
|
||||||
<DraftsPanel
|
<DraftsPanel
|
||||||
caseNumber={caseNumber}
|
caseNumber={caseNumber}
|
||||||
|
|||||||
161
web-ui/src/app/missing-precedents/page.tsx
Normal file
161
web-ui/src/app/missing-precedents/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
useMissingPrecedents,
|
||||||
|
type MissingPrecedentStatus,
|
||||||
|
} from "@/lib/api/missing-precedents";
|
||||||
|
import { MissingPrecedentsTable } from "@/components/missing-precedents/missing-precedents-table";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Missing-precedents page (TaskMaster #35).
|
||||||
|
*
|
||||||
|
* Surfaces citations that party briefs invoke but which aren't yet in the
|
||||||
|
* precedent_library. Four tabs by status; each tab uses the same table
|
||||||
|
* component with a different filter. Drawer (sheet) opens on row click
|
||||||
|
* with metadata + upload form that routes to internal_decision_upload
|
||||||
|
* (ערר/בל"מ citations) or precedent_library_upload (court rulings).
|
||||||
|
*/
|
||||||
|
function StatusBadge({ status, count }: { status: MissingPrecedentStatus; count: number }) {
|
||||||
|
if (!count) return null;
|
||||||
|
const variants: Record<MissingPrecedentStatus, string> = {
|
||||||
|
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 (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`ms-1 text-[0.65rem] ${variants[status]}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MissingPrecedentsPage() {
|
||||||
|
const [caseNumber, setCaseNumber] = useState("");
|
||||||
|
const [legalTopic, setLegalTopic] = useState("");
|
||||||
|
|
||||||
|
const counts = useMissingPrecedents({ limit: 1 });
|
||||||
|
const byStatus = counts.data?.by_status ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||||
|
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||||
|
<span aria-hidden> · </span>
|
||||||
|
<span className="text-navy">פסיקה חסרה בקורפוס</span>
|
||||||
|
</nav>
|
||||||
|
<h1 className="text-navy mb-0">פסיקה חסרה בקורפוס</h1>
|
||||||
|
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
|
||||||
|
פסיקות שצוטטו בכתבי הטענות אך אינן עדיין בקורפוס. סוכן המחקר רושם
|
||||||
|
פערים אוטומטית; היו"ר סוגר אותם על־ידי העלאת המסמך — ניתוב
|
||||||
|
אוטומטי בין הקורפוס הסמכותי (פסקי דין) להחלטות ועדות ערר.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5 space-y-5">
|
||||||
|
{/* Shared filters */}
|
||||||
|
<div className="flex items-end gap-3 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="text-[0.78rem] text-ink-muted">תיק (מספר ערר)</label>
|
||||||
|
<Input
|
||||||
|
value={caseNumber}
|
||||||
|
onChange={(e) => setCaseNumber(e.target.value)}
|
||||||
|
placeholder="1017-03-26"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="text-[0.78rem] text-ink-muted">נושא משפטי</label>
|
||||||
|
<Input
|
||||||
|
value={legalTopic}
|
||||||
|
onChange={(e) => setLegalTopic(e.target.value)}
|
||||||
|
placeholder="זכות עמידה"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="open" dir="rtl">
|
||||||
|
<TabsList className="bg-rule-soft/60">
|
||||||
|
<TabsTrigger value="open">
|
||||||
|
פתוחות
|
||||||
|
<StatusBadge status="open" count={byStatus.open ?? 0} />
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="uploaded">
|
||||||
|
הועלו
|
||||||
|
<StatusBadge status="uploaded" count={byStatus.uploaded ?? 0} />
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="closed">
|
||||||
|
נסגרו
|
||||||
|
<StatusBadge status="closed" count={byStatus.closed ?? 0} />
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="irrelevant">
|
||||||
|
לא רלוונטי
|
||||||
|
<StatusBadge
|
||||||
|
status="irrelevant"
|
||||||
|
count={byStatus.irrelevant ?? 0}
|
||||||
|
/>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="all">הכל</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="open" className="mt-4">
|
||||||
|
<MissingPrecedentsTable
|
||||||
|
status="open"
|
||||||
|
caseNumber={caseNumber.trim() || undefined}
|
||||||
|
legalTopic={legalTopic.trim() || undefined}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="uploaded" className="mt-4">
|
||||||
|
<MissingPrecedentsTable
|
||||||
|
status="uploaded"
|
||||||
|
caseNumber={caseNumber.trim() || undefined}
|
||||||
|
legalTopic={legalTopic.trim() || undefined}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="closed" className="mt-4">
|
||||||
|
<MissingPrecedentsTable
|
||||||
|
status="closed"
|
||||||
|
caseNumber={caseNumber.trim() || undefined}
|
||||||
|
legalTopic={legalTopic.trim() || undefined}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="irrelevant" className="mt-4">
|
||||||
|
<MissingPrecedentsTable
|
||||||
|
status="irrelevant"
|
||||||
|
caseNumber={caseNumber.trim() || undefined}
|
||||||
|
legalTopic={legalTopic.trim() || undefined}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="all" className="mt-4">
|
||||||
|
<MissingPrecedentsTable
|
||||||
|
caseNumber={caseNumber.trim() || undefined}
|
||||||
|
legalTopic={legalTopic.trim() || undefined}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { GlobalSearch } from "@/components/global-search";
|
import { GlobalSearch } from "@/components/global-search";
|
||||||
import { headerSubtitle } from "@/components/header-context";
|
import { headerSubtitle } from "@/components/header-context";
|
||||||
|
import { useMissingPrecedentsOpenCount } from "@/lib/api/missing-precedents";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ezer Mishpati navigation shell — two-row header.
|
* Ezer Mishpati navigation shell — two-row header.
|
||||||
@@ -46,6 +47,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
id: "knowledge",
|
id: "knowledge",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/precedents", label: "ספריית פסיקה" },
|
{ href: "/precedents", label: "ספריית פסיקה" },
|
||||||
|
{ href: "/missing-precedents", label: "פסיקה חסרה" },
|
||||||
{ href: "/training", label: "אימון סגנון" },
|
{ href: "/training", label: "אימון סגנון" },
|
||||||
{ href: "/methodology", 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"}
|
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.label}
|
<span>{item.label}</span>
|
||||||
|
{item.href === "/missing-precedents" ? <MissingPrecedentsBadge /> : null}
|
||||||
{active && (
|
{active && (
|
||||||
<span
|
<span
|
||||||
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
|
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
|
||||||
@@ -250,3 +253,18 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 (
|
||||||
|
<span
|
||||||
|
className="ms-1 inline-flex items-center justify-center min-w-[1.25rem] h-4 px-1 rounded-full bg-gold text-navy text-[0.65rem] font-semibold"
|
||||||
|
aria-label={`${openCount} פסיקות חסרות פתוחות`}
|
||||||
|
>
|
||||||
|
{openCount}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,17 +12,35 @@ const BUCKETS: Bucket[] = [
|
|||||||
{ key: "compensation_197", label: "פיצויים (ס׳ 197)", color: "var(--color-warn)" },
|
{ 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 {
|
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
|
? c.appeal_subtype
|
||||||
: deriveSubtype(c.case_number);
|
: deriveSubtype(c.case_number);
|
||||||
|
return collapseBlam(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppealTypeBars({ cases }: { cases?: Case[] }) {
|
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<AppealSubtype, number> type. */
|
||||||
const counts: Record<AppealSubtype, number> = {
|
const counts: Record<AppealSubtype, number> = {
|
||||||
building_permit: 0,
|
building_permit: 0,
|
||||||
betterment_levy: 0,
|
betterment_levy: 0,
|
||||||
compensation_197: 0,
|
compensation_197: 0,
|
||||||
|
extension_request_building_permit: 0,
|
||||||
|
extension_request_betterment_levy: 0,
|
||||||
|
extension_request_compensation: 0,
|
||||||
unknown: 0,
|
unknown: 0,
|
||||||
};
|
};
|
||||||
(cases ?? []).forEach((c) => {
|
(cases ?? []).forEach((c) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CreateRepoButton } from "@/components/cases/create-repo-button";
|
|||||||
import {
|
import {
|
||||||
PRACTICE_AREA_LABELS,
|
PRACTICE_AREA_LABELS,
|
||||||
APPEAL_SUBTYPE_LABELS,
|
APPEAL_SUBTYPE_LABELS,
|
||||||
|
isBlamSubtype,
|
||||||
} from "@/lib/practice-area";
|
} from "@/lib/practice-area";
|
||||||
import type { CaseDetail } from "@/lib/api/cases";
|
import type { CaseDetail } from "@/lib/api/cases";
|
||||||
|
|
||||||
@@ -62,6 +63,15 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
|
|||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{isBlamSubtype(data?.appeal_subtype) && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full px-2.5 py-0.5 text-[0.72rem] font-bold bg-warn/10 text-warn-deep border-warn/40"
|
||||||
|
title="בקשה להארכת מועד להגשת ערר"
|
||||||
|
>
|
||||||
|
בל"מ
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{data?.case_number && (
|
{data?.case_number && (
|
||||||
<CaseArchiveAction
|
<CaseArchiveAction
|
||||||
caseNumber={data.case_number}
|
caseNumber={data.case_number}
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { StatusBadge } from "@/components/cases/status-badge";
|
import { StatusBadge } from "@/components/cases/status-badge";
|
||||||
|
import { isBlamSubtype } from "@/lib/practice-area";
|
||||||
import type { Case } from "@/lib/api/cases";
|
import type { Case } from "@/lib/api/cases";
|
||||||
|
|
||||||
function formatDate(iso?: string) {
|
function formatDate(iso?: string) {
|
||||||
@@ -49,8 +54,17 @@ const columns: ColumnDef<Case>[] = [
|
|||||||
accessorKey: "title",
|
accessorKey: "title",
|
||||||
header: "כותרת",
|
header: "כותרת",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-ink max-w-[420px] truncate" title={row.original.title}>
|
<div className="text-ink max-w-[420px] truncate flex items-center gap-2" title={row.original.title}>
|
||||||
{row.original.title}
|
{isBlamSubtype(row.original.appeal_subtype) && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full px-1.5 py-0 text-[0.65rem] font-bold bg-warn/10 text-warn-deep border-warn/40 shrink-0"
|
||||||
|
title="בקשה להארכת מועד להגשת ערר"
|
||||||
|
>
|
||||||
|
בל"מ
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{row.original.title}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -94,8 +108,15 @@ export function CasesTable({
|
|||||||
{ id: "updated_at", desc: true },
|
{ id: "updated_at", desc: true },
|
||||||
]);
|
]);
|
||||||
const [globalFilter, setGlobalFilter] = useState("");
|
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({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -126,6 +147,20 @@ export function CasesTable({
|
|||||||
className="max-w-sm bg-surface"
|
className="max-w-sm bg-surface"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
value={blamFilter}
|
||||||
|
onValueChange={(v) => setBlamFilter(v as "all" | "blam" | "regular")}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40 bg-surface">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">כל התיקים</SelectItem>
|
||||||
|
<SelectItem value="blam">בל"מ בלבד</SelectItem>
|
||||||
|
<SelectItem value="regular">ערר רגיל בלבד</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<span className="text-sm text-ink-muted me-auto">
|
<span className="text-sm text-ink-muted me-auto">
|
||||||
{table.getFilteredRowModel().rows.length} תיקים
|
{table.getFilteredRowModel().rows.length} תיקים
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
222
web-ui/src/components/cases/legal-arguments-panel.tsx
Normal file
222
web-ui/src/components/cases/legal-arguments-panel.tsx
Normal file
@@ -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<LegalArgumentPriority, string> = {
|
||||||
|
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<LegalArgumentPriority, LegalArgument[]> {
|
||||||
|
const out: Record<LegalArgumentPriority, LegalArgument[]> = {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-baseline justify-between border-b border-rule pb-2">
|
||||||
|
<h3 className="text-navy text-base font-semibold">
|
||||||
|
{PARTY_LABELS_HE[party] ?? party}
|
||||||
|
</h3>
|
||||||
|
<span className="text-ink-muted text-xs">
|
||||||
|
{args.length} טיעונים
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{PRIORITY_ORDER.map((priority) => {
|
||||||
|
const list = grouped[priority];
|
||||||
|
if (!list?.length) return null;
|
||||||
|
return (
|
||||||
|
<div key={priority} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${PRIORITY_BADGE_TONE[priority]} text-xs`}
|
||||||
|
>
|
||||||
|
{PRIORITY_LABELS_HE[priority]}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-ink-muted text-xs">
|
||||||
|
{list.length} טיעונים
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Accordion type="multiple" className="rounded-md border border-rule bg-surface">
|
||||||
|
{list.map((arg) => (
|
||||||
|
<AccordionItem key={arg.id} value={arg.id} className="px-3">
|
||||||
|
<AccordionTrigger className="text-start">
|
||||||
|
<div className="flex flex-1 flex-col items-start gap-1">
|
||||||
|
<span className="text-navy text-sm font-medium leading-tight">
|
||||||
|
{arg.argument_index}. {arg.argument_title}
|
||||||
|
</span>
|
||||||
|
{arg.legal_topic && (
|
||||||
|
<span className="text-ink-muted text-xs">
|
||||||
|
{arg.legal_topic}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="space-y-2 px-1">
|
||||||
|
<p className="text-ink leading-relaxed whitespace-pre-line">
|
||||||
|
{arg.argument_body}
|
||||||
|
</p>
|
||||||
|
{arg.supporting_claims.length > 0 && (
|
||||||
|
<p className="text-ink-muted text-xs">
|
||||||
|
מסתמך על {arg.supporting_claims.length} פרופוזיציות
|
||||||
|
גולמיות.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LegalArgumentsPanelProps = {
|
||||||
|
caseNumber: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) {
|
||||||
|
const { data, isPending, isError, error } = useLegalArguments(caseNumber);
|
||||||
|
const aggregate = useAggregateArguments(caseNumber);
|
||||||
|
|
||||||
|
const parties = useMemo<LegalArgumentParty[]>(() => {
|
||||||
|
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 (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-navy text-base font-semibold">
|
||||||
|
טיעונים משפטיים
|
||||||
|
</h2>
|
||||||
|
<p className="text-ink-muted text-xs mt-0.5">
|
||||||
|
טיעונים מאוגדים מתוך הפרופוזיציות הגולמיות, מקובצים לפי צד וקדימות.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={aggregate.isPending}
|
||||||
|
onClick={() => handleAggregate(false)}
|
||||||
|
>
|
||||||
|
{aggregate.isPending ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="w-3.5 h-3.5 me-1.5" />
|
||||||
|
)}
|
||||||
|
חשב טיעונים
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={aggregate.isPending || !data?.total}
|
||||||
|
onClick={() => handleAggregate(true)}
|
||||||
|
title="חישוב מחדש (מוחק טיעונים קיימים)"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPending ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-6 w-48" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-danger text-sm">
|
||||||
|
שגיאה בטעינת טיעונים: {(error as Error).message}
|
||||||
|
</p>
|
||||||
|
) : !data?.total ? (
|
||||||
|
<p className="text-ink-muted text-sm">
|
||||||
|
אין טיעונים מאוגדים עדיין. לחץ "חשב טיעונים" כדי להריץ את ה-aggregator.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{parties.map((party) => (
|
||||||
|
<PartySection
|
||||||
|
key={party}
|
||||||
|
party={party}
|
||||||
|
args={data.by_party[party] ?? []}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<CitedByParty>("unknown");
|
||||||
|
const [citedByPartyName, setCitedByPartyName] = useState("");
|
||||||
|
const [caseName, setCaseName] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [status, setStatus] = useState<MissingPrecedentStatus>("open");
|
||||||
|
|
||||||
|
// Upload form fields.
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [decisionDate, setDecisionDate] = useState("");
|
||||||
|
const [court, setCourt] = useState("");
|
||||||
|
const [practiceArea, setPracticeArea] = useState<string>("");
|
||||||
|
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<string | null>(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 (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="left"
|
||||||
|
className="w-full sm:max-w-2xl overflow-y-auto"
|
||||||
|
>
|
||||||
|
<SheetHeader className="space-y-1">
|
||||||
|
<SheetTitle className="text-navy">
|
||||||
|
פסיקה חסרה
|
||||||
|
{mp ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="ms-2 align-middle"
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[mp.status]}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
פרטים מלאים והעלאת הפסיקה לקורפוס.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{isPending || !mp ? (
|
||||||
|
<div className="space-y-3 px-6 py-4">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6 px-6 py-4">
|
||||||
|
{/* ── Citation block (read-only) ── */}
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="text-[0.78rem] text-ink-muted">מראה מקום</div>
|
||||||
|
<div className="text-sm text-navy font-medium bg-rule-soft/40 rounded-md px-3 py-2 leading-relaxed">
|
||||||
|
{mp.citation}
|
||||||
|
</div>
|
||||||
|
{mp.claim_quote ? (
|
||||||
|
<>
|
||||||
|
<div className="text-[0.78rem] text-ink-muted mt-3">ציטוט מכתב הטענות</div>
|
||||||
|
<div className="text-xs text-ink bg-gold-wash/30 border-s-2 border-gold rounded-md px-3 py-2 leading-relaxed">
|
||||||
|
{mp.claim_quote}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Linked record (if closed) ── */}
|
||||||
|
{mp.linked_case_law_id ? (
|
||||||
|
<section className="space-y-1 bg-emerald-50 border border-emerald-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 text-emerald-800 font-medium text-sm">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
מקושר ל
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-emerald-900 truncate">
|
||||||
|
{mp.linked_case_law_name || "—"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-emerald-700 truncate">
|
||||||
|
{mp.linked_case_law_number}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── Editable metadata ── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-navy">מטא־דאטה</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="legal_topic">נושא משפטי</Label>
|
||||||
|
<Input
|
||||||
|
id="legal_topic"
|
||||||
|
value={legalTopic}
|
||||||
|
onChange={(e) => setLegalTopic(e.target.value)}
|
||||||
|
placeholder="זכות עמידה"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="case_name">שם פסיקה</Label>
|
||||||
|
<Input
|
||||||
|
id="case_name"
|
||||||
|
value={caseName}
|
||||||
|
onChange={(e) => setCaseName(e.target.value)}
|
||||||
|
placeholder="אנטרים"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="legal_issue">שאלה משפטית</Label>
|
||||||
|
<Textarea
|
||||||
|
id="legal_issue"
|
||||||
|
value={legalIssue}
|
||||||
|
onChange={(e) => setLegalIssue(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="cited_by_party">צד מצטט</Label>
|
||||||
|
<Select
|
||||||
|
value={cityParty}
|
||||||
|
onValueChange={(v) => setCitedByParty(v as CitedByParty)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.entries(CITED_BY_PARTY_LABELS) as [CitedByParty, string][]).map(
|
||||||
|
([v, label]) => (
|
||||||
|
<SelectItem key={v} value={v}>{label}</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="cited_by_party_name">שם צד</Label>
|
||||||
|
<Input
|
||||||
|
id="cited_by_party_name"
|
||||||
|
value={citedByPartyName}
|
||||||
|
onChange={(e) => setCitedByPartyName(e.target.value)}
|
||||||
|
placeholder="לינדאב בע״מ"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="status">סטטוס</Label>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(v) => setStatus(v as MissingPrecedentStatus)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.entries(STATUS_LABELS) as [
|
||||||
|
MissingPrecedentStatus,
|
||||||
|
string,
|
||||||
|
][]).map(([v, label]) => (
|
||||||
|
<SelectItem key={v} value={v}>{label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="notes">הערות</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveMetadata}
|
||||||
|
disabled={update.isPending}
|
||||||
|
variant="outline"
|
||||||
|
className="border-rule"
|
||||||
|
>
|
||||||
|
{update.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 me-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4 me-1" />
|
||||||
|
)}
|
||||||
|
שמור פרטים
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Upload section ── */}
|
||||||
|
{!mp.linked_case_law_id ? (
|
||||||
|
<section className="space-y-3 border-t border-rule pt-5">
|
||||||
|
<h3 className="text-sm font-semibold text-navy">
|
||||||
|
העלאת הפסיקה לקורפוס
|
||||||
|
</h3>
|
||||||
|
<div className="text-[0.78rem] text-ink-muted">
|
||||||
|
ניתוב אוטומטי לפי הציטוט:
|
||||||
|
<strong className="text-navy">
|
||||||
|
{isCommittee ? "החלטת ועדת ערר (internal)" : "פסק דין (library)"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleUpload} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="file">קובץ (PDF / DOCX / RTF / TXT / MD)</Label>
|
||||||
|
<Input
|
||||||
|
id="file"
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPT}
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="court">ערכאה</Label>
|
||||||
|
<Input
|
||||||
|
id="court"
|
||||||
|
value={court}
|
||||||
|
onChange={(e) => setCourt(e.target.value)}
|
||||||
|
placeholder="בית המשפט העליון"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="decision_date">תאריך</Label>
|
||||||
|
<Input
|
||||||
|
id="decision_date"
|
||||||
|
type="date"
|
||||||
|
value={decisionDate}
|
||||||
|
onChange={(e) => setDecisionDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="practice_area">תחום</Label>
|
||||||
|
<Select value={practiceArea} onValueChange={setPracticeArea}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="ללא" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PRACTICE_AREAS.map((a) => (
|
||||||
|
<SelectItem key={a.value} value={a.value}>
|
||||||
|
{a.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="appeal_subtype">תת־סוג</Label>
|
||||||
|
<Input
|
||||||
|
id="appeal_subtype"
|
||||||
|
value={appealSubtype}
|
||||||
|
onChange={(e) => setAppealSubtype(e.target.value)}
|
||||||
|
placeholder="זכות עמידה"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCommittee ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="chair_name">
|
||||||
|
יו״ר <span className="text-danger">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="chair_name"
|
||||||
|
value={chairName}
|
||||||
|
onChange={(e) => setChairName(e.target.value)}
|
||||||
|
placeholder="דפנה תמיר"
|
||||||
|
dir="rtl"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="district">
|
||||||
|
מחוז <span className="text-danger">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select value={district} onValueChange={setDistrict}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="בחר" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DISTRICTS.map((d) => (
|
||||||
|
<SelectItem key={d.value} value={d.value}>
|
||||||
|
{d.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="committee_case_number">
|
||||||
|
מספר ערר (לציטוט הקטן)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="committee_case_number"
|
||||||
|
value={committeeCaseNumber}
|
||||||
|
onChange={(e) => setCommitteeCaseNumber(e.target.value)}
|
||||||
|
placeholder="ערר 1112/22 ..."
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="precedent_level">רמת תקדים</Label>
|
||||||
|
<Select
|
||||||
|
value={precedentLevel}
|
||||||
|
onValueChange={setPrecedentLevel}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="ללא" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PRECEDENT_LEVELS.map((l) => (
|
||||||
|
<SelectItem key={l.value} value={l.value}>
|
||||||
|
{l.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="summary">תקציר</Label>
|
||||||
|
<Textarea
|
||||||
|
id="summary"
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!file || upload.isPending}
|
||||||
|
className="bg-navy text-parchment hover:bg-navy-soft"
|
||||||
|
>
|
||||||
|
{upload.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 me-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="w-4 h-4 me-1" />
|
||||||
|
)}
|
||||||
|
העלאה וסגירה
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Trash2, Upload, Pencil, ExternalLink } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
useMissingPrecedents,
|
||||||
|
useDeleteMissingPrecedent,
|
||||||
|
CITED_BY_PARTY_LABELS,
|
||||||
|
STATUS_LABELS,
|
||||||
|
type MissingPrecedent,
|
||||||
|
type MissingPrecedentStatus,
|
||||||
|
} from "@/lib/api/missing-precedents";
|
||||||
|
import { MissingPrecedentDetailDrawer } from "./missing-precedent-detail-drawer";
|
||||||
|
|
||||||
|
function formatDate(iso: string | null) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("he-IL");
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: MissingPrecedentStatus }) {
|
||||||
|
const variants: Record<MissingPrecedentStatus, string> = {
|
||||||
|
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 line-through",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={variants[status]}>
|
||||||
|
{STATUS_LABELS[status]}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton({ cols }: { cols: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<TableRow key={i} className="border-rule">
|
||||||
|
{Array.from({ length: cols }).map((__, j) => (
|
||||||
|
<TableCell key={j}>
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
status?: MissingPrecedentStatus | "";
|
||||||
|
caseNumber?: string;
|
||||||
|
legalTopic?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MissingPrecedentsTable({ status, caseNumber, legalTopic }: Props) {
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const { data, isPending, error } = useMissingPrecedents({
|
||||||
|
status: status === "" ? undefined : status,
|
||||||
|
caseNumber,
|
||||||
|
legalTopic,
|
||||||
|
limit: 200,
|
||||||
|
});
|
||||||
|
const del = useDeleteMissingPrecedent();
|
||||||
|
|
||||||
|
const handleDelete = async (mp: MissingPrecedent) => {
|
||||||
|
if (!confirm(`למחוק את הרשומה? ${mp.case_name || mp.citation.slice(0, 60)}...`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await del.mutateAsync(mp.id);
|
||||||
|
toast.success("הרשומה נמחקה");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("מחיקה נכשלה");
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-rule-soft/60">
|
||||||
|
<TableRow className="border-rule">
|
||||||
|
<TableHead className="text-navy text-right">פסיקה</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">נושא</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">תיק</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">צד מצטט</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">סטטוס</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">נוצר</TableHead>
|
||||||
|
<TableHead className="text-navy" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isPending ? (
|
||||||
|
<TableSkeleton cols={7} />
|
||||||
|
) : !data?.items.length ? (
|
||||||
|
<TableRow className="border-rule">
|
||||||
|
<TableCell colSpan={7} className="text-center text-ink-muted py-8">
|
||||||
|
אין פסיקות חסרות בקריטריונים הנוכחיים.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.items.map((mp) => (
|
||||||
|
<TableRow
|
||||||
|
key={mp.id}
|
||||||
|
className="border-rule hover:bg-rule-soft/30 cursor-pointer"
|
||||||
|
onClick={() => setOpenId(mp.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="max-w-[440px]">
|
||||||
|
<div className="text-sm text-navy font-medium truncate">
|
||||||
|
{mp.case_name || mp.citation.split(" ").slice(0, 6).join(" ")}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted truncate" dir="rtl">
|
||||||
|
{mp.citation}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm text-ink">{mp.legal_topic || "—"}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{mp.cited_in_case_number ? (
|
||||||
|
<Link
|
||||||
|
href={`/cases/${encodeURIComponent(mp.cited_in_case_number)}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-sm text-navy hover:text-gold-deep inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{mp.cited_in_case_number}
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-ink-muted text-sm">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-ink">
|
||||||
|
{mp.cited_by_party
|
||||||
|
? CITED_BY_PARTY_LABELS[mp.cited_by_party]
|
||||||
|
: "—"}
|
||||||
|
{mp.cited_by_party_name ? (
|
||||||
|
<div className="text-[0.7rem] text-ink-muted truncate max-w-[160px]">
|
||||||
|
{mp.cited_by_party_name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={mp.status} />
|
||||||
|
{mp.linked_case_law_number ? (
|
||||||
|
<div className="text-[0.7rem] text-emerald-700 mt-1">
|
||||||
|
↳ {mp.linked_case_law_name || mp.linked_case_law_number}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-[0.78rem] text-ink-muted">
|
||||||
|
{formatDate(mp.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-end">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpenId(mp.id);
|
||||||
|
}}
|
||||||
|
title={mp.status === "open" ? "העלאה" : "פרטים"}
|
||||||
|
>
|
||||||
|
{mp.status === "open" ? (
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(mp);
|
||||||
|
}}
|
||||||
|
disabled={del.isPending}
|
||||||
|
className="text-danger hover:text-danger"
|
||||||
|
title="מחיקה"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MissingPrecedentDetailDrawer
|
||||||
|
id={openId}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setOpenId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,21 @@ export const SOURCE_TYPES = [
|
|||||||
{ value: "appeals_committee", label: "החלטת ועדת ערר" },
|
{ value: "appeals_committee", label: "החלטת ועדת ערר" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Districts for ועדות ערר. The chair's committee is ירושלים; the rest
|
||||||
|
* are listed so that uploaded precedents from peer committees can be
|
||||||
|
* filed correctly. Order matches what's displayed in the UI dropdown.
|
||||||
|
*/
|
||||||
|
export const DISTRICTS = [
|
||||||
|
{ value: "ירושלים", label: "ירושלים" },
|
||||||
|
{ value: "מרכז", label: "מרכז" },
|
||||||
|
{ value: "תל אביב", label: "תל אביב" },
|
||||||
|
{ value: "צפון", label: "צפון" },
|
||||||
|
{ value: "דרום", label: "דרום" },
|
||||||
|
{ value: "חיפה", label: "חיפה" },
|
||||||
|
{ value: "ארצי", label: "ארצי" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function practiceAreaLabel(value: string | null | undefined): string {
|
export function practiceAreaLabel(value: string | null | undefined): string {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const match = PRACTICE_AREAS.find((p) => p.value === value);
|
const match = PRACTICE_AREAS.find((p) => p.value === value);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
type SourceType,
|
type SourceType,
|
||||||
} from "@/lib/api/precedent-library";
|
} from "@/lib/api/precedent-library";
|
||||||
import {
|
import {
|
||||||
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, appealSubtypeLabel,
|
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, DISTRICTS, appealSubtypeLabel,
|
||||||
} from "./practice-area";
|
} from "./practice-area";
|
||||||
import { ExtractedHalachotSection } from "./extracted-halachot";
|
import { ExtractedHalachotSection } from "./extracted-halachot";
|
||||||
|
|
||||||
@@ -188,9 +188,21 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="district">מחוז</Label>
|
<Label htmlFor="district">מחוז</Label>
|
||||||
<Input id="district" value={form.district}
|
<Select value={form.district || "_none"}
|
||||||
onChange={(e) => setForm({ ...form, district: e.target.value })}
|
onValueChange={(v) => setForm({ ...form, district: v === "_none" ? "" : v })}>
|
||||||
placeholder="ירושלים / תל אביב / מרכז" />
|
<SelectTrigger id="district"><SelectValue placeholder="—" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_none">—</SelectItem>
|
||||||
|
{DISTRICTS.map((d) => (
|
||||||
|
<SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
{/* Preserve legacy free-text values that don't match any
|
||||||
|
known district (e.g. older imports with typos). */}
|
||||||
|
{form.district && !DISTRICTS.some((d) => d.value === form.district) && (
|
||||||
|
<SelectItem value={form.district}>{form.district}</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="chair-name">יו"ר</Label>
|
<Label htmlFor="chair-name">יו"ר</Label>
|
||||||
|
|||||||
66
web-ui/src/components/ui/accordion.tsx
Normal file
66
web-ui/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b border-rule last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-start text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDown className="text-ink-muted pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
type CaseCreateInput,
|
type CaseCreateInput,
|
||||||
} from "@/lib/schemas/case";
|
} from "@/lib/schemas/case";
|
||||||
import {
|
import {
|
||||||
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtype,
|
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtypeWithBlam,
|
||||||
type AppealSubtype,
|
type AppealSubtype,
|
||||||
} from "@/lib/practice-area";
|
} from "@/lib/practice-area";
|
||||||
|
|
||||||
@@ -78,13 +78,16 @@ export function CaseWizard() {
|
|||||||
const userTouchedSubtype = useRef(false);
|
const userTouchedSubtype = useRef(false);
|
||||||
const caseNumber = form.watch("case_number");
|
const caseNumber = form.watch("case_number");
|
||||||
const practiceArea = form.watch("practice_area");
|
const practiceArea = form.watch("practice_area");
|
||||||
|
const subject = form.watch("subject");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userTouchedSubtype.current) return;
|
if (userTouchedSubtype.current) return;
|
||||||
const derived = deriveSubtype(caseNumber, practiceArea);
|
/* derive_subtype_with_blam picks extension_request_* when subject
|
||||||
|
* matches "בקשה להארכת מועד" / "בל\"מ" / "הארכת מועד להגשת". */
|
||||||
|
const derived = deriveSubtypeWithBlam(caseNumber, subject, practiceArea);
|
||||||
if (derived !== form.getValues("appeal_subtype")) {
|
if (derived !== form.getValues("appeal_subtype")) {
|
||||||
form.setValue("appeal_subtype", derived, { shouldValidate: false });
|
form.setValue("appeal_subtype", derived, { shouldValidate: false });
|
||||||
}
|
}
|
||||||
}, [caseNumber, practiceArea, form]);
|
}, [caseNumber, practiceArea, subject, form]);
|
||||||
|
|
||||||
const stepIndex = STEPS.findIndex((s) => s.key === step);
|
const stepIndex = STEPS.findIndex((s) => s.key === step);
|
||||||
const isLast = stepIndex === STEPS.length - 1;
|
const isLast = stepIndex === STEPS.length - 1;
|
||||||
|
|||||||
111
web-ui/src/lib/api/legal-arguments.ts
Normal file
111
web-ui/src/lib/api/legal-arguments.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Legal Arguments domain — aggregated propositions (claim de-dup).
|
||||||
|
*
|
||||||
|
* Each raw "claim" is an extracted proposition from a litigation brief;
|
||||||
|
* the LLM-driven aggregator groups them by party into 6-12 distinct
|
||||||
|
* legal arguments. These hooks expose the read + trigger endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
|
||||||
|
export type LegalArgumentParty =
|
||||||
|
| "appellant"
|
||||||
|
| "respondent"
|
||||||
|
| "committee"
|
||||||
|
| "permit_applicant"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export type LegalArgumentPriority =
|
||||||
|
| "threshold"
|
||||||
|
| "substantive"
|
||||||
|
| "procedural"
|
||||||
|
| "relief";
|
||||||
|
|
||||||
|
export type LegalArgument = {
|
||||||
|
id: string;
|
||||||
|
case_id: string;
|
||||||
|
party: LegalArgumentParty;
|
||||||
|
argument_index: number;
|
||||||
|
argument_title: string;
|
||||||
|
argument_body: string;
|
||||||
|
legal_topic: string | null;
|
||||||
|
priority: LegalArgumentPriority;
|
||||||
|
cited_precedents?: string[] | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
supporting_claims: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LegalArgumentsResponse = {
|
||||||
|
case_number: string;
|
||||||
|
total: number;
|
||||||
|
by_party: Partial<Record<LegalArgumentParty, LegalArgument[]>>;
|
||||||
|
arguments: LegalArgument[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const legalArgumentsKeys = {
|
||||||
|
all: ["legal-arguments"] as const,
|
||||||
|
byCase: (caseNumber: string) =>
|
||||||
|
[...legalArgumentsKeys.all, caseNumber] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useLegalArguments(caseNumber: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: legalArgumentsKeys.byCase(caseNumber ?? ""),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<LegalArgumentsResponse>(
|
||||||
|
`/api/cases/${caseNumber}/legal-arguments`,
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AggregateArgumentsResult = {
|
||||||
|
status: "started" | string;
|
||||||
|
case_number: string;
|
||||||
|
force: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAggregateArguments(caseNumber: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (force: boolean = false) =>
|
||||||
|
apiRequest<AggregateArgumentsResult>(
|
||||||
|
`/api/cases/${caseNumber}/aggregate-arguments${force ? "?force=true" : ""}`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (caseNumber) {
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: legalArgumentsKeys.byCase(caseNumber),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PARTY_LABELS_HE: Record<LegalArgumentParty, string> = {
|
||||||
|
appellant: "עוררים",
|
||||||
|
respondent: "משיבים",
|
||||||
|
committee: "ועדה מקומית",
|
||||||
|
permit_applicant: "מבקשי היתר",
|
||||||
|
unknown: "צד לא מזוהה",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRIORITY_LABELS_HE: Record<LegalArgumentPriority, string> = {
|
||||||
|
threshold: "סף",
|
||||||
|
substantive: "מהותי",
|
||||||
|
procedural: "פגם הליך",
|
||||||
|
relief: "סעד",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRIORITY_ORDER: LegalArgumentPriority[] = [
|
||||||
|
"threshold",
|
||||||
|
"substantive",
|
||||||
|
"procedural",
|
||||||
|
"relief",
|
||||||
|
];
|
||||||
277
web-ui/src/lib/api/missing-precedents.ts
Normal file
277
web-ui/src/lib/api/missing-precedents.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Missing precedents — citations the parties brought up but that aren't
|
||||||
|
* yet in the corpus.
|
||||||
|
*
|
||||||
|
* Lifecycle: 'open' → researcher logs gap → chair uploads decision via
|
||||||
|
* the dialog → POST /upload routes to internal_decision_upload (ערר/בל"מ)
|
||||||
|
* or precedent_library_upload (court rulings), then status flips to
|
||||||
|
* 'closed' with linked_case_law_id set.
|
||||||
|
*
|
||||||
|
* Endpoints touched:
|
||||||
|
* - POST /api/missing-precedents create (JSON body)
|
||||||
|
* - GET /api/missing-precedents?status=open list (filters)
|
||||||
|
* - GET /api/missing-precedents/{id} detail
|
||||||
|
* - PATCH /api/missing-precedents/{id} metadata edit
|
||||||
|
* - DELETE /api/missing-precedents/{id} remove
|
||||||
|
* - POST /api/missing-precedents/{id}/upload multipart upload + close
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ApiError, apiRequest } from "./client";
|
||||||
|
|
||||||
|
export type CitedByParty =
|
||||||
|
| "appellant"
|
||||||
|
| "respondent"
|
||||||
|
| "committee"
|
||||||
|
| "permit_applicant"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export type MissingPrecedentStatus =
|
||||||
|
| "open"
|
||||||
|
| "uploaded"
|
||||||
|
| "closed"
|
||||||
|
| "irrelevant";
|
||||||
|
|
||||||
|
export type MissingPrecedent = {
|
||||||
|
id: string;
|
||||||
|
citation: string;
|
||||||
|
case_name: string | null;
|
||||||
|
cited_in_case_id: string | null;
|
||||||
|
cited_in_case_number: string | null; // joined
|
||||||
|
cited_in_document_id: string | null;
|
||||||
|
cited_by_party: CitedByParty | null;
|
||||||
|
cited_by_party_name: string | null;
|
||||||
|
legal_topic: string | null;
|
||||||
|
legal_issue: string | null;
|
||||||
|
claim_quote: string | null;
|
||||||
|
status: MissingPrecedentStatus;
|
||||||
|
linked_case_law_id: string | null;
|
||||||
|
linked_case_law_number: string | null;
|
||||||
|
linked_case_law_name: string | null;
|
||||||
|
closed_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MissingPrecedentListResponse = {
|
||||||
|
items: MissingPrecedent[];
|
||||||
|
count: number;
|
||||||
|
by_status: Partial<Record<MissingPrecedentStatus, number>>;
|
||||||
|
total_open: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MissingPrecedentCreateInput = {
|
||||||
|
citation: string;
|
||||||
|
case_number?: string;
|
||||||
|
cited_in_document_id?: string;
|
||||||
|
cited_by_party?: CitedByParty;
|
||||||
|
cited_by_party_name?: string;
|
||||||
|
legal_topic?: string;
|
||||||
|
legal_issue?: string;
|
||||||
|
claim_quote?: string;
|
||||||
|
case_name?: string;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MissingPrecedentPatch = Partial<{
|
||||||
|
legal_topic: string;
|
||||||
|
legal_issue: string;
|
||||||
|
notes: string;
|
||||||
|
cited_by_party: CitedByParty;
|
||||||
|
cited_by_party_name: string;
|
||||||
|
case_name: string;
|
||||||
|
status: MissingPrecedentStatus;
|
||||||
|
citation: string;
|
||||||
|
claim_quote: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type MissingPrecedentFilters = {
|
||||||
|
status?: MissingPrecedentStatus | "";
|
||||||
|
caseNumber?: string;
|
||||||
|
caseId?: string;
|
||||||
|
legalTopic?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const missingPrecedentKeys = {
|
||||||
|
all: ["missing-precedents"] as const,
|
||||||
|
list: (filters: MissingPrecedentFilters) =>
|
||||||
|
[...missingPrecedentKeys.all, "list", filters] as const,
|
||||||
|
detail: (id: string) => [...missingPrecedentKeys.all, "detail", id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMissingPrecedents(filters: MissingPrecedentFilters = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: missingPrecedentKeys.list(filters),
|
||||||
|
queryFn: ({ signal }) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (filters.status) p.set("status", filters.status);
|
||||||
|
if (filters.caseNumber) p.set("case_number", filters.caseNumber);
|
||||||
|
if (filters.caseId) p.set("case_id", filters.caseId);
|
||||||
|
if (filters.legalTopic) p.set("legal_topic", filters.legalTopic);
|
||||||
|
if (filters.limit) p.set("limit", String(filters.limit));
|
||||||
|
const qs = p.toString();
|
||||||
|
return apiRequest<MissingPrecedentListResponse>(
|
||||||
|
`/api/missing-precedents${qs ? `?${qs}` : ""}`,
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
staleTime: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counter for the sidebar / nav badge — open rows only. */
|
||||||
|
export function useMissingPrecedentsOpenCount() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...missingPrecedentKeys.all, "open-count"] as const,
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<MissingPrecedentListResponse>(
|
||||||
|
"/api/missing-precedents?status=open&limit=1",
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
staleTime: 30_000,
|
||||||
|
select: (data) => data.total_open,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMissingPrecedent(id: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: missingPrecedentKeys.detail(id ?? ""),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<MissingPrecedent>(
|
||||||
|
`/api/missing-precedents/${encodeURIComponent(id!)}`,
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
staleTime: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateMissingPrecedent() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: MissingPrecedentCreateInput) =>
|
||||||
|
apiRequest<MissingPrecedent>("/api/missing-precedents", {
|
||||||
|
method: "POST",
|
||||||
|
body: input,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateMissingPrecedent() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, patch }: { id: string; patch: MissingPrecedentPatch }) =>
|
||||||
|
apiRequest<MissingPrecedent>(
|
||||||
|
`/api/missing-precedents/${encodeURIComponent(id)}`,
|
||||||
|
{ method: "PATCH", body: patch },
|
||||||
|
),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: missingPrecedentKeys.detail(id) });
|
||||||
|
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteMissingPrecedent() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiRequest<{ deleted: boolean }>(
|
||||||
|
`/api/missing-precedents/${encodeURIComponent(id)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MissingPrecedentUploadInput = {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
case_number?: string;
|
||||||
|
chair_name?: string;
|
||||||
|
district?: string;
|
||||||
|
case_name?: string;
|
||||||
|
court?: string;
|
||||||
|
decision_date?: string;
|
||||||
|
practice_area?: string;
|
||||||
|
appeal_subtype?: string;
|
||||||
|
subject_tags?: string[];
|
||||||
|
is_binding?: boolean;
|
||||||
|
headnote?: string;
|
||||||
|
summary?: string;
|
||||||
|
precedent_level?: string;
|
||||||
|
source_type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useUploadMissingPrecedent() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: MissingPrecedentUploadInput) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", input.file);
|
||||||
|
if (input.case_number) fd.append("case_number", input.case_number);
|
||||||
|
if (input.chair_name) fd.append("chair_name", input.chair_name);
|
||||||
|
if (input.district) fd.append("district", input.district);
|
||||||
|
if (input.case_name) fd.append("case_name", input.case_name);
|
||||||
|
if (input.court) fd.append("court", input.court);
|
||||||
|
if (input.decision_date) fd.append("decision_date", input.decision_date);
|
||||||
|
if (input.practice_area) fd.append("practice_area", input.practice_area);
|
||||||
|
if (input.appeal_subtype) fd.append("appeal_subtype", input.appeal_subtype);
|
||||||
|
if (input.subject_tags && input.subject_tags.length) {
|
||||||
|
fd.append("subject_tags", JSON.stringify(input.subject_tags));
|
||||||
|
}
|
||||||
|
fd.append("is_binding", String(input.is_binding ?? true));
|
||||||
|
if (input.headnote) fd.append("headnote", input.headnote);
|
||||||
|
if (input.summary) fd.append("summary", input.summary);
|
||||||
|
if (input.precedent_level)
|
||||||
|
fd.append("precedent_level", input.precedent_level);
|
||||||
|
if (input.source_type) fd.append("source_type", input.source_type);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/missing-precedents/${encodeURIComponent(input.id)}/upload`,
|
||||||
|
{ method: "POST", body: fd },
|
||||||
|
);
|
||||||
|
const parsed = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
`Upload failed with ${res.status}`,
|
||||||
|
res.status,
|
||||||
|
parsed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsed as {
|
||||||
|
missing_precedent: MissingPrecedent;
|
||||||
|
case_law_id: string;
|
||||||
|
route: "internal_committee" | "external_upload";
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: missingPrecedentKeys.detail(id) });
|
||||||
|
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
|
||||||
|
qc.invalidateQueries({ queryKey: ["precedent-library"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hebrew labels for display. */
|
||||||
|
export const CITED_BY_PARTY_LABELS: Record<CitedByParty, string> = {
|
||||||
|
appellant: "עורר",
|
||||||
|
respondent: "משיב",
|
||||||
|
committee: "ועדה",
|
||||||
|
permit_applicant: "מבקש היתר",
|
||||||
|
unknown: "לא ידוע",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_LABELS: Record<MissingPrecedentStatus, string> = {
|
||||||
|
open: "פתוח",
|
||||||
|
uploaded: "הועלה",
|
||||||
|
closed: "נסגר",
|
||||||
|
irrelevant: "לא רלוונטי",
|
||||||
|
};
|
||||||
@@ -18,6 +18,10 @@ export type AppealSubtype =
|
|||||||
| "building_permit"
|
| "building_permit"
|
||||||
| "betterment_levy"
|
| "betterment_levy"
|
||||||
| "compensation_197"
|
| "compensation_197"
|
||||||
|
/* בל"מ — בקשה להארכת מועד להגשת ערר. שלושה מסלולים נפרדים. */
|
||||||
|
| "extension_request_building_permit"
|
||||||
|
| "extension_request_betterment_levy"
|
||||||
|
| "extension_request_compensation"
|
||||||
| "unknown";
|
| "unknown";
|
||||||
|
|
||||||
export const PRACTICE_AREAS: ReadonlyArray<{
|
export const PRACTICE_AREAS: ReadonlyArray<{
|
||||||
@@ -37,9 +41,23 @@ export const APPEAL_SUBTYPES: ReadonlyArray<{
|
|||||||
{ value: "building_permit", label: "רישוי ובנייה" },
|
{ value: "building_permit", label: "רישוי ובנייה" },
|
||||||
{ value: "betterment_levy", label: "היטל השבחה" },
|
{ value: "betterment_levy", label: "היטל השבחה" },
|
||||||
{ value: "compensation_197", label: "פיצויים (ס' 197)" },
|
{ value: "compensation_197", label: "פיצויים (ס' 197)" },
|
||||||
|
{ value: "extension_request_building_permit", label: "בל\"מ — רישוי" },
|
||||||
|
{ value: "extension_request_betterment_levy", label: "בל\"מ — היטל השבחה" },
|
||||||
|
{ value: "extension_request_compensation", label: "בל\"מ — פיצויים" },
|
||||||
{ value: "unknown", label: "לא ידוע" },
|
{ value: "unknown", label: "לא ידוע" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* בל"מ subtypes — תת-קבוצה. שימוש: badges, filters */
|
||||||
|
export const BLAM_SUBTYPES: ReadonlySet<AppealSubtype> = new Set([
|
||||||
|
"extension_request_building_permit",
|
||||||
|
"extension_request_betterment_levy",
|
||||||
|
"extension_request_compensation",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isBlamSubtype(s?: AppealSubtype | null): boolean {
|
||||||
|
return s != null && BLAM_SUBTYPES.has(s);
|
||||||
|
}
|
||||||
|
|
||||||
export const PRACTICE_AREA_LABELS: Record<PracticeArea, string> =
|
export const PRACTICE_AREA_LABELS: Record<PracticeArea, string> =
|
||||||
Object.fromEntries(PRACTICE_AREAS.map((p) => [p.value, p.label])) as Record<
|
Object.fromEntries(PRACTICE_AREAS.map((p) => [p.value, p.label])) as Record<
|
||||||
PracticeArea,
|
PracticeArea,
|
||||||
@@ -70,3 +88,30 @@ export function deriveSubtype(
|
|||||||
if (first === "9") return "compensation_197";
|
if (first === "9") return "compensation_197";
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Detect a בל"מ subject (בקשה להארכת מועד). Mirrors the Python
|
||||||
|
* `is_blam_subject` in practice_area.py. Accepts common variants.
|
||||||
|
*/
|
||||||
|
const BLAM_SUBJECT_RE = /(?:בקשה\s+להארכת\s+מועד|בל["״]מ|הארכת\s+מועד\s+להגשת)/i;
|
||||||
|
|
||||||
|
export function isBlamSubject(subject: string): boolean {
|
||||||
|
return Boolean(subject) && BLAM_SUBJECT_RE.test(subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Like deriveSubtype() but also detects בל"מ from the subject. Mirrors
|
||||||
|
* `derive_subtype_with_blam()` in practice_area.py.
|
||||||
|
*/
|
||||||
|
export function deriveSubtypeWithBlam(
|
||||||
|
caseNumber: string,
|
||||||
|
subject: string = "",
|
||||||
|
practiceArea: PracticeArea = "appeals_committee",
|
||||||
|
): AppealSubtype {
|
||||||
|
const base = deriveSubtype(caseNumber, practiceArea);
|
||||||
|
if (!isBlamSubject(subject)) return base;
|
||||||
|
if (base === "building_permit") return "extension_request_building_permit";
|
||||||
|
if (base === "betterment_levy") return "extension_request_betterment_levy";
|
||||||
|
if (base === "compensation_197") return "extension_request_compensation";
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export const caseCreateSchema = z.object({
|
|||||||
"building_permit",
|
"building_permit",
|
||||||
"betterment_levy",
|
"betterment_levy",
|
||||||
"compensation_197",
|
"compensation_197",
|
||||||
|
"extension_request_building_permit",
|
||||||
|
"extension_request_betterment_levy",
|
||||||
|
"extension_request_compensation",
|
||||||
"unknown",
|
"unknown",
|
||||||
] as const satisfies readonly AppealSubtype[]),
|
] as const satisfies readonly AppealSubtype[]),
|
||||||
});
|
});
|
||||||
|
|||||||
412
web/app.py
412
web/app.py
@@ -1228,7 +1228,11 @@ class CaseCreateRequest(BaseModel):
|
|||||||
hearing_date: str = ""
|
hearing_date: str = ""
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
expected_outcome: str = ""
|
expected_outcome: str = ""
|
||||||
practice_area: str = "appeals_committee"
|
# Empty default → cases_tools.case_create auto-derives the domain
|
||||||
|
# practice_area from the case_number prefix (1xxx→rishuy_uvniya,
|
||||||
|
# 8xxx→betterment_levy, 9xxx→compensation_197). Callers can still
|
||||||
|
# send a domain value explicitly.
|
||||||
|
practice_area: str = ""
|
||||||
appeal_subtype: str = ""
|
appeal_subtype: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -1267,8 +1271,10 @@ async def api_case_create(req: CaseCreateRequest):
|
|||||||
)
|
)
|
||||||
parsed = json.loads(result)
|
parsed = json.loads(result)
|
||||||
|
|
||||||
# Auto-create Paperclip project for the new case
|
# Auto-create Paperclip project for the new case. case_create may have
|
||||||
appeal_type = req.appeal_subtype or "רישוי"
|
# auto-derived appeal_subtype from the case-number prefix; prefer the
|
||||||
|
# resolved value over the (possibly empty) request value.
|
||||||
|
appeal_type = parsed.get("appeal_subtype") or req.appeal_subtype or "רישוי"
|
||||||
try:
|
try:
|
||||||
pc_result = await pc_create_project(
|
pc_result = await pc_create_project(
|
||||||
case_number=req.case_number,
|
case_number=req.case_number,
|
||||||
@@ -1744,6 +1750,77 @@ async def api_get_claims(case_number: str):
|
|||||||
return {"case_number": case_number, "claims": claims_by_party, "total": len(rows)}
|
return {"case_number": case_number, "claims": claims_by_party, "total": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Legal Arguments (aggregated claims) ────────────────────────────
|
||||||
|
# The aggregator groups raw ``claims`` rows into ~6-12 distinct legal
|
||||||
|
# arguments per party. The heavy lifting (LLM call) runs in the local
|
||||||
|
# MCP server context where Claude CLI is available; here we expose
|
||||||
|
# read + trigger endpoints. The trigger is a BackgroundTask only when
|
||||||
|
# Claude CLI is actually present in the runtime (i.e. dev box) — inside
|
||||||
|
# the FastAPI container it short-circuits with status="llm_unavailable".
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/aggregate-arguments")
|
||||||
|
async def api_aggregate_arguments(
|
||||||
|
case_number: str,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
force: bool = False,
|
||||||
|
):
|
||||||
|
"""Aggregate raw claims into distinct legal arguments via Claude.
|
||||||
|
|
||||||
|
Runs as a BackgroundTask because the LLM pass can take 30-90 seconds.
|
||||||
|
"""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
try:
|
||||||
|
from legal_mcp.services import argument_aggregator
|
||||||
|
result = await argument_aggregator.aggregate_claims_to_arguments(
|
||||||
|
UUID(case["id"]), force=force,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"aggregate_arguments[%s] finished: %s",
|
||||||
|
case_number, result,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.exception(
|
||||||
|
"aggregate_arguments[%s] failed: %s", case_number, e,
|
||||||
|
)
|
||||||
|
|
||||||
|
background_tasks.add_task(_run)
|
||||||
|
return {
|
||||||
|
"status": "started",
|
||||||
|
"case_number": case_number,
|
||||||
|
"force": force,
|
||||||
|
"message": "Aggregation started in background. Poll /legal-arguments for results.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cases/{case_number}/legal-arguments")
|
||||||
|
async def api_get_legal_arguments(case_number: str, party: str = ""):
|
||||||
|
"""Return aggregated legal arguments for a case, grouped by party."""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
|
||||||
|
from legal_mcp.services import argument_aggregator
|
||||||
|
args = await argument_aggregator.get_legal_arguments(
|
||||||
|
UUID(case["id"]), party=party,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Group by party for the UI.
|
||||||
|
by_party: dict[str, list[dict]] = {}
|
||||||
|
for a in args:
|
||||||
|
by_party.setdefault(a["party"], []).append(a)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"case_number": case_number,
|
||||||
|
"total": len(args),
|
||||||
|
"by_party": by_party,
|
||||||
|
"arguments": args,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/cases/{case_number}/direction")
|
@app.post("/api/cases/{case_number}/direction")
|
||||||
async def api_set_direction(case_number: str, req: DirectionRequest):
|
async def api_set_direction(case_number: str, req: DirectionRequest):
|
||||||
"""Save the approved direction document for the discussion block."""
|
"""Save the approved direction document for the discussion block."""
|
||||||
@@ -4789,3 +4866,332 @@ async def halacha_update(halacha_id: str, req: HalachaUpdateRequest):
|
|||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "הלכה לא נמצאה")
|
raise HTTPException(404, "הלכה לא נמצאה")
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# ── Missing Precedents (TaskMaster #35) ────────────────────────────
|
||||||
|
# Track citations from party briefs that aren't yet in the precedent
|
||||||
|
# corpus. Researcher logs gaps; chair closes them by uploading the
|
||||||
|
# actual decision via /api/precedent-library/upload or
|
||||||
|
# /api/internal-decisions/upload, then links via the upload endpoint
|
||||||
|
# here which delegates to one of those depending on the citation type.
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_MP_PARTIES = {
|
||||||
|
"appellant", "respondent", "committee", "permit_applicant", "unknown",
|
||||||
|
}
|
||||||
|
_ALLOWED_MP_STATUS = {"open", "uploaded", "closed", "irrelevant"}
|
||||||
|
|
||||||
|
|
||||||
|
class MissingPrecedentCreate(BaseModel):
|
||||||
|
citation: str
|
||||||
|
case_number: str = "" # cited-in case
|
||||||
|
cited_in_document_id: str | None = None
|
||||||
|
cited_by_party: Literal[
|
||||||
|
"appellant", "respondent", "committee", "permit_applicant", "unknown",
|
||||||
|
] = "unknown"
|
||||||
|
cited_by_party_name: str | None = None
|
||||||
|
legal_topic: str | None = None
|
||||||
|
legal_issue: str | None = None
|
||||||
|
claim_quote: str | None = None
|
||||||
|
case_name: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MissingPrecedentPatch(BaseModel):
|
||||||
|
legal_topic: str | None = None
|
||||||
|
legal_issue: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
cited_by_party: Literal[
|
||||||
|
"appellant", "respondent", "committee", "permit_applicant", "unknown",
|
||||||
|
] | None = None
|
||||||
|
cited_by_party_name: str | None = None
|
||||||
|
case_name: str | None = None
|
||||||
|
status: Literal["open", "uploaded", "closed", "irrelevant"] | None = None
|
||||||
|
citation: str | None = None
|
||||||
|
claim_quote: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_internal_committee_citation(citation: str) -> bool:
|
||||||
|
"""Detect ועדת ערר citations — must go through internal_decision_upload
|
||||||
|
so they get chair_name + district. The legacy library upload doesn't
|
||||||
|
enforce those fields and the records end up un-searchable by chair."""
|
||||||
|
norm = citation.strip()
|
||||||
|
committee_prefixes = ("ערר ", "ערר(", "בל\"מ ", "בל\"מ(", "ARAR ")
|
||||||
|
return any(norm.startswith(p) for p in committee_prefixes)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/missing-precedents")
|
||||||
|
async def missing_precedent_create(req: MissingPrecedentCreate):
|
||||||
|
"""Log a new missing precedent (status='open'). Dedupes by
|
||||||
|
(citation, cited_in_case_id) — duplicate POST returns the existing row."""
|
||||||
|
if not req.citation.strip():
|
||||||
|
raise HTTPException(400, "citation חובה")
|
||||||
|
|
||||||
|
case_id: UUID | None = None
|
||||||
|
if req.case_number.strip():
|
||||||
|
c = await db.get_case_by_number(req.case_number.strip())
|
||||||
|
if not c:
|
||||||
|
raise HTTPException(404, f"תיק לא נמצא: {req.case_number}")
|
||||||
|
case_id = UUID(c["id"])
|
||||||
|
|
||||||
|
doc_id: UUID | None = None
|
||||||
|
if req.cited_in_document_id:
|
||||||
|
try:
|
||||||
|
doc_id = UUID(req.cited_in_document_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "cited_in_document_id לא תקין")
|
||||||
|
|
||||||
|
existing = await db.find_missing_precedent_by_citation(
|
||||||
|
citation=req.citation.strip(),
|
||||||
|
case_id=case_id,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return {**existing, "_duplicate": True}
|
||||||
|
|
||||||
|
row = await db.create_missing_precedent(
|
||||||
|
citation=req.citation.strip(),
|
||||||
|
case_name=req.case_name,
|
||||||
|
cited_in_case_id=case_id,
|
||||||
|
cited_in_document_id=doc_id,
|
||||||
|
cited_by_party=req.cited_by_party,
|
||||||
|
cited_by_party_name=req.cited_by_party_name,
|
||||||
|
legal_topic=req.legal_topic,
|
||||||
|
legal_issue=req.legal_issue,
|
||||||
|
claim_quote=req.claim_quote,
|
||||||
|
notes=req.notes,
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/missing-precedents")
|
||||||
|
async def missing_precedents_list(
|
||||||
|
status: str = "",
|
||||||
|
case_id: str = "",
|
||||||
|
case_number: str = "",
|
||||||
|
legal_topic: str = "",
|
||||||
|
limit: int = 200,
|
||||||
|
offset: int = 0,
|
||||||
|
):
|
||||||
|
"""List missing precedents, optionally filtered by status / case."""
|
||||||
|
s = status.strip() or None
|
||||||
|
if s and s not in _ALLOWED_MP_STATUS:
|
||||||
|
raise HTTPException(400, f"status לא תקין: {status}")
|
||||||
|
|
||||||
|
case_uuid: UUID | None = None
|
||||||
|
if case_id.strip():
|
||||||
|
try:
|
||||||
|
case_uuid = UUID(case_id.strip())
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "case_id לא תקין")
|
||||||
|
elif case_number.strip():
|
||||||
|
c = await db.get_case_by_number(case_number.strip())
|
||||||
|
if not c:
|
||||||
|
raise HTTPException(404, f"תיק לא נמצא: {case_number}")
|
||||||
|
case_uuid = UUID(c["id"])
|
||||||
|
|
||||||
|
rows = await db.list_missing_precedents(
|
||||||
|
status=s,
|
||||||
|
case_id=case_uuid,
|
||||||
|
legal_topic=legal_topic.strip() or None,
|
||||||
|
limit=max(1, min(int(limit), 500)),
|
||||||
|
offset=max(0, int(offset)),
|
||||||
|
)
|
||||||
|
# Counters useful for the sidebar badge.
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
counts = await conn.fetch(
|
||||||
|
"SELECT status, COUNT(*) AS n FROM missing_precedents GROUP BY status"
|
||||||
|
)
|
||||||
|
by_status = {r["status"]: r["n"] for r in counts}
|
||||||
|
return {
|
||||||
|
"items": rows,
|
||||||
|
"count": len(rows),
|
||||||
|
"by_status": by_status,
|
||||||
|
"total_open": by_status.get("open", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/missing-precedents/{mp_id}")
|
||||||
|
async def missing_precedent_get(mp_id: str):
|
||||||
|
try:
|
||||||
|
uid = UUID(mp_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "id לא תקין")
|
||||||
|
row = await db.get_missing_precedent(uid)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "רשומה לא נמצאה")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/missing-precedents/{mp_id}")
|
||||||
|
async def missing_precedent_update(mp_id: str, req: MissingPrecedentPatch):
|
||||||
|
try:
|
||||||
|
uid = UUID(mp_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "id לא תקין")
|
||||||
|
fields = {k: v for k, v in req.model_dump(exclude_unset=True).items() if v is not None}
|
||||||
|
if not fields:
|
||||||
|
row = await db.get_missing_precedent(uid)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "רשומה לא נמצאה")
|
||||||
|
return row
|
||||||
|
try:
|
||||||
|
row = await db.update_missing_precedent(uid, **fields)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "רשומה לא נמצאה")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/missing-precedents/{mp_id}")
|
||||||
|
async def missing_precedent_delete(mp_id: str):
|
||||||
|
try:
|
||||||
|
uid = UUID(mp_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "id לא תקין")
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
"DELETE FROM missing_precedents WHERE id = $1", uid,
|
||||||
|
)
|
||||||
|
deleted = int(result.split()[-1]) > 0
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(404, "רשומה לא נמצאה")
|
||||||
|
return {"deleted": True, "id": mp_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/missing-precedents/{mp_id}/upload")
|
||||||
|
async def missing_precedent_upload(
|
||||||
|
mp_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
case_number: str = Form(""), # for internal-committee path
|
||||||
|
chair_name: str = Form(""),
|
||||||
|
district: str = Form(""),
|
||||||
|
case_name: str = Form(""),
|
||||||
|
court: str = Form(""),
|
||||||
|
decision_date: str = Form(""),
|
||||||
|
practice_area: str = Form(""),
|
||||||
|
appeal_subtype: str = Form(""),
|
||||||
|
subject_tags: str = Form("[]"),
|
||||||
|
is_binding: bool = Form(True),
|
||||||
|
headnote: str = Form(""),
|
||||||
|
summary: str = Form(""),
|
||||||
|
precedent_level: str = Form(""),
|
||||||
|
source_type: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Upload the decision file behind a missing-precedent and link it.
|
||||||
|
|
||||||
|
Routes to ingest_internal_decision if the citation looks like a
|
||||||
|
committee decision (ערר / בל"מ prefix), otherwise to ingest_precedent.
|
||||||
|
Once the case_law row is created, the missing_precedents row is marked
|
||||||
|
status='closed' with linked_case_law_id pointing to the new row.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
uid = UUID(mp_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "id לא תקין")
|
||||||
|
mp = await db.get_missing_precedent(uid)
|
||||||
|
if not mp:
|
||||||
|
raise HTTPException(404, "רשומה לא נמצאה")
|
||||||
|
if mp["status"] in {"closed", "uploaded"} and mp.get("linked_case_law_id"):
|
||||||
|
raise HTTPException(409, "הרשומה כבר נסגרה — הסר קישור לפני העלאה חוזרת")
|
||||||
|
|
||||||
|
suffix = Path(file.filename or "").suffix.lower()
|
||||||
|
if suffix not in ALLOWED_EXTENSIONS:
|
||||||
|
raise HTTPException(400, f"סוג קובץ לא נתמך: {suffix}")
|
||||||
|
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
staged = UPLOAD_DIR / f"mp_{uuid4().hex[:8]}_{file.filename}"
|
||||||
|
size = 0
|
||||||
|
with staged.open("wb") as out:
|
||||||
|
while chunk := await file.read(1024 * 1024):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_FILE_SIZE:
|
||||||
|
staged.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(413, "קובץ גדול מדי")
|
||||||
|
out.write(chunk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tags = json.loads(subject_tags) if subject_tags else []
|
||||||
|
if not isinstance(tags, list):
|
||||||
|
tags = []
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
tags = []
|
||||||
|
|
||||||
|
citation = mp["citation"]
|
||||||
|
is_committee = _is_internal_committee_citation(citation)
|
||||||
|
case_law_id: str | None = None
|
||||||
|
closed: dict | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_committee:
|
||||||
|
if not chair_name.strip() or not district.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
"החלטת ועדת ערר דורשת chair_name + district",
|
||||||
|
)
|
||||||
|
# case_number for the committee decision (not the cited-in case)
|
||||||
|
committee_case_number = case_number.strip() or citation
|
||||||
|
result = await int_decisions_service.ingest_internal_decision(
|
||||||
|
case_number=committee_case_number,
|
||||||
|
case_name=(case_name.strip() or mp.get("case_name") or "").strip(),
|
||||||
|
court=court.strip(),
|
||||||
|
decision_date=decision_date or None,
|
||||||
|
chair_name=chair_name.strip(),
|
||||||
|
district=district.strip(),
|
||||||
|
practice_area=practice_area,
|
||||||
|
appeal_subtype=appeal_subtype.strip(),
|
||||||
|
subject_tags=tags,
|
||||||
|
is_binding=is_binding,
|
||||||
|
summary=summary.strip(),
|
||||||
|
file_path=staged,
|
||||||
|
)
|
||||||
|
case_law_id = (
|
||||||
|
result.get("case_law_id") if isinstance(result, dict) else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if practice_area and practice_area not in _PRACTICE_AREAS:
|
||||||
|
raise HTTPException(400, "practice_area לא תקין")
|
||||||
|
if source_type and source_type not in _SOURCE_TYPES:
|
||||||
|
raise HTTPException(400, "source_type לא תקין")
|
||||||
|
result = await plib_service.ingest_precedent(
|
||||||
|
file_path=staged,
|
||||||
|
citation=citation,
|
||||||
|
case_name=(case_name.strip() or mp.get("case_name") or "").strip(),
|
||||||
|
court=court.strip(),
|
||||||
|
decision_date=decision_date or None,
|
||||||
|
source_type=source_type or "court_ruling",
|
||||||
|
precedent_level=precedent_level,
|
||||||
|
practice_area=practice_area,
|
||||||
|
appeal_subtype=appeal_subtype.strip(),
|
||||||
|
subject_tags=tags,
|
||||||
|
is_binding=is_binding,
|
||||||
|
headnote=headnote.strip(),
|
||||||
|
summary=summary.strip(),
|
||||||
|
)
|
||||||
|
case_law_id = (
|
||||||
|
result.get("case_law_id") if isinstance(result, dict) else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if not case_law_id:
|
||||||
|
raise HTTPException(500, "לא התקבל case_law_id מההעלאה")
|
||||||
|
|
||||||
|
try:
|
||||||
|
closed = await db.close_missing_precedent(
|
||||||
|
mp_id=uid,
|
||||||
|
linked_case_law_id=UUID(case_law_id),
|
||||||
|
notes=mp.get("notes"),
|
||||||
|
status="closed",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("missing-precedent close failed")
|
||||||
|
raise HTTPException(500, f"קישור הרשומה נכשל: {e}")
|
||||||
|
finally:
|
||||||
|
staged.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"missing_precedent": closed,
|
||||||
|
"case_law_id": case_law_id,
|
||||||
|
"route": "internal_committee" if is_committee else "external_upload",
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ CURATOR_AGENTS = {
|
|||||||
COMPANIES["betterment"]: "d6f7c55d-570a-46b8-8d72-1286d07da0d8", # CMPA curator
|
COMPANIES["betterment"]: "d6f7c55d-570a-46b8-8d72-1286d07da0d8", # CMPA curator
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fallback mapping — used only when DB lookup returns no results
|
# Fallback mapping — used only when DB lookup returns no results.
|
||||||
|
# בל"מ (extension_request_*) variants route to the same company as their
|
||||||
|
# parent domain — בל"מ ברישוי → CMP, בל"מ בהיטל השבחה → CMPA, וכו'.
|
||||||
_FALLBACK_APPEAL_TYPE_TO_COMPANY = {
|
_FALLBACK_APPEAL_TYPE_TO_COMPANY = {
|
||||||
"רישוי": COMPANIES["licensing"],
|
"רישוי": COMPANIES["licensing"],
|
||||||
"היטל השבחה": COMPANIES["betterment"],
|
"היטל השבחה": COMPANIES["betterment"],
|
||||||
@@ -63,6 +65,10 @@ _FALLBACK_APPEAL_TYPE_TO_COMPANY = {
|
|||||||
"compensation_197": COMPANIES["betterment"],
|
"compensation_197": COMPANIES["betterment"],
|
||||||
"compensation": COMPANIES["betterment"],
|
"compensation": COMPANIES["betterment"],
|
||||||
"licensing": COMPANIES["licensing"],
|
"licensing": COMPANIES["licensing"],
|
||||||
|
# בל"מ subtypes — route per domain
|
||||||
|
"extension_request_building_permit": COMPANIES["licensing"],
|
||||||
|
"extension_request_betterment_levy": COMPANIES["betterment"],
|
||||||
|
"extension_request_compensation": COMPANIES["betterment"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Legal-AI DB URL for reading tag_company_mappings
|
# Legal-AI DB URL for reading tag_company_mappings
|
||||||
|
|||||||
Reference in New Issue
Block a user