5 Commits

Author SHA1 Message Date
3541238239 Update CLAUDE.md: add corpus-analysis.md to reference table
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:26:08 +00:00
50eaa887db Add chair feedback system and content checklists for block-yod
Backend changes cherry-picked from ui-rewrite branch to enable
feedback API endpoints for the Next.js staging UI.

- chair_feedback DB table + API endpoints (GET/POST/PATCH)
- Content checklists by appeal subtype injected into block-yod prompt
- MCP tools for recording and listing chair feedback
- Corpus analysis documentation (24 decisions)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:05:53 +00:00
e2088a4f60 Add case_precedents: attached legal support for the compose phase
New self-contained table + MCP tools + FastAPI endpoints for letting
the chair attach external case-law quotes (quote + citation מראה מקום,
optional chair note, optional archived PDF) to either a specific
threshold_claim / issue or the case as a whole.

Data model
  - case_precedents (SCHEMA_V5_SQL) — case_id, section_id NULL/
    "threshold_N"/"issue_N", quote, citation (free-text), chair_note,
    pdf_document_id FK to documents, denormalized practice_area for
    cross-case library filtering.
  - Deliberately NOT linked to the existing case_law table — that one
    has UNIQUE(case_number) which would force parsing the free-text
    citation into a structured key. A backfill pass into case_law is
    a later follow-up once the UI stabilizes.
  - db.py gains 4 helpers: create_case_precedent, list_case_precedents,
    delete_case_precedent, search_precedent_library. The last uses
    DISTINCT ON (citation) for the cross-case typeahead so each
    precedent appears once even if reused across many cases.

MCP tools (legal_mcp/tools/precedents.py)
  - precedent_attach, precedent_list, precedent_remove,
    precedent_search_library — registered in server.py.

FastAPI (web/app.py)
  - POST /api/cases/{n}/precedents — create, with PrecedentCreateRequest
  - POST /api/cases/{n}/precedents/upload-pdf — one-shot PDF upload to
    a dedicated documents/precedents/ subdirectory, creates a
    documents row with doc_type="precedent_archive" and no text
    extraction (archive only)
  - GET /api/cases/{n}/precedents — list
  - DELETE /api/precedents/{id} — uses path param since precedent_id
    is a UUID (slash-safe, unlike case numbers)
  - GET /api/precedents/search?q=...&practice_area=... — library
    typeahead

Block-writer integration into _build_precedents_context is a deferred
follow-up — Phase 1 surfaces the feature in the compose UI only.

Plan: ~/.claude/plans/woolly-cooking-graham.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:16:48 +00:00
8989ad9a9b Add case_delete: MCP tool + DELETE endpoint + DB helper
Wires a new case-deletion path across the three layers that needed it:

- db.delete_case(case_id) — single SQL DELETE; documents, chunks, and
  qa_results cascade via existing schema FKs, audit_log nullifies.
- cases_tools.case_delete(case_number, remove_files=False) — MCP tool
  wrapper. File tree on disk is kept by default (audit trail); pass
  remove_files=True for a hard delete.
- DELETE /api/cases?case_number=... — FastAPI endpoint taking the case
  number as a QUERY param rather than a path segment. Case numbers
  like "1000/0426" can't be passed through a path parameter because
  FastAPI routing decodes %2F before matching, so a query param is
  the only shape that works for historical data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:47:50 +00:00
26d09d648f Practice area separation: multi-tenant axis across DB, RAG, and UI
Adds two orthogonal columns — practice_area (top-level legal domain:
appeals_committee / national_insurance / labor_law) and appeal_subtype
(building_permit / betterment_levy / compensation_197) — denormalized
into cases, documents, document_chunks, decisions, and style_corpus so
vector searches can filter without JOINs.

Why: the system handles two unrelated sub-domains under the same
appeals committee (1xxx building permits and 8xxx/9xxx betterment/197),
with different rules and writing style. Without a separation axis,
search_similar() and the block-writer's precedent lookup were free to
surface betterment-levy paragraphs while drafting a building-permit
decision — a real risk of cross-domain contamination. The same axis
also lets future domains (national insurance, labor law) coexist
without separate schemas.

Schema (V4 migration in db.py):
- ALTER ... ADD COLUMN IF NOT EXISTS on all five tables + composite
  indexes (practice_area first).
- Idempotent backfill: case_number ~ '^1' → building_permit, '^8' →
  betterment_levy, '^9' → compensation_197; propagated to documents,
  chunks, and decisions via case_id; training-corpus rows (case_id NULL)
  default to appeals_committee.

Code:
- New services/practice_area.py with derive_subtype, validate, and
  is_override + enum constants.
- db.create_case / create_document / store_chunks / create_decision
  inherit practice_area from the parent case (or take an explicit
  override for the case_id=None training corpus).
- db.search_similar and search_similar_paragraphs accept practice_area
  + appeal_subtype filters using the denormalized columns.
- tools/search.py auto-resolves the filter from case_number when given.
- block_writer._build_precedents_context now passes the active case's
  practice_area to search_similar_paragraphs — closes the contamination
  hole for the discussion-block precedent fetch.
- tools/cases.case_create auto-derives subtype from case_number; an
  explicit override that disagrees writes a case_subtype_override entry
  to audit_log so we can spot bad classifications later.
- tools/documents.document_upload_training tags new training material
  with practice_area + subtype end-to-end (corpus, document, chunks).

UI (web/static/index.html + web/app.py):
- New-case wizard gets a practice_area dropdown (others disabled until
  national_insurance / labor_law arrive) and an appeal_subtype dropdown
  with JS auto-fill from the case-number prefix; manual edits stick.
- Case header shows a blue badge with practice_area · subtype.
- CaseCreateRequest plumbs both fields through to cases_tools.case_create.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:36:48 +00:00
14 changed files with 1699 additions and 36 deletions

View File

