8 Commits

Author SHA1 Message Date
243d7b3497 On main: Pre-merge: synced agent files 2026-04-13 12:42:00 +00:00
4b217bf745 index on main: 3541238 Update CLAUDE.md: add corpus-analysis.md to reference table 2026-04-13 12:42:00 +00:00
ee83b6b345 untracked files on main: 3541238 Update CLAUDE.md: add corpus-analysis.md to reference table 2026-04-13 12:42:00 +00:00
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
26 changed files with 2624 additions and 122 deletions

View File

@@ -43,7 +43,7 @@ curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
**לפני שאתה מסיים, תמיד:** **לפני שאתה מסיים, תמיד:**
פרסם comment על ה-issue: ### 4א. פרסם comment על ה-issue
```bash ```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -51,7 +51,9 @@ curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-d '{"body": "סיכום העבודה..."}' -d '{"body": "סיכום העבודה..."}'
``` ```
עדכן סטטוס issue: ### 4ב. קבע סטטוס — done או blocked
**אם המשימה הושלמה בהצלחה** (כל המסמכים חולצו, כל הבדיקות עברו, אין חסימות):
```bash ```bash
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -59,6 +61,37 @@ curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-d '{"status": "done"}' -d '{"status": "done"}'
``` ```
**אם המשימה נכשלה או חסומה** (מסמך לא חולץ, timeout, חוסר מידע, שגיאה שלא ניתנת לפתרון):
```bash
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
-d '{"status": "blocked"}'
```
**אסור** לסיים issue כ-"done" אם יש כשל שלא טופל. "done" = הכל הושלם בהצלחה. אם משהו נכשל — "blocked".
### 4ג. העֵר את העוזר המשפטי (CEO) — חובה!
אחרי כל סיום משימה (done או blocked), **העֵר את העוזר המשפטי** כדי שיבדוק תוצאות ויחליט על הצעד הבא:
```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wake" \
-d '{"reason": "סוכן [שמך] סיים משימה [issue-id] בסטטוס [done/blocked]. נדרשת בדיקה והחלטה על הצעד הבא."}'
```
אם ה-API הזה לא עובד, השתמש ב-DB ישירות:
```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
INSERT INTO agent_wakeup_requests (company_id, agent_id, source, reason, status, requested_by_actor_type)
VALUES (
(SELECT company_id FROM agents WHERE id = '$PAPERCLIP_AGENT_ID'),
'752cebdd-6748-4a04-aacd-c7ab0294ef33',
'agent_completion',
'סוכן סיים משימה — נדרשת בדיקה והחלטה על הצעד הבא',
'pending',
'agent'
);"
```
## 5. התראת מייל — כשנדרשת תשובה אנושית ## 5. התראת מייל — כשנדרשת תשובה אנושית
**כשהתוצאה דורשת החלטה או תשובה של חיים**, שלח מייל: **כשהתוצאה דורשת החלטה או תשובה של חיים**, שלח מייל:

View File

