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/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
| [`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 | להתמצאות כללית |
| [`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 ───────────────────────────────────────
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
@@ -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
@mcp.tool()
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)
@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():
mcp.run(transport="stdio")

View File

@@ -20,6 +20,7 @@ from uuid import UUID
from legal_mcp import config
from legal_mcp.services import db, embeddings, claude_session
from legal_mcp.services.lessons import get_content_checklist
logger = logging.getLogger(__name__)
@@ -215,6 +216,8 @@ BLOCK_PROMPTS = {
- **ללא כותרות משנה** (חריג: נושאים נפרדים לחלוטין)
- מספור רציף
{content_checklist}
## כיוון מאושר (חובה):
{direction_context}
@@ -310,6 +313,15 @@ async def write_block(
outcome = (decision or {}).get("outcome", "rejected")
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:
# Place source documents FIRST (top of prompt), instructions LAST.
# "Queries at the end can improve response quality by up to 30%"
@@ -323,6 +335,7 @@ async def write_block(
style_context=style_context,
discussion_context=discussion_context,
structure_guidance=structure_guidance,
content_checklist=content_checklist,
)
# 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_number = case.get("case_number", "") 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_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(
query_embedding=query_emb, limit=10, block_type="block-yod",
practice_area=practice_area, appeal_subtype=appeal_subtype,
)
# Filter out same case
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 '[]';
"""
# ── 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 ────────────────────
SCHEMA_V2_SQL = """
@@ -358,6 +462,22 @@ CREATE TABLE IF NOT EXISTS case_law_embeddings (
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
-- ═══════════════════════════════════════════════════════════════════
@@ -388,7 +508,9 @@ async def init_schema() -> None:
await conn.execute(MIGRATIONS_SQL)
await conn.execute(SCHEMA_V2_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 ───────────────────────────────────────────────────────
@@ -405,6 +527,8 @@ async def create_case(
hearing_date: date | None = None,
notes: str = "",
expected_outcome: str = "",
practice_area: str = "appeals_committee",
appeal_subtype: str | None = None,
) -> dict:
pool = await get_pool()
case_id = uuid4()
@@ -412,17 +536,43 @@ async def create_case(
await conn.execute(
"""INSERT INTO cases (id, case_number, title, appellants, respondents,
subject, property_address, permit_number, committee_type,
hearing_date, notes, expected_outcome)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)""",
hearing_date, notes, expected_outcome,
practice_area, appeal_subtype)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)""",
case_id, case_number, title,
json.dumps(appellants or []),
json.dumps(respondents or []),
subject, property_address, permit_number, committee_type,
hearing_date, notes, expected_outcome,
practice_area, appeal_subtype,
)
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:
pool = await get_pool()
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]
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:
if not fields:
return await get_case(case_id)
@@ -488,19 +648,34 @@ def _row_to_case(row: asyncpg.Record) -> dict:
# ── Document CRUD ───────────────────────────────────────────────────
async def create_document(
case_id: UUID,
case_id: UUID | None,
doc_type: str,
title: str,
file_path: str,
page_count: int | None = None,
practice_area: str | None = None,
appeal_subtype: str | None = None,
) -> dict:
pool = await get_pool()
doc_id = uuid4()
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(
"""INSERT INTO documents (id, case_id, doc_type, title, file_path, page_count)
VALUES ($1, $2, $3, $4, $5, $6)""",
"""INSERT INTO documents (id, case_id, doc_type, title, file_path,
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,
practice_area, appeal_subtype,
)
row = await conn.fetchrow("SELECT * FROM documents WHERE id = $1", doc_id)
return _row_to_doc(row)
@@ -556,6 +731,113 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
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 ─────────────────────────────────────────────────────────
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
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(
"""INSERT INTO decisions (id, case_id, version, outcome, outcome_summary,
outcome_reasoning, direction_doc)
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
outcome_reasoning, direction_doc,
practice_area, appeal_subtype)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
decision_id, case_id, version, outcome, outcome_summary,
outcome_reasoning, json.dumps(direction_doc) if direction_doc else None,
practice_area, appeal_subtype,
)
return await get_decision(decision_id)
@@ -701,12 +991,37 @@ async def store_chunks(
document_id: UUID,
case_id: UUID | None,
chunks: list[dict],
practice_area: str | None = None,
appeal_subtype: str | None = None,
) -> int:
"""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()
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
await conn.execute(
"DELETE FROM document_chunks WHERE document_id = $1", document_id
@@ -714,14 +1029,16 @@ async def store_chunks(
for chunk in chunks:
await conn.execute(
"""INSERT INTO document_chunks
(document_id, case_id, chunk_index, content, section_type, embedding, page_number)
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
(document_id, case_id, chunk_index, content, section_type,
embedding, page_number, practice_area, appeal_subtype)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
document_id, case_id,
chunk["chunk_index"],
chunk["content"],
chunk.get("section_type", "other"),
chunk["embedding"],
chunk.get("page_number"),
practice_area, appeal_subtype,
)
return len(chunks)
@@ -731,8 +1048,15 @@ async def search_similar(
limit: int = 10,
case_id: UUID | None = None,
section_type: str | None = None,
practice_area: str | None = None,
appeal_subtype: str | None = None,
) -> 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()
conditions = []
params: list = [query_embedding, limit]
@@ -746,6 +1070,14 @@ async def search_similar(
conditions.append(f"dc.section_type = ${param_idx}")
params.append(section_type)
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 ""
@@ -778,6 +1110,8 @@ async def add_to_style_corpus(
summary: str = "",
outcome: str = "",
key_principles: list[str] | None = None,
practice_area: str = "appeals_committee",
appeal_subtype: str | None = None,
) -> UUID:
pool = await get_pool()
corpus_id = uuid4()
@@ -785,11 +1119,13 @@ async def add_to_style_corpus(
await conn.execute(
"""INSERT INTO style_corpus
(id, document_id, decision_number, decision_date,
subject_categories, full_text, summary, outcome, key_principles)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
subject_categories, full_text, summary, outcome, key_principles,
practice_area, appeal_subtype)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)""",
corpus_id, document_id, decision_number, decision_date,
json.dumps(subject_categories), full_text, summary, outcome,
json.dumps(key_principles or []),
practice_area, appeal_subtype,
)
return corpus_id
@@ -893,8 +1229,15 @@ async def search_similar_paragraphs(
query_embedding: list[float],
limit: int = 10,
block_type: str | None = None,
practice_area: str | None = None,
appeal_subtype: str | None = None,
) -> 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()
conditions = []
params: list = [query_embedding, limit]
@@ -904,6 +1247,14 @@ async def search_similar_paragraphs(
conditions.append(f"db.block_id = ${param_idx}")
params.append(block_type)
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 ""
@@ -986,3 +1337,72 @@ async def search_precedents(
results.sort(key=lambda x: x["score"], reverse=True)
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]
return f"יעד: {lo}-{hi}% מסך ההחלטה"
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
import json
import shutil
import subprocess
from pathlib import Path
from uuid import UUID
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(
@@ -23,6 +24,8 @@ async def case_create(
hearing_date: str = "",
notes: str = "",
expected_outcome: str = "",
practice_area: str = "appeals_committee",
appeal_subtype: str = "",
) -> str:
"""יצירת תיק ערר חדש.
@@ -38,6 +41,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)
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
ריק = יוסק אוטומטית ממספר התיק
"""
from datetime import date as date_type
@@ -45,6 +51,12 @@ 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)
if not appeal_subtype:
appeal_subtype = derived_subtype
pa.validate(practice_area, appeal_subtype)
case = await db.create_case(
case_number=case_number,
title=title,
@@ -57,8 +69,24 @@ async def case_create(
hearing_date=h_date,
notes=notes,
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
case_dir = config.find_case_dir(case_number)
case_dir.mkdir(parents=True, exist_ok=True)
@@ -187,3 +215,37 @@ async def case_update(
)
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 = "",
subject_categories: list[str] | None = None,
title: str = "",
practice_area: str = "appeals_committee",
appeal_subtype: str = "",
) -> str:
"""העלאת החלטה קודמת של דפנה לקורפוס הסגנון (training).
@@ -114,10 +116,13 @@ async def document_upload_training(
decision_date: תאריך ההחלטה (YYYY-MM-DD)
subject_categories: קטגוריות - אפשר לבחור כמה (בנייה, שימוש חורג, תכנית, היתר, הקלה, חלוקה, תמ"א 38, היטל השבחה, פיצויים 197)
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 legal_mcp.services import extractor, embeddings, chunker
from legal_mcp.services import chunker, embeddings, extractor, practice_area as pa
source = Path(file_path)
if not source.exists():
@@ -126,6 +131,11 @@ async def document_upload_training(
if not title:
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)
config.TRAINING_DIR.mkdir(parents=True, exist_ok=True)
dest = config.TRAINING_DIR / source.name
@@ -140,25 +150,29 @@ async def document_upload_training(
if 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(
document_id=None,
decision_number=decision_number,
decision_date=d_date,
subject_categories=subject_categories or [],
full_text=text,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
)
# Chunk and embed for RAG search over training corpus
chunks = chunker.chunk_document(text)
if chunks:
# Create a document record (no case association)
# Create a document record (no case association — tag explicitly)
doc = await db.create_document(
case_id=None,
doc_type="decision",
title=f"[קורפוס] {title}",
file_path=str(dest),
page_count=page_count,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
)
doc_id = UUID(doc["id"])
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)
]
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({
"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
import json
import logging
from uuid import UUID
from legal_mcp.services import db, embeddings
logger = logging.getLogger(__name__)
async def search_decisions(
query: str,
limit: int = 10,
section_type: str = "",
practice_area: str = "",
appeal_subtype: str = "",
case_number: str = "",
) -> str:
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים.
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי.
Args:
query: שאילתת חיפוש בעברית (לדוגמה: "שימוש חורג למסחר באזור מגורים")
query: שאילתת חיפוש בעברית
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)
results = await db.search_similar(
query_embedding=query_emb,
limit=limit,
section_type=section_type or None,
practice_area=practice_area or None,
appeal_subtype=appeal_subtype or None,
)
if not results:
@@ -61,6 +85,7 @@ async def search_case_documents(
return f"תיק {case_number} לא נמצא."
query_emb = await embeddings.embed_query(query)
# Restricted to case_id — practice_area filter would be redundant.
results = await db.search_similar(
query_embedding=query_emb,
limit=limit,
@@ -86,17 +111,37 @@ async def search_case_documents(
async def find_similar_cases(
description: str,
limit: int = 5,
practice_area: str = "",
appeal_subtype: str = "",
case_number: str = "",
) -> str:
"""מציאת תיקים דומים על בסיס תיאור.
"""מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי.
Args:
description: תיאור התיק או הנושא (לדוגמה: "ערר על סירוב להיתר בנייה לתוספת קומה")
description: תיאור התיק או הנושא
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)
results = await db.search_similar(
query_embedding=query_emb,
limit=limit * 3, # Get more to deduplicate by case
practice_area=practice_area or None,
appeal_subtype=appeal_subtype or None,
)
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)
except ValueError as e:
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.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)
_web_dir = Path(__file__).resolve().parent
@@ -1069,6 +1069,8 @@ class CaseCreateRequest(BaseModel):
hearing_date: str = ""
notes: str = ""
expected_outcome: str = ""
practice_area: str = "appeals_committee"
appeal_subtype: str = ""
class CaseUpdateRequest(BaseModel):
@@ -1097,6 +1099,8 @@ async def api_case_create(req: CaseCreateRequest):
hearing_date=req.hearing_date,
notes=req.notes,
expected_outcome=req.expected_outcome,
practice_area=req.practice_area,
appeal_subtype=req.appeal_subtype,
)
return json.loads(result)
@@ -1131,6 +1135,22 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest):
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")
async def api_case_status(case_number: str):
"""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}")
# ── 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 ──
@@ -2302,6 +2436,133 @@ async def api_reprocess_document(case_number: str, doc_id: str):
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 ─────────────────────────────────────────

View File

@@ -1964,14 +1964,26 @@ kbd {
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>סוג ערר</label>
<select id="wiz-committee-type">
<option value="רישוי">רישוי ובניה</option>
<option value="היטל השבחה">היטל השבחה</option>
<option value="פיצויים">פיצויים (ס' 197)</option>
<div class="form-group" style="max-width:200px">
<label>תחום משפטי</label>
<select id="wiz-practice-area">
<option value="appeals_committee">ועדת ערר</option>
<option value="national_insurance" disabled>ביטוח לאומי (בקרוב)</option>
<option value="labor_law" disabled>דיני עבודה (בקרוב)</option>
</select>
</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">
<label>כתובת נכס</label>
<input type="text" id="wiz-address" placeholder="רח' אבינדב 23, קריית יערים">
@@ -2730,11 +2742,14 @@ function getListValues(listId) {
function buildSummary() {
const data = getWizardData();
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 = `
<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">כותרת</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">${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>
@@ -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() {
return {
case_number: document.getElementById('wiz-case-number').value.trim(),
title: document.getElementById('wiz-title').value.trim(),
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(),
permit_number: document.getElementById('wiz-permit').value.trim(),
appellants: getListValues('appellantsList'),
@@ -2847,9 +2895,18 @@ async function loadCaseView(caseNumber) {
new: 'חדש', in_progress: 'בתהליך', documents_ready: 'מסמכים מוכנים',
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 = [];
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.appellants?.length) meta.push('עוררים: ' + data.appellants.join(', '));
document.getElementById('caseViewMeta').innerHTML = meta.map(m => `<span>${m}</span>`).join('');