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__halacha_review
|
||||
- mcp__legal-ai__halachot_pending
|
||||
- mcp__legal-ai__missing_precedent_create
|
||||
- mcp__legal-ai__missing_precedent_list
|
||||
- mcp__legal-ai__missing_precedent_close
|
||||
- mcp__legal-ai__workflow_status
|
||||
---
|
||||
|
||||
@@ -258,6 +261,33 @@ search_internal_decisions(
|
||||
|
||||
**מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו.
|
||||
|
||||
#### 2ב.5 — תיעוד פסיקה חסרה (`missing_precedent_create`) — חובה
|
||||
|
||||
**מתי לקרוא:** לכל ציטוט שהצדדים הביאו (בכתב ערר / תגובה / תגובת ועדה) **שלא נמצא בקורפוס** אחרי חיפוש מובנה (`search_precedent_library` + `search_internal_decisions` + `precedent_search_library`).
|
||||
|
||||
**למה זה חשוב:**
|
||||
- ה-writer יודע שלא להסתמך על פסיקה שלא ב-DB ("טוענים שמופיע" ≠ "אומת")
|
||||
- היו"ר רואה בדף ייחודי `/missing-precedents` מה ממתין להעלאה ויכול לסגור פערים בקליק
|
||||
- ההיסטוריה נשמרת: ראינו את הציטוט, לא מצאנו, חיכינו להעלאה, הועלה, נסגר
|
||||
|
||||
```python
|
||||
mcp__legal-ai__missing_precedent_create(
|
||||
citation = "עע\"מ 1461/20 אנטרים אינווסטמנטס נ' הועדה המקומית ירושלים (נבו 4.5.2021)",
|
||||
case_number = "1017-03-26", # תיק הערר שבו הצד ציטט
|
||||
cited_by_party = "permit_applicant", # appellant/respondent/committee/permit_applicant/unknown
|
||||
cited_by_party_name = "לינדאב בע\"מ",
|
||||
legal_topic = "זכות עמידה",
|
||||
legal_issue = "זכות ערר על בקשה להיתר מוקנית רק לבעל זכות במקרקעין",
|
||||
claim_quote = "...הציטוט המדויק מכתב הטענות...",
|
||||
case_name = "אנטרים", # שם קצר
|
||||
notes = "אופציונלי"
|
||||
)
|
||||
```
|
||||
|
||||
הכלי deduplicates: ציטוט+תיק זהים → מחזיר את הרשומה הקיימת. אם הציטוט כבר תויג (אפילו ב-status='closed' כי היו"ר העלה אותו בינתיים) — אל תיצור כפילות.
|
||||
|
||||
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
|
||||
|
||||
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||
|
||||
### שלב 3: מיפוי תכנית
|
||||
|
||||
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,
|
||||
precedent_library as plib,
|
||||
internal_decisions as int_tools,
|
||||
legal_arguments as la_tools,
|
||||
missing_precedents as mp_tools,
|
||||
)
|
||||
|
||||
|
||||
@@ -364,6 +366,28 @@ async def get_claims(
|
||||
return await documents.get_claims(case_number, party_role)
|
||||
|
||||
|
||||
# Legal arguments — aggregated (de-duped) propositions
|
||||
@mcp.tool()
|
||||
async def aggregate_claims_to_arguments(
|
||||
case_number: str,
|
||||
force: bool = False,
|
||||
) -> str:
|
||||
"""כינוס פרופוזיציות גולמיות (claims) לטיעונים משפטיים מובחנים — ~6-12 לכל צד.
|
||||
|
||||
משתמש ב-Claude headless לסיווג ואיגוד. force=True מוחק טיעונים קיימים לפני חישוב מחדש.
|
||||
"""
|
||||
return await la_tools.aggregate_claims_to_arguments(case_number, force=force)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_legal_arguments(
|
||||
case_number: str,
|
||||
party: str = "",
|
||||
) -> str:
|
||||
"""שליפת טיעונים משפטיים מאוגדים. party: appellant/respondent/committee/permit_applicant (ריק=הכל)."""
|
||||
return await la_tools.get_legal_arguments(case_number, party)
|
||||
|
||||
|
||||
# References
|
||||
@mcp.tool()
|
||||
async def extract_references(
|
||||
@@ -703,6 +727,82 @@ async def internal_decision_upload(
|
||||
)
|
||||
|
||||
|
||||
# ── Missing precedents (TaskMaster #35) ───────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def missing_precedent_create(
|
||||
citation: str,
|
||||
case_number: str = "",
|
||||
cited_in_document_id: str = "",
|
||||
cited_by_party: str = "unknown",
|
||||
cited_by_party_name: str = "",
|
||||
legal_topic: str = "",
|
||||
legal_issue: str = "",
|
||||
claim_quote: str = "",
|
||||
case_name: str = "",
|
||||
notes: str = "",
|
||||
) -> str:
|
||||
"""תיעוד פסיקה שצוטטה בכתבי הטענות אך אינה בקורפוס.
|
||||
|
||||
שימוש: סוכן המחקר (legal-researcher) קורא לזה כשהוא מזהה ציטוט שלא
|
||||
ניתן לאמת מול הקורפוס. הרשומה נשארת 'open' עד שהיו"ר מעלה את הפסיקה.
|
||||
cited_by_party: appellant / respondent / committee / permit_applicant / unknown.
|
||||
דה-דופ אוטומטי: ציטוט+תיק זהים → מחזיר את הרשומה הקיימת.
|
||||
"""
|
||||
return await mp_tools.missing_precedent_create(
|
||||
citation=citation,
|
||||
case_number=case_number,
|
||||
cited_in_document_id=cited_in_document_id,
|
||||
cited_by_party=cited_by_party,
|
||||
cited_by_party_name=cited_by_party_name,
|
||||
legal_topic=legal_topic,
|
||||
legal_issue=legal_issue,
|
||||
claim_quote=claim_quote,
|
||||
case_name=case_name,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def missing_precedent_list(
|
||||
case_number: str = "",
|
||||
status: str = "open",
|
||||
legal_topic: str = "",
|
||||
limit: int = 50,
|
||||
) -> str:
|
||||
"""רשימת פסיקות חסרות לתיק או בכלל. status: open/uploaded/closed/irrelevant.
|
||||
|
||||
שימוש: היו"ר רואה מה ממתין להעלאה; הסוכן מאשר שלא יוצר כפילויות.
|
||||
"""
|
||||
return await mp_tools.missing_precedent_list(
|
||||
case_number=case_number,
|
||||
status=status,
|
||||
legal_topic=legal_topic,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def missing_precedent_close(
|
||||
id: str,
|
||||
linked_case_law_id: str = "",
|
||||
notes: str = "",
|
||||
status: str = "closed",
|
||||
) -> str:
|
||||
"""סגירת רשומת פסיקה חסרה לאחר העלאה לקורפוס.
|
||||
|
||||
status: closed (הועלה ונקשר) / uploaded (הועלה, ממתין לקישור) /
|
||||
irrelevant (היו"ר החליט שזה לא רלוונטי לקורפוס).
|
||||
"""
|
||||
return await mp_tools.missing_precedent_close(
|
||||
id=id,
|
||||
linked_case_law_id=linked_case_law_id,
|
||||
notes=notes,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def record_chair_feedback(
|
||||
case_number: str,
|
||||
|
||||
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 with pool.acquire() as conn:
|
||||
await conn.execute(SCHEMA_SQL)
|
||||
@@ -760,7 +838,9 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||
await conn.execute(SCHEMA_V10_SQL)
|
||||
await conn.execute(SCHEMA_V11_SQL)
|
||||
await conn.execute(SCHEMA_V12_SQL)
|
||||
logger.info("Database schema initialized (v1-v12)")
|
||||
await conn.execute(SCHEMA_V13_SQL)
|
||||
await conn.execute(SCHEMA_V14_SQL)
|
||||
logger.info("Database schema initialized (v1-v14)")
|
||||
|
||||
|
||||
async def init_schema() -> None:
|
||||
@@ -782,7 +862,10 @@ async def create_case(
|
||||
hearing_date: date | None = None,
|
||||
notes: str = "",
|
||||
expected_outcome: str = "",
|
||||
practice_area: str = "appeals_committee",
|
||||
# Default "" — DB CHECK constraint accepts empty, the upstream tool
|
||||
# (cases.case_create) is responsible for deriving the domain value
|
||||
# from the case_number prefix before calling here.
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
) -> dict:
|
||||
pool = await get_pool()
|
||||
@@ -3106,3 +3189,228 @@ async def search_precedent_library_hybrid(
|
||||
merged.append(d)
|
||||
merged.sort(key=lambda x: -x["score"])
|
||||
return merged[:limit]
|
||||
|
||||
|
||||
# ── Missing precedents (V13) ───────────────────────────────────────
|
||||
# Track citations from party briefs that aren't yet in the corpus.
|
||||
# Lifecycle: 'open' → researcher logs gap → chair uploads decision
|
||||
# → status='uploaded' (file ingested) → status='closed' (linked to
|
||||
# case_law row). 'irrelevant' = chair decided the citation isn't worth
|
||||
# adding to the library.
|
||||
|
||||
ALLOWED_MP_PARTIES = {
|
||||
"appellant", "respondent", "committee", "permit_applicant", "unknown",
|
||||
}
|
||||
ALLOWED_MP_STATUS = {"open", "uploaded", "closed", "irrelevant"}
|
||||
|
||||
|
||||
def _row_to_missing_precedent(row: asyncpg.Record) -> dict:
|
||||
d = dict(row)
|
||||
d["id"] = str(d["id"])
|
||||
if d.get("cited_in_case_id") is not None:
|
||||
d["cited_in_case_id"] = str(d["cited_in_case_id"])
|
||||
if d.get("cited_in_document_id") is not None:
|
||||
d["cited_in_document_id"] = str(d["cited_in_document_id"])
|
||||
if d.get("linked_case_law_id") is not None:
|
||||
d["linked_case_law_id"] = str(d["linked_case_law_id"])
|
||||
return d
|
||||
|
||||
|
||||
async def create_missing_precedent(
|
||||
citation: str,
|
||||
case_name: str | None = None,
|
||||
cited_in_case_id: UUID | None = None,
|
||||
cited_in_document_id: UUID | None = None,
|
||||
cited_by_party: str | None = None,
|
||||
cited_by_party_name: str | None = None,
|
||||
legal_topic: str | None = None,
|
||||
legal_issue: str | None = None,
|
||||
claim_quote: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a new missing-precedent row (status='open' by default)."""
|
||||
if not citation.strip():
|
||||
raise ValueError("citation is required")
|
||||
if cited_by_party and cited_by_party not in ALLOWED_MP_PARTIES:
|
||||
raise ValueError(
|
||||
f"cited_by_party must be one of {sorted(ALLOWED_MP_PARTIES)}"
|
||||
)
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO missing_precedents (
|
||||
citation, case_name, cited_in_case_id, cited_in_document_id,
|
||||
cited_by_party, cited_by_party_name, legal_topic, legal_issue,
|
||||
claim_quote, notes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *""",
|
||||
citation.strip(), case_name, cited_in_case_id, cited_in_document_id,
|
||||
cited_by_party, cited_by_party_name, legal_topic, legal_issue,
|
||||
claim_quote, notes,
|
||||
)
|
||||
return _row_to_missing_precedent(row)
|
||||
|
||||
|
||||
async def list_missing_precedents(
|
||||
status: str | None = None,
|
||||
case_id: UUID | None = None,
|
||||
legal_topic: str | None = None,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""List missing precedents, joining the cited-in case_number for display."""
|
||||
pool = await get_pool()
|
||||
conditions: list[str] = []
|
||||
params: list = []
|
||||
idx = 1
|
||||
if status:
|
||||
conditions.append(f"mp.status = ${idx}")
|
||||
params.append(status)
|
||||
idx += 1
|
||||
if case_id:
|
||||
conditions.append(f"mp.cited_in_case_id = ${idx}")
|
||||
params.append(case_id)
|
||||
idx += 1
|
||||
if legal_topic:
|
||||
conditions.append(f"mp.legal_topic ILIKE ${idx}")
|
||||
params.append(f"%{legal_topic}%")
|
||||
idx += 1
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
params.append(limit)
|
||||
params.append(offset)
|
||||
sql = f"""
|
||||
SELECT mp.*,
|
||||
c.case_number AS cited_in_case_number,
|
||||
cl.case_number AS linked_case_law_number,
|
||||
cl.case_name AS linked_case_law_name
|
||||
FROM missing_precedents mp
|
||||
LEFT JOIN cases c ON c.id = mp.cited_in_case_id
|
||||
LEFT JOIN case_law cl ON cl.id = mp.linked_case_law_id
|
||||
{where}
|
||||
ORDER BY
|
||||
CASE mp.status
|
||||
WHEN 'open' THEN 0
|
||||
WHEN 'uploaded' THEN 1
|
||||
WHEN 'closed' THEN 2
|
||||
WHEN 'irrelevant' THEN 3
|
||||
END,
|
||||
mp.created_at DESC
|
||||
LIMIT ${idx} OFFSET ${idx + 1}
|
||||
"""
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(sql, *params)
|
||||
return [_row_to_missing_precedent(r) for r in rows]
|
||||
|
||||
|
||||
async def get_missing_precedent(mp_id: UUID) -> dict | None:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT mp.*,
|
||||
c.case_number AS cited_in_case_number,
|
||||
cl.case_number AS linked_case_law_number,
|
||||
cl.case_name AS linked_case_law_name
|
||||
FROM missing_precedents mp
|
||||
LEFT JOIN cases c ON c.id = mp.cited_in_case_id
|
||||
LEFT JOIN case_law cl ON cl.id = mp.linked_case_law_id
|
||||
WHERE mp.id = $1
|
||||
""",
|
||||
mp_id,
|
||||
)
|
||||
return _row_to_missing_precedent(row) if row else None
|
||||
|
||||
|
||||
async def update_missing_precedent(mp_id: UUID, **fields) -> dict | None:
|
||||
"""Patch a missing-precedent row. Allowed fields: legal_topic,
|
||||
legal_issue, notes, cited_by_party, cited_by_party_name, case_name,
|
||||
status, linked_case_law_id, closed_at."""
|
||||
if not fields:
|
||||
return await get_missing_precedent(mp_id)
|
||||
allowed = {
|
||||
"legal_topic", "legal_issue", "notes", "cited_by_party",
|
||||
"cited_by_party_name", "case_name", "status", "linked_case_law_id",
|
||||
"closed_at", "claim_quote", "citation",
|
||||
}
|
||||
clean = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not clean:
|
||||
return await get_missing_precedent(mp_id)
|
||||
if "status" in clean and clean["status"] not in ALLOWED_MP_STATUS:
|
||||
raise ValueError(
|
||||
f"status must be one of {sorted(ALLOWED_MP_STATUS)}"
|
||||
)
|
||||
if "cited_by_party" in clean and clean["cited_by_party"] and \
|
||||
clean["cited_by_party"] not in ALLOWED_MP_PARTIES:
|
||||
raise ValueError(
|
||||
f"cited_by_party must be one of {sorted(ALLOWED_MP_PARTIES)}"
|
||||
)
|
||||
set_clauses = []
|
||||
values = []
|
||||
for i, (key, val) in enumerate(clean.items(), start=2):
|
||||
set_clauses.append(f"{key} = ${i}")
|
||||
values.append(val)
|
||||
set_clauses.append("updated_at = now()")
|
||||
sql = (
|
||||
f"UPDATE missing_precedents SET {', '.join(set_clauses)} "
|
||||
f"WHERE id = $1 RETURNING *"
|
||||
)
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(sql, mp_id, *values)
|
||||
return _row_to_missing_precedent(row) if row else None
|
||||
|
||||
|
||||
async def close_missing_precedent(
|
||||
mp_id: UUID,
|
||||
linked_case_law_id: UUID | None = None,
|
||||
notes: str | None = None,
|
||||
status: str = "closed",
|
||||
) -> dict | None:
|
||||
"""Mark a missing-precedent row as closed (or 'uploaded'/'irrelevant')
|
||||
and link it to a case_law row if provided."""
|
||||
if status not in ALLOWED_MP_STATUS:
|
||||
raise ValueError(
|
||||
f"status must be one of {sorted(ALLOWED_MP_STATUS)}"
|
||||
)
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
sets = ["status = $2", "closed_at = now()", "updated_at = now()"]
|
||||
params: list = [mp_id, status]
|
||||
idx = 3
|
||||
if linked_case_law_id is not None:
|
||||
sets.append(f"linked_case_law_id = ${idx}")
|
||||
params.append(linked_case_law_id)
|
||||
idx += 1
|
||||
if notes is not None:
|
||||
sets.append(f"notes = ${idx}")
|
||||
params.append(notes)
|
||||
idx += 1
|
||||
sql = (
|
||||
f"UPDATE missing_precedents SET {', '.join(sets)} "
|
||||
f"WHERE id = $1 RETURNING *"
|
||||
)
|
||||
row = await conn.fetchrow(sql, *params)
|
||||
return _row_to_missing_precedent(row) if row else None
|
||||
|
||||
|
||||
async def find_missing_precedent_by_citation(
|
||||
citation: str,
|
||||
case_id: UUID | None = None,
|
||||
) -> dict | None:
|
||||
"""Look up an existing row by citation string (exact match) and optionally
|
||||
cited-in case_id. Used to deduplicate auto-creation by the researcher."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
if case_id is not None:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM missing_precedents "
|
||||
"WHERE citation = $1 AND cited_in_case_id = $2 LIMIT 1",
|
||||
citation.strip(), case_id,
|
||||
)
|
||||
else:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM missing_precedents WHERE citation = $1 LIMIT 1",
|
||||
citation.strip(),
|
||||
)
|
||||
return _row_to_missing_precedent(row) if row else None
|
||||
|
||||
@@ -52,16 +52,44 @@ DOMAIN_PRACTICE_AREAS: set[str] = {
|
||||
"compensation_197",
|
||||
}
|
||||
|
||||
# Union — what ``validate()`` accepts for backward-compat
|
||||
PRACTICE_AREAS: set[str] = MULTI_TENANT_PRACTICE_AREAS | DOMAIN_PRACTICE_AREAS
|
||||
# Union — what ``validate()`` accepts for backward-compat.
|
||||
# Empty string is permitted because the DB CHECK constraint allows it as
|
||||
# a "not yet classified" sentinel (e.g. when auto-derivation fails on an
|
||||
# unrecognized case_number format).
|
||||
PRACTICE_AREAS: set[str] = MULTI_TENANT_PRACTICE_AREAS | DOMAIN_PRACTICE_AREAS | {""}
|
||||
|
||||
APPEALS_COMMITTEE_SUBTYPES: set[str] = {
|
||||
"building_permit",
|
||||
"betterment_levy",
|
||||
"compensation_197",
|
||||
# בל"מ — בקשה להארכת מועד להגשת ערר. מסלולים נפרדים לפי domain:
|
||||
"extension_request_building_permit", # 1xxx — סעיף 152, 30 ימים
|
||||
"extension_request_betterment_levy", # 8xxx — סעיף 14 לתוספת ג', 45 ימים
|
||||
"extension_request_compensation", # 9xxx — סעיף 198(ד), 30 ימים
|
||||
"unknown",
|
||||
}
|
||||
|
||||
# בל"מ subtypes — קל לזהות ע"י prefix
|
||||
BLAM_SUBTYPES: set[str] = {
|
||||
"extension_request_building_permit",
|
||||
"extension_request_betterment_levy",
|
||||
"extension_request_compensation",
|
||||
}
|
||||
|
||||
# מיפוי domain → בל"מ subtype
|
||||
_DOMAIN_TO_BLAM_SUBTYPE: dict[str, str] = {
|
||||
"rishuy_uvniya": "extension_request_building_permit",
|
||||
"betterment_levy": "extension_request_betterment_levy",
|
||||
"compensation_197": "extension_request_compensation",
|
||||
}
|
||||
|
||||
# מיפוי first-digit → בל"מ subtype (אותו מבנה כמו _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE)
|
||||
_APPEALS_COMMITTEE_DIGIT_TO_BLAM = {
|
||||
"1": "extension_request_building_permit",
|
||||
"8": "extension_request_betterment_levy",
|
||||
"9": "extension_request_compensation",
|
||||
}
|
||||
|
||||
DEFAULT_PRACTICE_AREA = "appeals_committee"
|
||||
|
||||
# Subtypes per practice_area (extend when adding domains)
|
||||
@@ -70,9 +98,11 @@ SUBTYPES_BY_AREA: dict[str, set[str]] = {
|
||||
"national_insurance": {"unknown"},
|
||||
"labor_law": {"unknown"},
|
||||
# Domain values — subtype is implicit in the value itself
|
||||
"rishuy_uvniya": {"building_permit", "unknown"},
|
||||
"betterment_levy": {"betterment_levy", "unknown"},
|
||||
"compensation_197": {"compensation_197", "unknown"},
|
||||
"rishuy_uvniya": {"building_permit", "extension_request_building_permit", "unknown"},
|
||||
"betterment_levy": {"betterment_levy", "extension_request_betterment_levy", "unknown"},
|
||||
"compensation_197": {"compensation_197", "extension_request_compensation", "unknown"},
|
||||
# Empty (unclassified) — allow any of the appeals_committee subtypes
|
||||
"": APPEALS_COMMITTEE_SUBTYPES,
|
||||
}
|
||||
|
||||
# Mapping: (multi_tenant_pa, appeal_subtype) → domain_pa
|
||||
@@ -80,9 +110,39 @@ _SUBTYPE_TO_DOMAIN: dict[str, str] = {
|
||||
"building_permit": "rishuy_uvniya",
|
||||
"betterment_levy": "betterment_levy",
|
||||
"compensation_197": "compensation_197",
|
||||
"extension_request_building_permit": "rishuy_uvniya",
|
||||
"extension_request_betterment_levy": "betterment_levy",
|
||||
"extension_request_compensation": "compensation_197",
|
||||
}
|
||||
|
||||
|
||||
# Regex לזיהוי "בקשה להארכת מועד" בנושא הערר (subject) —
|
||||
# וריאציות נפוצות. case-insensitive, מתחשב במרכאות חכמות/רגילות.
|
||||
_BLAM_SUBJECT_PATTERNS = (
|
||||
re.compile(r"בקשה\s+להארכת\s+מועד", re.IGNORECASE),
|
||||
re.compile(r"בל[\"״״]מ", re.IGNORECASE), # בל"מ עם quote variants
|
||||
re.compile(r"הארכת\s+מועד\s+להגשת", re.IGNORECASE),
|
||||
)
|
||||
|
||||
|
||||
def is_blam_subject(subject: str) -> bool:
|
||||
"""True iff subject indicates a בל"מ (extension-of-time request).
|
||||
|
||||
מזהה: "בקשה להארכת מועד", "בל\"מ", "הארכת מועד להגשת..."
|
||||
|
||||
Examples:
|
||||
>>> is_blam_subject("בל\"מ אלחנן ברלינגר נ' לינדאב")
|
||||
True
|
||||
>>> is_blam_subject("בקשה להארכת מועד להגשת ערר")
|
||||
True
|
||||
>>> is_blam_subject("היתר בנייה ברחוב X")
|
||||
False
|
||||
"""
|
||||
if not subject:
|
||||
return False
|
||||
return any(p.search(subject) for p in _BLAM_SUBJECT_PATTERNS)
|
||||
|
||||
|
||||
def to_db_practice_area(practice_area: str, appeal_subtype: str = "") -> str:
|
||||
"""Convert a multi-tenant practice_area + appeal_subtype to the
|
||||
domain value stored in DB columns (case_law/cases).
|
||||
@@ -120,14 +180,28 @@ _CASE_NUM = re.compile(r"(?:ARAR[-\s]*\d{2}[-\s]*(?:\d{2}[-\s]*)?)(\d{4})", re.I
|
||||
_PLAIN_NUM = re.compile(r"(\d{4})")
|
||||
|
||||
|
||||
_DOMAIN_TO_SUBTYPE: dict[str, str] = {
|
||||
"rishuy_uvniya": "building_permit",
|
||||
"betterment_levy": "betterment_levy",
|
||||
"compensation_197": "compensation_197",
|
||||
}
|
||||
|
||||
|
||||
def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) -> str:
|
||||
"""Infer the appeal_subtype from case_number.
|
||||
|
||||
For appeals_committee, the convention is:
|
||||
For appeals_committee (axis A), the convention is:
|
||||
1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197.
|
||||
|
||||
For domain values (axis B — rishuy_uvniya/betterment_levy/compensation_197),
|
||||
the subtype is implicit in the practice_area itself — we map directly
|
||||
without parsing the case number.
|
||||
|
||||
Handles multiple formats: ARAR-25-8126, 8126/25, 1170, ערר 1024-25.
|
||||
"""
|
||||
# Axis B: practice_area is already a domain value — map directly.
|
||||
if practice_area in DOMAIN_PRACTICE_AREAS:
|
||||
return _DOMAIN_TO_SUBTYPE.get(practice_area, "unknown")
|
||||
if practice_area != "appeals_committee":
|
||||
return "unknown"
|
||||
cn = case_number or ""
|
||||
@@ -142,6 +216,82 @@ def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA)
|
||||
return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit, "unknown")
|
||||
|
||||
|
||||
def derive_subtype_with_blam(
|
||||
case_number: str,
|
||||
subject: str = "",
|
||||
practice_area: str = DEFAULT_PRACTICE_AREA,
|
||||
) -> str:
|
||||
"""Like ``derive_subtype()`` but also detects בל"מ from the subject.
|
||||
|
||||
If ``subject`` indicates a בקשה להארכת מועד, the returned subtype is
|
||||
one of the ``extension_request_*`` values (chosen per case_number /
|
||||
practice_area). Otherwise behaviour matches ``derive_subtype()``.
|
||||
|
||||
Examples:
|
||||
>>> derive_subtype_with_blam("1017-03-26", "בל\"מ ברלינגר נ' לינדאב")
|
||||
'extension_request_building_permit'
|
||||
>>> derive_subtype_with_blam("8500-25", "בקשה להארכת מועד")
|
||||
'extension_request_betterment_levy'
|
||||
>>> derive_subtype_with_blam("1033-25", "ערר על החלטת ועדה")
|
||||
'building_permit'
|
||||
"""
|
||||
base = derive_subtype(case_number, practice_area)
|
||||
if not is_blam_subject(subject):
|
||||
return base
|
||||
# subject says it's בל"מ — return the matching extension_request_* variant.
|
||||
# For domain practice_area (axis B), use the direct mapping.
|
||||
if practice_area in DOMAIN_PRACTICE_AREAS:
|
||||
return _DOMAIN_TO_BLAM_SUBTYPE.get(practice_area, base)
|
||||
# For appeals_committee (axis A), derive from case_number digit.
|
||||
if practice_area == "appeals_committee":
|
||||
cn = case_number or ""
|
||||
m = _CASE_NUM.search(cn) or _PLAIN_NUM.search(cn)
|
||||
if m:
|
||||
first_digit = m.group(1)[0]
|
||||
blam = _APPEALS_COMMITTEE_DIGIT_TO_BLAM.get(first_digit)
|
||||
if blam:
|
||||
return blam
|
||||
return base
|
||||
|
||||
|
||||
def is_blam_subtype(appeal_subtype: str) -> bool:
|
||||
"""True iff appeal_subtype is one of the extension_request_* variants.
|
||||
|
||||
Useful for UI badges and routing logic that need to detect בל"מ cases
|
||||
regardless of which domain they belong to.
|
||||
"""
|
||||
return appeal_subtype in BLAM_SUBTYPES
|
||||
|
||||
|
||||
def derive_domain_practice_area(case_number: str) -> str:
|
||||
"""Map a case_number prefix to a domain practice_area (axis B).
|
||||
|
||||
Returns:
|
||||
``"rishuy_uvniya"`` for 1xxx, ``"betterment_levy"`` for 8xxx,
|
||||
``"compensation_197"`` for 9xxx, or ``""`` when the prefix is
|
||||
unrecognized (caller decides the fallback).
|
||||
|
||||
Examples:
|
||||
>>> derive_domain_practice_area("8126/25")
|
||||
'betterment_levy'
|
||||
>>> derive_domain_practice_area("1170")
|
||||
'rishuy_uvniya'
|
||||
>>> derive_domain_practice_area("ARAR-24-01-9007")
|
||||
'compensation_197'
|
||||
>>> derive_domain_practice_area("foo")
|
||||
''
|
||||
"""
|
||||
cn = case_number or ""
|
||||
m = _CASE_NUM.search(cn) or _PLAIN_NUM.search(cn)
|
||||
if not m:
|
||||
return ""
|
||||
first_digit = m.group(1)[0]
|
||||
subtype = _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit)
|
||||
if not subtype:
|
||||
return ""
|
||||
return _SUBTYPE_TO_DOMAIN.get(subtype, "")
|
||||
|
||||
|
||||
# ── Validation ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -164,6 +314,20 @@ def validate(practice_area: str, appeal_subtype: str | None) -> None:
|
||||
|
||||
def is_override(case_number: str, practice_area: str, appeal_subtype: str) -> bool:
|
||||
"""True iff the user-supplied subtype disagrees with what derive_subtype
|
||||
would have produced (and the derived value is not 'unknown')."""
|
||||
would have produced (and the derived value is not 'unknown').
|
||||
|
||||
Note: בל"מ variants (extension_request_*) are NOT considered overrides
|
||||
of their parent domain — extension_request_building_permit on a 1xxx
|
||||
case is consistent with the case-number convention.
|
||||
"""
|
||||
derived = derive_subtype(case_number, practice_area)
|
||||
return derived != "unknown" and derived != appeal_subtype
|
||||
if derived == "unknown":
|
||||
return False
|
||||
if derived == appeal_subtype:
|
||||
return False
|
||||
# בל"מ variants of the same domain are not overrides.
|
||||
if appeal_subtype in BLAM_SUBTYPES:
|
||||
# extension_request_building_permit ↔ building_permit (1xxx) — same domain
|
||||
if _SUBTYPE_TO_DOMAIN.get(appeal_subtype) == _SUBTYPE_TO_DOMAIN.get(derived):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -128,7 +128,7 @@ async def case_create(
|
||||
hearing_date: str = "",
|
||||
notes: str = "",
|
||||
expected_outcome: str = "",
|
||||
practice_area: str = "appeals_committee",
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
) -> str:
|
||||
"""יצירת תיק ערר חדש.
|
||||
@@ -145,7 +145,9 @@ async def case_create(
|
||||
hearing_date: תאריך דיון (YYYY-MM-DD)
|
||||
notes: הערות
|
||||
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
|
||||
practice_area: תחום משפטי (appeals_committee / national_insurance / labor_law)
|
||||
practice_area: תחום משפטי — domain value (rishuy_uvniya / betterment_levy /
|
||||
compensation_197). ריק או "appeals_committee" = יוסק
|
||||
אוטומטית ממספר התיק (1xxx→רישוי, 8xxx→השבחה, 9xxx→197)
|
||||
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
|
||||
ריק = יוסק אוטומטית ממספר התיק
|
||||
"""
|
||||
@@ -155,8 +157,18 @@ async def case_create(
|
||||
if hearing_date:
|
||||
h_date = date_type.fromisoformat(hearing_date)
|
||||
|
||||
# Resolve appeal_subtype: explicit override > auto-derive > 'unknown'
|
||||
derived_subtype = pa.derive_subtype(case_number, practice_area)
|
||||
# Auto-derive practice_area when missing or set to the legacy multi-tenant
|
||||
# value. The DB's cases_practice_area_check rejects 'appeals_committee',
|
||||
# so we MUST map it to a domain value before INSERT. If derivation fails
|
||||
# (unknown case number format), fall back to '' which the constraint allows.
|
||||
if not practice_area or practice_area == "appeals_committee":
|
||||
practice_area = pa.derive_domain_practice_area(case_number)
|
||||
|
||||
# Resolve appeal_subtype: explicit override > auto-derive > 'unknown'.
|
||||
# derive_subtype_with_blam inspects the subject to detect בל"מ
|
||||
# (בקשה להארכת מועד) and returns an extension_request_* variant when
|
||||
# appropriate. Falls back to regular derive_subtype when subject is empty.
|
||||
derived_subtype = pa.derive_subtype_with_blam(case_number, subject, practice_area)
|
||||
if not appeal_subtype:
|
||||
appeal_subtype = derived_subtype
|
||||
pa.validate(practice_area, appeal_subtype)
|
||||
|
||||
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 |
|
||||
| `multimodal_backfill.py` | python | Backfill voyage-multimodal-3 page embeddings על מסמכי תיקים קיימים. idempotent (skips by default), forces `MULTIMODAL_ENABLED=true` ל-run, רץ מהקונטיינר. שלב C — ראה `docs/voyage-upgrades-plan.md` | ידני per-case (`python multimodal_backfill.py 8174-24 8137-24`) |
|
||||
| `backfill_chunk_pages.py` | python | Backfill `page_number` ב-`document_chunks` קיימים. legacy chunker לא tracked עמודים → `page_number=NULL` חוסם boost של multimodal hybrid (text+image join על אותו עמוד). re-extracts כל PDF (re-OCR אם צריך, ~$0.0015/page), מחשב page_offsets, ומעדכן chunks. idempotent | ידני per-case (`python backfill_chunk_pages.py 8174-24 8137-24`) |
|
||||
| `backfill_legal_arguments.py` | python | Backfill `legal_arguments` לתיקים עם `claims` קיימים (TaskMaster #36). מקבץ פרופוזיציות גולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד) דרך `argument_aggregator.aggregate_claims_to_arguments` (Claude CLI). תומך `--dry-run`/`--apply`/`--force`/`--case <num>...`. **חייב לרוץ מהמכונה המקומית** (לא קונטיינר) — `claude_session` דורש Claude CLI | ידני per-case (`python scripts/backfill_legal_arguments.py --apply --case 1017-03-26`) |
|
||||
|
||||
## תיקיית `.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 { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||
@@ -77,6 +78,9 @@ export default function CaseDetailPage({
|
||||
<div className="flex items-center justify-between gap-3 mb-1 flex-wrap">
|
||||
<TabsList className="bg-rule-soft/60">
|
||||
<TabsTrigger value="overview">סקירה</TabsTrigger>
|
||||
<TabsTrigger value="arguments">
|
||||
טיעונים
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="drafts">
|
||||
טיוטות והערות
|
||||
</TabsTrigger>
|
||||
@@ -139,6 +143,10 @@ export default function CaseDetailPage({
|
||||
<DocumentsPanel data={data} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="arguments" className="mt-5">
|
||||
<LegalArgumentsPanel caseNumber={caseNumber} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts" className="mt-5">
|
||||
<DraftsPanel
|
||||
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";
|
||||
import { GlobalSearch } from "@/components/global-search";
|
||||
import { headerSubtitle } from "@/components/header-context";
|
||||
import { useMissingPrecedentsOpenCount } from "@/lib/api/missing-precedents";
|
||||
|
||||
/**
|
||||
* Ezer Mishpati navigation shell — two-row header.
|
||||
@@ -45,9 +46,10 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: "knowledge",
|
||||
items: [
|
||||
{ href: "/precedents", label: "ספריית פסיקה" },
|
||||
{ href: "/training", label: "אימון סגנון" },
|
||||
{ href: "/methodology", label: "מתודולוגיה" },
|
||||
{ href: "/precedents", label: "ספריית פסיקה" },
|
||||
{ href: "/missing-precedents", label: "פסיקה חסרה" },
|
||||
{ href: "/training", label: "אימון סגנון" },
|
||||
{ href: "/methodology", label: "מתודולוגיה" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -240,7 +242,8 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
|
||||
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
<span>{item.label}</span>
|
||||
{item.href === "/missing-precedents" ? <MissingPrecedentsBadge /> : null}
|
||||
{active && (
|
||||
<span
|
||||
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
|
||||
@@ -250,3 +253,18 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
|
||||
</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)" },
|
||||
];
|
||||
|
||||
/* For chart aggregation, collapse בל"מ variants back to their parent
|
||||
* domain — building_permit / betterment_levy / compensation_197. The
|
||||
* dedicated בל"מ filter in the cases table handles the cross-cutting view. */
|
||||
function collapseBlam(s: AppealSubtype): AppealSubtype {
|
||||
if (s === "extension_request_building_permit") return "building_permit";
|
||||
if (s === "extension_request_betterment_levy") return "betterment_levy";
|
||||
if (s === "extension_request_compensation") return "compensation_197";
|
||||
return s;
|
||||
}
|
||||
|
||||
export function subtypeOf(c: Case): AppealSubtype {
|
||||
return c.appeal_subtype && c.appeal_subtype !== "unknown"
|
||||
const raw = c.appeal_subtype && c.appeal_subtype !== "unknown"
|
||||
? c.appeal_subtype
|
||||
: deriveSubtype(c.case_number);
|
||||
return collapseBlam(raw);
|
||||
}
|
||||
|
||||
export function AppealTypeBars({ cases }: { cases?: Case[] }) {
|
||||
/* All seven subtypes initialized to 0 — subtypeOf() collapses בל"מ
|
||||
* variants back to their parent domain, so the extension_request_*
|
||||
* counters will remain 0 in practice; they exist here to satisfy the
|
||||
* Record<AppealSubtype, number> type. */
|
||||
const counts: Record<AppealSubtype, number> = {
|
||||
building_permit: 0,
|
||||
betterment_levy: 0,
|
||||
compensation_197: 0,
|
||||
extension_request_building_permit: 0,
|
||||
extension_request_betterment_levy: 0,
|
||||
extension_request_compensation: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
(cases ?? []).forEach((c) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CreateRepoButton } from "@/components/cases/create-repo-button";
|
||||
import {
|
||||
PRACTICE_AREA_LABELS,
|
||||
APPEAL_SUBTYPE_LABELS,
|
||||
isBlamSubtype,
|
||||
} from "@/lib/practice-area";
|
||||
import type { CaseDetail } from "@/lib/api/cases";
|
||||
|
||||
@@ -62,6 +63,15 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
|
||||
)}
|
||||
</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 && (
|
||||
<CaseArchiveAction
|
||||
caseNumber={data.case_number}
|
||||
|
||||
@@ -16,7 +16,12 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 { isBlamSubtype } from "@/lib/practice-area";
|
||||
import type { Case } from "@/lib/api/cases";
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
@@ -49,8 +54,17 @@ const columns: ColumnDef<Case>[] = [
|
||||
accessorKey: "title",
|
||||
header: "כותרת",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-ink max-w-[420px] truncate" title={row.original.title}>
|
||||
{row.original.title}
|
||||
<div className="text-ink max-w-[420px] truncate flex items-center gap-2" 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>
|
||||
),
|
||||
},
|
||||
@@ -94,8 +108,15 @@ export function CasesTable({
|
||||
{ id: "updated_at", desc: true },
|
||||
]);
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
/* "all" = all cases; "blam" = only בל"מ; "regular" = exclude בל"מ */
|
||||
const [blamFilter, setBlamFilter] = useState<"all" | "blam" | "regular">("all");
|
||||
|
||||
const data = useMemo(() => cases ?? [], [cases]);
|
||||
const data = useMemo(() => {
|
||||
const all = cases ?? [];
|
||||
if (blamFilter === "blam") return all.filter((c) => isBlamSubtype(c.appeal_subtype));
|
||||
if (blamFilter === "regular") return all.filter((c) => !isBlamSubtype(c.appeal_subtype));
|
||||
return all;
|
||||
}, [cases, blamFilter]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
@@ -126,6 +147,20 @@ export function CasesTable({
|
||||
className="max-w-sm bg-surface"
|
||||
dir="rtl"
|
||||
/>
|
||||
<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">
|
||||
{table.getFilteredRowModel().rows.length} תיקים
|
||||
</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: "החלטת ועדת ערר" },
|
||||
] 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 {
|
||||
if (!value) return "—";
|
||||
const match = PRACTICE_AREAS.find((p) => p.value === value);
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type SourceType,
|
||||
} from "@/lib/api/precedent-library";
|
||||
import {
|
||||
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, appealSubtypeLabel,
|
||||
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, DISTRICTS, appealSubtypeLabel,
|
||||
} from "./practice-area";
|
||||
import { ExtractedHalachotSection } from "./extracted-halachot";
|
||||
|
||||
@@ -188,9 +188,21 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="district">מחוז</Label>
|
||||
<Input id="district" value={form.district}
|
||||
onChange={(e) => setForm({ ...form, district: e.target.value })}
|
||||
placeholder="ירושלים / תל אביב / מרכז" />
|
||||
<Select value={form.district || "_none"}
|
||||
onValueChange={(v) => setForm({ ...form, district: v === "_none" ? "" : v })}>
|
||||
<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 className="space-y-1">
|
||||
<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,
|
||||
} from "@/lib/schemas/case";
|
||||
import {
|
||||
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtype,
|
||||
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtypeWithBlam,
|
||||
type AppealSubtype,
|
||||
} from "@/lib/practice-area";
|
||||
|
||||
@@ -78,13 +78,16 @@ export function CaseWizard() {
|
||||
const userTouchedSubtype = useRef(false);
|
||||
const caseNumber = form.watch("case_number");
|
||||
const practiceArea = form.watch("practice_area");
|
||||
const subject = form.watch("subject");
|
||||
useEffect(() => {
|
||||
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")) {
|
||||
form.setValue("appeal_subtype", derived, { shouldValidate: false });
|
||||
}
|
||||
}, [caseNumber, practiceArea, form]);
|
||||
}, [caseNumber, practiceArea, subject, form]);
|
||||
|
||||
const stepIndex = STEPS.findIndex((s) => s.key === step);
|
||||
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"
|
||||
| "betterment_levy"
|
||||
| "compensation_197"
|
||||
/* בל"מ — בקשה להארכת מועד להגשת ערר. שלושה מסלולים נפרדים. */
|
||||
| "extension_request_building_permit"
|
||||
| "extension_request_betterment_levy"
|
||||
| "extension_request_compensation"
|
||||
| "unknown";
|
||||
|
||||
export const PRACTICE_AREAS: ReadonlyArray<{
|
||||
@@ -34,12 +38,26 @@ export const APPEAL_SUBTYPES: ReadonlyArray<{
|
||||
value: AppealSubtype;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "building_permit", label: "רישוי ובנייה" },
|
||||
{ value: "betterment_levy", label: "היטל השבחה" },
|
||||
{ value: "compensation_197", label: "פיצויים (ס' 197)" },
|
||||
{ value: "unknown", label: "לא ידוע" },
|
||||
{ value: "building_permit", label: "רישוי ובנייה" },
|
||||
{ value: "betterment_levy", label: "היטל השבחה" },
|
||||
{ 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: "לא ידוע" },
|
||||
];
|
||||
|
||||
/* בל"מ 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> =
|
||||
Object.fromEntries(PRACTICE_AREAS.map((p) => [p.value, p.label])) as Record<
|
||||
PracticeArea,
|
||||
@@ -70,3 +88,30 @@ export function deriveSubtype(
|
||||
if (first === "9") return "compensation_197";
|
||||
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",
|
||||
"betterment_levy",
|
||||
"compensation_197",
|
||||
"extension_request_building_permit",
|
||||
"extension_request_betterment_levy",
|
||||
"extension_request_compensation",
|
||||
"unknown",
|
||||
] as const satisfies readonly AppealSubtype[]),
|
||||
});
|
||||
|
||||
412
web/app.py
412
web/app.py
@@ -1228,7 +1228,11 @@ class CaseCreateRequest(BaseModel):
|
||||
hearing_date: str = ""
|
||||
notes: 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 = ""
|
||||
|
||||
|
||||
@@ -1267,8 +1271,10 @@ async def api_case_create(req: CaseCreateRequest):
|
||||
)
|
||||
parsed = json.loads(result)
|
||||
|
||||
# Auto-create Paperclip project for the new case
|
||||
appeal_type = req.appeal_subtype or "רישוי"
|
||||
# Auto-create Paperclip project for the new case. case_create may have
|
||||
# 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:
|
||||
pc_result = await pc_create_project(
|
||||
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)}
|
||||
|
||||
|
||||
# ── 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")
|
||||
async def api_set_direction(case_number: str, req: DirectionRequest):
|
||||
"""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:
|
||||
raise HTTPException(404, "הלכה לא נמצאה")
|
||||
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
|
||||
}
|
||||
|
||||
# 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 = {
|
||||
"רישוי": COMPANIES["licensing"],
|
||||
"היטל השבחה": COMPANIES["betterment"],
|
||||
@@ -63,6 +65,10 @@ _FALLBACK_APPEAL_TYPE_TO_COMPANY = {
|
||||
"compensation_197": COMPANIES["betterment"],
|
||||
"compensation": COMPANIES["betterment"],
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user