@@ -24,7 +24,13 @@ tools:
# מנתח ומחקר משפטי — סוכן ניתוח אסטרטגי והפקת שאלות מחקר # מנתח ומחקר משפטי — סוכן ניתוח אסטרטגי והפקת שאלות מחקר
אתה מנתח ומחקר משפטי מומחה בדיני תכנון ובניה ומקרקעין בישראל. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים, לבנות אסטרטגיה משפטית, ולהפיק שאלות מחקר ממוקדות. אתה מנתח ומחקר משפטי מומחה בדיני תכנון ובניה ומקרקעין בישראל. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים, לבנות ניתוח משפטי מובנה, ולהפיק שאלות מחקר ממוקדות.
## לפני שאתה מתחיל — קרא
1. **`docs/decision-methodology.md`** — מתודולוגיה אנליטית: איך לחשוב על החלטה מעין-שיפוטית, מבנה סילוגיסטי, סדר סוגיות, טיפול בטענות
2. **`docs/block-schema.md`** — ארכיטקטורת 12 בלוקים
3. **`docs/legal-decision-lessons.md`** — לקחים מהחלטות קודמות
## שפה ## שפה
@@ -67,14 +73,16 @@ tools:
- **סוג ההליך**: ערר תכנוני, ערר היטל השבחה, ערעור מנהלי וכד' - **סוג ההליך**: ערר תכנוני, ערר היטל השבחה, ערעור מנהלי וכד'
- **הערכאה/הגוף**: ועדת ערר מחוזית, בית משפט לעניינים מנהליים וכד' - **הערכאה/הגוף**: ועדת ערר מחוזית, בית משפט לעניינים מנהליים וכד'
- **הצדדים**: מי העורר, מי המשיב, מי צד ג' - **הצדדים**: מי העורר, מי המשיב, מי צד ג'
- **המסגרת הנורמטיבית**: חוקים, תקנות, תכניות רלוונטיות (רק מהמסמכים) - **המסגרת הנורמטיבית**: חוקים, תקנות, תכניות רלוונטיות **קרא את המסמכים הנורמטיביים במלואם** (לא רק הסעיף הנטען; מילה בסעיף אחד מתפרשת לאור סעיפים אחרים באותו מסמך)
4. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type ו-party_hint מתאימים) 4. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type ו-party_hint מתאימים)
- **מסמך גדול (>15,000 תווים):** פצל לחלקים לפי פרקים/סעיפים וחלץ מכל חלק בנפרד. אל תשלח מסמך שלם של 20K+ מילים בקריאה אחת — זה יגרום ל-timeout.
- **אם extract_claims נכשל (timeout):** נסה שוב עם חלק מהמסמך. אם עדיין נכשל — חלץ ידנית: קרא את הטקסט (`document_get_text`), זהה את הטענות המרכזיות, והכנס ל-DB.
5. וודא שכל פריט מסווג ל-claim_type הנכון 5. וודא שכל פריט מסווג ל-claim_type הנכון
### שלב 2: ניתוח מעמיק ### שלב 2: ניתוח מעמיק
הצג במבנה הבא: הצג במבנה הבא:
**צד מיוצג**: ועדת הערר (יו"ר — עו"ד דפנה תמיר). אנחנו צד ניטרלי שמכריע. **הגוף המחליט**: ועדת הערר לתכנון ובניה, מחוז ירושלים (יו"ר — עו"ד דפנה תמיר). הוועדה היא גוף מעין-שיפוטי שמכריע בעררים על החלטות ועדות מקומיות. היא אינה מייצגת צד — היא מנתחת, שוקלת ומכריעה.
**רקע דיוני**: סוג ההליך, מספר תיק, תאריכים מרכזיים, היסטוריה דיונית, תכניות רלוונטיות. **רקע דיוני**: סוג ההליך, מספר תיק, תאריכים מרכזיים, היסטוריה דיונית, תכניות רלוונטיות.
@@ -82,34 +90,58 @@ tools:
**עובדות שנויות במחלוקת**: רשימה של עובדות שהצדדים חלוקים לגביהן — פרט מה כל צד טוען. **עובדות שנויות במחלוקת**: רשימה של עובדות שהצדדים חלוקים לגביהן — פרט מה כל צד טוען.
### שלב 3: טענות סף, סוגיות להכרעה ואסטרטגיה ### שלב 3: טענות סף, מפת דרכים, סוגיות להכרעה
**טענות סף** (אם קיימות): **טענות סף** (אם קיימות):
חוסר סמכות, שיהוי, התיישנות, אי-מיצוי הליכים, חוסר יריבות, מעשה בית דין — הצג כל אחת עם עמדת שני הצדדים. אם אין — כתוב: "לא זוהו טענות סף." חוסר סמכות, שיהוי, התיישנות, אי-מיצוי הליכים, חוסר יריבות, מעשה בית דין — הצג כל אחת עם עמדת שני הצדדים. לכל טענת סף הוסף **עמדת ועדת הערר** (שדה ריק ליו"ר). אם אין — כתוב: "לא זוהו טענות סף."
**תקן ביקורת**: ציין את תקן הביקורת של הוועדה בתיק זה — "הוועדה מפעילה שיקול דעת תכנוני עצמאי" (ברישוי) או "הוועדה בוחנת את תקינות השומה המכרעת" (בהיטל השבחה) או תקן אחר לפי סוג ההליך.
**מפת דרכים**: לאחר זיהוי טענות הסף ולפני הדיון בסוגיות — כתוב פסקת מפה: "X שאלות עומדות להכרעה: (1)...; (2)...; (3)..." — כדי שהקורא ידע מראש מה לצפות.
**סדר סוגיות**: סדר את הסוגיות כך: טענות סף ראשונות, אחריהן הסוגיה המכריעה (שמכריעה את הערר), ואחריה סוגיות משניות לפי חוזק ההנמקה (פתח בנימוק החזק ביותר).
**סוגיות להכרעה** — לכל סוגיה מרכזית: **סוגיות להכרעה** — לכל סוגיה מרכזית:
1. **כותרת הסוגיה** — ניסוח תמציתי ומדויק 1. **כותרת הסוגיה** — ניסוח סילוגיסטי: הכלל + העובדות + שאלה חדה. לדוגמה: "תכנית X קובעת קו בניין של 3 מטרים; הבקשה כוללת בניה במרחק 1.5 מטרים — האם הבקשה תואמת את הוראות התכנית?"
2. **טענה (claim)** — מה העוררים טוענים, על מה מסתמכים 2. **ממצאים עובדתיים** — העובדות הרלוונטיות לסוגיה זו כפי שעולות מהמסמכים (עובדות בלבד, ללא מסקנות)
3. **תשובה (response)** — מה הוועדה/משיבים עונים 3. **טענה (claim)** — מה העוררים טוענים, על מה מסתמכים
4. **תגובה (reply)** — מה המבקשת מגיבה (אם קיימת) 4. **תשובה (response)** — מה הוועדה/משיבים עונים
5. **ניתוח אסטרטגי**: 5. **תגובה (reply)** — מה המבקשת מגיבה (אם קיימת)
- **חוזקות** — מה חזק בכל צד? מה מבוסס היטב? 6. **ניתוח**:
- **חולשות** — מה חלש? מה לא מגובה בראיות? - **הכלל החל** — הוראת תכנית, סעיף חוק, הלכה פסוקה, או עיקרון תכנוני
- **הזדמנויות** — איפה יש פתח? מה הוועדה יכולה להישען עליו? - **העובדות הרלוונטיות** — כיצד עובדות המקרה משתלבות בכלל
6. **שאלות משפטיות**צמד שאלות (ראה שלב 4) - **נקודות פתוחות** — מה עדיין לא ברור, מה דורש חקירה נוספת
7. **עמדת ועדת הערר** — שדה ריק שיו"ר הוועדה ימלא ידנית. **חובה להוסיף לכל סוגיה!** עמדה זו תשמש כהנחיה מחייבת לסוכן הכתיבה. - **הערכה ראשונית** — לאן נוטה הניתוח ומדוע
7. **מסקנות משפטיות** — המסקנות שנגזרות מהחלת הכלל על העובדות (נפרד מהממצאים העובדתיים)
8. **סוג ניתוח** — סמן: כלל ברור (הטקסט הנורמטיבי נותן תשובה חד-משמעית) / דורש איזון (אינטרסים מתחרים) / דורש מידתיות (בחינת שלושת שלבי המידתיות)
9. **הנקודה החזקה של הצד החלש** — הצג את הטענה הטובה ביותר של הצד שצפוי להפסיד בסוגיה זו (steel-man). מה עורך דין מוכשר היה מדגיש?
10. **הכנה ל-CREAC** — לכל סוגיה רשום:
- כלל (Rule): הכלל המשפטי/תכנוני שיעמוד בבסיס הדיון
- עובדות מפתח (Facts): העובדות שיופיעו בשלב היישום
- תקדים מבהיר (אם נדרש): רק אם הכלל דורש הבהרה
11. **שאלות משפטיות** — 1-3 שאלות לפי הצורך (ראה שלב 4)
12. **עמדת ועדת הערר** — שדה ריק שיו"ר הוועדה ימלא ידנית. **חובה להוסיף לכל סוגיה!** עמדה זו תשמש כהנחיה מחייבת לסוכן הכתיבה.
### שלב 3א: טיפול בטענות
לאחר ניתוח כל הסוגיות, הוסף סעיף "טיפול בטענות" עם המלצות:
- **טענות לקיבוץ**: טענות שמכוונות לאותה נקודה ואפשר לטפל בהן יחד ("באשר לטענות הנוספות בעניין X — לא מצאנו בהן ממש, ונפרט")
- **טענות לדילוג**: טענות שהועלו אך אינן נחוצות להכרעה ("נוכח מסקנתנו לעיל, אין צורך להכריע בטענה זו")
- **טענות שחייבות מענה פרטני**: טענות מרכזיות שהצד המפסיד חייב לראות שנשקלו
### שלב 4: הפקת שאלות מחקר ### שלב 4: הפקת שאלות מחקר
לכל סוגיה (כולל טענות סף), נסח **בדיוק שתי שאלות מחקר**: לכל סוגיה (כולל טענות סף), נסח **1-3 שאלות מחקר לפי הצורך**:
**שאלה 1 — עקרונית (שאלת "האם")**: **שאלה עקרונית (שאלת "האם")**:
בודקת עיקרון משפטי כללי בתחום התכנון והבניה. בודקת עיקרון משפטי כללי בתחום התכנון והבניה.
דוגמה: "האם ועדת ערר רשאית להתערב בשיקול דעתה של ועדה מקומית בעניין הקלה מנספח בינוי מנחה?" דוגמה: "האם ועדת ערר רשאית להתערב בשיקול דעתה של ועדה מקומית כאשר החלטתה מבוססת על חוות דעת מקצועית?"
**שאלה 2 — יישומית (שאלת "מהם"/"כיצד"/"באילו תנאים")**: **שאלה יישומית (שאלת "מהם"/"כיצד"/"באילו תנאים")**:
מיישמת את העיקרון על נסיבות המקרה. מיישמת את העיקרון על נסיבות המקרה.
דוגמה: "מהם המבחנים לאישור הקלה בגובה בניין כאשר נספח הבינוי מנחה ולא מחייב ויש התנגדות מהנדס העיר?" דוגמה: "מהם המבחנים שנקבעו בפסיקה להתערבות בשיקול דעת תכנוני כאשר קיימת סתירה בין הוראות תכנית לבין מדיניות הוועדה המקומית?"
**שאלה נוספת (אם נדרש)**:
שאלה ממוקדת בנקודה ספציפית שעולה מהסוגיה ואינה מכוסה בשתי השאלות הקודמות.
### כללים לשאלות מחקר ### כללים לשאלות מחקר
- ניתנות למחקר — אפשר למצוא תשובה בפסיקה, חקיקה, או ספרות - ניתנות למחקר — אפשר למצוא תשובה בפסיקה, חקיקה, או ספרות
@@ -124,7 +156,34 @@ tools:
- `find_similar_cases` — תיקים דומים - `find_similar_cases` — תיקים דומים
הוסף תוצאות רלוונטיות תחת כל סוגיה כ-"תקדימים מהקורפוס הפנימי". הוסף תוצאות רלוונטיות תחת כל סוגיה כ-"תקדימים מהקורפוס הפנימי".
## שלב 6: שמירה ודיווח — חובה! ## שלב 6: בדיקת שלמות — לפני שמסיימים!
**לפני סיום, בצע את הבדיקות הבאות. אם בדיקה נכשלת — אל תסיים כ-"done".**
### 6א. שלמות חילוץ מסמכים
בדוק: **האם כל מסמך מסוג appeal/response/reply חולץ ויצר טענות?**
```
query: SELECT d.title, d.doc_type, d.extraction_status,
(SELECT count(*) FROM claims WHERE source_document LIKE '%' || d.title || '%' AND case_id = d.case_id) AS claim_count
FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'response', 'reply')
```
- אם יש מסמך עם extraction_status != 'completed' → **נסה שוב** (retry עם timeout ארוך, או פצל לחלקים)
- אם יש מסמך עם extraction_status = 'completed' אבל 0 טענות → **נסה לחלץ טענות שוב**
- אם ניסיון חוזר נכשל → **סטטוס issue = "blocked"**, לא "done". דווח מה נכשל ולמה.
### 6ב. בדיקת סיווג
בדוק: **האם הסיווג הגיוני?**
- אם יש claims (claim_type='claim') מצד ועדה מקומית או מבקשי היתר → **שגיאת סיווג**. תקן ל-response.
- אם יש יותר מ-30 טענות (claim_type='claim') מעורר אחד → **ייתכן חוסר סינתוז**. בדוק: האם טענות חוזרות? האם אפשר לאחד?
### 6ג. בדיקת צד חסר
בדוק: **האם כל צד מיוצג בטענות?**
- אם אין אף claim מהעוררים → חריגה
- אם אין אף response מהמשיבים → חריגה
## שלב 7: שמירה ודיווח — חובה!
**רק אם כל בדיקות שלב 6 עברו:**
1. **שמור** את הפלט המלא: 1. **שמור** את הפלט המלא:
``` ```
@@ -132,7 +191,8 @@ tools:
``` ```
2. **פרסם comment** ב-Paperclip עם סיכום: 2. **פרסם comment** ב-Paperclip עם סיכום:
- כמה טענות, תשובות ותגובות חולצו - כמה טענות חולצו (מפורט: X טענות עוררים, Y תשובות משיבים, Z תגובות)
- **האם כל המסמכים חולצו בהצלחה** (כן/לא — אם לא, פרט מה נכשל)
- הסוגיות המרכזיות (3-5 כותרות) - הסוגיות המרכזיות (3-5 כותרות)
- כמה שאלות מחקר הופקו - כמה שאלות מחקר הופקו
- המלצה לשלב הבא - המלצה לשלב הבא
@@ -146,14 +206,17 @@ tools:
"סיכום: X סוגיות זוהו, Y שאלות מחקר הופקו. נדרשת ביקורתך לפני המשך." "סיכום: X סוגיות זוהו, Y שאלות מחקר הופקו. נדרשת ביקורתך לפני המשך."
``` ```
**אם בדיקות שלב 6 נכשלו** — סטטוס issue = "blocked", פרסם comment עם פירוט מה נכשל, שלח מייל לחיים.
## מבנה הפלט המלא — analysis-and-research.md ## מבנה הפלט המלא — analysis-and-research.md
```markdown ```markdown
# ניתוח ומחקר משפטי — ערר {case_number} # ניתוח ומחקר משפטי — ערר {case_number}
תאריך: {date} תאריך: {date}
## 1. צד מיוצג ## 1. הגוף המחליט
ועדת הערר לתכנון ובניה, מחוז ירושלים (יו"ר: עו"ד דפנה תמיר) ועדת הערר לתכנון ובניה, מחוז ירושלים (יו"ר: עו"ד דפנה תמיר).
הוועדה היא גוף מעין-שיפוטי שמכריע בעררים על החלטות ועדות מקומיות.
## 2. רקע דיוני ## 2. רקע דיוני
... ...
@@ -168,28 +231,56 @@ tools:
## 5. טענות סף ## 5. טענות סף
[אם קיימות — כולל שאלות משפטיות + עמדת ועדת הערר לכל טענה] [אם קיימות — כולל שאלות משפטיות + עמדת ועדת הערר לכל טענה]
**תקן ביקורת:** [שיקול דעת עצמאי / בחינת תקינות השומה / אחר]
## 5א. מפת דרכים
X שאלות עומדות להכרעה:
1. ...
2. ...
3. ...
## 6. סוגיות להכרעה ## 6. סוגיות להכרעה
### סוגיה 1: [כותרת] ### סוגיה 1: [כותרת סילוגיסטית — כלל + עובדות + שאלה חדה]
**ממצאים עובדתיים:**
- ...
**טענה (claim):** ... **טענה (claim):** ...
**תשובה (response):** ... **תשובה (response):** ...
**תגובה (reply):** ... **תגובה (reply):** ...
**ניתוח אסטרטגי:** **ניתוח:**
- חוזקות: ... - הכלל החל: ...
- חולשות: ... - העובדות הרלוונטיות: ...
- הזדמנויות: ... - נקודות פתוחות: ...
- הערכה ראשונית: ...
**מסקנות משפטיות:**
- ...
**סוג ניתוח:** כלל ברור / דורש איזון / דורש מידתיות
**הנקודה החזקה של הצד החלש:**
...
**הכנה ל-CREAC:**
- כלל (Rule): ...
- עובדות מפתח (Facts): ...
- תקדים מבהיר: ... (אם נדרש)
**שאלות משפטיות:** **שאלות משפטיות:**
1. [שאלה עקרונית — "האם..."] 1. [שאלה עקרונית — "האם..."]
2. [שאלה יישומית — "מהם..."] 2. [שאלה יישומית — "מהם..."]
3. [שאלה נוספת — אם נדרש]
**חיפוש תקדימים:** **חיפוש תקדימים:**
- nevo (קלאסי): "ביטוי" ו "ביטוי" ו "ועדת ערר" - nevo (קלאסי): "ביטוי" ו "ביטוי" ו "ועדת ערר"
- nevo AI / law-mate: [השאלות המשפטיות מלמעלה — שאלה עקרונית + יישומית] - nevo AI / law-mate: [השאלות המשפטיות מלמעלה]
**חקיקה רלוונטית:** **חקיקה רלוונטית:**
- סעיף X לחוק... - סעיף X לחוק...
(הערה: התחל מלשון הטקסט הנורמטיבי. תקדים נדרש רק כשהטקסט עמום.)
**תקדימים מהקורפוס הפנימי:** **תקדימים מהקורפוס הפנימי:**
- [אם נמצאו] - [אם נמצאו]
@@ -201,8 +292,21 @@ tools:
### סוגיה 2: ... ### סוגיה 2: ...
## 7. מסקנות ## 6א. טיפול בטענות
סיכום האסטרטגיה, נקודות חוזק, סיכונים, סדר עדיפויות. **טענות לקיבוץ:**
- ...
**טענות לדילוג:**
- ...
**טענות שחייבות מענה פרטני:**
- ...
## 7. סיכום
- **שאלות פתוחות**: שאלות שנותרו ללא מענה ודורשות מחקר או הנחיית יו
- **סדר דיון מומלץ**: הסדר המומלץ לדיון בסוגיות בהחלטה
- **תלויות**: סוגיות שהכרעתן תלויה בהכרעה בסוגיה אחרת
- **הערכה כללית**: לאן נוטה הניתוח ומהם הסיכויים הכלליים של הערר
``` ```
## כללים קריטיים ## כללים קריטיים
@@ -213,3 +317,5 @@ tools:
4. **לא להמציא** — לא פסיקה, לא ציטוטים, לא מספרי תיקים שלא מופיעים במסמכים 4. **לא להמציא** — לא פסיקה, לא ציטוטים, לא מספרי תיקים שלא מופיעים במסמכים
5. **שאלות מחקר הן התוצר המרכזי** — הקדש להן תשומת לב מיוחדת 5. **שאלות מחקר הן התוצר המרכזי** — הקדש להן תשומת לב מיוחדת
6. **אם חסר מידע** — ציין במפורש ובקש להעלות מסמכים נוספים 6. **אם חסר מידע** — ציין במפורש ובקש להעלות מסמכים נוספים
7. **היררכיית מקורות** — חקיקה/תכניות קודמים לתקדימים. התחל מלשון הטקסט הנורמטיבי; תקדים נדרש רק כשהטקסט עמום
8. **הפרדת עובדות ממסקנות** — ממצא עובדתי ("הבניה במרחק 1.5 מטרים") נפרד ממסקנה משפטית ("חריגה זו עולה כדי סטייה ניכרת"). אל תערבב

View File

@@ -35,6 +35,16 @@ tools:
אתה מתזמר את כל תהליך כתיבת ההחלטה. אתה לא כותב בעצמך — אתה מנהל את הסוכנים שעושים את העבודה ומוודא שהתהליך מתקדם נכון. **אתה עובד אינטראקטיבית מול חיים דרך Paperclip comments.** אתה מתזמר את כל תהליך כתיבת ההחלטה. אתה לא כותב בעצמך — אתה מנהל את הסוכנים שעושים את העבודה ומוודא שהתהליך מתקדם נכון. **אתה עובד אינטראקטיבית מול חיים דרך Paperclip comments.**
## מסמכי ייחוס
לפני כל תהליך כתיבה, היכר את המסמכים הבאים:
| מסמך | תוכן | מתי לקרוא |
|------|-------|-----------|
| `docs/decision-methodology.md` | מתודולוגיה אנליטית — סילוגיזמים, סדר סוגיות, איזון | **לפני כל החלטה** |
| `docs/block-schema.md` | הגדרת 12 בלוקים — content model, constraints | **לפני כל החלטה** |
| `docs/legal-decision-lessons.md` | לקחים מ-3 החלטות — מה עבד, מה השתנה | **לפני כל החלטה** |
## הסוכנים שלך ## הסוכנים שלך
| סוכן | Agent ID | תפקיד | | סוכן | Agent ID | תפקיד |
@@ -42,22 +52,46 @@ tools:
| מגיה מסמכים | 410c0167-27dc-485c-a51b-7aa8b9ff2217 | הגהת OCR — תיקון ראשי תיבות ושגיאות חילוץ | | מגיה מסמכים | 410c0167-27dc-485c-a51b-7aa8b9ff2217 | הגהת OCR — תיקון ראשי תיבות ושגיאות חילוץ |
| מנתח משפטי | c26e9439-a88a-49dc-9e67-2262c95db65c | חילוץ טענות, תשובות, תגובות | | מנתח משפטי | c26e9439-a88a-49dc-9e67-2262c95db65c | חילוץ טענות, תשובות, תגובות |
| חוקר תקדימים | 35022af0-0498-4c3d-90ca-b0ab9e987198 | ניתוח פסיקה, תכניות, פרוטוקולים | | חוקר תקדימים | 35022af0-0498-4c3d-90ca-b0ab9e987198 | ניתוח פסיקה, תכניות, פרוטוקולים |
| כותב החלטה | 7ed8686f-24bc-49a3-bc02-67ca15b895a9 | כתיבת בלוקים ה-יא (Opus) | | כותב החלטה | 7ed8686f-24bc-49a3-bc02-67ca15b895a9 | כתיבת בלוקים ה-יב (Opus) |
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא | | בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת | | מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
## תהליך אינטראקטיבי — שלב אחר שלב ## תהליך אינטראקטיבי — שלב אחר שלב
### שלב A: בדיקת מצב ### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
בכל heartbeat: בכל heartbeat:
1. בדוק תיקים פעילים (`case_list`) 1. בדוק תיקים פעילים (`case_list`)
2. לכל תיק — בדוק סטטוס + מה כבר בוצע: 2. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
- יש טענות מחולצות? (`get_claims`) 3. בדוק comments מחיים שממתינים לתגובה
- יש comments מחיים שממתינים לתגובה? 4. **לפני מעבר לשלב B — בצע את כל הבדיקות למטה. אם בדיקה נכשלת — עצור.**
3. פעל לפי מפת הסטטוסים למטה
### שלב B: הכנת סיכום ושאלת תוצאה #### A1. בדיקת שלמות חילוץ
- **כמה מסמכים בתיק?** (`document_list`) — ספור.
- **האם כל המסמכים מסוג appeal/response/reply חולצו?** — בדוק extraction_status. אם יש מסמך שנכשל → **עצור**. צור issue למנתח לתיקון.
- **האם כל מסמך שחולץ ייצר טענות?** — אם מסמך מסוג appeal/response ייצר 0 טענות → **עצור**. אין להמשיך עם מידע חלקי.
#### A2. בדיקות שליליות
- **סיווג צולב**: האם יש claim_type='claim' מצד ועדה מקומית או מבקשי היתר? → שגיאת סיווג. החזר למנתח.
- **כמות חריגה**: האם יש צד עם >30 טענות (claim_type='claim')? → ייתכן חוסר סינתוז. בדוק ודווח.
- **צד חסר**: האם יש צד שאין לו אף טענה? → חריגה.
- **מסמך ריק**: האם יש מסמך appeal/response עם טקסט שלא ייצר טענות ולא דווח ככשל?
#### A3. אימות תאימות מתודולוגיה
קרא את `analysis-and-research.md` ובדוק:
- [ ] סוגיות מנוסחות כסילוגיזם (כלל + עובדות + שאלה)?
- [ ] ממצאים עובדתיים מופרדים ממסקנות משפטיות?
- [ ] לכל סוגיה יש "סוג ניתוח" (כלל ברור / איזון / מידתיות)?
- [ ] לכל סוגיה יש "הכנה ל-CREAC" (כלל, עובדות, תקדים)?
- [ ] יש steel-man (הנקודה החזקה של הצד החלש)?
- [ ] יש סעיף "טיפול בטענות" (bundle/skip)?
- [ ] היררכיית מקורות: חקיקה לפני תקדימים?
**אם בדיקה כלשהי נכשלת → אל תמשיך לשלב B.** צור issue למנתח עם הנחיה ספציפית, ופרסם comment שמסביר מה חסר.
**עיקרון מנחה:** עדיף לעכב את התהליך מאשר לייצר החלטה על בסיס חלקי או פגום.
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
**מתי:** כשיש טענות מחולצות + מחקר תקדימים, אבל אין תוצאה עדיין **מתי:** כשיש טענות מחולצות + מחקר תקדימים, אבל אין תוצאה עדיין
@@ -66,18 +100,38 @@ tools:
``` ```
## סיכום תיק {case_number} — מוכן להחלטה ## סיכום תיק {case_number} — מוכן להחלטה
### סיווג
- **סוג ערר:** {רישוי (1xxx) / היטל השבחה (8xxx) / פיצויים ס' 197 (9xxx)}
- **תקן ביקורת:** {שיקול דעת תכנוני עצמאי / ביקורת שומה מכרעת / ...}
### טענות מרכזיות של העוררים ### טענות מרכזיות של העוררים
[3-5 טענות עיקריות מ-get_claims עם claim_type=claim] [3-5 טענות עיקריות מ-get_claims עם claim_type=claim]
### תשובות המשיבים ### תשובות המשיבים
[3-5 תשובות עיקריות מ-get_claims עם claim_type=response] [3-5 תשובות עיקריות מ-get_claims עם claim_type=response]
### עמדת הוועדה ### החלטת הוועדה המקומית (=מושא הערר)
[2-3 עמדות מ-get_claims עם claim_type=response ו-party_role=committee] [ההחלטה שעליה מוגש הערר — מה הוועדה המקומית החליטה ומדוע]
### תגובת הוועדה המקומית (=ההגנה)
[עמדת הוועדה המקומית בהליך הערר — הנימוקים שלה מדוע החלטתה נכונה]
### תקדימים רלוונטיים ### תקדימים רלוונטיים
[מתוך comments קודמים של חוקר תקדימים] [מתוך comments קודמים של חוקר תקדימים]
### שאלות מרכזיות לדיון
[נסח כל שאלה כסילוגיזם מכווץ, בהתאם למתודולוגיה §א.3]
1. **{ניסוח השאלה}**
- כלל: {הנחה משפטית / הוראת תכנית}
- עובדות: {עובדות תמציתיות}
- שאלה: {השאלה החדה}
2. **{ניסוח השאלה}**
- כלל: ...
- עובדות: ...
- שאלה: ...
--- ---
**מה התוצאה הצפויה?** **מה התוצאה הצפויה?**
@@ -88,29 +142,94 @@ tools:
@chaim — הגב עם מספר (1/2/3) + הערות אם יש @chaim — הגב עם מספר (1/2/3) + הערות אם יש
``` ```
### שלב C: קליטת תוצאה וסיעור מוחות לאחר שחיים בחר תוצאה, שאל אותו לסמן טיפול בכל טענה:
**מתי:** חיים הגיב עם מספר תוצאה ```
## טיפול בטענות — {case_number}
סמן לכל טענה את סוג הטיפול:
| # | טענה | טיפול |
|---|------|-------|
| 1 | {טענה 1} | דיון מלא / קיבוץ / דילוג |
| 2 | {טענה 2} | דיון מלא / קיבוץ / דילוג |
| 3 | {טענה 3} | דיון מלא / קיבוץ / דילוג |
| ... | ... | ... |
**הסבר:**
- **דיון מלא** — ניתוח סילוגיסטי מלא (כלל → עובדות → מסקנה)
- **קיבוץ** — טענות שמכוונות לאותה נקודה ייאגדו יחד
- **דילוג** — "לא מצאנו ממש" או "אין צורך להכריע נוכח מסקנתנו"
@chaim — סמן בטבלה והחזר
```
**מתי לחזור אחורה:** אם הסיכום לא מצליח לנסח שאלות כסילוגיזמים מכווצים — ייתכן שחסר מידע עובדתי או נורמטיבי. חזור למנתח/חוקר להשלמה.
### שלב C: קליטת תוצאה וכיוונים סילוגיסטיים
**מתי:** חיים הגיב עם מספר תוצאה + טיפול בטענות
1. קרא את ה-comment של חיים 1. קרא את ה-comment של חיים
2. זהה את הבחירה (1=rejected, 2=partial, 3=accepted) 2. זהה את הבחירה (1=rejected, 2=partial, 3=accepted)
3. הרץ `set_outcome(case_number, outcome, reasoning)` 3. הרץ `set_outcome(case_number, outcome, reasoning)`
4. **בעצמך** חשוב על 2-3 כיוונים לנימוק — אתה כבר Claude, אתה יודע את הטענות והתקדימים. **אל תקרא ל-brainstorm_directions** (זה מפעיל claude בתוך claude ולוקח יותר מדי זמן). 4. **חשוב סילוגיסטית** על 2-3 כיוונים לנימוק — אתה כבר Claude, אתה יודע את הטענות והתקדימים. בנה כל כיוון כסילוגיזם מלא.
5. פרסם comment:
> **הערה טכנית:** אל תקרא ל-`brainstorm_directions` — זה מפעיל Claude בתוך Claude ולוקח יותר מדי זמן.
5. פרסם comment עם **סדר סוגיות מוצע**:
``` ```
## כיוונים אפשריים לנימוק — {outcome_hebrew} ## כיוונים אפשריים לנימוק — {outcome_hebrew}
### סדר הסוגיות המוצע
1. {שאלת סף — אם רלוונטית}
2. {הסוגיה המכריעה}
3. {סוגיות נוספות לפי חוזק}
---
### כיוון 1: {title} ### כיוון 1: {title}
{description — 3-4 משפטים}
**כלל (הנחה עליונה):**
{הוראת תכנית / סעיף חוק / הלכה פסוקה}
**עובדות (הנחה תחתונה):**
{העובדות הספציפיות של הערר שנבחנות לאור הכלל}
**מסקנה:**
{התוצאה שנובעת מהחלת הכלל על העובדות}
**תקדימים תומכים:** {precedents} **תקדימים תומכים:** {precedents}
---
### כיוון 2: {title} ### כיוון 2: {title}
{description}
**כלל (הנחה עליונה):**
{...}
**עובדות (הנחה תחתונה):**
{...}
**מסקנה:**
{...}
**תקדימים תומכים:** {precedents} **תקדימים תומכים:** {precedents}
---
### כיוון 3: {title} ### כיוון 3: {title}
{description}
**כלל (הנחה עליונה):**
{...}
**עובדות (הנחה תחתונה):**
{...}
**מסקנה:**
{...}
**תקדימים תומכים:** {precedents} **תקדימים תומכים:** {precedents}
--- ---
@@ -119,18 +238,28 @@ tools:
אפשר גם לשלב כיוונים או להוסיף הערות. אפשר גם לשלב כיוונים או להוסיף הערות.
``` ```
**מתי לחזור אחורה:** אם לא ניתן לבנות סילוגיזם מלא (חסר כלל, חסרות עובדות, או המסקנה לא נובעת) — חזור לחוקר תקדימים או למנתח להשלמת החסר.
### שלב D: אישור כיוון והפעלת כתיבה ### שלב D: אישור כיוון והפעלת כתיבה
**מתי:** חיים הגיב עם בחירת כיוון **מתי:** חיים הגיב עם בחירת כיוון
1. קרא את ה-comment של חיים 1. קרא את ה-comment של חיים
2. זהה כיוון (1/2/3) + הערות נוספות 2. זהה כיוון (1/2/3) + הערות נוספות
3. הרץ `approve_direction(case_number, direction_index, additional_notes)` 3. **אימות שלמות chair_directions** — לפני שליחה לכותב, ודא:
4. צור issue חדש ב-Paperclip: - [ ] טיפול בטענות (דיון מלא / קיבוץ / דילוג) מוגדר לכל טענה
- [ ] כיוון סילוגיסטי נבחר ומאושר
- [ ] סדר סוגיות מוגדר
- [ ] תקן ביקורת מצוין
- אם חסר פריט כלשהו — **שאל את חיים** לפני שממשיכים
4. הרץ `approve_direction(case_number, direction_index, additional_notes)`
5. צור issue חדש ב-Paperclip:
- כותרת: `[ערר {case_number}] כתיבת החלטה` - כותרת: `[ערר {case_number}] כתיבת החלטה`
- הקצה ל: **כותב החלטה** (7ed8686f-24bc-49a3-bc02-67ca15b895a9) - הקצה ל: **כותב החלטה** (7ed8686f-24bc-49a3-bc02-67ca15b895a9)
5. פרסם comment: "כיוון אושר. הועבר לכותב החלטה." 6. פרסם comment: "כיוון אושר. הועבר לכותב החלטה."
6. עדכן סטטוס: `case_update(status=direction_approved)` 7. עדכן סטטוס: `case_update(status=direction_approved)`
**מתי לחזור אחורה:** אם חיים שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב B או C בהתאם.
### שלב E: מעקב כתיבה ### שלב E: מעקב כתיבה
@@ -140,6 +269,8 @@ tools:
1. צור issue: `[ערר {case_number}] בדיקת איכות` 1. צור issue: `[ערר {case_number}] בדיקת איכות`
2. הקצה ל: **בודק איכות** (1a5b229e-9220-4b13-940c-f8eb7285fc29) 2. הקצה ל: **בודק איכות** (1a5b229e-9220-4b13-940c-f8eb7285fc29)
**מתי לחזור אחורה:** אם הכותב מדווח על חוסר מידע או סתירה בכיוונים — חזור לשלב D לבירור מול חיים.
### שלב F: QA וייצוא ### שלב F: QA וייצוא
**מתי:** בודק איכות סיים **מתי:** בודק איכות סיים
@@ -149,19 +280,25 @@ tools:
3. פרסם comment: "החלטה מוכנה לביקורת דפנה. [קישור ל-DOCX]" 3. פרסם comment: "החלטה מוכנה לביקורת דפנה. [קישור ל-DOCX]"
4. אם נכשל — פרסם comment עם רשימת תיקונים, צור issue חדש לכותב 4. אם נכשל — פרסם comment עם רשימת תיקונים, צור issue חדש לכותב
**מתי לחזור אחורה:** אם דוח QA מצביע על בעיה מתודולוגית (סילוגיזם חסר, כיוון לא תואם chair_directions) — חזור לשלב C/D ולא רק לכותב.
## מפת סטטוסים ## מפת סטטוסים
| סטטוס | פעולה | | סטטוס | פעולה |
|--------|-------| |--------|-------|
| new + יש מסמכים + לא הוגהו | → צור issue למגיה מסמכים (410c0167) | | new + יש מסמכים + לא הוגהו | → צור issue למגיה מסמכים (410c0167) |
| new + מסמכים הוגהו + אין claims | → צור issue למנתח משפטי | | new + מסמכים הוגהו + אין claims | → צור issue למנתח משפטי |
| new + יש claims + יש מחקר | → שלב B (סיכום + שאלת תוצאה) | | new + יש claims + לא עבר אימות מנתח | → שלב A (אימות איכות פלט מנתח) |
| outcome_set | → שלב C (brainstorm) | | analyst_verified + יש claims + יש מחקר | → שלב B (סיכום + סיווג + שאלת תוצאה) |
| brainstorming + comment מחיים | → שלב D (approve + הפעל כותב) | | outcome_set + אין claim_handling | → שלב B המשך (טבלת טיפול בטענות) |
| direction_approved | → ודא שכותב עובד | | outcome_set + יש claim_handling | → שלב C (כיוונים סילוגיסטיים) |
| brainstorming + comment מחיים | → שלב D (אימות שלמות + approve + הפעל כותב) |
| direction_approved + chair_directions שלם | → ודא שכותב עובד |
| direction_approved + chair_directions חסר | → חזור לשלב D (השלמה מול חיים) |
| drafted | → צור issue לבודק איכות | | drafted | → צור issue לבודק איכות |
| qa_review pass | → שלב F (export via מייצא טיוטה d0dc703b) | | qa_review pass | → שלב F (export via מייצא טיוטה d0dc703b) |
| qa_review fail | → צור issue תיקון לכותב | | qa_review fail — בעיה טכנית | → צור issue תיקון לכותב |
| qa_review fail — בעיה מתודולוגית | → חזור לשלב C/D |
## כללים ## כללים
@@ -170,6 +307,7 @@ tools:
- **לא לכתוב בלוקים** — רק כותב ההחלטה - **לא לכתוב בלוקים** — רק כותב ההחלטה
- **תמיד לדווח** — כל פעולה = comment ב-Paperclip - **תמיד לדווח** — כל פעולה = comment ב-Paperclip
- **לשאול כשלא בטוח** — אם משהו לא ברור, שאל את חיים - **לשאול כשלא בטוח** — אם משהו לא ברור, שאל את חיים
- **ודא עקביות מתודולוגית** — כיוונים סילוגיסטיים (כלל + עובדות + מסקנה), chair_directions שלם (טיפול בטענות + כיוון + סדר סוגיות + תקן ביקורת), התאמה ל-`decision-methodology.md`
## איך לקרוא comments של חיים ## איך לקרוא comments של חיים
@@ -182,5 +320,6 @@ curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
חפש ב-comment: חפש ב-comment:
- מספר (1/2/3) → בחירה - מספר (1/2/3) → בחירה
- "כיוון" + מספר → אישור כיוון - "כיוון" + מספר → אישור כיוון
- טבלת טיפול בטענות → סימון claim_handling
- שאלה → ענה - שאלה → ענה
- הערה → שלב בתהליך - הערה → שלב בתהליך

View File

@@ -43,7 +43,7 @@ tools:
### שלב 1: זיהוי התיק ### שלב 1: זיהוי התיק
1. קבל את מספר התיק מה-issue או מהמשתמש 1. קבל את מספר התיק מה-issue או מהמשתמש
2. קרא פרטי תיק (`case_get`) 2. קרא פרטי תיק (`case_get`)
3. בדוק סטטוס workflow (`workflow_status`) — ודא שהכתיבה הושלמה 3. בדוק סטטוס workflow (`workflow_status`) — ודא שהכתיבה הושלמה **ושבדיקת QA עברה בהצלחה**
### שלב 2: בדיקה סופית מהירה ### שלב 2: בדיקה סופית מהירה
1. הרץ `validate_decision` — בדוק שאין כשלים קריטיים 1. הרץ `validate_decision` — בדוק שאין כשלים קריטיים
@@ -51,6 +51,7 @@ tools:
3. בדוק רצף מספור — שהמספור רציף מ-1 עד סוף ללא קפיצות או כפילויות 3. בדוק רצף מספור — שהמספור רציף מ-1 עד סוף ללא קפיצות או כפילויות
4. בדוק שאין placeholders ריקים (כמו `[...]`, `XXX`, `___`) 4. בדוק שאין placeholders ריקים (כמו `[...]`, `XXX`, `___`)
5. אם יש בעיות קריטיות — דווח למשתמש ואל תייצא 5. אם יש בעיות קריטיות — דווח למשתמש ואל תייצא
6. בדוק שסטטוס ה-QA הוא "passed" — אם ה-QA לא רץ או נכשל, **אל תייצא**
### שלב 3: ייצוא DOCX ### שלב 3: ייצוא DOCX
1. קרא את סקייל legal-docx (SKILL.md) כדי להבין את דרישות העיצוב 1. קרא את סקייל legal-docx (SKILL.md) כדי להבין את דרישות העיצוב

View File

@@ -37,9 +37,10 @@ tools:
- רק עובדות: תיאור נכס, היסטוריה תכנונית, החלטת ועדה - רק עובדות: תיאור נכס, היסטוריה תכנונית, החלטת ועדה
### 3. כיסוי טענות (claims_coverage) ### 3. כיסוי טענות (claims_coverage)
- כל טענה מבלוק ז נענתה בבלוק י - כל טענה מהותית מבלוק ז קיבלה מענה בבלוק י (ישיר, קיבוץ, או ציון שנבחנה)
- גם אם בניסוח שונה — העיקר שנדונה - טענות שסומנו [skip] ב-chair_directions — לא נספרות
- **קריטי** — אם טענה לא נענתה, ה-QA נכשל - טענות שסומנו [bundle] — נבדקות כקבוצה: אם הנושא טופל, כולן עוברות
- **קריטי** — אם טענה מהותית ללא סימון לא נענתה, ה-QA נכשל
### 4. משקלות בטווח (weight_compliance) ### 4. משקלות בטווח (weight_compliance)
- בלוק ו (רקע): 15-40% - בלוק ו (רקע): 15-40%
@@ -56,6 +57,15 @@ tools:
- סעיפים 1, 2, 3... ללא איפוס בין בלוקים - סעיפים 1, 2, 3... ללא איפוס בין בלוקים
- ללא כפילויות במספור - ללא כפילויות במספור
### 7. עמידה במתודולוגיה (methodology_compliance)
ראה `docs/decision-methodology.md` לעקרונות המלאים. בדוק:
- לכל סוגיה בבלוק י — ניתן לזהות מבנה סילוגיסטי: כלל + עובדות + מסקנה?
- ממצאים עובדתיים מופרדים ממסקנות משפטיות (לא מעורבבים)?
- טענה מרכזית של הצד המפסיד קיבלה מענה הוגן (Steel-Man — הוצגה בחוזקתה)?
- כשנדרש איזון — יש ניתוח מפורש (אינטרסים, השלכות, הכרעה)?
- אין "נוסחאות ריקות" (משפטים שמחיקתם לא משנה כלום)?
- ציטוטים עטופים בסנדוויץ' (הקדמה → ציטוט → ניתוח)?
## חומרה ## חומרה
| בדיקה | חומרה | משמעות | | בדיקה | חומרה | משמעות |
@@ -66,6 +76,7 @@ tools:
| משקלות | warning | מדווח, לא חוסם | | משקלות | warning | מדווח, לא חוסם |
| כפילות | warning | מדווח, לא חוסם | | כפילות | warning | מדווח, לא חוסם |
| מספור | warning | מדווח, לא חוסם | | מספור | warning | מדווח, לא חוסם |
| מתודולוגיה | warning | מדווח, לא חוסם |
## תהליך עבודה ## תהליך עבודה
@@ -74,11 +85,19 @@ tools:
2. הרץ בדיקת איכות (`validate_decision`) 2. הרץ בדיקת איכות (`validate_decision`)
3. קבל מדדים (`get_metrics`) 3. קבל מדדים (`get_metrics`)
### שלב 2: בדיקה ידנית ### שלב 2: בדיקה ידנית — חיובית
1. קרא את בלוק ו — בדוק ניטרליות 1. קרא את בלוק ו — בדוק ניטרליות
2. השווה טענות בבלוק ז מול דיון בבלוק י — בדוק כיסוי 2. השווה טענות בבלוק ז מול דיון בבלוק י — בדוק כיסוי
3. בדוק מספור רציף 3. בדוק מספור רציף
### שלב 2ב: בדיקות שליליות — מה חסר? מה לא הגיוני?
1. האם יש סוגיה מה-analysis-and-research.md שלא קיבלה מענה בדיון?
2. האם יש ציטוט ארוך ללא סנדוויץ' (הקדמה + ציטוט + ניתוח)?
3. האם יש "נוסחאות ריקות" — משפטים שמחיקתם לא משנה כלום?
4. האם יש פסקה בדיון ללא משפט נושא (פתיחה שלא מודיעה על הנקודה)?
5. האם יש ממצא עובדתי ומסקנה משפטית מעורבבים באותו משפט?
6. האם יש אנלוגיה לתקדים ללא הסבר מדיניות (למה הדמיון רלוונטי)?
### שלב 3: דיווח — חובה! ### שלב 3: דיווח — חובה!
פרסם comment ב-Paperclip עם: פרסם comment ב-Paperclip עם:
- תוצאת כל בדיקה (pass/fail) - תוצאת כל בדיקה (pass/fail)

View File

@@ -27,6 +27,11 @@ tools:
עבוד תמיד בעברית. עבוד תמיד בעברית.
## לפני שאתה מתחיל — קרא!
1. **מתודולוגיה אנליטית**: `docs/decision-methodology.md` — במיוחד סעיפים ד.2 (התחל מלשון הטקסט), ד.3 (שלושה מקורות להנחה עליונה), ז (ציטוטים ואזכורי פסיקה)
2. לקחים מהחלטות קודמות: `docs/legal-decision-lessons.md`
## סוגי מסמכים שאתה מטפל בהם ## סוגי מסמכים שאתה מטפל בהם
| סוג מסמך | מה לעשות | | סוג מסמך | מה לעשות |
@@ -52,12 +57,18 @@ tools:
לכל פסק דין: לכל פסק דין:
1. קרא את הטקסט (`document_get_text`) 1. קרא את הטקסט (`document_get_text`)
2. סכם: עובדות, שאלה משפטית, הכרעה, רלוונטיות לתיק שלנו 2. סכם: עובדות, שאלה משפטית, הכרעה, רלוונטיות לתיק שלנו
3. הפק הפניות (`extract_references`) 3. בנוסף ציין:
- **רמת התקדים**: עליון / מנהלי / ועדת ערר ארצית / ועדת ערר מחוזית
- **הלכה מחייבת או אמרת אגב**
- **כיצד ישרת את מבנה ההנמקה**: כ"כלל" (הנחה עליונה), כ"הרחבה" (Explanation ב-CREAC), או כאנלוגיה
4. הפק הפניות (`extract_references`)
### שלב 3: מיפוי תכנית ### שלב 3: מיפוי תכנית
1. קרא הוראות התכנית 1. קרא הוראות התכנית **במלואן** — לא רק את הסעיף הנטען
2. זהה סעיפים רלוונטיים למחלוקת 2. זהה סעיפים רלוונטיים למחלוקת
3. ציין: ייעוד, זכויות בנייה, מגבלות, חניה 3. **צטט את לשון ההוראות הרלוונטיות** — הנוסח המדויק, לא סיכום (המתודולוגיה דורשת: "התחל מלשון הטקסט")
4. סמן **עמימויות או סתירות** בין הוראות באותה תכנית
5. ציין: ייעוד, זכויות בנייה, מגבלות, תנאים
### שלב 4: סיכום פרוטוקולים והחלטות ### שלב 4: סיכום פרוטוקולים והחלטות
1. קרא כל פרוטוקול והחלטת ביניים 1. קרא כל פרוטוקול והחלטת ביניים
@@ -68,7 +79,10 @@ tools:
- סיכום כל פסק דין (2-3 שורות לכל אחד) - סיכום כל פסק דין (2-3 שורות לכל אחד)
- מיפוי הוראות תכנית רלוונטיות - מיפוי הוראות תכנית רלוונטיות
- ציר זמן ההליך - ציר זמן ההליך
- המלצה: אילו תקדימים הכי חזקים, אילו סעיפי תכנית מרכזיים - **המלצה מובנית לפי מקורות הנמקה:**
- **טקסט**: אילו סעיפי תכנית/חוק מרכזיים (ציטוט הנוסח)
- **תקדים**: אילו פסקי דין הכי חזקים (עם ציון היררכיה ומעמד — הלכה/אגב)
- **מדיניות**: אילו שיקולים תכנוניים עולים מהחומר
## כללים ## כללים
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים - **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים

View File

@@ -34,9 +34,10 @@ tools:
## לפני שאתה מתחיל — קרא! ## לפני שאתה מתחיל — קרא!
1. מדריך סגנון: `skills/decision/SKILL.md` 1. **מתודולוגיה אנליטית: `docs/decision-methodology.md`** — איך לחשוב על החלטה
2. ארכיטקטורת 12 בלוקים: `docs/block-schema.md` 2. מדריך סגנון: `skills/decision/SKILL.md` — איך דפנה כותבת
3. לקחים מהחלטות קודמות: `docs/legal-decision-lessons.md` 3. ארכיטקטורת 12 בלוקים: `docs/block-schema.md`
4. לקחים מהחלטות קודמות: `docs/legal-decision-lessons.md`
## ארכיטקטורת 12 בלוקים ## ארכיטקטורת 12 בלוקים
@@ -145,11 +146,35 @@ case_update(case_number, status="drafted")
## בלוק י — דיון (הבלוק החשוב ביותר) ## בלוק י — דיון (הבלוק החשוב ביותר)
- מבנה CREAC: מסקנה בפתיחה → כלל → הסבר → יישום → מסקנה **עקוב אחר `docs/decision-methodology.md` — שלבי הניתוח:**
- ענה על כל טענה מבלוק ז
- השתמש בציטוטים ארוכים (200-600 מילים) מפסיקה ### שלב א: פסקת מפה
- אל תחזור על עובדות מבלוק ו פתח בפסקה שמודיעה מה ייבחן: "שלוש שאלות עומדות להכרעה: (1)...; (2)...; (3)..."
- אל תשתמש בכותרות משנה (למעט נושאים נפרדים לחלוטין)
### שלב ב: סוגיות סף (אם רלוונטיות)
אם עולה שאלת סף — היא נדונה ראשונה. אם נדחית — פסקה אחת ועבור לגוף.
### שלב ג: לכל סוגיה — מבנה סילוגיסטי (CREAC)
1. **מסקנה** — פתח בתשובה
2. **כלל** — ציטוט הוראת תכנית/חוק (התחל מלשון הטקסט, לא מפסיקה)
3. **הרחבה** — תקדים רלוונטי אחד (טכניקת סנדוויץ': הקדמה→ציטוט→ניתוח)
4. **יישום** — החל את הכלל על העובדות. הפרד ממצא עובדתי ממסקנה משפטית. השתמש בנתונים (מספרים, מידות, אחוזים).
5. **Steel-Man** — הצג את הטענה הטובה ביותר של הצד המפסיד: "אמנם צודק העורר כי..., אולם..."
6. **מסקנה חוזרת** — סגור
### שלב ד: איזון (כשנדרש)
אם אין כלל ברור — בנה איזון: זהה אינטרסים קונקרטיים → בחן השלכות לכל כיוון → שקול השלכות מערכתיות → הכרע.
### שלב ה: טענות נותרות
- טענות מרכזיות ללא סימון: מענה פרטני
- טענות שסומנו [bundle] ב-chair_directions: קבץ ודון יחד
- טענות שסומנו [skip] ב-chair_directions: "נבחנה ולא מצאנו בה ממש"
- טענות חלשות: קיבוץ. "באשר לטענות הנוספות — לא מצאנו בהן ממש"
### כללים נוספים
- אל תחזור על עובדות מבלוק ו — הפנה: "כאמור בסעיף X לעיל"
- כל מילה עובדת — אין "לאחר ששקלנו את כלל השיקולים"
- כנות לגבי קושי — "הדבר אינו נקי מספקות, אולם..."
### חובה: שימוש בעמדות יו"ר מ-`get_chair_directions` ### חובה: שימוש בעמדות יו"ר מ-`get_chair_directions`

View File

@@ -1,20 +1,20 @@
{ {
"models": { "models": {
"main": { "main": {
"provider": "anthropic", "provider": "claude-code",
"modelId": "claude-opus-4-20250514", "modelId": "opus",
"maxTokens": 64000, "maxTokens": 32000,
"temperature": 0.2 "temperature": 0.2
}, },
"research": { "research": {
"provider": "anthropic", "provider": "claude-code",
"modelId": "claude-sonnet-4-20250514", "modelId": "opus",
"maxTokens": 64000, "maxTokens": 32000,
"temperature": 0.1 "temperature": 0.1
}, },
"fallback": { "fallback": {
"provider": "anthropic", "provider": "claude-code",
"modelId": "claude-sonnet-4-20250514", "modelId": "sonnet",
"maxTokens": 64000, "maxTokens": 64000,
"temperature": 0.2 "temperature": 0.2
} }

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) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |

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

@@ -0,0 +1,238 @@
# ניתוח שיטתי של קורפוס ההחלטות — מפת תוכן
> נוצר: 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 ~~פער: הפרומפט הנוכחי לא מכיל "צ'קליסט תוכן"~~ — **נסגר (2026-04-12)**
נוספו:
- ✅ צ'קליסטים תוכניים לפי סוג ערר (`lessons.py: CONTENT_CHECKLISTS`) — מוזרקים לפרומפט
- ✅ מתודולוגיה אנליטית (`docs/decision-methodology.md`) — מלמדת איך לחשוב, לא רק מה לכסות
- ✅ טיפול גמיש בטענות (bundle/skip דרך chair_directions)
- ✅ בדיקת QA חדשה (methodology compliance)
### 5.4 פער: הבחנה לא מספיקה בין תת-סוגי רישוי
תיקי רישוי שונים מאוד זה מזה:
- **סמכות/סף** — דיון משפטי טהור, אין צורך בתכנון
- **קנייני** — תימוכין קנייניים, אין צורך בתכנון
- **תכנוני מובהק** — ייעוד, חניה, גובה — דיון תכנוני מקיף
- **שימוש חורג** — פרשנות תכניות, דיון תכנוני עמוק
- **הקלה** — מידתיות + תכנון
- **תמ"א 38** — איזון אינטרסים + תכנון
---
## 6. המלצות לשלב הבא
### 6.1 צ'קליסט תוכן מוצע לערר רישוי ובנייה
```
בהתאם לנושא הערר, הדיון צריך לכלול:
□ הקשר תכנוני רחב (תמיד כשהערר מגיע למריט):
- תכניות חלות (מקומית, מחוזית, ארצית — לפי הצורך)
- ייעוד הקרקע
- אופי הסביבה
□ ניתוח הוראות תכנית (כשיש שאלה של התאמה/סטייה):
- ציטוט ישיר מהוראות רלוונטיות
- פרשנות — תכלית ההוראה
- יישום על המקרה
□ חניה (כשרלוונטי):
- הוראות תכנית + נספח תנועה
- חישוב מקומות נדרשים vs. מסופקים
- חלופות (קרן חניה, חפיפה, תח"צ)
□ שכנות/פגיעה (כשרלוונטי):
- ממצאי סיור
- צל, פרטיות, רעש, נוף
- מידתיות
□ קווי בניין (כשרלוונטי):
- הוראת תכנית
- סטייה ניכרת — תקנה 2(19)
- הצדקה/מידתיות
□ גובה/קומות (כשרלוונטי):
- הוראת תכנית + נספח בינוי
- מטרת ההגבלה
- סטייה ניכרת — תקנה 2(10)
```
### 6.2 הבחנה בין תת-סוגים
הפרומפט צריך לזהות את סוג הערר ולהתאים את הצ'קליסט:
- **ערר סמכות/סף** → ללא דיון תכנוני
- **ערר קנייני** → דיון משפטי, ללא תכנון
- **ערר מהותי** → דיון תכנוני מקיף + משפטי
### 6.3 צורך דחוף: החלטות היטל השבחה
צריך להוסיף לקורפוס לפחות 5-10 החלטות של היטל השבחה לפני שהמערכת יכולה לכתוב החלטות בתחום הזה.

View File

@@ -161,3 +161,94 @@ Our skill was "over-indexed" on one case type (הכט = rejected appeal). The co
- Created `create-decision-structure.cjs` script for generating structure DOCX - Created `create-decision-structure.cjs` script for generating structure DOCX
- Key innovation from Arieli: "ההליכים בפני ועדת הערר" as separate section (Block ח) - Key innovation from Arieli: "ההליכים בפני ועדת הערר" as separate section (Block ח)
- "Judge Test": every block written as if administrative court judge reads cold - "Judge Test": every block written as if administrative court judge reads cold
---
## Lessons from Systematic Corpus Analysis (24 decisions, April 2026)
### Source
- All 24 proofread decisions in `/data/training/proofread/`
- Full analysis: [`docs/corpus-analysis.md`](corpus-analysis.md)
- Date: April 2026
### 12. System Learned Style but Not Substantive Content
- **Problem:** Dafna reviewed Kiryat Yearim draft and noted missing planning discussion in block-yod
- **Root cause:** The block-yod prompt taught CREAC methodology and "answer all claims" but never said "in licensing cases, include comprehensive planning discussion"
- **Fix:** Content checklists added to `lessons.py` (`CONTENT_CHECKLISTS`), injected into block-yod prompt via `{content_checklist}`
- **Applied to:** `lessons.py`, `block_writer.py`
### 13. Corpus Composition — All Licensing, No Betterment Levy
- All 24 training decisions are licensing/construction (1xxx)
- Zero betterment levy (8xxx) decisions in corpus
- Not a current priority gap — focusing on licensing first
### 14. Planning Discussion Patterns in Licensing Decisions
- **Always present** when the appeal reaches substantive planning questions
- **Never present** when the appeal is purely jurisdictional or property-based
- **Structure**: broad planning context → direct plan provision citations (200-600 words) → application to specific case → planning conclusion
- **Deepest planning**: פרומר (pure plan interpretation), לבנון (height/building appendix), בית הכרם (multi-plan TAMA 38)
- **No planning**: טלי-אביב (property only), גבאי (jurisdiction only)
### 15. Five Appeal Subtypes Identified (Not Just Three)
Licensing appeals are not homogeneous — the discussion structure varies significantly:
1. **Substantive licensing** — full planning discussion + legal analysis (majority of cases)
2. **Threshold/jurisdiction** — legal analysis only, no planning
3. **Property-focused** — תימוכין קנייניים, minimal planning
4. **TAMA 38** — balancing public interest + planning + neighbor impact
5. **Deviant use (שימוש חורג)** — deep plan interpretation across multiple plans
### 16. Chair Feedback System Established
- DB table `chair_feedback` records Dafna's comments on drafts
- Categories: missing_content, wrong_tone, wrong_structure, factual_error, style, other
- MCP tools + UI page for recording and reviewing feedback
- First entry: Kiryat Yearim — missing planning discussion (2026-04-12)
---
## Lessons from External Expertise Research (April 2026)
### Source
- Federal Judicial Center, *Judicial Writing Manual* (1991, 2nd ed. 2020)
- Bryan Garner, *Legal Writing in Plain English* (2001)
- Scalia & Garner, *Making Your Case: The Art of Persuading Judges* (2008)
- Richard Posner, *How Judges Think* (2008)
- Full texts stored in: `docs/sources/`
### 17. Methodology Document Created — Separating "How to Think" from "How to Write"
**Problem:** The system knew Dafna's STYLE (SKILL.md) and WHAT TOPICS to cover (content checklists), but had no formal methodology for HOW TO REASON through a decision — the analytical stages, when to balance, how to structure arguments, how to handle counterarguments.
**Fix:** Created `docs/decision-methodology.md` — a standalone analytical methodology document based on synthesis of all four external sources. 3,400 words, 12 sections, 10 guiding principles. Covers: pre-analysis, threshold questions, issue ordering, syllogistic structure (CREAC), balancing/proportionality, claims handling (steel-man, bundling), quotation technique (sandwich), factual findings vs. legal conclusions, disposition, writing techniques, analogy/precedent, editing checklist.
**Key principle:** Methodology is UNIVERSAL — it teaches how to think about any quasi-judicial decision. It does not contain case-specific content (parking, building lines, etc.). Case-specific content stays in the content checklists.
**Applied to:**
- `docs/decision-methodology.md` — new document
- `lessons.py` — new function `get_methodology_summary()` injected into block-yod prompt
- `block_writer.py` — new `{methodology_guidance}` placeholder in block-yod prompt
- `.claude/agents/legal-writer.md` — restructured block-yod workflow to follow methodology stages
- `.claude/agents/legal-qa.md` — new check #7 (methodology compliance)
### 18. "Answer All Claims" Made Flexible
**Problem:** The block-yod prompt hardcoded "answer every claim individually" and the QA check enforced it. But Dafna sometimes bundles weak claims, skips irrelevant ones, and focuses on what matters.
**Fix:**
- Block-yod prompt changed from "חובה לענות על כל אחת" to flexible handling: address substantive claims; bundle [bundle]; skip [skip]
- Chair can mark claims in `chair_directions` as bundle or skip
- QA check #3 updated to respect these markings
- Methodology teaches WHEN to address individually vs. bundle vs. skip (methodology §ו)
### 19. Source Library Established
Downloaded and converted to text 5 authoritative sources for the methodology:
- `docs/sources/fjc-judicial-writing-manual-1991.txt` (13,567 words)
- `docs/sources/fjc-judicial-writing-manual-2nd-ed-2020.txt` (15,912 words)
- `docs/sources/garner-legal-writing-plain-english.txt` (97,475 words)
- `docs/sources/posner-how-judges-think.txt` (156,789 words)
- `docs/sources/scalia-garner-making-your-case.txt` (54,683 words)
Total: ~340,000 words of source material.
Intermediate extraction documents also saved:
- `docs/fjc-principles-extraction.md` — 38 principles from FJC
- `docs/garner-methodology-extraction.md` — ~50 principles from Garner/Scalia

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(
@@ -160,6 +204,16 @@ async def get_claims(
return await documents.get_claims(case_number, party_role) return await documents.get_claims(case_number, party_role)
@mcp.tool()
async def document_update_status(
case_number: str,
doc_title: str,
status: str,
) -> str:
"""עדכון סטטוס עיבוד מסמך. status: pending/extracted/proofread/error."""
return await documents.document_update_status(case_number, doc_title, status)
# References # References
@mcp.tool() @mcp.tool()
async def extract_references( async def extract_references(
@@ -217,6 +271,22 @@ async def draft_section(
return await drafting.draft_section(case_number, section, instructions) return await drafting.draft_section(case_number, section, instructions)
@mcp.tool()
async def get_research_findings(case_number: str) -> str:
"""שליפת ממצאי מחקר — סיכומי פסיקה, מיפוי תכניות, ציר זמן, והמלצות.
קורא מ-research-findings.md שנוצר ע"י חוקר התקדימים.
"""
return await drafting.get_research_findings(case_number)
@mcp.tool()
async def get_full_analysis(case_number: str) -> str:
"""שליפת הניתוח המשפטי המלא — טענות, תשובות, חוזקות/חולשות, שאלות מחקר,
חקיקה, תקדימים ועמדות יו"ר. הכלי המרכזי לכותב לפני כתיבת בלוק י.
"""
return await drafting.get_full_analysis(case_number)
@mcp.tool() @mcp.tool()
async def get_chair_directions(case_number: str) -> str: async def get_chair_directions(case_number: str) -> str:
"""שליפת עמדות יו"ר הוועדה (דפנה) על סוגיות הערר כ-direction_doc לכותב. """שליפת עמדות יו"ר הוועדה (דפנה) על סוגיות הערר כ-direction_doc לכותב.
@@ -252,6 +322,15 @@ async def save_block_content(
return await drafting.save_block_content(case_number, block_id, content) return await drafting.save_block_content(case_number, block_id, content)
@mcp.tool()
async def get_decision_blocks(
case_number: str,
block_id: str = "",
) -> str:
"""שליפת בלוקים שנכתבו — תוכן, מילים, משקלות. ריק = כל הבלוקים."""
return await drafting.get_decision_blocks(case_number, block_id)
@mcp.tool() @mcp.tool()
async def validate_decision(case_number: str) -> str: async def validate_decision(case_number: str) -> str:
"""בדיקת QA — 6 בדיקות איכות על ההחלטה. אם בדיקה קריטית נכשלת — ייצוא חסום.""" """בדיקת QA — 6 בדיקות איכות על ההחלטה. אם בדיקה קריטית נכשלת — ייצוא חסום."""
@@ -346,6 +425,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]
@@ -507,14 +525,31 @@ async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
text = r["key_quote"] or r["summary"] or "" text = r["key_quote"] or r["summary"] or ""
if text: if text:
parts.append( parts.append(
f"סיקה: {r['case_number']} {r['case_name']} ({r.get('court', '')})] " f"<EFBFBD><EFBFBD>יקה: {r['case_number']} {r['case_name']} ({r.get('court', '')})] "
f"score={r['score']:.3f}\n{text[:400]}" f"score={r['score']:.3f}\n{text[:400]}"
) )
# Search 3: case_precedents (user-attached quotes by Daphna)
# These are hand-picked citations — highest priority.
attached = await db.list_case_precedents(case_id)
if attached:
parts.insert(0, "## תקדימים שצורפו ידנית ע\"י יו\"ר הוועדה\n")
for i, prec in enumerate(attached):
section = prec.get("section_id") or "כללי"
citation = prec.get("citation", "")
quote = prec.get("quote", "")
note = prec.get("chair_note", "")
entry = f"[תקדים מצורף #{i+1} — סוגיה: {section}] {citation}"
if quote:
entry += f"\nציטוט: {quote[:600]}"
if note:
entry += f"\nהערת יו\"ר: {note}"
parts.insert(i + 1, entry)
except Exception as e: except Exception as e:
logger.warning("Failed to fetch precedents: %s", e) logger.warning("Failed to fetch precedents: %s", e)
return "\n\n".join(parts) if parts else "(אין תקדימים)" return "\n\n".join(parts) if parts else "(אין תקד<EFBFBD><EFBFBD>מים)"
async def _build_style_context() -> str: async def _build_style_context() -> str:

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

@@ -26,6 +26,13 @@ CHAIR_POSITION_PLACEHOLDERS = (
"[טרם מולא]", "[טרם מולא]",
) )
# Any text starting with these prefixes is also a placeholder
# (the analyst sometimes adds explanatory text after the bracket)
CHAIR_POSITION_PLACEHOLDER_PREFIXES = (
"[ימולא",
"ימולא ע",
)
CHAIR_POSITION_LABEL = "עמדת ועדת הערר" CHAIR_POSITION_LABEL = "עמדת ועדת הערר"
# Matches "## N. title" or "## title" for main sections # Matches "## N. title" or "## title" for main sections
@@ -47,6 +54,9 @@ CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILIN
DATE_RE = re.compile(r"^תאריך:\s*(.+?)\s*$", re.MULTILINE) DATE_RE = re.compile(r"^תאריך:\s*(.+?)\s*$", re.MULTILINE)
RESEARCH_FINDINGS_FILENAME = "research-findings.md"
def _is_placeholder(text: str) -> bool: def _is_placeholder(text: str) -> bool:
"""Check if a field value is one of the placeholder strings (empty).""" """Check if a field value is one of the placeholder strings (empty)."""
stripped = text.strip() stripped = text.strip()
@@ -55,6 +65,9 @@ def _is_placeholder(text: str) -> bool:
for ph in CHAIR_POSITION_PLACEHOLDERS: for ph in CHAIR_POSITION_PLACEHOLDERS:
if ph in stripped: if ph in stripped:
return True return True
for prefix in CHAIR_POSITION_PLACEHOLDER_PREFIXES:
if stripped.startswith(prefix):
return True
return False return False
@@ -434,3 +447,199 @@ def extract_chair_directions(file_path: Path) -> dict[str, Any]:
"threshold_claims": threshold, "threshold_claims": threshold,
"issues": issues, "issues": issues,
} }
# ── Full analysis extraction (for legal-writer) ──────────────────
# Map Hebrew field labels → stable English keys for JSON output
_FIELD_KEY_MAP = {
"טענה": "claims",
"טענה (claim)": "claims",
"טענות": "claims",
"תשובה": "responses",
"תשובה (response)": "responses",
"תשובות": "responses",
"תגובה": "replies",
"תגובה (reply)": "replies",
"תגובות": "replies",
# Analyst sometimes appends party name to the label
# e.g. "תגובה (reply — קובר)" — catch the pattern dynamically below
"ניתוח אסטרטגי": "strategic_analysis",
"חוזקות": "strengths",
"חולשות": "weaknesses",
"הזדמנויות": "opportunities",
"שאלות משפטיות": "legal_questions",
"חיפוש תקדימים": "precedent_search",
"חקיקה רלוונטית": "relevant_legislation",
"תקדימים מהקורפוס הפנימי": "internal_precedents",
}
def _fields_to_dict(fields: list[dict]) -> dict[str, str]:
"""Convert ordered field list to a dict with stable English keys.
Unknown labels are kept as-is (Hebrew) so no data is lost.
Handles dynamic labels like "תגובה (reply — קובר)" by matching prefix.
"""
result: dict[str, str] = {}
for f in fields:
label = f["label"]
key = _FIELD_KEY_MAP.get(label)
if key is None:
# Try prefix matching for dynamic labels (e.g. "תגובה (reply — name)")
if label.startswith("תגובה"):
key = "replies"
elif label.startswith("טענה"):
key = "claims"
elif label.startswith("תשובה"):
key = "responses"
else:
key = label
result[key] = f["content"]
return result
def extract_full_analysis(file_path: Path) -> dict[str, Any]:
"""Extract the complete strategic analysis from analysis-and-research.md.
Unlike extract_chair_directions (which returns only chair positions),
this returns ALL fields per issue: claims, responses, replies,
strengths/weaknesses/opportunities, legal questions, legislation,
and internal precedents — everything the legal-writer needs to
produce block-yod (discussion).
Returns the same envelope as extract_chair_directions (status, counts)
plus full field data in each item.
"""
if not file_path.exists():
return {
"file_exists": False,
"status": "missing",
"error": "analysis-and-research.md not found",
"procedural_background": "",
"agreed_facts": "",
"disputed_facts": "",
"conclusions": "",
"threshold_claims": [],
"issues": [],
"total_items": 0,
"filled_count": 0,
"empty_count": 0,
}
parsed = parse(file_path)
def enrich_item(item: dict) -> dict:
"""Return full item with all fields as a flat dict."""
enriched = {
"id": item["id"],
"number": item["number"],
"title": item["title"],
"direction": item.get("chair_position", "") or "",
}
# Add all extracted fields with stable keys
enriched.update(_fields_to_dict(item.get("fields", [])))
return enriched
threshold = [enrich_item(t) for t in parsed.get("threshold_claims", [])]
issues = [enrich_item(i) for i in parsed.get("issues", [])]
all_items = threshold + issues
total = len(all_items)
filled = sum(1 for x in all_items if x["direction"].strip())
empty = total - filled
if total == 0:
status = "missing"
elif filled == 0:
status = "empty"
elif filled == total:
status = "complete"
else:
status = "partial"
return {
"file_exists": True,
"file_path": str(file_path),
"case_number": parsed.get("header", {}).get("case_number", ""),
"modified_at": parsed.get("header", {}).get("modified_at", ""),
"status": status,
"total_items": total,
"filled_count": filled,
"empty_count": empty,
"procedural_background": parsed.get("procedural_background", ""),
"agreed_facts": parsed.get("agreed_facts", ""),
"disputed_facts": parsed.get("disputed_facts", ""),
"conclusions": parsed.get("conclusions", ""),
"threshold_claims": threshold,
"issues": issues,
}
# ── Research findings extraction ──────────────────────────────────
def extract_research_findings(file_path: Path) -> dict[str, Any]:
"""Extract structured research findings from research-findings.md.
The file is produced by the legal-researcher agent and contains:
precedent summaries, plan mappings, timeline, and recommendations.
Returns a structured dict or a status-only dict if file is missing.
"""
if not file_path.exists():
return {
"file_exists": False,
"status": "missing",
"error": "research-findings.md not found",
}
content = file_path.read_text(encoding="utf-8")
stat = file_path.stat()
mtime_iso = datetime.fromtimestamp(stat.st_mtime).isoformat()
sections = _split_main_sections(content)
result: dict[str, Any] = {
"file_exists": True,
"file_path": str(file_path),
"modified_at": mtime_iso,
"file_size": stat.st_size,
"precedent_summaries": [],
"plan_mappings": [],
"timeline": "",
"recommendations": "",
"other_sections": [],
}
for _number, title, body in sections:
title_norm = title.strip()
if "סיכום פסיקה" in title_norm or "פסיקה" in title_norm:
subs = _split_subsections(body)
for sub_title, sub_body in subs:
fields = _extract_fields(sub_body)
result["precedent_summaries"].append({
"title": sub_title,
"fields": {f["label"]: f["content"] for f in fields},
"raw": sub_body if not fields else "",
})
elif "מיפוי תכנית" in title_norm or "תכנית" in title_norm:
subs = _split_subsections(body)
for sub_title, sub_body in subs:
fields = _extract_fields(sub_body)
result["plan_mappings"].append({
"title": sub_title,
"fields": {f["label"]: f["content"] for f in fields},
"raw": sub_body if not fields else "",
})
elif "ציר זמן" in title_norm:
result["timeline"] = body
elif "המלצות" in title_norm:
result["recommendations"] = body
else:
result["other_sections"].append({
"title": title_norm,
"body": body,
})
return result

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,8 +69,24 @@ 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
case_dir = config.find_case_dir(case_number) case_dir = config.find_case_dir(case_number)
case_dir.mkdir(parents=True, exist_ok=True) 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) 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),
@@ -366,3 +383,49 @@ async def get_claims(case_number: str, party_role: str = "") -> str:
}) })
return json.dumps(formatted, default=str, ensure_ascii=False, indent=2) return json.dumps(formatted, default=str, ensure_ascii=False, indent=2)
async def document_update_status(
case_number: str,
doc_title: str,
status: str,
) -> str:
"""עדכון סטטוס עיבוד מסמך (extraction_status).
ערכים אפשריים: pending, extracted, proofread, error.
Args:
case_number: מספר תיק הערר
doc_title: שם/כותרת המסמך (או חלק ממנו)
status: הסטטוס החדש
"""
valid_statuses = ("pending", "extracted", "proofread", "error")
if status not in valid_statuses:
return f"סטטוס לא חוקי: {status}. ערכים אפשריים: {', '.join(valid_statuses)}"
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
docs = await db.get_documents(case_id)
# Find matching document by title (partial match)
matched = None
for d in docs:
if doc_title.lower() in d.get("title", "").lower():
matched = d
break
if not matched:
titles = [d.get("title", "?") for d in docs]
return f"מסמך '{doc_title}' לא נמצא בתיק {case_number}. מסמכים קיימים: {titles}"
doc_id = UUID(matched["id"])
await db.update_document(doc_id, extraction_status=status)
return json.dumps({
"updated": True,
"document": matched["title"],
"new_status": status,
}, ensure_ascii=False, indent=2)

View File

@@ -281,6 +281,39 @@ async def draft_section(
return json.dumps(context, ensure_ascii=False, indent=2) return json.dumps(context, ensure_ascii=False, indent=2)
async def get_research_findings(case_number: str) -> str:
"""שליפת ממצאי מחקר — סיכומי פסיקה, מיפוי תכניות, ציר זמן, והמלצות.
קורא מ-research-findings.md שנוצר ע"י סוכן חוקר התקדימים (legal-researcher).
מחזיר JSON מובנה עם הממצאים, או status=missing אם הקובץ לא קיים עדיין.
Args:
case_number: מספר תיק הערר
"""
case_dir = config.find_case_dir(case_number)
file_path = case_dir / "documents" / "research" / research_md.RESEARCH_FINDINGS_FILENAME
result = research_md.extract_research_findings(file_path)
return json.dumps(result, ensure_ascii=False, indent=2)
async def get_full_analysis(case_number: str) -> str:
"""שליפת הניתוח המשפטי המלא מ-analysis-and-research.md — כולל טענות, תשובות,
תגובות, ניתוח אסטרטגי (חוזקות/חולשות/הזדמנויות), שאלות מחקר, חקיקה רלוונטית,
תקדימים פנימיים, ועמדות יו"ר הוועדה.
זה הכלי המרכזי שכותב ההחלטה צריך לקרוא **לפני** כתיבת בלוק י (דיון).
מחזיר JSON מובנה עם כל השדות שהניתוח המשפטי הפיק, ולא רק עמדות כמו
get_chair_directions.
Args:
case_number: מספר תיק הערר
"""
case_dir = config.find_case_dir(case_number)
file_path = case_dir / "documents" / "research" / "analysis-and-research.md"
result = research_md.extract_full_analysis(file_path)
return json.dumps(result, ensure_ascii=False, indent=2)
async def get_chair_directions(case_number: str) -> str: async def get_chair_directions(case_number: str) -> str:
"""שליפת עמדות יו"ר הוועדה (דפנה) על סוגיות הערר, לצורך יצירת direction_doc """שליפת עמדות יו"ר הוועדה (דפנה) על סוגיות הערר, לצורך יצירת direction_doc
לכותב. קורא מ-analysis-and-research.md (שנוצר ע"י legal-analyst ומולא ע"י לכותב. קורא מ-analysis-and-research.md (שנוצר ע"י legal-analyst ומולא ע"י
@@ -454,6 +487,71 @@ async def save_block_content(case_number: str, block_id: str, content: str) -> s
return str(e) return str(e)
async def get_decision_blocks(case_number: str, block_id: str = "") -> str:
"""שליפת בלוקים שנכתבו בהחלטה — תוכן, ספירת מילים, משקלות.
אם block_id ריק — מחזיר את כל הבלוקים. אם מצוין — רק בלוק ספציפי.
שימושי לבודק איכות (QA) שצריך לקרוא בלוקים בודדים.
Args:
case_number: מספר תיק הערר
block_id: מזהה בלוק (ריק = כולם). למשל: block-yod, block-vav
"""
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
decision = await db.get_decision_by_case(case_id)
if not decision:
return f"אין החלטה בתיק {case_number}."
decision_id = UUID(decision["id"])
pool = await db.get_pool()
async with pool.acquire() as conn:
if block_id:
rows = await conn.fetch(
"SELECT block_id, block_index, title, content, word_count, "
"weight_percent, status FROM decision_blocks "
"WHERE decision_id = $1 AND block_id = $2 "
"ORDER BY block_index",
decision_id, block_id,
)
else:
rows = await conn.fetch(
"SELECT block_id, block_index, title, content, word_count, "
"weight_percent, status FROM decision_blocks "
"WHERE decision_id = $1 ORDER BY block_index",
decision_id,
)
if not rows:
if block_id:
return f"בלוק {block_id} לא נמצא בהחלטה."
return "אין בלוקים בהחלטה."
blocks = []
total_words = 0
for r in rows:
total_words += r["word_count"] or 0
blocks.append({
"block_id": r["block_id"],
"index": r["block_index"],
"title": r["title"],
"content": r["content"],
"word_count": r["word_count"],
"weight_percent": float(r["weight_percent"] or 0),
"status": r["status"],
})
return json.dumps({
"case_number": case_number,
"total_blocks": len(blocks),
"total_words": total_words,
"blocks": blocks,
}, default=str, ensure_ascii=False, indent=2)
async def analyze_style() -> str: async def analyze_style() -> str:
"""הרצת ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ דפוסי כתיבה ושומר אותם.""" """הרצת ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ דפוסי כתיבה ושומר אותם."""
from legal_mcp.services.style_analyzer import analyze_corpus from legal_mcp.services.style_analyzer import analyze_corpus

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

@@ -76,6 +76,14 @@ async def create_project(
VALUES ($1, $2::uuid, $3, $4, 'backlog', $5)""", VALUES ($1, $2::uuid, $3, $4, 'backlog', $5)""",
project_id, company_id, project_name, description[:500] if description else "", color, project_id, company_id, project_name, description[:500] if description else "", color,
) )
# Create workspace pointing to the legal-ai repo root
workspace_id = str(uuid.uuid4())
await conn.execute(
"""INSERT INTO project_workspaces (id, company_id, project_id, name, cwd)
VALUES ($1, $2::uuid, $3::uuid, 'legal-ai', '/home/chaim/legal-ai')""",
workspace_id, company_id, project_id,
)
# Create initial issue linked to the project # Create initial issue linked to the project
issue_id, identifier = await _create_issue( issue_id, identifier = await _create_issue(
conn, company_id, project_id, case_number, title, prefix, conn, company_id, project_id, case_number, title, prefix,

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('');