@@ -42,6 +42,7 @@
| [`docs/block-schema.md`](docs/block-schema.md) | הגדרת 12 בלוקים — content model, constraints, processing params | **לפני כל כתיבת החלטה** | | [`docs/block-schema.md`](docs/block-schema.md) | הגדרת 12 בלוקים — content model, constraints, processing params | **לפני כל כתיבת החלטה** |
| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים | | [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
| [`docs/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** | | [`docs/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** |
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
| [`docs/memory.md`](docs/memory.md) | הקשר כללי — skills, פרויקטים שהושלמו, מבנה vault | להתמצאות כללית | | [`docs/memory.md`](docs/memory.md) | הקשר כללי — skills, פרויקטים שהושלמו, מבנה vault | להתמצאות כללית |
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** | | [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |

240
docs/corpus-analysis.md Normal file
View File

@@ -0,0 +1,240 @@
# ניתוח שיטתי של קורפוס ההחלטות — מפת תוכן
> נוצר: 2026-04-12
> מקור: ניתוח 24 החלטות מתוך `/data/training/proofread/`
> מטרה: לחלץ דפוסי תוכן בפרק הדיון וההכרעה לפי סוג תיק
---
## 1. סקירה כללית של הקורפוס
### הרכב הקורפוס
| סוג | כמות | החלטות |
|-----|------|--------|
| רישוי ובנייה (1xxx) | 22 | כל ההחלטות מלבד גבאי ובית הכרם |
| תמ"א 38 | 1 | בית הכרם 1126+1141 |
| סמכות/סף בלבד | 1 | גבאי 1105 |
| **היטל השבחה (8xxx)** | **0** | **פער קריטי — אין אף החלטה בקורפוס** |
### תוצאות
| תוצאה | כמות | דוגמאות |
|-------|------|---------|
| דחייה | 12 | עמית, פרומר, זעיתר, בית שמש, אנשין (חניה), אהרן, שטרית, גבאי, ירושלים שקופה, לבנון, יפה |
| קבלה | 5 | טלי-אביב, הראל 1043+1054, הראל 1071+1077, מינץ, לוי |
| קבלה חלקית | 4 | אמיתי, בר-און, אנשין (1096), בית הכרם, אואקנין |
### אורך פרק הדיון
- טווח: 465 — 12,000 מילים
- ממוצע: ~5,000 מילים
- הקצר ביותר: גבאי (465, סמכות בלבד)
- הארוך ביותר: תורן/1015 (~11,000, שימוש חורג), מינץ/1071 (~12,000, סבב שני)
---
## 2. נושאים שנמצאו בפרקי הדיון — מפת תוכן מלאה
### 2.1 נושאים תכנוניים (Planning Content)
| נושא | מופיע ב-X החלטות | עומק טיפוסי | דוגמאות בולטות |
|------|-----------------|-------------|----------------|
| **ניתוח הוראות תכנית** (ציטוט ישיר, פרשנות) | 18/24 | 3-15 סעיפים | פרומר (MI/200), לבנון (Hal/435), בית הכרם (10038, 16000) |
| **חניה** (חישוב, נספח תנועה, חלופות) | 8/24 | 5-15 סעיפים | אנשין-1096 (הרחבה), בית הכרם (הרחבה), אנשין-1109, לוי |
| **קווי בניין ומרווחים** | 7/24 | 3-10 סעיפים | אמיתי, אואקנין, שטרית, בר-און |
| **גובה/קומות** | 4/24 | 3-6 סעיפים | לבנון (הרחבה), בר-און, לוי |
| **סביבה ואופי שכונה** | 6/24 | 2-5 סעיפים | זעיתר (טיפולוגיה), פרומר (חקלאי), בית הכרם |
| **שימושים מותרים/שימוש חורג** | 2/24 | 5-20 סעיפים | תורן (הרחבה חריגה), יפה |
| **שימור** | 2/24 | 2-5 סעיפים | בית הכרם, בר-און (עצים) |
| **טופוגרפיה/טיפולוגיה** | 2/24 | 3-8 סעיפים | זעיתר (הרחבה), לבנון |
| **תכנית אב כמסגרת** | 2/24 | 2-3 סעיפים | בית הכרם (16000), תורן (צור הדסה) |
| **אינטרס ציבורי (חיזוק/התחדשות)** | 2/24 | 3-8 סעיפים | בית הכרם (תמ"א 38), מינץ |
| **היררכיית תכניות** (ארצית→מחוזית→מקומית) | 3/24 | 5-12 סעיפים | פרומר (הרחבה), לבנון, תורן |
| **נספח בינוי** (ניתוח פרטני) | 5/24 | 3-8 סעיפים | לבנון, לוי, מינץ, בר-און, בית שמש |
| **פגיעה בשכנים** (צל, פרטיות, רעש) | 5/24 | 2-5 סעיפים | אמיתי, שטרית, בית הכרם, אואקנין, זעיתר |
| **עצים/נוף** | 3/24 | 1-3 סעיפים | בר-און, בית שמש, בית הכרם |
### 2.2 נושאים משפטיים (Legal Content)
| נושא | מופיע ב-X החלטות | עומק טיפוסי |
|------|-----------------|-------------|
| **סמכות/זכות ערר** (ס' 152, 12ב) | 10/24 | 3-12 סעיפים |
| **הלכת שפר** (ערר על היתר תואם תכנית) | 8/24 | 2-5 סעיפים |
| **תימוכין קנייניים** (property feasibility) | 6/24 | 5-15 סעיפים |
| **סטייה ניכרת** (תקנה 2) | 5/24 | 3-8 סעיפים |
| **שיהוי** (delay/laches) | 5/24 | 2-5 סעיפים |
| **מידתיות** (proportionality) | 4/24 | 2-5 סעיפים |
| **עבריינות בנייה** (building violations) | 6/24 | 2-5 סעיפים |
| **שיקול דעת הוועדה המקומית** | 8/24 | 2-5 סעיפים |
| **קניין vs. תכנון** (הפרדת סמכויות) | 7/24 | 3-10 סעיפים |
| **הכשרת בנייה קיימת** (regularization) | 3/24 | 5-12 סעיפים |
---
## 3. דפוסי "דיון תכנוני" שזוהו
### 3.1 מתי דפנה מקיימת דיון תכנוני מקיף?
**תמיד** כאשר:
- הערר עוסק בהתאמה לתכנית (ייעוד, שימוש, גובה, בנייה)
- יש שאלה של סטייה מהוראות תכנית
- הנושא הוא חניה/תשתיות
- התיק מערב תמ"א 38 או התחדשות עירונית
**לעולם לא** כאשר:
- התיק הוא סף/סמכות בלבד (גבאי)
- השאלה היא קניינית טהורה (טלי-אביב/1043+1054, בית שמש/1180+1181)
**עומק משתנה** כאשר:
- יש מספר נושאים שחלקם תכנוניים — הדיון התכנוני מוגבל לנושאים הרלוונטיים
### 3.2 איך דפנה בונה דיון תכנוני — הדפוס
**שלב 1: הקשר תכנוני רחב (2-8 סעיפים)**
- תכניות חלות ברמה הרלוונטית (מקומית, מחוזית, ארצית)
- ייעוד הקרקע, שימושים מותרים
- אופי הסביבה, מרקם בנוי
- *דוגמה*: פרומר — 12 סעיפים על MI/200, TAMA 35, TAMAM 30/1
**שלב 2: ציטוט ישיר מהוראות תכנית (3-15 סעיפים)**
- בלוקים ארוכים (200-600 מילים) של הוראות תכנית
- הדגשות בולד על המילים הרלוונטיות
- "הדגשת הח"מ" / "הדגשת הח.מ."
- *דוגמה*: בית הכרם — 400+ מילים מהוראות חניה של תכנית 5166ב
**שלב 3: יישום על המקרה הספציפי (3-8 סעיפים)**
- הוראה → עובדה → מסקנה
- "הנה מה שאומרת התכנית, הנה מה שקורה בפועל, הנה המסקנה"
- *דוגמה*: לבנון — השוואת חתכים של נספח בינוי עם הבקשה
**שלב 4: מסקנה תכנונית (1-3 סעיפים)**
- האם הבקשה תואמת/סוטה
- האם הסטייה מוצדקת
- מה צריך לתקן
### 3.3 הדפוס של "דיון תכנוני" לפי סוג נושא
| נושא | סדר ניתוח טיפוסי | רמת עומק |
|------|-----------------|----------|
| **חניה** | הוראות תכנית → תקנות חניה → נספח תנועה → חישוב → חלופות (קרן חניה, חפיפה, תחבורה ציבורית) | עמוק מאוד (8-15 סעיפים) |
| **קווי בניין** | הוראת תכנית → סטייה ניכרת? (תקנה 2(19)) → מידתיות → פגיעה בשכנים | בינוני-עמוק (5-10 סעיפים) |
| **גובה** | הוראת תכנית → נספח בינוי → מטרת ההגבלה → סטייה ניכרת? | בינוני (4-8 סעיפים) |
| **ייעוד/שימוש** | פרשנות תכנית → היררכיית תכניות → פרשנות מהותית → יישום | עמוק מאוד (10-20 סעיפים) |
| **שכנות** | עובדות (סיור) → השפעה (צל, פרטיות, רעש) → מידתיות | בינוני (3-6 סעיפים) |
| **סביבה** | תכנית אב → אופי שכונה → מרקם → השתלבות | בינוני (3-5 סעיפים) |
---
## 4. דפוסים חוצי-החלטות
### 4.1 מבנה הדיון — סדר הנושאים
1. **שאלות סף** (אם יש) — סמכות, זכות ערר, שיהוי
2. **הקשר תכנוני רחב** — תכניות, ייעוד, סביבה
3. **ניתוח ענייני** — נושא אחר נושא, כל אחד ב-CREAC
4. **מענה לטענות ספציפיות** — עובר על כל טענה מבלוק ז
5. **מסקנה** — תוצאה + הוראות אופרטיביות
### 4.2 כמה תכנון יש בכל החלטה?
| דרגה | תיאור | החלטות |
|------|-------|--------|
| **כבד** (>50% תכנון) | הדיון הוא בעיקר תכנוני | פרומר, זעיתר, בית הכרם, תורן, לבנון |
| **מאוזן** (30-50%) | שילוב תכנון + משפט | עמית, אמיתי, בר-און, אנשין-1096, אואקנין, לוי, שטרית |
| **קל** (<30%) | בעיקר משפטי, תכנון מינימלי | בית שמש, אנשין-1109, יפה |
| **אין** (0%) | רק משפטי/סמכות | טלי-אביב, הראל 1043+1054, גבאי, ירושלים שקופה |
### 4.3 פסיקה חוזרת (Recurring Case Law)
| פסיקה | נושא | מופיעה ב-X החלטות |
|-------|------|-------------------|
| הלכת שפר (עע"מ 317/10) | ערר על היתר תואם תכנית | 8 |
| הלכת עייזן (בג"ץ 1578/90) | תימוכין קנייניים | 6 |
| הלכת בן-יקר-גת | סטייה ניכרת | 4 |
| ערר אדלר 1181/22 | שיקול דעת תמ"א 38 | 2 |
| עע"מ 3975/22 קרן נכסים | קניין vs. תכנון | 4 |
### 4.4 טכניקות ניתוח ייחודיות
1. **פרשנות הרמונית** — כשיש מספר תכניות, דפנה מפרשת אותן ביחד (תורן)
2. **בדיקת תקדימים עובדתית** — הוועדה בדקה בעצמה 3 נכסים שנטענו כתקדים (לבנון)
3. **ציטוט מהחלטה מרכזת** — במקום לצטט 7 פס"ד, מצטטת אחד שריכז את כולם
4. **מבחן "המגרש הריק"** — להכשרת בנייה קיימת (אמיתי)
5. **מיפוי מתחים** — רשימת 3-6 מתחים לפני הניתוח (בית הכרם)
6. **"למעלה מן הצורך"** — דיון obiter אחרי הכרעה בסף (עמית, בית שמש)
---
## 5. פערים שזוהו
### 5.1 פער קריטי: אין החלטות היטל השבחה בקורפוס
למרות שהמערכת מגדירה 3 סוגי עררים (רישוי, היטל השבחה, פיצויים) — **כל 24 ההחלטות הן רישוי ובנייה**. אין לנו אף מודל לכתיבת החלטה בהיטל השבחה.
### 5.2 פער: לא כל נושא תכנוני מכוסה
נושאים שמופיעים רק בהחלטה אחת-שתיים:
- שימור → רק בית הכרם
- תמ"א 38 → רק בית הכרם
- שימוש חורג → רק תורן
- טיפולוגיה/טופוגרפיה → רק זעיתר
- תכנית אב כמסגרת → רק בית הכרם + תורן
### 5.3 פער: הפרומפט הנוכחי לא מכיל "צ'קליסט תוכן"
הפרומפט של block-yod (שורות 198-234 ב-block_writer.py) אומר:
- ✅ CREAC methodology
- ✅ ענה על כל טענה
- ✅ צטט פסיקה
-**אין**: "בתיק רישוי, כסה את הנושאים התכנוניים הרלוונטיים"
-**אין**: צ'קליסט תוכן לפי סוג ערר
-**אין**: "הקשר תכנוני רחב" כמרכיב חובה
### 5.4 פער: הבחנה לא מספיקה בין תת-סוגי רישוי
תיקי רישוי שונים מאוד זה מזה:
- **סמכות/סף** — דיון משפטי טהור, אין צורך בתכנון
- **קנייני** — תימוכין קנייניים, אין צורך בתכנון
- **תכנוני מובהק** — ייעוד, חניה, גובה — דיון תכנוני מקיף
- **שימוש חורג** — פרשנות תכניות, דיון תכנוני עמוק
- **הקלה** — מידתיות + תכנון
- **תמ"א 38** — איזון אינטרסים + תכנון
---
## 6. המלצות לשלב הבא
### 6.1 צ'קליסט תוכן מוצע לערר רישוי ובנייה
```
בהתאם לנושא הערר, הדיון צריך לכלול:
□ הקשר תכנוני רחב (תמיד כשהערר מגיע למריט):
- תכניות חלות (מקומית, מחוזית, ארצית — לפי הצורך)
- ייעוד הקרקע
- אופי הסביבה
□ ניתוח הוראות תכנית (כשיש שאלה של התאמה/סטייה):
- ציטוט ישיר מהוראות רלוונטיות
- פרשנות — תכלית ההוראה
- יישום על המקרה
□ חניה (כשרלוונטי):
- הוראות תכנית + נספח תנועה
- חישוב מקומות נדרשים vs. מסופקים
- חלופות (קרן חניה, חפיפה, תח"צ)
□ שכנות/פגיעה (כשרלוונטי):
- ממצאי סיור
- צל, פרטיות, רעש, נוף
- מידתיות
□ קווי בניין (כשרלוונטי):
- הוראת תכנית
- סטייה ניכרת — תקנה 2(19)
- הצדקה/מידתיות
□ גובה/קומות (כשרלוונטי):
- הוראת תכנית + נספח בינוי
- מטרת ההגבלה
- סטייה ניכרת — תקנה 2(10)
```
### 6.2 הבחנה בין תת-סוגים
הפרומפט צריך לזהות את סוג הערר ולהתאים את הצ'קליסט:
- **ערר סמכות/סף** → ללא דיון תכנוני
- **ערר קנייני** → דיון משפטי, ללא תכנון
- **ערר מהותי** → דיון תכנוני מקיף + משפטי
### 6.3 צורך דחוף: החלטות היטל השבחה
צריך להוסיף לקורפוס לפחות 5-10 החלטות של היטל השבחה לפני שהמערכת יכולה לכתוב החלטות בתחום הזה.

View File

@@ -45,7 +45,9 @@ mcp = FastMCP(
# ── Import and register tools ─────────────────────────────────────── # ── Import and register tools ───────────────────────────────────────
from legal_mcp.tools import cases, documents, search, drafting, workflow # noqa: E402 from legal_mcp.tools import ( # noqa: E402
cases, documents, search, drafting, workflow, precedents,
)
# Case management # Case management
@@ -102,6 +104,48 @@ async def case_update(
) )
@mcp.tool()
async def case_delete(case_number: str, remove_files: bool = False) -> str:
"""מחיקת תיק ערר. קבצים בדיסק נשארים אלא אם remove_files=true."""
return await cases.case_delete(case_number, remove_files)
# Precedent attachments (user-supplied legal support for the compose phase)
@mcp.tool()
async def precedent_attach(
case_number: str,
quote: str,
citation: str,
section_id: str = "",
chair_note: str = "",
pdf_document_id: str = "",
) -> str:
"""צירוף פסיקה תומכת לתיק. section_id ריק = כללי לתיק; אחרת threshold_1/issue_3."""
return await precedents.precedent_attach(
case_number, quote, citation, section_id, chair_note, pdf_document_id,
)
@mcp.tool()
async def precedent_list(case_number: str) -> str:
"""רשימת כל הפסיקות שצורפו לתיק."""
return await precedents.precedent_list(case_number)
@mcp.tool()
async def precedent_remove(precedent_id: str) -> str:
"""הסרת פסיקה מצורפת."""
return await precedents.precedent_remove(precedent_id)
@mcp.tool()
async def precedent_search_library(
query: str, practice_area: str = "", limit: int = 10,
) -> str:
"""חיפוש בספרייה הרוחבית של ציטוטים שנצברו בין תיקים."""
return await precedents.precedent_search_library(query, practice_area, limit)
# Documents # Documents
@mcp.tool() @mcp.tool()
async def document_upload( async def document_upload(
@@ -346,6 +390,29 @@ async def ingest_final_version(
return await workflow.ingest_final_version(case_number, file_path, final_text) return await workflow.ingest_final_version(case_number, file_path, final_text)
@mcp.tool()
async def record_chair_feedback(
case_number: str,
feedback_text: str,
block_id: str = "block-yod",
category: str = "missing_content",
lesson_extracted: str = "",
) -> str:
"""תיעוד הערת יו"ר (דפנה) על טיוטת החלטה — חסר, שגיאה, סגנון."""
return await workflow.record_chair_feedback(
case_number, feedback_text, block_id, category, lesson_extracted,
)
@mcp.tool()
async def list_chair_feedback(
case_number: str = "",
category: str = "",
unresolved_only: bool = True,
) -> str:
"""הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות."""
return await workflow.list_chair_feedback(case_number, category, unresolved_only)
def main(): def main():
mcp.run(transport="stdio") mcp.run(transport="stdio")

View File

@@ -20,6 +20,7 @@ from uuid import UUID
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import db, embeddings, claude_session from legal_mcp.services import db, embeddings, claude_session
from legal_mcp.services.lessons import get_content_checklist
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -215,6 +216,8 @@ BLOCK_PROMPTS = {
- **ללא כותרות משנה** (חריג: נושאים נפרדים לחלוטין) - **ללא כותרות משנה** (חריג: נושאים נפרדים לחלוטין)
- מספור רציף - מספור רציף
{content_checklist}
## כיוון מאושר (חובה): ## כיוון מאושר (חובה):
{direction_context} {direction_context}
@@ -310,6 +313,15 @@ async def write_block(
outcome = (decision or {}).get("outcome", "rejected") outcome = (decision or {}).get("outcome", "rejected")
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "") structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
# Content checklist — tells block-yod WHAT topics to cover
content_checklist = ""
if block_id == "block-yod":
content_checklist = get_content_checklist(
appeal_type=case.get("appeal_type", ""),
subject=case.get("subject", ""),
subject_categories=case.get("subject_categories", []),
)
# Format prompt — per Anthropic long-context best practices: # Format prompt — per Anthropic long-context best practices:
# Place source documents FIRST (top of prompt), instructions LAST. # Place source documents FIRST (top of prompt), instructions LAST.
# "Queries at the end can improve response quality by up to 30%" # "Queries at the end can improve response quality by up to 30%"
@@ -323,6 +335,7 @@ async def write_block(
style_context=style_context, style_context=style_context,
discussion_context=discussion_context, discussion_context=discussion_context,
structure_guidance=structure_guidance, structure_guidance=structure_guidance,
content_checklist=content_checklist,
) )
# Restructure: sources first, then instructions # Restructure: sources first, then instructions
@@ -476,12 +489,17 @@ async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
case = await db.get_case(case_id) case = await db.get_case(case_id)
case_number = case.get("case_number", "") if case else "" case_number = case.get("case_number", "") if case else ""
subject = case.get("subject", "") if case else "" subject = case.get("subject", "") if case else ""
practice_area = case.get("practice_area") if case else None
appeal_subtype = case.get("appeal_subtype") if case else None
query = f"דיון משפטי בנושא {subject}" if subject else "דיון משפטי ועדת ערר" query = f"דיון משפטי בנושא {subject}" if subject else "דיון משפטי ועדת ערר"
query_emb = await embeddings.embed_query(query) query_emb = await embeddings.embed_query(query)
# Search 1: paragraph_embeddings (from other decisions by Dafna) # Search 1: paragraph_embeddings (from other decisions by Dafna).
# Filter by practice_area + appeal_subtype so we don't pull a
# betterment-levy paragraph when writing a building-permit decision.
para_results = await db.search_similar_paragraphs( para_results = await db.search_similar_paragraphs(
query_embedding=query_emb, limit=10, block_type="block-yod", query_embedding=query_emb, limit=10, block_type="block-yod",
practice_area=practice_area, appeal_subtype=appeal_subtype,
) )
# Filter out same case # Filter out same case
para_results = [r for r in para_results if r.get("case_number", "") != case_number] para_results = [r for r in para_results if r.get("case_number", "") != case_number]

View File

@@ -200,6 +200,110 @@ CREATE TABLE IF NOT EXISTS appeal_type_rules (
ALTER TABLE decision_blocks ADD COLUMN IF NOT EXISTS image_placeholders JSONB DEFAULT '[]'; ALTER TABLE decision_blocks ADD COLUMN IF NOT EXISTS image_placeholders JSONB DEFAULT '[]';
""" """
# ── Phase 4: Practice area separation (multi-tenant axis) ──────────
SCHEMA_V4_SQL = """
-- ═══════════════════════════════════════════════════════════════════
-- practice_area = top-level legal domain (multi-tenant axis):
-- appeals_committee | national_insurance | labor_law | ...
-- appeal_subtype = refines within practice_area:
-- building_permit | betterment_levy | compensation_197 | unknown
-- Both columns are denormalized to documents/chunks/decisions/style_corpus
-- so vector searches can filter without expensive JOINs.
-- ═══════════════════════════════════════════════════════════════════
ALTER TABLE cases ADD COLUMN IF NOT EXISTS practice_area TEXT;
ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
ALTER TABLE documents ADD COLUMN IF NOT EXISTS practice_area TEXT;
ALTER TABLE documents ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
ALTER TABLE document_chunks ADD COLUMN IF NOT EXISTS practice_area TEXT;
ALTER TABLE document_chunks ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
ALTER TABLE decisions ADD COLUMN IF NOT EXISTS practice_area TEXT;
ALTER TABLE decisions ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
ALTER TABLE style_corpus ADD COLUMN IF NOT EXISTS practice_area TEXT;
ALTER TABLE style_corpus ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
CREATE INDEX IF NOT EXISTS idx_cases_practice
ON cases(practice_area, appeal_subtype);
CREATE INDEX IF NOT EXISTS idx_chunks_practice
ON document_chunks(practice_area);
CREATE INDEX IF NOT EXISTS idx_corpus_practice
ON style_corpus(practice_area, appeal_subtype);
CREATE INDEX IF NOT EXISTS idx_decisions_practice
ON decisions(practice_area);
-- Backfill (idempotent — only fills NULLs)
UPDATE cases SET practice_area = 'appeals_committee' WHERE practice_area IS NULL;
UPDATE cases SET appeal_subtype = CASE
WHEN case_number ~ '^1[0-9]{3}' THEN 'building_permit'
WHEN case_number ~ '^8[0-9]{3}' THEN 'betterment_levy'
WHEN case_number ~ '^9[0-9]{3}' THEN 'compensation_197'
ELSE 'unknown'
END WHERE appeal_subtype IS NULL;
UPDATE documents d
SET practice_area = c.practice_area, appeal_subtype = c.appeal_subtype
FROM cases c
WHERE d.case_id = c.id AND d.practice_area IS NULL;
UPDATE document_chunks dc
SET practice_area = c.practice_area, appeal_subtype = c.appeal_subtype
FROM cases c
WHERE dc.case_id = c.id AND dc.practice_area IS NULL;
UPDATE decisions de
SET practice_area = c.practice_area, appeal_subtype = c.appeal_subtype
FROM cases c
WHERE de.case_id = c.id AND de.practice_area IS NULL;
-- All existing style_corpus entries are דפנה's appeals-committee decisions
UPDATE style_corpus SET practice_area = 'appeals_committee' WHERE practice_area IS NULL;
-- Training corpus documents/chunks have case_id = NULL. All historical
-- training material is from דפנה's appeals committee, so default them.
UPDATE documents SET practice_area = 'appeals_committee'
WHERE case_id IS NULL AND practice_area IS NULL;
UPDATE document_chunks dc
SET practice_area = d.practice_area, appeal_subtype = d.appeal_subtype
FROM documents d
WHERE dc.document_id = d.id AND dc.practice_area IS NULL;
"""
# ── Phase 5: case_precedents (user-attached legal quotes) ──────────
SCHEMA_V5_SQL = """
-- ═══════════════════════════════════════════════════════════════════
-- case_precedents: legal support the chair attaches to a case / section
-- during the compose phase. Self-contained — quote + citation are
-- stored inline, with an optional FK to an archived PDF in documents.
-- Not linked to case_law (which has UNIQUE(case_number)) to keep the
-- citation as free-text. A backfill pass into case_law is a future
-- follow-up once the UI stabilizes.
-- ═══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS case_precedents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
section_id TEXT, -- NULL = case-level
-- else "threshold_1" / "issue_3"
quote TEXT NOT NULL,
citation TEXT NOT NULL, -- free-text "מראה מקום"
chair_note TEXT DEFAULT '',
pdf_document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
practice_area TEXT, -- denormalized from cases
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_case_precedents_case
ON case_precedents(case_id, section_id);
CREATE INDEX IF NOT EXISTS idx_case_precedents_library
ON case_precedents(citation);
CREATE INDEX IF NOT EXISTS idx_case_precedents_area
ON case_precedents(practice_area);
"""
# ── Phase 2: Decision + Knowledge + RAG layers ──────────────────── # ── Phase 2: Decision + Knowledge + RAG layers ────────────────────
SCHEMA_V2_SQL = """ SCHEMA_V2_SQL = """
@@ -358,6 +462,22 @@ CREATE TABLE IF NOT EXISTS case_law_embeddings (
created_at TIMESTAMPTZ DEFAULT now() created_at TIMESTAMPTZ DEFAULT now()
); );
-- ═══════════════════════════════════════════════════════════════════
-- Chair Feedback (הערות דפנה על טיוטות)
-- ═══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS chair_feedback (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_id UUID REFERENCES cases(id) ON DELETE SET NULL,
block_id TEXT DEFAULT '', -- block-yod, block-vav, etc.
feedback_text TEXT NOT NULL, -- ההערה של דפנה
category TEXT DEFAULT 'other', -- missing_content/wrong_tone/wrong_structure/factual_error/style/other
lesson_extracted TEXT DEFAULT '', -- הלקח שהופק
applied_to TEXT[] DEFAULT '{}', -- לאילו קבצים/כללים הלקח יושם
resolved BOOLEAN DEFAULT FALSE, -- האם הלקח יושם
created_at TIMESTAMPTZ DEFAULT now()
);
-- ═══════════════════════════════════════════════════════════════════ -- ═══════════════════════════════════════════════════════════════════
-- Indexes -- Indexes
-- ═══════════════════════════════════════════════════════════════════ -- ═══════════════════════════════════════════════════════════════════
@@ -388,7 +508,9 @@ async def init_schema() -> None:
await conn.execute(MIGRATIONS_SQL) await conn.execute(MIGRATIONS_SQL)
await conn.execute(SCHEMA_V2_SQL) await conn.execute(SCHEMA_V2_SQL)
await conn.execute(SCHEMA_V3_SQL) await conn.execute(SCHEMA_V3_SQL)
logger.info("Database schema initialized (v1 + v2 + v3)") await conn.execute(SCHEMA_V4_SQL)
await conn.execute(SCHEMA_V5_SQL)
logger.info("Database schema initialized (v1 + v2 + v3 + v4 + v5)")
# ── Case CRUD ─────────────────────────────────────────────────────── # ── Case CRUD ───────────────────────────────────────────────────────
@@ -405,6 +527,8 @@ async def create_case(
hearing_date: date | None = None, hearing_date: date | None = None,
notes: str = "", notes: str = "",
expected_outcome: str = "", expected_outcome: str = "",
practice_area: str = "appeals_committee",
appeal_subtype: str | None = None,
) -> dict: ) -> dict:
pool = await get_pool() pool = await get_pool()
case_id = uuid4() case_id = uuid4()
@@ -412,17 +536,43 @@ async def create_case(
await conn.execute( await conn.execute(
"""INSERT INTO cases (id, case_number, title, appellants, respondents, """INSERT INTO cases (id, case_number, title, appellants, respondents,
subject, property_address, permit_number, committee_type, subject, property_address, permit_number, committee_type,
hearing_date, notes, expected_outcome) hearing_date, notes, expected_outcome,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)""", practice_area, appeal_subtype)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)""",
case_id, case_number, title, case_id, case_number, title,
json.dumps(appellants or []), json.dumps(appellants or []),
json.dumps(respondents or []), json.dumps(respondents or []),
subject, property_address, permit_number, committee_type, subject, property_address, permit_number, committee_type,
hearing_date, notes, expected_outcome, hearing_date, notes, expected_outcome,
practice_area, appeal_subtype,
) )
return await get_case(case_id) return await get_case(case_id)
async def get_case_practice_area(case_id: UUID) -> tuple[str | None, str | None]:
"""Return (practice_area, appeal_subtype) for a case, or (None, None) if missing."""
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT practice_area, appeal_subtype FROM cases WHERE id = $1", case_id
)
if row is None:
return None, None
return row["practice_area"], row["appeal_subtype"]
async def get_case_practice_area_by_number(case_number: str) -> tuple[str | None, str | None]:
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT practice_area, appeal_subtype FROM cases WHERE case_number = $1",
case_number,
)
if row is None:
return None, None
return row["practice_area"], row["appeal_subtype"]
async def get_case(case_id: UUID) -> dict | None: async def get_case(case_id: UUID) -> dict | None:
pool = await get_pool() pool = await get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
@@ -458,6 +608,16 @@ async def list_cases(status: str | None = None, limit: int = 50) -> list[dict]:
return [_row_to_case(r) for r in rows] return [_row_to_case(r) for r in rows]
async def delete_case(case_id: UUID) -> bool:
"""Delete a case. Dependent rows in documents/document_chunks/qa_results
cascade automatically (schema-level ON DELETE CASCADE); audit_log rows
nullify their case_id reference. Returns True if a row was deleted."""
pool = await get_pool()
async with pool.acquire() as conn:
result = await conn.execute("DELETE FROM cases WHERE id = $1", case_id)
return result.endswith(" 1")
async def update_case(case_id: UUID, **fields) -> dict | None: async def update_case(case_id: UUID, **fields) -> dict | None:
if not fields: if not fields:
return await get_case(case_id) return await get_case(case_id)
@@ -488,19 +648,34 @@ def _row_to_case(row: asyncpg.Record) -> dict:
# ── Document CRUD ─────────────────────────────────────────────────── # ── Document CRUD ───────────────────────────────────────────────────
async def create_document( async def create_document(
case_id: UUID, case_id: UUID | None,
doc_type: str, doc_type: str,
title: str, title: str,
file_path: str, file_path: str,
page_count: int | None = None, page_count: int | None = None,
practice_area: str | None = None,
appeal_subtype: str | None = None,
) -> dict: ) -> dict:
pool = await get_pool() pool = await get_pool()
doc_id = uuid4() doc_id = uuid4()
async with pool.acquire() as conn: async with pool.acquire() as conn:
# If practice_area not explicitly given, inherit from the parent case
# (for case-bound documents). Training corpus passes case_id=None and
# provides the practice_area directly.
if practice_area is None and case_id is not None:
case_row = await conn.fetchrow(
"SELECT practice_area, appeal_subtype FROM cases WHERE id = $1",
case_id,
)
if case_row:
practice_area = case_row["practice_area"]
appeal_subtype = case_row["appeal_subtype"]
await conn.execute( await conn.execute(
"""INSERT INTO documents (id, case_id, doc_type, title, file_path, page_count) """INSERT INTO documents (id, case_id, doc_type, title, file_path,
VALUES ($1, $2, $3, $4, $5, $6)""", page_count, practice_area, appeal_subtype)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)""",
doc_id, case_id, doc_type, title, file_path, page_count, doc_id, case_id, doc_type, title, file_path, page_count,
practice_area, appeal_subtype,
) )
row = await conn.fetchrow("SELECT * FROM documents WHERE id = $1", doc_id) row = await conn.fetchrow("SELECT * FROM documents WHERE id = $1", doc_id)
return _row_to_doc(row) return _row_to_doc(row)
@@ -556,6 +731,113 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
return d return d
# ── case_precedents CRUD ───────────────────────────────────────────
def _row_to_precedent(row: asyncpg.Record) -> dict:
d = dict(row)
for k in ("id", "case_id"):
if d.get(k) is not None:
d[k] = str(d[k])
if d.get("pdf_document_id") is not None:
d["pdf_document_id"] = str(d["pdf_document_id"])
for ts in ("created_at", "updated_at"):
if d.get(ts) is not None:
d[ts] = d[ts].isoformat()
return d
async def create_case_precedent(
case_id: UUID,
quote: str,
citation: str,
section_id: str | None = None,
chair_note: str = "",
pdf_document_id: UUID | None = None,
practice_area: str | None = None,
) -> dict:
"""Insert a new attached precedent. practice_area is inherited from
the parent case when not explicitly supplied, so the cross-case
library search can filter without a JOIN."""
pool = await get_pool()
async with pool.acquire() as conn:
if practice_area is None:
row = await conn.fetchrow(
"SELECT practice_area FROM cases WHERE id = $1", case_id
)
practice_area = row["practice_area"] if row else None
inserted = await conn.fetchrow(
"""INSERT INTO case_precedents
(case_id, section_id, quote, citation, chair_note,
pdf_document_id, practice_area)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *""",
case_id, section_id, quote, citation, chair_note,
pdf_document_id, practice_area,
)
return _row_to_precedent(inserted)
async def list_case_precedents(case_id: UUID) -> list[dict]:
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT * FROM case_precedents WHERE case_id = $1 "
"ORDER BY section_id NULLS FIRST, created_at",
case_id,
)
return [_row_to_precedent(r) for r in rows]
async def delete_case_precedent(precedent_id: UUID) -> bool:
pool = await get_pool()
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM case_precedents WHERE id = $1", precedent_id
)
return result.endswith(" 1")
async def search_precedent_library(
query: str, practice_area: str = "", limit: int = 10,
) -> list[dict]:
"""Cross-case typeahead for the citation field. Returns one row per
distinct citation so the user sees each precedent once even if they
previously attached it to multiple cases/sections. No embeddings —
simple ILIKE is fine at this scale."""
pool = await get_pool()
pattern = f"%{query}%"
async with pool.acquire() as conn:
if practice_area:
rows = await conn.fetch(
"""SELECT DISTINCT ON (citation)
id, citation, quote, chair_note, practice_area, created_at
FROM case_precedents
WHERE practice_area = $1
AND (citation ILIKE $2 OR quote ILIKE $2)
ORDER BY citation, created_at DESC
LIMIT $3""",
practice_area, pattern, limit,
)
else:
rows = await conn.fetch(
"""SELECT DISTINCT ON (citation)
id, citation, quote, chair_note, practice_area, created_at
FROM case_precedents
WHERE citation ILIKE $1 OR quote ILIKE $1
ORDER BY citation, created_at DESC
LIMIT $2""",
pattern, limit,
)
out = []
for r in rows:
d = dict(r)
d["id"] = str(d["id"])
if d.get("created_at"):
d["created_at"] = d["created_at"].isoformat()
out.append(d)
return out
# ── Claims ───────────────────────────────────────────────────────── # ── Claims ─────────────────────────────────────────────────────────
async def store_claims(case_id: UUID, claims: list[dict], source_document: str = "") -> int: async def store_claims(case_id: UUID, claims: list[dict], source_document: str = "") -> int:
@@ -622,12 +904,20 @@ async def create_decision(
) )
version = (existing["version"] + 1) if existing else 1 version = (existing["version"] + 1) if existing else 1
case_row = await conn.fetchrow(
"SELECT practice_area, appeal_subtype FROM cases WHERE id = $1", case_id
)
practice_area = case_row["practice_area"] if case_row else None
appeal_subtype = case_row["appeal_subtype"] if case_row else None
await conn.execute( await conn.execute(
"""INSERT INTO decisions (id, case_id, version, outcome, outcome_summary, """INSERT INTO decisions (id, case_id, version, outcome, outcome_summary,
outcome_reasoning, direction_doc) outcome_reasoning, direction_doc,
VALUES ($1, $2, $3, $4, $5, $6, $7)""", practice_area, appeal_subtype)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
decision_id, case_id, version, outcome, outcome_summary, decision_id, case_id, version, outcome, outcome_summary,
outcome_reasoning, json.dumps(direction_doc) if direction_doc else None, outcome_reasoning, json.dumps(direction_doc) if direction_doc else None,
practice_area, appeal_subtype,
) )
return await get_decision(decision_id) return await get_decision(decision_id)
@@ -701,12 +991,37 @@ async def store_chunks(
document_id: UUID, document_id: UUID,
case_id: UUID | None, case_id: UUID | None,
chunks: list[dict], chunks: list[dict],
practice_area: str | None = None,
appeal_subtype: str | None = None,
) -> int: ) -> int:
"""Store document chunks with embeddings. Each chunk dict has: """Store document chunks with embeddings. Each chunk dict has:
content, section_type, embedding (list[float]), page_number, chunk_index content, section_type, embedding (list[float]), page_number, chunk_index.
practice_area defaults to the parent case's value, or — when case_id is
None (training corpus) — falls back to the parent document's value so
vector search can still filter cleanly.
""" """
pool = await get_pool() pool = await get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
# Resolve practice_area in priority order: explicit > case > document.
if practice_area is None:
if case_id is not None:
case_row = await conn.fetchrow(
"SELECT practice_area, appeal_subtype FROM cases WHERE id = $1",
case_id,
)
if case_row:
practice_area = case_row["practice_area"]
appeal_subtype = case_row["appeal_subtype"]
if practice_area is None:
doc_row = await conn.fetchrow(
"SELECT practice_area, appeal_subtype FROM documents WHERE id = $1",
document_id,
)
if doc_row:
practice_area = doc_row["practice_area"]
appeal_subtype = doc_row["appeal_subtype"]
# Delete existing chunks for this document # Delete existing chunks for this document
await conn.execute( await conn.execute(
"DELETE FROM document_chunks WHERE document_id = $1", document_id "DELETE FROM document_chunks WHERE document_id = $1", document_id
@@ -714,14 +1029,16 @@ async def store_chunks(
for chunk in chunks: for chunk in chunks:
await conn.execute( await conn.execute(
"""INSERT INTO document_chunks """INSERT INTO document_chunks
(document_id, case_id, chunk_index, content, section_type, embedding, page_number) (document_id, case_id, chunk_index, content, section_type,
VALUES ($1, $2, $3, $4, $5, $6, $7)""", embedding, page_number, practice_area, appeal_subtype)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
document_id, case_id, document_id, case_id,
chunk["chunk_index"], chunk["chunk_index"],
chunk["content"], chunk["content"],
chunk.get("section_type", "other"), chunk.get("section_type", "other"),
chunk["embedding"], chunk["embedding"],
chunk.get("page_number"), chunk.get("page_number"),
practice_area, appeal_subtype,
) )
return len(chunks) return len(chunks)
@@ -731,8 +1048,15 @@ async def search_similar(
limit: int = 10, limit: int = 10,
case_id: UUID | None = None, case_id: UUID | None = None,
section_type: str | None = None, section_type: str | None = None,
practice_area: str | None = None,
appeal_subtype: str | None = None,
) -> list[dict]: ) -> list[dict]:
"""Cosine similarity search on document chunks.""" """Cosine similarity search on document chunks.
Filter by practice_area to keep precedents from the same legal domain
(e.g. don't surface betterment-levy chunks when working on building
permits). Uses the denormalized column on document_chunks — no JOIN.
"""
pool = await get_pool() pool = await get_pool()
conditions = [] conditions = []
params: list = [query_embedding, limit] params: list = [query_embedding, limit]
@@ -746,6 +1070,14 @@ async def search_similar(
conditions.append(f"dc.section_type = ${param_idx}") conditions.append(f"dc.section_type = ${param_idx}")
params.append(section_type) params.append(section_type)
param_idx += 1 param_idx += 1
if practice_area:
conditions.append(f"dc.practice_area = ${param_idx}")
params.append(practice_area)
param_idx += 1
if appeal_subtype:
conditions.append(f"dc.appeal_subtype = ${param_idx}")
params.append(appeal_subtype)
param_idx += 1
where = f"WHERE {' AND '.join(conditions)}" if conditions else "" where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
@@ -778,6 +1110,8 @@ async def add_to_style_corpus(
summary: str = "", summary: str = "",
outcome: str = "", outcome: str = "",
key_principles: list[str] | None = None, key_principles: list[str] | None = None,
practice_area: str = "appeals_committee",
appeal_subtype: str | None = None,
) -> UUID: ) -> UUID:
pool = await get_pool() pool = await get_pool()
corpus_id = uuid4() corpus_id = uuid4()
@@ -785,11 +1119,13 @@ async def add_to_style_corpus(
await conn.execute( await conn.execute(
"""INSERT INTO style_corpus """INSERT INTO style_corpus
(id, document_id, decision_number, decision_date, (id, document_id, decision_number, decision_date,
subject_categories, full_text, summary, outcome, key_principles) subject_categories, full_text, summary, outcome, key_principles,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""", practice_area, appeal_subtype)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)""",
corpus_id, document_id, decision_number, decision_date, corpus_id, document_id, decision_number, decision_date,
json.dumps(subject_categories), full_text, summary, outcome, json.dumps(subject_categories), full_text, summary, outcome,
json.dumps(key_principles or []), json.dumps(key_principles or []),
practice_area, appeal_subtype,
) )
return corpus_id return corpus_id
@@ -893,8 +1229,15 @@ async def search_similar_paragraphs(
query_embedding: list[float], query_embedding: list[float],
limit: int = 10, limit: int = 10,
block_type: str | None = None, block_type: str | None = None,
practice_area: str | None = None,
appeal_subtype: str | None = None,
) -> list[dict]: ) -> list[dict]:
"""Search decision paragraphs by semantic similarity.""" """Search decision paragraphs by semantic similarity.
Filtering by practice_area uses the denormalized column on `decisions`
so we don't pull, e.g., betterment-levy paragraphs when writing a
building-permit decision.
"""
pool = await get_pool() pool = await get_pool()
conditions = [] conditions = []
params: list = [query_embedding, limit] params: list = [query_embedding, limit]
@@ -904,6 +1247,14 @@ async def search_similar_paragraphs(
conditions.append(f"db.block_id = ${param_idx}") conditions.append(f"db.block_id = ${param_idx}")
params.append(block_type) params.append(block_type)
param_idx += 1 param_idx += 1
if practice_area:
conditions.append(f"d.practice_area = ${param_idx}")
params.append(practice_area)
param_idx += 1
if appeal_subtype:
conditions.append(f"d.appeal_subtype = ${param_idx}")
params.append(appeal_subtype)
param_idx += 1
where = f"WHERE {' AND '.join(conditions)}" if conditions else "" where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
@@ -986,3 +1337,72 @@ async def search_precedents(
results.sort(key=lambda x: x["score"], reverse=True) results.sort(key=lambda x: x["score"], reverse=True)
return results[:limit] return results[:limit]
# ── Chair feedback ────────────────────────────────────────────────
async def record_chair_feedback(
case_id: UUID | None,
block_id: str,
feedback_text: str,
category: str = "other",
lesson_extracted: str = "",
) -> UUID:
"""Record feedback from the chair (Dafna) on a draft block."""
pool = await get_pool()
feedback_id = uuid4()
async with pool.acquire() as conn:
await conn.execute(
"""INSERT INTO chair_feedback
(id, case_id, block_id, feedback_text, category, lesson_extracted)
VALUES ($1, $2, $3, $4, $5, $6)""",
feedback_id, case_id, block_id, feedback_text, category,
lesson_extracted,
)
return feedback_id
async def list_chair_feedback(
case_id: UUID | None = None,
category: str | None = None,
unresolved_only: bool = False,
) -> list[dict]:
"""List chair feedback, optionally filtered."""
pool = await get_pool()
conditions = []
params: list = []
idx = 1
if case_id:
conditions.append(f"case_id = ${idx}")
params.append(case_id)
idx += 1
if category:
conditions.append(f"category = ${idx}")
params.append(category)
idx += 1
if unresolved_only:
conditions.append("resolved = FALSE")
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
async with pool.acquire() as conn:
rows = await conn.fetch(
f"SELECT * FROM chair_feedback {where} ORDER BY created_at DESC",
*params,
)
return [dict(r) for r in rows]
async def resolve_chair_feedback(
feedback_id: UUID,
applied_to: list[str],
) -> None:
"""Mark feedback as resolved and record where it was applied."""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""UPDATE chair_feedback
SET resolved = TRUE, applied_to = $2
WHERE id = $1""",
feedback_id, applied_to,
)

View File

@@ -329,3 +329,193 @@ def format_ratios_comment(outcome: str, section: str) -> str:
lo, hi = ratios[section] lo, hi = ratios[section]
return f"יעד: {lo}-{hi}% מסך ההחלטה" return f"יעד: {lo}-{hi}% מסך ההחלטה"
return "" return ""
# ── Content checklists by appeal subtype ──────────────────────────
# Based on systematic analysis of 24 decisions from Dafna's corpus.
# See: docs/corpus-analysis.md
CONTENT_CHECKLISTS: dict[str, str] = {
"licensing_substantive": """## צ'קליסט תוכן — ערר רישוי מהותי (חובה)
הדיון חייב לכלול את הנושאים הרלוונטיים מהרשימה הבאה.
**אל תדלג על נושא שרלוונטי לתיק — בדוק כל סעיף.**
### א. הקשר תכנוני רחב (חובה בכל ערר מהותי)
- תכניות חלות — ציין את התכניות הרלוונטיות ברמה מקומית, מחוזית וארצית (לפי הצורך)
- ייעוד הקרקע — מה הייעוד בתכנית? מה השימושים המותרים?
- אופי הסביבה — מרקם בנוי, צפיפות, אופי שכונה/ישוב
- *דוגמה*: בערר פרומר — 12 סעיפים על MI/200, תמ"א 35, תמ"מ 30/1
### ב. ניתוח הוראות תכנית (כשיש שאלה של התאמה/סטייה)
- ציטוט ישיר מהוראות התכנית הרלוונטיות (200-600 מילים לכל ציטוט)
- פרשנות — מה תכלית ההוראה?
- יישום — האם הבקשה תואמת או סוטה?
- *דוגמה*: בערר לבנון — ניתוח חתכים של נספח בינוי מול הבקשה
### ג. חניה (כשרלוונטי — מופיע ב-8 מתוך 24 החלטות)
- הוראות תכנית + נספח תנועה (ציטוט ישיר)
- חישוב מקומות חניה נדרשים vs. מסופקים
- חלופות: קרן חניה, חפיפת שימושים, קרבה לתח"צ
- *דוגמה*: בערר בית הכרם — 8 סעיפים, 400+ מילים מהוראות תכנית 5166ב
### ד. קווי בניין ומרווחים (כשרלוונטי)
- הוראת תכנית על מרווחים
- סטייה ניכרת? — תקנה 2(19) / הלכת בן-יקר-גת
- הצדקה + מידתיות — פגיעה בשכנים?
### ה. גובה וקומות (כשרלוונטי)
- הוראת תכנית + נספח בינוי (חתכים)
- מטרת ההגבלה — למה יש הגבלת גובה כאן?
- סטייה ניכרת — תקנה 2(10) / 2(8)
### ו. פגיעה בשכנים (כשרלוונטי)
- ממצאי סיור באתר
- השפעה: צל, פרטיות, רעש, נוף
- מידתיות — האם הפגיעה סבירה?
### ז. שימוש חורג (כשרלוונטי)
- מה השימוש המותר בתכנית? מה השימוש המבוקש?
- "מבחן ההתאמה" — האם השימוש מתאים למיקום?
- תנאים ומגבלות
""",
"licensing_threshold": """## צ'קליסט תוכן — ערר רישוי סף/סמכות
הערר עוסק בשאלות סף — אין צורך בדיון תכנוני מקיף.
### א. שאלת הסמכות
- סעיפי חוק רלוונטיים (ס' 12ב, 152, וכו')
- פסיקה על גבולות הסמכות
### ב. זכות ערר
- מי רשאי לערור? באיזה מסלול?
- הלכת שפר (עע"מ 317/10) — כשרלוונטית
### ג. שיהוי (אם רלוונטי)
""",
"licensing_property": """## צ'קליסט תוכן — ערר רישוי קנייני
הערר עוסק בעיקר בשאלת תימוכין קנייניים — דיון משפטי.
### א. מסגרת נורמטיבית
- הלכת עייזן, בני אליעזר, רוזן — "היתכנות קניינית"
- ס' 71ב לחוק המקרקעין
### ב. בחינת הראיות
- הסכמות, רישום, היסטוריית בנייה
- חלוקה דה-פקטו ארוכת שנים
### ג. הפרדה בין קניין לתכנון
- גוף תכנוני אינו מכריע בסכסוכי קניין
- "היתכנות קניינית" ≠ הוכחת בעלות
### ד. שאלות תכנוניות (אם רלוונטיות)
- אם הערר עולה גם שאלות תכנוניות — דון בהן בנפרד
""",
"tama38": """## צ'קליסט תוכן — ערר תמ"א 38
הדיון חייב לאזן בין אינטרס ציבורי לפגיעה בשכנים.
### א. אינטרס ציבורי — חיזוק/התחדשות
- עוצמת האינטרס — בניין גדול vs. בית בודד
- "בית בודד" מחליש את אינטרס החיזוק
- תרומה לרקמה העירונית
### ב. תכנית אב / מדיניות אזורית
- האם יש תכנית אב? מדיניות 16000?
- התאמה לראיה כללית vs. אד-הוק
### ג. ניתוח השוואתי
- זכויות לפי תכנית קיימת vs. מבוקש לפי תמ"א 38
- שטחים, קומות, קווי בניין — טבלת השוואה
### ד. שימור (כשרלוונטי)
- חוות דעת אגף שימור
- השפעה על מיקום/צורת הבניין
### ה. חניה (כמעט תמיד רלוונטי)
- הוראות תכנית + ס' 17 לתמ"א 38
- פטורים — קרבה לתח"צ, קרן חניה, תכנית אב
- ניתוח מפורט של חלופות
### ו. פגיעה בשכנים
- ממצאי סיור
- צל, פרטיות, קרבה
- מידתיות — מה הפגיעה ביחס לתועלת?
### ז. מטרדי בנייה
- "מטרד בנייה אינו עילה לסירוב" — אך תנאים נדרשים
- תכנית ארגון אתר
""",
"betterment_levy": """## צ'קליסט תוכן — ערר היטל השבחה
⚠️ שים לב: אין עדיין החלטות היטל השבחה בקורפוס האימון.
הצ'קליסט הזה מבוסס על ידע כללי — לא על ניתוח ספציפי של סגנון דפנה.
### א. המסגרת הנורמטיבית
- התוספת השלישית לחוק התכנון והבנייה
- אירוע מס — מה יצר את ההשבחה?
### ב. שומה
- שיטת השומה (שומה מכרעת / שמאי מייעץ)
- מועד הקובע
- זכויות בנייה — לפני ואחרי
### ג. שאלות משפטיות
- פטורים (ס' 19)
- מועדי תשלום
- שיערוך
### ד. ניתוח שמאי
- האם השומה תקינה?
- פערים בין השומות
""",
}
def get_content_checklist(
appeal_type: str = "",
subject: str = "",
subject_categories: list[str] | None = None,
) -> str:
"""Return the appropriate content checklist based on case characteristics.
Determines the subtype from case metadata:
- TAMA 38 cases → tama38 checklist
- Betterment levy (8xxx) → betterment_levy checklist
- Property-only cases → licensing_property checklist
- Threshold/jurisdiction cases → licensing_threshold checklist
- All other licensing → licensing_substantive checklist
"""
cats = subject_categories or []
subject_lower = subject.lower() if subject else ""
appeal_lower = appeal_type.lower() if appeal_type else ""
# TAMA 38
if any(
kw in subject_lower
for kw in ["תמ\"א 38", "תמא 38", "תמ\"א38", "חיזוק", "tama"]
) or "תמ\"א 38" in cats:
return CONTENT_CHECKLISTS["tama38"]
# Betterment levy
if "היטל השבחה" in appeal_lower or "betterment" in appeal_lower or any(
"היטל" in c for c in cats
):
return CONTENT_CHECKLISTS["betterment_levy"]
# Property-focused (תימוכין קנייניים)
if any(
kw in subject_lower
for kw in ["תימוכין", "קנייני", "בעלות", "הסכמת דיירים"]
):
return CONTENT_CHECKLISTS["licensing_property"]
# Threshold/jurisdiction
if any(
kw in subject_lower
for kw in ["סמכות", "סף", "סילוק על הסף", "זכות ערר"]
):
return CONTENT_CHECKLISTS["licensing_threshold"]
# Default: substantive licensing
return CONTENT_CHECKLISTS["licensing_substantive"]

View File

@@ -0,0 +1,96 @@
"""Practice area + appeal subtype: derivation, validation, constants.
Two orthogonal axes used to separate legal domains across the system:
practice_area — top-level domain (multi-tenant axis). Examples:
appeals_committee, national_insurance, labor_law.
appeal_subtype — refines within a domain. For appeals_committee:
building_permit (1xxx), betterment_levy (8xxx),
compensation_197 (9xxx), unknown.
Both columns are denormalized into documents/chunks/decisions/style_corpus
so vector searches can filter cheaply.
"""
from __future__ import annotations
import re
# ── Enums ──────────────────────────────────────────────────────────
PRACTICE_AREAS: set[str] = {
"appeals_committee",
"national_insurance",
"labor_law",
}
APPEALS_COMMITTEE_SUBTYPES: set[str] = {
"building_permit",
"betterment_levy",
"compensation_197",
"unknown",
}
DEFAULT_PRACTICE_AREA = "appeals_committee"
# Subtypes per practice_area (extend when adding domains)
SUBTYPES_BY_AREA: dict[str, set[str]] = {
"appeals_committee": APPEALS_COMMITTEE_SUBTYPES,
"national_insurance": {"unknown"},
"labor_law": {"unknown"},
}
# ── Derivation ─────────────────────────────────────────────────────
_FIRST_DIGIT = re.compile(r"^\s*(\d)")
_APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE = {
"1": "building_permit",
"8": "betterment_levy",
"9": "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:
1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197.
For other practice areas there is no public numbering convention yet,
so we return 'unknown' until a real rule is defined.
"""
if practice_area != "appeals_committee":
return "unknown"
m = _FIRST_DIGIT.match(case_number or "")
if not m:
return "unknown"
return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(m.group(1), "unknown")
# ── Validation ─────────────────────────────────────────────────────
def validate(practice_area: str, appeal_subtype: str | None) -> None:
"""Raise ValueError on unknown values. appeal_subtype=None is allowed."""
if practice_area not in PRACTICE_AREAS:
raise ValueError(
f"unknown practice_area: {practice_area!r}. "
f"expected one of {sorted(PRACTICE_AREAS)}"
)
if appeal_subtype is None:
return
allowed = SUBTYPES_BY_AREA.get(practice_area, {"unknown"})
if appeal_subtype not in allowed:
raise ValueError(
f"unknown appeal_subtype {appeal_subtype!r} for practice_area "
f"{practice_area!r}. expected one of {sorted(allowed)}"
)
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')."""
derived = derive_subtype(case_number, practice_area)
return derived != "unknown" and derived != appeal_subtype

View File

@@ -3,12 +3,13 @@
from __future__ import annotations from __future__ import annotations
import json import json
import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import db from legal_mcp.services import audit, db, practice_area as pa
async def case_create( async def case_create(
@@ -23,6 +24,8 @@ async def case_create(
hearing_date: str = "", hearing_date: str = "",
notes: str = "", notes: str = "",
expected_outcome: str = "", expected_outcome: str = "",
practice_area: str = "appeals_committee",
appeal_subtype: str = "",
) -> str: ) -> str:
"""יצירת תיק ערר חדש. """יצירת תיק ערר חדש.
@@ -38,6 +41,9 @@ async def case_create(
hearing_date: תאריך דיון (YYYY-MM-DD) hearing_date: תאריך דיון (YYYY-MM-DD)
notes: הערות notes: הערות
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy) expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
practice_area: תחום משפטי (appeals_committee / national_insurance / labor_law)
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
ריק = יוסק אוטומטית ממספר התיק
""" """
from datetime import date as date_type from datetime import date as date_type
@@ -45,6 +51,12 @@ async def case_create(
if hearing_date: if hearing_date:
h_date = date_type.fromisoformat(hearing_date) h_date = date_type.fromisoformat(hearing_date)
# Resolve appeal_subtype: explicit override > auto-derive > 'unknown'
derived_subtype = pa.derive_subtype(case_number, practice_area)
if not appeal_subtype:
appeal_subtype = derived_subtype
pa.validate(practice_area, appeal_subtype)
case = await db.create_case( case = await db.create_case(
case_number=case_number, case_number=case_number,
title=title, title=title,
@@ -57,6 +69,22 @@ async def case_create(
hearing_date=h_date, hearing_date=h_date,
notes=notes, notes=notes,
expected_outcome=expected_outcome, expected_outcome=expected_outcome,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
)
# If the user overrode the case-number convention (e.g. case 8500 marked
# as building_permit), record it so we can audit later.
if pa.is_override(case_number, practice_area, appeal_subtype):
await audit.log_action(
action="case_subtype_override",
case_id=UUID(case["id"]),
details={
"case_number": case_number,
"derived_subtype": derived_subtype,
"chosen_subtype": appeal_subtype,
"practice_area": practice_area,
},
) )
# Initialize git repo for the case # Initialize git repo for the case
@@ -187,3 +215,37 @@ async def case_update(
) )
return json.dumps(updated, default=str, ensure_ascii=False, indent=2) return json.dumps(updated, default=str, ensure_ascii=False, indent=2)
async def case_delete(case_number: str, remove_files: bool = False) -> str:
"""מחיקת תיק ערר. מסיר את התיק מ-DB עם cascade לכל המסמכים והטענות.
Args:
case_number: מספר תיק הערר
remove_files: האם למחוק גם את תיקיית הדיסק (drafts, git repo).
ברירת מחדל False — ה-DB נמחק אבל הקבצים נשמרים לגיבוי.
"""
case = await db.get_case_by_number(case_number)
if not case:
return json.dumps(
{"deleted": False, "reason": f"תיק {case_number} לא נמצא."},
ensure_ascii=False,
)
case_id = UUID(case["id"])
ok = await db.delete_case(case_id)
result = {
"deleted": ok,
"case_number": case_number,
"case_id": str(case_id),
"removed_files": False,
}
if ok and remove_files:
case_dir = config.find_case_dir(case_number)
if case_dir.exists():
shutil.rmtree(case_dir, ignore_errors=True)
result["removed_files"] = True
return json.dumps(result, ensure_ascii=False, indent=2)

View File

@@ -105,6 +105,8 @@ async def document_upload_training(
decision_date: str = "", decision_date: str = "",
subject_categories: list[str] | None = None, subject_categories: list[str] | None = None,
title: str = "", title: str = "",
practice_area: str = "appeals_committee",
appeal_subtype: str = "",
) -> str: ) -> str:
"""העלאת החלטה קודמת של דפנה לקורפוס הסגנון (training). """העלאת החלטה קודמת של דפנה לקורפוס הסגנון (training).
@@ -114,10 +116,13 @@ async def document_upload_training(
decision_date: תאריך ההחלטה (YYYY-MM-DD) decision_date: תאריך ההחלטה (YYYY-MM-DD)
subject_categories: קטגוריות - אפשר לבחור כמה (בנייה, שימוש חורג, תכנית, היתר, הקלה, חלוקה, תמ"א 38, היטל השבחה, פיצויים 197) subject_categories: קטגוריות - אפשר לבחור כמה (בנייה, שימוש חורג, תכנית, היתר, הקלה, חלוקה, תמ"א 38, היטל השבחה, פיצויים 197)
title: שם המסמך title: שם המסמך
practice_area: תחום משפטי (appeals_committee / national_insurance / labor_law)
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
ריק = יוסק אוטומטית ממספר ההחלטה
""" """
from datetime import date as date_type from datetime import date as date_type
from legal_mcp.services import extractor, embeddings, chunker from legal_mcp.services import chunker, embeddings, extractor, practice_area as pa
source = Path(file_path) source = Path(file_path)
if not source.exists(): if not source.exists():
@@ -126,6 +131,11 @@ async def document_upload_training(
if not title: if not title:
title = source.stem title = source.stem
# Resolve subtype: explicit > derived from decision_number > 'unknown'
if not appeal_subtype:
appeal_subtype = pa.derive_subtype(decision_number, practice_area)
pa.validate(practice_area, appeal_subtype)
# Copy to training directory (skip if already there) # Copy to training directory (skip if already there)
config.TRAINING_DIR.mkdir(parents=True, exist_ok=True) config.TRAINING_DIR.mkdir(parents=True, exist_ok=True)
dest = config.TRAINING_DIR / source.name dest = config.TRAINING_DIR / source.name
@@ -140,25 +150,29 @@ async def document_upload_training(
if decision_date: if decision_date:
d_date = date_type.fromisoformat(decision_date) d_date = date_type.fromisoformat(decision_date)
# Add to style corpus # Add to style corpus (tagged by domain so block-writer can filter)
corpus_id = await db.add_to_style_corpus( corpus_id = await db.add_to_style_corpus(
document_id=None, document_id=None,
decision_number=decision_number, decision_number=decision_number,
decision_date=d_date, decision_date=d_date,
subject_categories=subject_categories or [], subject_categories=subject_categories or [],
full_text=text, full_text=text,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
) )
# Chunk and embed for RAG search over training corpus # Chunk and embed for RAG search over training corpus
chunks = chunker.chunk_document(text) chunks = chunker.chunk_document(text)
if chunks: if chunks:
# Create a document record (no case association) # Create a document record (no case association — tag explicitly)
doc = await db.create_document( doc = await db.create_document(
case_id=None, case_id=None,
doc_type="decision", doc_type="decision",
title=f"[קורפוס] {title}", title=f"[קורפוס] {title}",
file_path=str(dest), file_path=str(dest),
page_count=page_count, page_count=page_count,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
) )
doc_id = UUID(doc["id"]) doc_id = UUID(doc["id"])
await db.update_document(doc_id, extracted_text=text, extraction_status="completed") await db.update_document(doc_id, extracted_text=text, extraction_status="completed")
@@ -176,7 +190,10 @@ async def document_upload_training(
} }
for c, emb in zip(chunks, embs) for c, emb in zip(chunks, embs)
] ]
await db.store_chunks(doc_id, None, chunk_dicts) await db.store_chunks(
doc_id, None, chunk_dicts,
practice_area=practice_area, appeal_subtype=appeal_subtype,
)
return json.dumps({ return json.dumps({
"corpus_id": str(corpus_id), "corpus_id": str(corpus_id),

View File

@@ -0,0 +1,95 @@
"""MCP tools for attached legal precedents (user-supplied case-law quotes).
These complement the existing `case_law` table (which is populated from
structured sources and is what the block-writer RAG searches) by storing
free-text citations the chair attaches during the compose phase.
"""
from __future__ import annotations
import json
from pathlib import Path
from uuid import UUID
from legal_mcp.services import db
async def precedent_attach(
case_number: str,
quote: str,
citation: str,
section_id: str = "",
chair_note: str = "",
pdf_document_id: str = "",
) -> str:
"""צירוף פסיקה תומכת לתיק ערר.
Args:
case_number: מספר תיק הערר
quote: הציטוט המדויק שיוכנס להחלטה
citation: מראה המקום (ערר 1126-08-25 ... נ' ... (נבו 9.3.2026))
section_id: מזהה הטענה/סוגיה (threshold_1, issue_3); ריק = כללי לתיק
chair_note: הערה אופציונלית — למה הציטוט תומך בעמדה
pdf_document_id: מזהה קובץ PDF מצורף (אופציונלי)
"""
case = await db.get_case_by_number(case_number)
if not case:
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False)
pdf_uuid: UUID | None = None
if pdf_document_id:
try:
pdf_uuid = UUID(pdf_document_id)
except ValueError:
return json.dumps({"error": "pdf_document_id לא תקין"}, ensure_ascii=False)
row = await db.create_case_precedent(
case_id=UUID(case["id"]),
quote=quote,
citation=citation,
section_id=section_id or None,
chair_note=chair_note,
pdf_document_id=pdf_uuid,
practice_area=case.get("practice_area"),
)
return json.dumps(row, ensure_ascii=False, indent=2)
async def precedent_list(case_number: str) -> str:
"""רשימת כל הפסיקות שצורפו לתיק, ממוינות לפי סעיף ואז לפי זמן יצירה."""
case = await db.get_case_by_number(case_number)
if not case:
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False)
rows = await db.list_case_precedents(UUID(case["id"]))
return json.dumps(rows, ensure_ascii=False, indent=2)
async def precedent_remove(precedent_id: str) -> str:
"""הסרת פסיקה מצורפת. קובץ ה-PDF (אם צורף) נשאר ב-documents לצורך audit."""
try:
pid = UUID(precedent_id)
except ValueError:
return json.dumps({"error": "precedent_id לא תקין"}, ensure_ascii=False)
ok = await db.delete_case_precedent(pid)
return json.dumps(
{"deleted": ok, "precedent_id": precedent_id}, ensure_ascii=False,
)
async def precedent_search_library(
query: str, practice_area: str = "", limit: int = 10,
) -> str:
"""חיפוש בספרייה הרוחבית — כל הפסיקות שצורפו אי-פעם בכל התיקים.
Args:
query: מחרוזת חיפוש (מתחרה מול citation ומול quote)
practice_area: אופציונלי — סינון לתחום משפטי מסוים
limit: מספר תוצאות מקסימלי
"""
if not query or len(query.strip()) < 2:
return json.dumps([], ensure_ascii=False)
rows = await db.search_precedent_library(query.strip(), practice_area, limit)
return json.dumps(rows, ensure_ascii=False, indent=2)

View File

@@ -3,28 +3,52 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from uuid import UUID from uuid import UUID
from legal_mcp.services import db, embeddings from legal_mcp.services import db, embeddings
logger = logging.getLogger(__name__)
async def search_decisions( async def search_decisions(
query: str, query: str,
limit: int = 10, limit: int = 10,
section_type: str = "", section_type: str = "",
practice_area: str = "",
appeal_subtype: str = "",
case_number: str = "",
) -> str: ) -> str:
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים. """חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי.
Args: Args:
query: שאילתת חיפוש בעברית (לדוגמה: "שימוש חורג למסחר באזור מגורים") query: שאילתת חיפוש בעברית
limit: מספר תוצאות מקסימלי limit: מספר תוצאות מקסימלי
section_type: סינון לפי סוג סעיף (facts, legal_analysis, conclusion, ruling, וכו'). ריק = הכל section_type: סינון לפי סוג סעיף (facts, legal_analysis, ...)
practice_area: תחום משפטי לסינון (appeals_committee/national_insurance/...)
appeal_subtype: סוג ערר לסינון (building_permit/betterment_levy/compensation_197)
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
""" """
# Auto-resolve practice_area from case_number if available
if case_number and not practice_area:
case = await db.get_case_by_number(case_number)
if case:
practice_area = case.get("practice_area") or ""
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
if not practice_area:
logger.warning(
"search_decisions called without practice_area filter — "
"results may mix legal domains"
)
query_emb = await embeddings.embed_query(query) query_emb = await embeddings.embed_query(query)
results = await db.search_similar( results = await db.search_similar(
query_embedding=query_emb, query_embedding=query_emb,
limit=limit, limit=limit,
section_type=section_type or None, section_type=section_type or None,
practice_area=practice_area or None,
appeal_subtype=appeal_subtype or None,
) )
if not results: if not results:
@@ -61,6 +85,7 @@ async def search_case_documents(
return f"תיק {case_number} לא נמצא." return f"תיק {case_number} לא נמצא."
query_emb = await embeddings.embed_query(query) query_emb = await embeddings.embed_query(query)
# Restricted to case_id — practice_area filter would be redundant.
results = await db.search_similar( results = await db.search_similar(
query_embedding=query_emb, query_embedding=query_emb,
limit=limit, limit=limit,
@@ -86,17 +111,37 @@ async def search_case_documents(
async def find_similar_cases( async def find_similar_cases(
description: str, description: str,
limit: int = 5, limit: int = 5,
practice_area: str = "",
appeal_subtype: str = "",
case_number: str = "",
) -> str: ) -> str:
"""מציאת תיקים דומים על בסיס תיאור. """מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי.
Args: Args:
description: תיאור התיק או הנושא (לדוגמה: "ערר על סירוב להיתר בנייה לתוספת קומה") description: תיאור התיק או הנושא
limit: מספר תוצאות מקסימלי limit: מספר תוצאות מקסימלי
practice_area: תחום משפטי לסינון
appeal_subtype: סוג ערר לסינון
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
""" """
if case_number and not practice_area:
case = await db.get_case_by_number(case_number)
if case:
practice_area = case.get("practice_area") or ""
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
if not practice_area:
logger.warning(
"find_similar_cases called without practice_area filter — "
"results may mix legal domains"
)
query_emb = await embeddings.embed_query(description) query_emb = await embeddings.embed_query(description)
results = await db.search_similar( results = await db.search_similar(
query_embedding=query_emb, query_embedding=query_emb,
limit=limit * 3, # Get more to deduplicate by case limit=limit * 3, # Get more to deduplicate by case
practice_area=practice_area or None,
appeal_subtype=appeal_subtype or None,
) )
if not results: if not results:

View File

@@ -318,3 +318,97 @@ async def ingest_final_version(
return json.dumps(result, default=str, ensure_ascii=False, indent=2) return json.dumps(result, default=str, ensure_ascii=False, indent=2)
except ValueError as e: except ValueError as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2) return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
# ── Chair feedback tools ──────────────────────────────────────────
async def record_chair_feedback(
case_number: str,
feedback_text: str,
block_id: str = "block-yod",
category: str = "missing_content",
lesson_extracted: str = "",
) -> str:
"""תיעוד הערת יו"ר (דפנה) על טיוטת החלטה.
Args:
case_number: מספר תיק הערר
feedback_text: ההערה של דפנה (מה חסר, מה לא נכון, מה צריך לשנות)
block_id: הבלוק שההערה מתייחסת אליו (ברירת מחדל: block-yod)
category: קטגוריה — missing_content/wrong_tone/wrong_structure/factual_error/style/other
lesson_extracted: הלקח שהופק מההערה (אם ברור כבר)
"""
case = await db.get_case_by_number(case_number)
case_id = UUID(case["id"]) if case else None
valid_categories = [
"missing_content", "wrong_tone", "wrong_structure",
"factual_error", "style", "other",
]
if category not in valid_categories:
return f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}"
feedback_id = await db.record_chair_feedback(
case_id=case_id,
block_id=block_id,
feedback_text=feedback_text,
category=category,
lesson_extracted=lesson_extracted,
)
return json.dumps({
"status": "ok",
"feedback_id": str(feedback_id),
"message": f"הערה נרשמה בהצלחה. קטגוריה: {category}.",
"next_steps": [
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
"כדי לסמן כמטופל: resolve_chair_feedback",
],
}, ensure_ascii=False, indent=2)
async def list_chair_feedback(
case_number: str = "",
category: str = "",
unresolved_only: bool = True,
) -> str:
"""הצגת הערות יו"ר שתועדו, עם אפשרות סינון.
Args:
case_number: סינון לפי תיק (אם ריק — כל ההערות)
category: סינון לפי קטגוריה
unresolved_only: האם להציג רק הערות שלא טופלו (ברירת מחדל: כן)
"""
case_id = None
if case_number:
case = await db.get_case_by_number(case_number)
if case:
case_id = UUID(case["id"])
feedbacks = await db.list_chair_feedback(
case_id=case_id,
category=category or None,
unresolved_only=unresolved_only,
)
if not feedbacks:
return "אין הערות שמתאימות לסינון."
items = []
for fb in feedbacks:
items.append({
"id": str(fb["id"]),
"case_id": str(fb["case_id"]) if fb["case_id"] else None,
"block_id": fb["block_id"],
"category": fb["category"],
"feedback": fb["feedback_text"],
"lesson": fb["lesson_extracted"],
"resolved": fb["resolved"],
"date": fb["created_at"].isoformat() if fb.get("created_at") else None,
})
return json.dumps({
"total": len(items),
"feedbacks": items,
}, ensure_ascii=False, indent=2, default=str)

View File

@@ -29,7 +29,7 @@ import asyncpg
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor, processor, proofreader, research_md from legal_mcp.services import chunker, db, embeddings, extractor, processor, proofreader, research_md
from legal_mcp.tools import cases as cases_tools, search as search_tools, workflow as workflow_tools, drafting as drafting_tools from legal_mcp.tools import cases as cases_tools, search as search_tools, workflow as workflow_tools, drafting as drafting_tools, precedents as precedents_tools
# Import integration clients (same directory) # Import integration clients (same directory)
_web_dir = Path(__file__).resolve().parent _web_dir = Path(__file__).resolve().parent
@@ -1069,6 +1069,8 @@ class CaseCreateRequest(BaseModel):
hearing_date: str = "" hearing_date: str = ""
notes: str = "" notes: str = ""
expected_outcome: str = "" expected_outcome: str = ""
practice_area: str = "appeals_committee"
appeal_subtype: str = ""
class CaseUpdateRequest(BaseModel): class CaseUpdateRequest(BaseModel):
@@ -1097,6 +1099,8 @@ async def api_case_create(req: CaseCreateRequest):
hearing_date=req.hearing_date, hearing_date=req.hearing_date,
notes=req.notes, notes=req.notes,
expected_outcome=req.expected_outcome, expected_outcome=req.expected_outcome,
practice_area=req.practice_area,
appeal_subtype=req.appeal_subtype,
) )
return json.loads(result) return json.loads(result)
@@ -1131,6 +1135,22 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest):
raise HTTPException(404, result) raise HTTPException(404, result)
@app.delete("/api/cases")
async def api_case_delete(case_number: str, remove_files: bool = False):
"""Delete a case, identified by case_number in the query string.
Uses a query param (not a path segment) because case numbers may contain
characters like `/` that FastAPI path routing cannot capture even when
URL-encoded (%2F). Dependent documents/chunks/qa_results cascade via
FK ON DELETE CASCADE; audit_log rows nullify their case_id.
Pass `remove_files=true` to also rm -rf the on-disk case directory."""
result = await cases_tools.case_delete(case_number, remove_files)
data = json.loads(result)
if not data.get("deleted"):
raise HTTPException(404, data.get("reason", f"תיק {case_number} לא נמצא"))
return data
@app.get("/api/cases/{case_number}/status") @app.get("/api/cases/{case_number}/status")
async def api_case_status(case_number: str): async def api_case_status(case_number: str):
"""Get full workflow status for a case.""" """Get full workflow status for a case."""
@@ -1623,6 +1643,120 @@ async def api_research_chair_position(case_number: str, req: ChairPositionReques
raise HTTPException(500, f"שגיאה בשמירה: {e}") raise HTTPException(500, f"שגיאה בשמירה: {e}")
# ── Precedents API — attached case-law quotes for the compose phase ──
class PrecedentCreateRequest(BaseModel):
quote: str
citation: str
section_id: str = "" # empty = case-level / general discussion
chair_note: str = ""
pdf_document_id: str = "" # UUID string, empty = no PDF
@app.post("/api/cases/{case_number}/precedents")
async def api_precedent_attach(case_number: str, req: PrecedentCreateRequest):
"""Attach a legal precedent (quote + citation) to a case, optionally
scoped to a specific threshold_claim / issue section. Cross-case
library reuse happens at the search endpoint — this one always
inserts a new row."""
if req.section_id and not re.match(r"^(threshold|issue)_\d+$", req.section_id):
raise HTTPException(400, "section_id לא תקין")
if not req.quote.strip() or not req.citation.strip():
raise HTTPException(400, "quote ו-citation חובה")
result = await precedents_tools.precedent_attach(
case_number=case_number,
quote=req.quote,
citation=req.citation,
section_id=req.section_id,
chair_note=req.chair_note,
pdf_document_id=req.pdf_document_id,
)
data = json.loads(result)
if data.get("error"):
raise HTTPException(404, data["error"])
return data
@app.post("/api/cases/{case_number}/precedents/upload-pdf")
async def api_precedent_upload_pdf(
case_number: str,
file: UploadFile = File(...),
):
"""One-shot PDF upload for a precedent attachment. Stores the file
on disk alongside other case documents and creates a `documents`
row with doc_type='precedent_archive'. Returns {document_id} so the
frontend can pass it into POST /precedents. No SSE / background
processing — archive only, no text extraction."""
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
if not file.filename:
raise HTTPException(400, "No filename provided")
ext = Path(file.filename).suffix.lower()
if ext not in {".pdf", ".docx", ".doc"}:
raise HTTPException(400, f"סוג קובץ לא נתמך לפסיקה: {ext}")
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(400, f"קובץ גדול מדי. מקסימום: {MAX_FILE_SIZE // (1024*1024)}MB")
# Save under a dedicated precedents/ subdirectory so they don't mix
# with extracted originals.
case_dir = config.find_case_dir(case_number) / "documents" / "precedents"
case_dir.mkdir(parents=True, exist_ok=True)
safe_name = re.sub(r"[^\w\u0590-\u05FF\s.\-()]", "", Path(file.filename).stem).strip()
dest = case_dir / f"{safe_name or 'precedent'}{ext}"
counter = 1
while dest.exists():
dest = case_dir / f"{safe_name or 'precedent'}-{counter}{ext}"
counter += 1
dest.write_bytes(content)
case_id = UUID(case["id"])
doc = await db.create_document(
case_id=case_id,
doc_type="precedent_archive",
title=safe_name or "precedent",
file_path=str(dest),
)
return {"document_id": doc["id"], "filename": dest.name}
@app.get("/api/cases/{case_number}/precedents")
async def api_precedent_list(case_number: str):
"""List all precedents attached to a case, grouped client-side by section_id."""
result = await precedents_tools.precedent_list(case_number)
data = json.loads(result)
if isinstance(data, dict) and data.get("error"):
raise HTTPException(404, data["error"])
return data
@app.delete("/api/precedents/{precedent_id}")
async def api_precedent_delete(precedent_id: str):
"""Delete a precedent attachment. The archived PDF (if any) stays
in the documents table — orphaned references nullify via FK
ON DELETE SET NULL — so we keep the audit trail of the file."""
result = await precedents_tools.precedent_remove(precedent_id)
data = json.loads(result)
if data.get("error"):
raise HTTPException(400, data["error"])
if not data.get("deleted"):
raise HTTPException(404, "לא נמצא")
return data
@app.get("/api/precedents/search")
async def api_precedent_search(q: str, practice_area: str = "", limit: int = 10):
"""Cross-case library typeahead. Returns one row per distinct citation."""
result = await precedents_tools.precedent_search_library(q, practice_area, limit)
return json.loads(result)
# ── Exports API — drafts, versions, download, upload, mark-final ── # ── Exports API — drafts, versions, download, upload, mark-final ──
@@ -2302,6 +2436,133 @@ async def api_reprocess_document(case_number: str, doc_id: str):
return {"status": "reprocessing"} return {"status": "reprocessing"}
# ── Chair feedback endpoints ──────────────────────────────────────
@app.get("/api/feedback")
async def api_list_feedback(
case_number: str = "",
category: str = "",
unresolved_only: bool = False,
):
"""List chair feedback, optionally filtered by case/category."""
case_id = None
if case_number:
case = await db.get_case_by_number(case_number)
if case:
case_id = UUID(case["id"])
feedbacks = await db.list_chair_feedback(
case_id=case_id,
category=category or None,
unresolved_only=unresolved_only,
)
items = []
# Build case_number lookup
case_numbers: dict[str, str] = {}
pool = await db.get_pool()
for fb in feedbacks:
cid = fb.get("case_id")
cn = ""
if cid and str(cid) not in case_numbers:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT case_number, title FROM cases WHERE id = $1", cid,
)
if row:
case_numbers[str(cid)] = row["case_number"]
if cid:
cn = case_numbers.get(str(cid), "")
items.append({
"id": str(fb["id"]),
"case_id": str(fb["case_id"]) if fb["case_id"] else None,
"case_number": cn,
"block_id": fb["block_id"],
"category": fb["category"],
"feedback_text": fb["feedback_text"],
"lesson_extracted": fb["lesson_extracted"],
"resolved": fb["resolved"],
"applied_to": fb.get("applied_to", []),
"created_at": fb["created_at"].isoformat() if fb.get("created_at") else None,
})
return items
@app.post("/api/feedback")
async def api_create_feedback(
case_number: str = Form(""),
block_id: str = Form("block-yod"),
feedback_text: str = Form(...),
category: str = Form("missing_content"),
lesson_extracted: str = Form(""),
):
"""Record a new chair feedback entry."""
case_id = None
if case_number:
case = await db.get_case_by_number(case_number)
if case:
case_id = UUID(case["id"])
valid_categories = [
"missing_content", "wrong_tone", "wrong_structure",
"factual_error", "style", "other",
]
if category not in valid_categories:
raise HTTPException(400, f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}")
feedback_id = await db.record_chair_feedback(
case_id=case_id,
block_id=block_id,
feedback_text=feedback_text,
category=category,
lesson_extracted=lesson_extracted,
)
return {"id": str(feedback_id), "status": "created"}
@app.post("/api/feedback/json")
async def api_create_feedback_json(body: dict):
"""Record a new chair feedback entry (JSON body)."""
case_number = body.get("case_number", "")
case_id = None
if case_number:
case = await db.get_case_by_number(case_number)
if case:
case_id = UUID(case["id"])
valid_categories = [
"missing_content", "wrong_tone", "wrong_structure",
"factual_error", "style", "other",
]
category = body.get("category", "missing_content")
if category not in valid_categories:
raise HTTPException(400, f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}")
feedback_id = await db.record_chair_feedback(
case_id=case_id,
block_id=body.get("block_id", "block-yod"),
feedback_text=body.get("feedback_text", ""),
category=category,
lesson_extracted=body.get("lesson_extracted", ""),
)
return {"id": str(feedback_id), "status": "created"}
@app.patch("/api/feedback/{feedback_id}/resolve")
async def api_resolve_feedback(feedback_id: str, body: dict):
"""Mark feedback as resolved."""
await db.resolve_chair_feedback(
feedback_id=UUID(feedback_id),
applied_to=body.get("applied_to", []),
)
return {"status": "resolved"}
# ── Background Processing ───────────────────────────────────────── # ── Background Processing ─────────────────────────────────────────

View File

@@ -1964,14 +1964,26 @@ kbd {
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group" style="max-width:200px">
<label>סוג ערר</label> <label>תחום משפטי</label>
<select id="wiz-committee-type"> <select id="wiz-practice-area">
<option value="רישוי">רישוי ובניה</option> <option value="appeals_committee">ועדת ערר</option>
<option value="היטל השבחה">היטל השבחה</option> <option value="national_insurance" disabled>ביטוח לאומי (בקרוב)</option>
<option value="פיצויים">פיצויים (ס' 197)</option> <option value="labor_law" disabled>דיני עבודה (בקרוב)</option>
</select> </select>
</div> </div>
<div class="form-group">
<label>סוג ערר <span style="color:#888;font-size:0.85em">(מוסק אוטומטית ממספר התיק)</span></label>
<select id="wiz-appeal-subtype">
<option value="building_permit">רישוי ובנייה</option>
<option value="betterment_levy">היטל השבחה</option>
<option value="compensation_197">פיצויים (ס' 197)</option>
<option value="unknown">לא ידוע</option>
</select>
</div>
<input type="hidden" id="wiz-committee-type" value="רישוי">
</div>
<div class="form-row">
<div class="form-group"> <div class="form-group">
<label>כתובת נכס</label> <label>כתובת נכס</label>
<input type="text" id="wiz-address" placeholder="רח' אבינדב 23, קריית יערים"> <input type="text" id="wiz-address" placeholder="רח' אבינדב 23, קריית יערים">
@@ -2730,11 +2742,14 @@ function getListValues(listId) {
function buildSummary() { function buildSummary() {
const data = getWizardData(); const data = getWizardData();
const OUTCOME_LABELS = { rejection: 'דחייה', partial_acceptance: 'קבלה חלקית', full_acceptance: 'קבלה מלאה', betterment_levy: 'היטל השבחה' }; const OUTCOME_LABELS = { rejection: 'דחייה', partial_acceptance: 'קבלה חלקית', full_acceptance: 'קבלה מלאה', betterment_levy: 'היטל השבחה' };
const PRACTICE_AREA_LABELS = { appeals_committee: 'ועדת ערר', national_insurance: 'ביטוח לאומי', labor_law: 'דיני עבודה' };
const SUBTYPE_LABELS = { building_permit: 'רישוי ובנייה', betterment_levy: 'היטל השבחה', compensation_197: "פיצויים (ס' 197)", unknown: 'לא ידוע' };
document.getElementById('wizSummary').innerHTML = ` document.getElementById('wizSummary').innerHTML = `
<table style="width:100%;font-size:0.88em;border-collapse:collapse"> <table style="width:100%;font-size:0.88em;border-collapse:collapse">
<tr><td style="padding:6px;color:#888;width:120px">מספר תיק</td><td style="padding:6px;font-weight:600">${esc(data.case_number)}</td></tr> <tr><td style="padding:6px;color:#888;width:120px">מספר תיק</td><td style="padding:6px;font-weight:600">${esc(data.case_number)}</td></tr>
<tr><td style="padding:6px;color:#888">כותרת</td><td style="padding:6px">${esc(data.title)}</td></tr> <tr><td style="padding:6px;color:#888">כותרת</td><td style="padding:6px">${esc(data.title)}</td></tr>
<tr><td style="padding:6px;color:#888">סוג</td><td style="padding:6px">${esc(data.committee_type)}</td></tr> <tr><td style="padding:6px;color:#888">תחום</td><td style="padding:6px">${esc(PRACTICE_AREA_LABELS[data.practice_area] || data.practice_area)}</td></tr>
<tr><td style="padding:6px;color:#888">סוג ערר</td><td style="padding:6px">${esc(SUBTYPE_LABELS[data.appeal_subtype] || data.appeal_subtype)}</td></tr>
<tr><td style="padding:6px;color:#888">כתובת</td><td style="padding:6px">${esc(data.property_address || '—')}</td></tr> <tr><td style="padding:6px;color:#888">כתובת</td><td style="padding:6px">${esc(data.property_address || '—')}</td></tr>
<tr><td style="padding:6px;color:#888">עוררים</td><td style="padding:6px">${data.appellants.length ? data.appellants.map(esc).join(', ') : '—'}</td></tr> <tr><td style="padding:6px;color:#888">עוררים</td><td style="padding:6px">${data.appellants.length ? data.appellants.map(esc).join(', ') : '—'}</td></tr>
<tr><td style="padding:6px;color:#888">משיבים</td><td style="padding:6px">${data.respondents.length ? data.respondents.map(esc).join(', ') : '—'}</td></tr> <tr><td style="padding:6px;color:#888">משיבים</td><td style="padding:6px">${data.respondents.length ? data.respondents.map(esc).join(', ') : '—'}</td></tr>
@@ -2743,11 +2758,44 @@ function buildSummary() {
`; `;
} }
// 1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197
function deriveSubtypeFromCaseNumber(caseNumber) {
const m = (caseNumber || '').trim().match(/^(\d)/);
if (!m) return 'unknown';
return ({1: 'building_permit', 8: 'betterment_levy', 9: 'compensation_197'})[m[1]] || 'unknown';
}
// Auto-fill subtype + committee_type when the user types/edits the case number.
// User can override the dropdown manually afterwards.
function wireSubtypeAutofill() {
const cnInput = document.getElementById('wiz-case-number');
const subtypeSel = document.getElementById('wiz-appeal-subtype');
const committeeHidden = document.getElementById('wiz-committee-type');
if (!cnInput || !subtypeSel) return;
const SUBTYPE_TO_COMMITTEE = {
building_permit: 'רישוי',
betterment_levy: 'היטל השבחה',
compensation_197: 'פיצויים',
unknown: 'רישוי',
};
let userOverrode = false;
subtypeSel.addEventListener('change', () => { userOverrode = true; });
cnInput.addEventListener('input', () => {
if (userOverrode) return;
const derived = deriveSubtypeFromCaseNumber(cnInput.value);
subtypeSel.value = derived;
if (committeeHidden) committeeHidden.value = SUBTYPE_TO_COMMITTEE[derived];
});
}
document.addEventListener('DOMContentLoaded', wireSubtypeAutofill);
function getWizardData() { function getWizardData() {
return { return {
case_number: document.getElementById('wiz-case-number').value.trim(), case_number: document.getElementById('wiz-case-number').value.trim(),
title: document.getElementById('wiz-title').value.trim(), title: document.getElementById('wiz-title').value.trim(),
committee_type: document.getElementById('wiz-committee-type').value, committee_type: document.getElementById('wiz-committee-type').value,
practice_area: document.getElementById('wiz-practice-area').value,
appeal_subtype: document.getElementById('wiz-appeal-subtype').value,
property_address: document.getElementById('wiz-address').value.trim(), property_address: document.getElementById('wiz-address').value.trim(),
permit_number: document.getElementById('wiz-permit').value.trim(), permit_number: document.getElementById('wiz-permit').value.trim(),
appellants: getListValues('appellantsList'), appellants: getListValues('appellantsList'),
@@ -2847,9 +2895,18 @@ async function loadCaseView(caseNumber) {
new: 'חדש', in_progress: 'בתהליך', documents_ready: 'מסמכים מוכנים', new: 'חדש', in_progress: 'בתהליך', documents_ready: 'מסמכים מוכנים',
drafted: 'טיוטה', final: 'סופי', drafted: 'טיוטה', final: 'סופי',
}; };
const PRACTICE_AREA_LABELS = { appeals_committee: 'ועדת ערר', national_insurance: 'ביטוח לאומי', labor_law: 'דיני עבודה' };
const SUBTYPE_LABELS = { building_permit: 'רישוי ובנייה', betterment_levy: 'היטל השבחה', compensation_197: "פיצויים (ס' 197)", unknown: 'לא ידוע' };
const meta = []; const meta = [];
meta.push(`<span class="badge ${data.status}">${STATUS_LABELS[data.status] || data.status}</span>`); meta.push(`<span class="badge ${data.status}">${STATUS_LABELS[data.status] || data.status}</span>`);
if (data.committee_type) meta.push(data.committee_type); if (data.practice_area || data.appeal_subtype) {
const parts = [];
if (data.practice_area) parts.push(PRACTICE_AREA_LABELS[data.practice_area] || data.practice_area);
if (data.appeal_subtype) parts.push(SUBTYPE_LABELS[data.appeal_subtype] || data.appeal_subtype);
meta.push(`<span class="badge" style="background:#e8f0fe;color:#1a56db" title="תחום משפטי / סוג ערר">${parts.join(' · ')}</span>`);
} else if (data.committee_type) {
meta.push(data.committee_type);
}
if (data.property_address) meta.push(data.property_address); if (data.property_address) meta.push(data.property_address);
if (data.appellants?.length) meta.push('עוררים: ' + data.appellants.join(', ')); if (data.appellants?.length) meta.push('עוררים: ' + data.appellants.join(', '));
document.getElementById('caseViewMeta').innerHTML = meta.map(m => `<span>${m}</span>`).join(''); document.getElementById('caseViewMeta').innerHTML = meta.map(m => `<span>${m}</span>`).join('');