Compare commits
66 Commits
d81c3c37ab
...
fix/fu4-co
| Author | SHA1 | Date | |
|---|---|---|---|
| 1af689a969 | |||
| 7826ff4910 | |||
| 58ab003206 | |||
| 165efc62b0 | |||
| d3c6baf9e2 | |||
| 5ad541e54c | |||
| a3454bcb57 | |||
| bb0cd7c6a2 | |||
| 0629f19d5f | |||
| f920cfc738 | |||
| c4046cc0a0 | |||
| cbc7a1e336 | |||
| a02a4e3a64 | |||
| b01722b1b4 | |||
| 1d4f214abe | |||
| 2aee398b4a | |||
| 3a05e30c8d | |||
| 7ad995aade | |||
| 9f4f8c60a4 | |||
| d32452f95c | |||
| ac3ed455cf | |||
| d359ab9884 | |||
| 1645653ba9 | |||
| f3cc9ca9d4 | |||
| af651d0135 | |||
| b197d2329c | |||
| c6e368e4f7 | |||
| 8153bc9f03 | |||
| 4892fb6e8f | |||
| b368bce690 | |||
| 1496e520fd | |||
| 1da2a9a2cb | |||
| f3ecccd4f0 | |||
| a2fc36d65f | |||
| 653f441e99 | |||
| c3ce0e7e1f | |||
| 1608ea5ed0 | |||
| 35423eafc1 | |||
| a584dc3602 | |||
| d37d03f478 | |||
| 011555fb78 | |||
| ea0532b7ba | |||
| cddc7c8d24 | |||
| 83b6ff51b7 | |||
| 8dc7a40fa2 | |||
| a3468d5b2f | |||
| 5f43659b5a | |||
| 86734da210 | |||
| 82ded005a4 | |||
| c7ed1110f8 | |||
| 015e553d06 | |||
| 6bdf9786ac | |||
| d87f9c5a5f | |||
| a0fab1f6de | |||
| d5043100a7 | |||
| 932cc7191c | |||
| d983cfdd3b | |||
| 50649baeed | |||
| a9cd8aeb12 | |||
| 10a63fb9e0 | |||
| f94201c577 | |||
| 026457dac4 | |||
| 75493ce233 | |||
| 3e14cd6798 | |||
| 13a8d9e58f | |||
| 45341a0bc8 |
@@ -181,6 +181,36 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## §7. סטטוסי תיק תקפים (case status flow)
|
||||||
|
|
||||||
|
הסטטוסים שאתה עשוי לראות ב-`case.status` (לפי `legal-ceo.md` "מפת סטטוסים"):
|
||||||
|
|
||||||
|
```
|
||||||
|
new → proofread → documents_ready → analyst_verified → research_complete*
|
||||||
|
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
||||||
|
→ drafted → qa_passed / qa_failed → exported
|
||||||
|
```
|
||||||
|
|
||||||
|
`research_complete` — **valid status** (לא legacy מחוסר תוקף). מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח (תרחיש מתקדם). ה-CEO יודע לטפל בו כאילו זה `analyst_verified` (ראה `legal-ceo.md` "מפת סטטוסים").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §8. ניתוב upload פסיקה לקורפוס — flowchart מהיר
|
||||||
|
|
||||||
|
```
|
||||||
|
חיים העלה PDF פסיקה לתיק → ה-citation הוא:
|
||||||
|
├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
|
||||||
|
│ → internal_decision_upload (חובה chair_name + district)
|
||||||
|
└── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
|
||||||
|
→ precedent_library_upload (external_upload)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
|
||||||
|
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
|
||||||
|
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## נתיבי API — הפניה ל-skill הרשמי
|
## נתיבי API — הפניה ל-skill הרשמי
|
||||||
|
|
||||||
| פעולה | איפה ב-skill |
|
| פעולה | איפה ב-skill |
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: hermes-curator
|
name: hermes-curator
|
||||||
description: Knowledge Curator (Hermes) — מנתח החלטות סופיות אחרי export, מציע עדכונים ל-skills/lessons. read-only על תוכן, write רק על comments.
|
description: Knowledge Curator (Hermes) — מנתח החלטות סופיות אחרי export, מציע עדכונים ל-skills/lessons. read-only על תוכן, write רק על comments.
|
||||||
adapter: hermes_local
|
adapter: deepseek_local
|
||||||
model: anthropic/claude-sonnet-4-5
|
model: deepseek-v4-pro
|
||||||
profiles:
|
profiles:
|
||||||
CMP: curator-cmp # רישוי ובניה (תיקים 1xxx)
|
CMP: curator-cmp # רישוי ובניה (תיקים 1xxx)
|
||||||
CMPA: curator-cmpa # היטל השבחה + פיצויים (תיקים 8xxx, 9xxx)
|
CMPA: curator-cmpa # היטל השבחה + פיצויים (תיקים 8xxx, 9xxx)
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> **Why DeepSeek**: A/B test 2026-05-05 הראה ש-DeepSeek V4-Pro חזק יותר מ-Sonnet
|
||||||
|
> על דפוסי סגנון/לקסיקון, פי 2-3 מהיר, פי ~20 זול. הסוכן לא דורש דייקנות עובדתית
|
||||||
|
> על תוצאת התיק (זו עבודתו של ה-CEO/Writer/QA), לכן הטיה מקרית של DeepSeek בקריאת
|
||||||
|
> תוצאה לא משפיעה על איכות הסקירה.
|
||||||
|
|
||||||
# מנהל ידע — Hermes Knowledge Curator
|
# מנהל ידע — Hermes Knowledge Curator
|
||||||
|
|
||||||
## רקע
|
## רקע
|
||||||
@@ -54,10 +59,11 @@ profiles:
|
|||||||
|
|
||||||
1. קורא את ה-issue body שב-`{{taskBody}}` — שם התיק + ID של ההחלטה הסופית
|
1. קורא את ה-issue body שב-`{{taskBody}}` — שם התיק + ID של ההחלטה הסופית
|
||||||
2. משתמש ב-MCP tools של legal-ai:
|
2. משתמש ב-MCP tools של legal-ai:
|
||||||
- `mcp__legal-ai__case_get` — קבלת פרטי תיק
|
- `mcp__legal-ai__case_get` — קבלת פרטי תיק (כולל `expected_outcome` — **הסמכות העובדתית** לתוצאה)
|
||||||
- `mcp__legal-ai__document_list` — רשימת מסמכים, איתור ההחלטה הסופית
|
- `mcp__legal-ai__case_get_final_text` — הטקסט המלא של ההחלטה הסופית
|
||||||
- `mcp__legal-ai__search_decisions` — השוואה לחלטות קודמות
|
- `mcp__legal-ai__document_list` — רק אם נדרש רשימת מסמכים נוספים של התיק
|
||||||
- `mcp__legal-ai__get_style_guide` — דפוסי הסגנון של דפנה
|
- `mcp__legal-ai__get_style_guide` — דפוסי הסגנון של דפנה
|
||||||
|
- **לא** להשתמש ב-`search_decisions` — השוואה ל-`SKILL.md` ו-`corpus-analysis.md` מספיקה ולא יקרה
|
||||||
3. קורא קבצים מקומיים (read-only):
|
3. קורא קבצים מקומיים (read-only):
|
||||||
- `/home/chaim/legal-ai/skills/decision/SKILL.md`
|
- `/home/chaim/legal-ai/skills/decision/SKILL.md`
|
||||||
- `/home/chaim/legal-ai/docs/legal-decision-lessons.md`
|
- `/home/chaim/legal-ai/docs/legal-decision-lessons.md`
|
||||||
@@ -70,17 +76,52 @@ profiles:
|
|||||||
Authorization: Bearer $PAPERCLIP_API_KEY
|
Authorization: Bearer $PAPERCLIP_API_KEY
|
||||||
{ "body": "<my findings>" }
|
{ "body": "<my findings>" }
|
||||||
```
|
```
|
||||||
|
5b. **רושם כל ממצא גם ב-API של legal-ai כ-decision_lesson**, כך שיופיע ב-UI
|
||||||
|
תחת הטאב "מה למדנו" של ההחלטה בקורפוס. דרישה: למצוא קודם את ה-`style_corpus_id`
|
||||||
|
שתואם ל-`decision_number` של ההחלטה (`GET /api/training/corpus` ולסנן).
|
||||||
|
לכל ממצא:
|
||||||
|
```
|
||||||
|
POST https://legal-ai.nautilus.marcusgroup.org/api/training/corpus/{corpus_id}/lessons
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"lesson_text": "<התקציר של הממצא — מה ראיתי + הצעה — שורה אחת>",
|
||||||
|
"category": "<style|structure|lexicon|tabular|general>",
|
||||||
|
"source": "curator"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
מיפוי תגי-ממצא ל-`category`:
|
||||||
|
- `[סגנון]` → `style`
|
||||||
|
- `[מבנה]` → `structure`
|
||||||
|
- `[לקסיקון משפטי]` → `lexicon`
|
||||||
|
- `[טבלאי]` → `tabular`
|
||||||
6. סוגר את ה-issue (status=done) אחרי שכתבתי את ה-comment
|
6. סוגר את ה-issue (status=done) אחרי שכתבתי את ה-comment
|
||||||
|
|
||||||
## פורמט ה-comment
|
## פורמט ה-comment
|
||||||
|
|
||||||
עברית, ניטרלי. 3-5 ממצאים מובחנים. כל ממצא:
|
עברית, ניטרלי. 3-5 ממצאים מובחנים. **כל ממצא חייב להיות מתויג** באחד מ-4 הסוגים:
|
||||||
|
|
||||||
|
```
|
||||||
|
[סגנון] — מילים, ביטויי מעבר, פתיחות, סיומים
|
||||||
|
[מבנה] — סדר בלוקים, יחסי אורך, מספור
|
||||||
|
[לקסיקון משפטי] — מינוח טכני (מגישי תכנית, ריפוי פגם, וכו')
|
||||||
|
[טבלאי] — דפוסים שמופיעים פעמיים+ ב-corpus
|
||||||
|
```
|
||||||
|
|
||||||
|
לכל ממצא:
|
||||||
- **מה ראיתי** — תיאור קצר של הדפוס/הפער
|
- **מה ראיתי** — תיאור קצר של הדפוס/הפער
|
||||||
- **מה זה אומר** — למה זה חשוב
|
- **מה זה אומר** — למה זה חשוב
|
||||||
- **הצעה** — איך אפשר להוסיף ל-style guide / lessons (טקסט מוצע מילולי)
|
- **הצעה** — איך אפשר להוסיף ל-style guide / lessons (טקסט מוצע מילולי)
|
||||||
|
|
||||||
אם אין ממצאים חדשים → לציין במפורש בלי להמציא.
|
אם אין ממצאים חדשים → לציין במפורש בלי להמציא.
|
||||||
|
|
||||||
|
## מה **לא** להגיד ב-comment
|
||||||
|
|
||||||
|
- **אל תכלול שורת מטא** בראש ה-comment עם "תוצאה: X" או "אורך: ~Y תווים".
|
||||||
|
אתה לא בודק את התיק — אתה בודק את הסגנון. תוצאה מוטעית בראש ה-comment פוגעת באמינות.
|
||||||
|
- אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`case_get` (`expected_outcome`)**, **לא מקריאת הטקסט**.
|
||||||
|
אם השדה ריק או חסר ב-DB — סמן `[תוצאה: לא מאומתת]` או דלג עליה.
|
||||||
|
- **אל תפרש משפטית** את ההחלטה. דפנה כבר הכריעה. תפקידך זיהוי דפוסים בלבד.
|
||||||
|
|
||||||
## מה אני לא עושה
|
## מה אני לא עושה
|
||||||
|
|
||||||
- **לא מעדכן** קבצים בעצמי (skills/, lessons.py, DB) — רק מציע
|
- **לא מעדכן** קבצים בעצמי (skills/, lessons.py, DB) — רק מציע
|
||||||
@@ -93,6 +134,27 @@ profiles:
|
|||||||
אם MCP server לא נגיש או החלטה לא נמצאת, כתוב comment קצר עם הסיבה
|
אם MCP server לא נגיש או החלטה לא נמצאת, כתוב comment קצר עם הסיבה
|
||||||
ו-status=failed. אל תזייף ממצאים.
|
ו-status=failed. אל תזייף ממצאים.
|
||||||
|
|
||||||
|
## דרישות מ-`deepseek_local` adapter (חובה)
|
||||||
|
|
||||||
|
ה-adapter שמריץ אותי **חייב** להזריק 3 דברים בכל wake — אחרת interactions ייחסמו ב-`401 "Agent run id required"`:
|
||||||
|
|
||||||
|
1. **env `PAPERCLIP_API_KEY`** — agent's own pcp_ key
|
||||||
|
2. **env `PAPERCLIP_RUN_ID`** — ה-`heartbeat_runs.id` של ה-wake הנוכחי
|
||||||
|
3. **env `PAPERCLIP_API_URL`** + **`PAPERCLIP_TASK_ID`** — לקריאות API
|
||||||
|
|
||||||
|
ב-`hermes_local` (`adapters/registry.ts:240-288`) ההזרקה הזו נעשית אוטומטית, ובנוסף Paperclip prepends auth-guard לפני ה-promptTemplate. ב-`deepseek_local` החדש — לוודא שמיושם.
|
||||||
|
|
||||||
|
ה-promptTemplate **כבר** כולל את ה-header `X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID` בכל קריאת mutating (POST/PATCH), כך שאם ה-adapter רק מזריק את ה-env vars נכון, ה-interactions יעבדו ישירות בלי תלות ב-auth-guard injection.
|
||||||
|
|
||||||
|
### Verification:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# על תיק חי, אחרי שדפנה לוחצת mark-final, ה-curator יקבל:
|
||||||
|
echo "PAPERCLIP_RUN_ID=$PAPERCLIP_RUN_ID" # חייב להיות UUID חוקי
|
||||||
|
echo "PAPERCLIP_API_KEY=${PAPERCLIP_API_KEY:0:8}..." # חייב להתחיל ב-pcp_
|
||||||
|
echo "PAPERCLIP_API_URL=$PAPERCLIP_API_URL" # חייב להיות http://localhost:3100/api
|
||||||
|
```
|
||||||
|
|
||||||
## קונטקסט קבוע (לא לשכוח)
|
## קונטקסט קבוע (לא לשכוח)
|
||||||
|
|
||||||
- היו"ר: עו"ד דפנה תמיר
|
- היו"ר: עו"ד דפנה תמיר
|
||||||
|
|||||||
@@ -63,6 +63,26 @@ tools:
|
|||||||
- חוקי תמ"א 38, פינוי ובינוי, והתחדשות עירונית
|
- חוקי תמ"א 38, פינוי ובינוי, והתחדשות עירונית
|
||||||
- ועדות ערר — תכנון ובניה והיטל השבחה (סמכות, הרכב, סדרי דין)
|
- ועדות ערר — תכנון ובניה והיטל השבחה (סמכות, הרכב, סדרי דין)
|
||||||
|
|
||||||
|
## טקסונומיה — שני namespaces ל-`practice_area`
|
||||||
|
|
||||||
|
⚠️ **חובה לדעת לפני שאתה כותב practice_area לכל כלי MCP או יוצר תיק חדש.**
|
||||||
|
|
||||||
|
יש שני namespaces שונים:
|
||||||
|
|
||||||
|
| Axis | ערכים | איפה משתמשים |
|
||||||
|
|------|--------|--------------|
|
||||||
|
| **A. Multi-tenant (legacy/routing)** | `appeals_committee`, `national_insurance`, `labor_law` | בחירת tenant. הסוכנים בוועדת ערר תמיד `appeals_committee` |
|
||||||
|
| **B. Domain (DB + filters)** | `rishuy_uvniya`, `betterment_levy`, `compensation_197` | **DB columns + כל פילטר ב-`search_precedent_library` / `search_internal_decisions`** |
|
||||||
|
|
||||||
|
**כלל זהב — בכל קריאה לכלי שמחפש או כותב לקורפוס, השתמש ב-Axis B בלבד:**
|
||||||
|
- 1xxx → `rishuy_uvniya`
|
||||||
|
- 8xxx → `betterment_levy`
|
||||||
|
- 9xxx → `compensation_197`
|
||||||
|
|
||||||
|
**יצירת תיק חדש (`case_create`):** ב-DB, העמודה `cases.practice_area` מאוכפת ע"י CHECK constraint לערכי Axis B (או ריק). **אסור** לכתוב `appeals_committee` ל-`cases.practice_area` — זה ידחה. אם אתה לא בטוח באיזה axis תיק קיים נמצא, קרא קודם `case_get` ובדוק.
|
||||||
|
|
||||||
|
**זיהוי בל"מ (בקשה להארכת מועד):** אם ה-subject של מסמך/תיק מכיל "בקשה להארכת מועד" או הקידומת "בל\"מ" — זהו סיווג ייחודי (במיוחד תיקי 8xxx). חלץ זאת בעת הניתוח וציין ב-`appeal_subtype` כאחד הסיווגים המקובלים. בל"מ הוא דיוני בעיקרו ולכן הניתוח שלו שונה — לרוב יש טענת סף יחידה (האם להאריך) ולא דיון מהותי. סמן זאת בפלט כדי שהכותב ידע לבחור תבנית קצרה.
|
||||||
|
|
||||||
## הבחנה קריטית — 3 סוגי פריטים מחולצים
|
## הבחנה קריטית — 3 סוגי פריטים מחולצים
|
||||||
|
|
||||||
| סוג (claim_type) | מה זה | מי אמר |
|
| סוג (claim_type) | מה זה | מי אמר |
|
||||||
@@ -181,8 +201,8 @@ tools:
|
|||||||
| סיווג תיק | practice_area |
|
| סיווג תיק | practice_area |
|
||||||
|------------|---------------|
|
|------------|---------------|
|
||||||
| 1xxx (רישוי ובניה) | `rishuy_uvniya` |
|
| 1xxx (רישוי ובניה) | `rishuy_uvniya` |
|
||||||
| 8xxx (היטל השבחה) | `histael_hashbacha` |
|
| 8xxx (היטל השבחה) | `betterment_levy` |
|
||||||
| 9xxx (פיצויים ס' 197) | `pitsuim_197` |
|
| 9xxx (פיצויים ס' 197) | `compensation_197` |
|
||||||
|
|
||||||
אם הסוגיה מאוזכרת ב-`appeal_subtype` ידוע (כמו "שימוש חורג", "חריגות בנייה", "סטייה ניכרת") — הוסף `appeal_subtype` לפילטר. צמצום מוקדם > הרחבה מאוחרת.
|
אם הסוגיה מאוזכרת ב-`appeal_subtype` ידוע (כמו "שימוש חורג", "חריגות בנייה", "סטייה ניכרת") — הוסף `appeal_subtype` לפילטר. צמצום מוקדם > הרחבה מאוחרת.
|
||||||
|
|
||||||
@@ -288,11 +308,11 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
|||||||
|
|
||||||
**אם הכל עבר בהצלחה (בדיקות שלב 6 + טענות + עובדות שמאי):**
|
**אם הכל עבר בהצלחה (בדיקות שלב 6 + טענות + עובדות שמאי):**
|
||||||
```bash
|
```bash
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "done"}'```
|
||||||
|
|
||||||
**אם בדיקות שלב 6 נכשלו או חילוץ נכשל:**
|
**אם בדיקות שלב 6 נכשלו או חילוץ נכשל:**
|
||||||
```bash
|
```bash
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "blocked"}'```
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם ניסיון חוזר נכשל, סטטוס = `blocked` + comment עם פירוט.
|
**אסור** לסיים `done` עם פלט חסר — אם ניסיון חוזר נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||||
|
|
||||||
5. **שלח מייל**:
|
5. **שלח מייל**:
|
||||||
@@ -304,16 +324,19 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
|||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||||
```bash
|
```bash
|
||||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
# $PAPERCLIP_TASK_ID הוא UUID המלא שPaperclip מספק בסביבת הריצה — לעולם לא CMP-XX
|
||||||
|
# אסור להחליף ידנית: משתמשים ב-$PAPERCLIP_TASK_ID ישירות
|
||||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
||||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
||||||
else
|
else
|
||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||||
fi
|
fi
|
||||||
|
|
||||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"מנתח משפטי סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
|
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
|
||||||
|
"{\"source\":\"automation\",\"triggerDetail\":\"system\",\"reason\":\"מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_ID\",\"mutation\":\"agent_completion\"}}"```
|
||||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||||
|
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** המשתנה מוגדר אוטומטית ע"י Paperclip בסביבת הריצה. אם משתמשים בו ב-double-quotes (`"..."`), bash מרחיב אותו לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
|
||||||
|
|
||||||
## מבנה הפלט המלא — analysis-and-research.md
|
## מבנה הפלט המלא — analysis-and-research.md
|
||||||
|
|
||||||
@@ -397,7 +420,7 @@ X שאלות עומדות להכרעה:
|
|||||||
- [אם נמצאו — חיסכון או הבחנה?]
|
- [אם נמצאו — חיסכון או הבחנה?]
|
||||||
|
|
||||||
**עמדת ועדת הערר:**
|
**עמדת ועדת הערר:**
|
||||||
[ימולא ע"י יו"ר הוועדה — עמדה/הנחיה לגבי סוגיה זו שתשמש את סוכן הכתיבה]
|
[ימולא ע"י יו"ר הוועדה]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -432,12 +455,12 @@ X שאלות עומדות להכרעה:
|
|||||||
### 8א. אימות פסיקה
|
### 8א. אימות פסיקה
|
||||||
סרוק את עמדות היו"ר וזהה כל אזכור פסיקה (בג"ץ, עע"מ, עת"מ, ע"א, ערר וכו').
|
סרוק את עמדות היו"ר וזהה כל אזכור פסיקה (בג"ץ, עע"מ, עת"מ, ע"א, ערר וכו').
|
||||||
לכל פסק דין שמוזכר:
|
לכל פסק דין שמוזכר:
|
||||||
1. חפש ב**קורפוס הסמכותי** (`search_precedent_library`) — חובה ראשונה. שם נמצאות הלכות מאושרות עם supporting_quote מוכן לציטוט.
|
1. חפש ב**קורפוס הסמכותי** (`search_precedent_library`) — חובה ראשונה. שם נמצאות הלכות מאושרות עם supporting_quote מוכן לציטוט. הקורפוס כולל גם הלכות מהחלטות ועדות ערר שהועלו (internal_committee).
|
||||||
2. חפש בקאנון דפנה (`search_decisions`, `find_similar_cases`)
|
2. חפש בקאנון דפנה (`search_decisions`, `find_similar_cases`)
|
||||||
3. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות
|
3. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות
|
||||||
4. **אם נמצא ב-precedent_library** — צטט citation+supporting_quote מדויקים מהקורפוס.
|
4. **אם נמצא ב-precedent_library** — צטט citation+supporting_quote מדויקים מהקורפוס.
|
||||||
5. **אם נמצא רק במסמכי התיק** — סמן: "מקור: כתבי טענות, דורש אימות מול הקורפוס".
|
5. **אם נמצא רק במסמכי התיק** — סמן: "מקור: כתבי טענות, דורש אימות מול הקורפוס".
|
||||||
6. **אם לא נמצא בכלל** — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש.
|
6. **אם לא נמצא בכלל** — קודם **נסה שוב עם הקשר** (לא שם לבדו): צרף מונחי תוכן או מספר תיק לשאילתה. שם תיק לבדו (`"אגסי"`) אינו מפתח אמין — הוא עלול להחזיר את מי שמצטט את התיק ולא את התיק עצמו. רק אם גם זה ריק — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש.
|
||||||
|
|
||||||
הוסף לסעיף "7א. שאילתות לקורפוסים" כל query נוסף שהורצה ב-pass 2.
|
הוסף לסעיף "7א. שאילתות לקורפוסים" כל query נוסף שהורצה ב-pass 2.
|
||||||
|
|
||||||
@@ -482,7 +505,8 @@ X שאלות עומדות להכרעה:
|
|||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||||
fi
|
fi
|
||||||
|
|
||||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"מנתח משפטי סיים העמקת ניתוח (pass 2) [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
|
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
|
||||||
|
"{\"source\":\"automation\",\"triggerDetail\":\"system\",\"reason\":\"מנתח משפטי סיים העמקת ניתוח (pass 2) $PAPERCLIP_TASK_ID\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_ID\",\"mutation\":\"agent_completion\"}}"```
|
||||||
**⚠️ אם ה-API מחזיר שגיאה — אל תיגע ב-DB.** `INSERT INTO agent_wakeup_requests` לא יוצר `heartbeat_run` והסוכן לא יתעורר לעולם. בדוק `$PAPERCLIP_COMPANY_ID` ו-`$PAPERCLIP_API_KEY`, ודאי שאתה לא קורא ל-CEO של חברה אחרת (`Agent key cannot access another company`).
|
**⚠️ אם ה-API מחזיר שגיאה — אל תיגע ב-DB.** `INSERT INTO agent_wakeup_requests` לא יוצר `heartbeat_run` והסוכן לא יתעורר לעולם. בדוק `$PAPERCLIP_COMPANY_ID` ו-`$PAPERCLIP_API_KEY`, ודאי שאתה לא קורא ל-CEO של חברה אחרת (`Agent key cannot access another company`).
|
||||||
|
|
||||||
## כללים קריטיים
|
## כללים קריטיים
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: "legal-ceo"
|
name: "legal-ceo"
|
||||||
description: "עוזר משפטי — מנהל תהליך כתיבת החלטות, מתזמר סוכנים, מפקח על התקדמות"
|
description: "עוזר משפטי — מנהל תהליך כתיבת החלטות, מתזמר סוכנים, מפקח על התקדמות"
|
||||||
model: "claude-sonnet-4-6"
|
model: "claude-opus-4-7"
|
||||||
tools:
|
tools:
|
||||||
- Read
|
- Read
|
||||||
- Bash
|
- Bash
|
||||||
@@ -18,6 +18,8 @@ tools:
|
|||||||
- mcp__legal-ai__list_chair_feedback
|
- mcp__legal-ai__list_chair_feedback
|
||||||
- mcp__legal-ai__search_case_documents
|
- mcp__legal-ai__search_case_documents
|
||||||
- mcp__legal-ai__search_precedent_library
|
- mcp__legal-ai__search_precedent_library
|
||||||
|
- mcp__legal-ai__search_internal_decisions
|
||||||
|
- mcp__legal-ai__internal_decision_upload
|
||||||
- mcp__legal-ai__workflow_status
|
- mcp__legal-ai__workflow_status
|
||||||
- mcp__legal-ai__processing_status
|
- mcp__legal-ai__processing_status
|
||||||
- mcp__legal-ai__get_metrics
|
- mcp__legal-ai__get_metrics
|
||||||
@@ -75,14 +77,57 @@ tools:
|
|||||||
| `docs/daphna-architecture-by-outcome.md` | מבנה בלוק י לפי תוצאה | writer + qa |
|
| `docs/daphna-architecture-by-outcome.md` | מבנה בלוק י לפי תוצאה | writer + qa |
|
||||||
| `docs/daphna-acceptance-architecture.md` | 5 תבניות קבלה | writer + qa (אם תוצאה = קבלה) |
|
| `docs/daphna-acceptance-architecture.md` | 5 תבניות קבלה | writer + qa (אם תוצאה = קבלה) |
|
||||||
| `docs/daphna-block-zayin-claims.md` | כללי בלוק ז | analyst + writer + qa |
|
| `docs/daphna-block-zayin-claims.md` | כללי בלוק ז | analyst + writer + qa |
|
||||||
|
| `docs/daphna-procedural-patterns.md` | תבניות פרוצדורליות (החלטת ביניים, חזרה לשמאי) | CEO + writer (8xxx בלבד) |
|
||||||
| `docs/voice-1130-25.md` | דוגמה עמוקה | writer (אם תיק 1xxx מורכב) |
|
| `docs/voice-1130-25.md` | דוגמה עמוקה | writer (אם תיק 1xxx מורכב) |
|
||||||
|
|
||||||
|
## טקסונומיה — שני namespaces ל-`practice_area` (חובה לדעת)
|
||||||
|
|
||||||
|
⚠️ **קריטי לפני שאתה כותב practice_area לכל כלי MCP — יש שני namespaces שונים שמוגדרים במערכת:**
|
||||||
|
|
||||||
|
| Axis | ערכים | איפה משתמשים |
|
||||||
|
|------|--------|--------------|
|
||||||
|
| **A. Multi-tenant (legacy, routing)** | `appeals_committee`, `national_insurance`, `labor_law` | רק לבחירת ה-tenant ברמת המוצר. הסוכנים בוועדת ערר תמיד `appeals_committee` |
|
||||||
|
| **B. Domain (DB columns + filters)** | `rishuy_uvniya`, `betterment_levy`, `compensation_197` | **כל קריאה ל-`search_precedent_library` / `search_internal_decisions` / `precedent_library_upload` / `internal_decision_upload`** — זה ה-namespace הקובע |
|
||||||
|
|
||||||
|
**המרה אוטומטית:** `to_db_practice_area(multi_tenant_pa, appeal_subtype)` ממירה Axis A → Axis B (משתמש פנימי בלבד).
|
||||||
|
|
||||||
|
**כללי ברזל לכלי MCP:**
|
||||||
|
- בכל קריאה לכלי שמחפש או כותב לקורפוס פסיקה — **השתמש בערכי Axis B בלבד**:
|
||||||
|
- 1xxx (רישוי ובניה) → `rishuy_uvniya`
|
||||||
|
- 8xxx (היטל השבחה) → `betterment_levy`
|
||||||
|
- 9xxx (פיצויים ס' 197) → `compensation_197`
|
||||||
|
- **אסור** לעבור `appeals_committee` כ-`practice_area` ל-`search_precedent_library` — זה ייתן 0 תוצאות (הקורפוס מאוחסן ב-Axis B).
|
||||||
|
- DB constraint `cases_practice_area_check` אוכף: practice_area של תיק חייב להיות אחד מהשלושה ב-Axis B (או ריק).
|
||||||
|
|
||||||
|
## כלי MCP חדשים (יוני 2026) — חובה לקרוא
|
||||||
|
|
||||||
|
### `internal_decision_upload` — העלאת החלטת ועדת ערר לקורפוס
|
||||||
|
|
||||||
|
החלטות של ועדות ערר אחרות (`source_kind='internal_committee'`) עוברות **רק** דרך כלי זה — לא דרך `precedent_library_upload` (citation guard דוחה).
|
||||||
|
|
||||||
|
**חתימה (חובה כל ארבעת השדות):**
|
||||||
|
```
|
||||||
|
internal_decision_upload(
|
||||||
|
file_path=..., # נתיב מלא ל-PDF/DOCX/RTF/TXT/MD
|
||||||
|
case_number=..., # "ערר 1024-25" / "בל\"מ 8126/25" / וכו'
|
||||||
|
chair_name=..., # שם יו"ר — חובה (לחיפוש סלקטיבי)
|
||||||
|
district=..., # ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי
|
||||||
|
... # case_name, court, decision_date, practice_area, וכו' — אופציונליים
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**מי משתמש בפועל:** ב-`legal-researcher` (ראה `legal-researcher.md`). ה-CEO רק יודע שזה קיים — אם חוקר מדווח שלא הצליח להעלות החלטת ועדת ערר, ה-CEO בודק שה-chair_name + district סופקו.
|
||||||
|
|
||||||
|
### `search_internal_decisions` — חיפוש בהחלטות ועדות ערר
|
||||||
|
|
||||||
|
`search_decisions` = רק החלטות דפנה (style corpus). `search_internal_decisions` = כל ועדות הערר בכל המחוזות, עם פילטרים `chair_name` ו-`district`. ה-CEO משתמש בכלי זה בתרחישי routing מתקדמים — בד"כ ה-researcher ו-analyst הם המשתמשים העיקריים.
|
||||||
|
|
||||||
## הסוכנים שלך
|
## הסוכנים שלך
|
||||||
|
|
||||||
| סוכן | Agent ID | תפקיד |
|
| סוכן | Agent ID | תפקיד |
|
||||||
|-------|----------|--------|
|
|-------|----------|--------|
|
||||||
| מגיה מסמכים | 410c0167-27dc-485c-a51b-7aa8b9ff2217 | הגהת OCR — תיקון ראשי תיבות ושגיאות חילוץ |
|
| מגיה מסמכים | 410c0167-27dc-485c-a51b-7aa8b9ff2217 | הגהת OCR — תיקון ראשי תיבות ושגיאות חילוץ |
|
||||||
| מנתח משפטי | c26e9439-a88a-49dc-9e67-2262c95db65c | חילוץ טענות, תשובות, תגובות |
|
| מנתח משפטי | c26e9439-a88a-49dc-9e67-2262c95db65c | ניתוח משפטי מלא — חילוץ טענות, ניתוח עמוק, מחקר בקורפוסים, כתיבת analysis-and-research.md |
|
||||||
| חוקר תקדימים | 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 לפני ייצוא |
|
||||||
@@ -113,8 +158,7 @@ PGPASSWORD=paperclip psql -h localhost -p 54329 -U paperclip -d paperclip -c \
|
|||||||
|
|
||||||
**אם** ה-issue שלך הוא בעצמו תת-משימה (יש לו parent), השתמש ב-parent של ה-parent — כלומר ה-issue הראשי של התיק. לקבלת ה-parent:
|
**אם** ה-issue שלך הוא בעצמו תת-משימה (יש לו parent), השתמש ב-parent של ה-parent — כלומר ה-issue הראשי של התיק. לקבלת ה-parent:
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('parentId') or d['id'])"
|
||||||
"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('parentId') or d['id'])"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -161,6 +205,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
- אם ה-reason מכיל `user_commented` → **דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.**
|
- אם ה-reason מכיל `user_commented` → **דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.**
|
||||||
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
||||||
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
||||||
|
- אם ה-reason מכיל `weekly-feedback-job` → **דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
|
||||||
- אחרת → המשך לשלב A (heartbeat רגיל)
|
- אחרת → המשך לשלב A (heartbeat רגיל)
|
||||||
|
|
||||||
### חילוץ פסיקה אוטומטי
|
### חילוץ פסיקה אוטומטי
|
||||||
@@ -187,6 +232,26 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
|
|
||||||
**אל**: אל תיצור issues של ביצוע בתיקי ערר, אל תיכנס לתהליך כתיבת החלטה — זו רק עבודת תחזוקה של ספריית הפסיקה.
|
**אל**: אל תיצור issues של ביצוע בתיקי ערר, אל תיכנס לתהליך כתיבת החלטה — זו רק עבודת תחזוקה של ספריית הפסיקה.
|
||||||
|
|
||||||
|
### ניתוח פידבק שבועי (weekly-feedback-job)
|
||||||
|
|
||||||
|
**מתי:** `$PAPERCLIP_WAKE_REASON` מכיל `weekly-feedback-job`
|
||||||
|
|
||||||
|
ה-prompt שתקבל מכיל סיכום של כל הפידבק מיו"ר מהשבוע האחרון, בפורמט:
|
||||||
|
```
|
||||||
|
- תיק X (קטגוריה): טקסט הפידבק
|
||||||
|
- תיק Y (קטגוריה): ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**מה לעשות:**
|
||||||
|
1. **קרא את `docs/legal-decision-lessons.md`** — הבן מה כבר מתועד שם.
|
||||||
|
2. **נתח את הפידבק** — אילו דפוסים חוזרים? מה חדש שלא מופיע בלקחים?
|
||||||
|
3. **עדכן את `docs/legal-decision-lessons.md`** — הוסף רק לקחים חדשים ומהותיים (לא כפל). כל לקח = משפט אחד ברור.
|
||||||
|
4. **רשום ל-stdout** (לא ל-issue): `echo "weekly feedback done: N lessons added"` — החלף N במספר הלקחים שנוספו.
|
||||||
|
|
||||||
|
⚠️ **אין issue ב-Paperclip עבור job זה** — `$PAPERCLIP_TASK_ID` ריק. אל תנסה לפרסם comment ואל תנסה לסגור issue. הפעולה מסתיימת לאחר כתיבת הקובץ.
|
||||||
|
|
||||||
|
**כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים, אל תבצע heartbeat רגיל — זו משימת תחזוקה בלבד.
|
||||||
|
|
||||||
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
||||||
|
|
||||||
בכל heartbeat **רגיל** (לא comment routing):
|
בכל heartbeat **רגיל** (לא comment routing):
|
||||||
@@ -207,6 +272,12 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
- **מסמך ריק**: האם יש מסמך appeal/response עם טקסט שלא ייצר טענות ולא דווח ככשל?
|
- **מסמך ריק**: האם יש מסמך appeal/response עם טקסט שלא ייצר טענות ולא דווח ככשל?
|
||||||
|
|
||||||
#### A3. אימות תאימות מתודולוגיה
|
#### A3. אימות תאימות מתודולוגיה
|
||||||
|
**תנאי קדם — קודם וודא שהמסמך קיים:**
|
||||||
|
```bash
|
||||||
|
ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||||
|
```
|
||||||
|
אם הקובץ **לא קיים** — עצור. המנתח לא ביצע את הניתוח המלא. בדוק את issue המנתח: אם הוא `done` אבל הקובץ חסר — צור issue מנתח חדש עם הנחיה לבצע שלבים 2-7 מ-`legal-analyst.md` (לא לחלץ טענות מחדש — `get_claims` להצגה).
|
||||||
|
|
||||||
קרא את `analysis-and-research.md` ובדוק:
|
קרא את `analysis-and-research.md` ובדוק:
|
||||||
- [ ] סוגיות מנוסחות כסילוגיזם (כלל + עובדות + שאלה)?
|
- [ ] סוגיות מנוסחות כסילוגיזם (כלל + עובדות + שאלה)?
|
||||||
- [ ] ממצאים עובדתיים מופרדים ממסקנות משפטיות?
|
- [ ] ממצאים עובדתיים מופרדים ממסקנות משפטיות?
|
||||||
@@ -222,7 +293,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
|
|
||||||
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
|
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
|
||||||
|
|
||||||
**מתי:** כשיש טענות מחולצות + מחקר תקדימים, אבל אין תוצאה עדיין
|
**מתי:** כשיש `analysis-and-research.md` מלא (מנתח סיים שלבים 1-7) וסטטוס `analyst_verified`, אבל אין תוצאה עדיין
|
||||||
|
|
||||||
**שיטה — dual dispatch:** קודם פרסם comment עם הסיכום המלא (לתיעוד), ואז צור interaction עם כפתורים (לחיים).
|
**שיטה — dual dispatch:** קודם פרסם comment עם הסיכום המלא (לתיעוד), ואז צור interaction עם כפתורים (לחיים).
|
||||||
|
|
||||||
@@ -565,11 +636,12 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
|
|
||||||
| סטטוס | מי שינה לזה | פעולה הבאה |
|
| סטטוס | מי שינה לזה | פעולה הבאה |
|
||||||
|--------|-------------|------------|
|
|--------|-------------|------------|
|
||||||
|
| `processing` | start-workflow (ממשק) | → בדוק אם כבר קיים issue פעיל לסוכן משנה. אם לא → המשך ל-§A כרגיל (בדוק documents + claims) |
|
||||||
| `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח |
|
| `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח |
|
||||||
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
||||||
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified` |
|
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified` |
|
||||||
| `analyst_verified` | CEO (אחרי שלב A) | → האם יש מחקר תקדימים? אם לא → צור issue לחוקר (35022af0). אם כן → שלב B |
|
| `analyst_verified` | CEO (אחרי שלב A) | → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
||||||
| `research_complete` | חוקר | → שלב B (סיכום + סיווג + שאלת תוצאה לחיים) |
|
| `research_complete` | מנתח / חוקר תקדימים (valid status — legacy + תרחישים מתקדמים) | → שלב B (סיכום + שאלת תוצאה לחיים). **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך ל-§B כרגיל. |
|
||||||
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
||||||
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
||||||
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
||||||
@@ -626,15 +698,51 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
---
|
---
|
||||||
|
|
||||||
**תבנית issue למנתח — חובה בכל תיק:**
|
**תבנית issue למנתח — חובה בכל תיק:**
|
||||||
1. **טבלת מיפוי מסמכים** — לכל מסמך: שם, doc_type, פעולה נדרשת:
|
|
||||||
- `appeal` → `extract_claims` (claim_type=claim, party_role=appellant)
|
**כותרת:** `[ערר CASE_NUMBER] ניתוח משפטי ומחקר — CASE_NAME`
|
||||||
- `response` → `extract_claims` (claim_type=response, party_role=respondent/committee)
|
|
||||||
- `reply` → `extract_claims` (claim_type=reply, party_role=permit_applicant/appellant)
|
**תיאור חובה — כלול את כל הסעיפים הבאים:**
|
||||||
- **`appraisal` → `extract_appraiser_facts`** (לא extract_claims! שומה אינה כתב טענות. חובה בכל תיק 8xxx/9xxx)
|
|
||||||
- `reference`/`plan`/`protocol`/`permit`/`decision`/`court_decision` → אל תחלץ — חומר רקע בלבד
|
```
|
||||||
2. **בדיקת השלמה** — לכל doc_type='appraisal' בתיק, וודא שה-issue אומר במפורש להריץ `extract_appraiser_facts`. בלי זה ה-writer יקבל בלוק ז ריק ממספרים.
|
בצע ניתוח משפטי מלא לפי legal-analyst.md שלבים 1-7:
|
||||||
3. **הנחיה לסגור את ה-issue ב-PATCH** — סטטוס `done` בהצלחה, `blocked` בכשל. בלי זה Paperclip יפעיל retry בלולאה (נצפה בפועל ב-CMPA-16 / 30-04-26).
|
|
||||||
4. **הנחיה לשלוח wakeup ל-CEO בסיום** (כך שאתה תידע להמשיך)
|
שלב 1: קליטה וזיהוי
|
||||||
|
- חלץ טענות/תשובות/תגובות מכל מסמכי appeal/response/reply (ראה טבלה למטה)
|
||||||
|
- לכל appraisal: הרץ extract_appraiser_facts (לא extract_claims)
|
||||||
|
|
||||||
|
טבלת מסמכים:
|
||||||
|
[לכל מסמך: שם | doc_type | פעולה נדרשת]
|
||||||
|
- appeal → extract_claims(claim_type=claim, party_role=appellant)
|
||||||
|
- response → extract_claims(claim_type=response, party_role=respondent/committee)
|
||||||
|
- reply → extract_claims(claim_type=reply, party_role=permit_applicant/appellant)
|
||||||
|
- appraisal → extract_appraiser_facts (לא extract_claims!)
|
||||||
|
- reference/plan/protocol/permit/decision → אל תחלץ — רקע בלבד
|
||||||
|
|
||||||
|
שלב 2: ניתוח מעמיק — גוף מחליט, רקע דיוני, עובדות מוסכמות, עובדות שנויות
|
||||||
|
|
||||||
|
שלב 3: טענות סף, מפת דרכים, סוגיות להכרעה (כולל CREAC + עמדת ועדת הערר ריקה)
|
||||||
|
|
||||||
|
שלב 4: שאלות מחקר (1-3 לכל סוגיה)
|
||||||
|
|
||||||
|
שלב 5: חיפוש בשלושת הקורפוסים — חובה:
|
||||||
|
- search_precedent_library(practice_area=RELEVANT_AREA)
|
||||||
|
- search_decisions
|
||||||
|
- find_similar_cases
|
||||||
|
|
||||||
|
שלב 6: בדיקת שלמות — get_claims ≥ 1 מכל צד
|
||||||
|
|
||||||
|
שלב 7: שמור analysis-and-research.md ב-data/cases/CASE_NUMBER/documents/research/
|
||||||
|
עדכן case_update(status='documents_ready')
|
||||||
|
סגור issue: PATCH status=done (או blocked אם נכשל)
|
||||||
|
שלח wakeup ל-CEO עם $PAPERCLIP_TASK_ID כ-issueId (ראה HEARTBEAT.md §4ג)
|
||||||
|
|
||||||
|
⚠️ אחרי יצירת task זה — עדכן את ה-issue הראשי ל-status=in_review והמתן ל-wakeup
|
||||||
|
עם mutation=agent_completion מהמנתח. אין לבדוק get_claims לפני ה-wakeup.
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **בדיקת השלמה** — לכל doc_type='appraisal' בתיק, וודא שה-issue אומר במפורש להריץ `extract_appraiser_facts`. בלי זה ה-writer יקבל בלוק ז ריק ממספרים.
|
||||||
|
2. **הנחיה לסגור את ה-issue ב-PATCH** — סטטוס `done` בהצלחה, `blocked` בכשל. בלי זה Paperclip יפעיל retry בלולאה (נצפה בפועל ב-CMPA-16 / 30-04-26).
|
||||||
|
3. **הנחיה לשלוח wakeup ל-CEO בסיום** (כך שאתה תידע להמשיך) — חובה להשתמש ב-`$PAPERCLIP_TASK_ID` (UUID) ולא ב-CMP-XX.
|
||||||
|
|
||||||
## סינון תיקים לפי חברה — חובה!
|
## סינון תיקים לפי חברה — חובה!
|
||||||
|
|
||||||
@@ -746,8 +854,10 @@ case_prefix="${case_number:0:1}"
|
|||||||
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" '{"body": "..."}'
|
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" '{"body": "..."}'
|
||||||
|
|
||||||
# צור issue חדש (עם הקצאה לסוכן → מפעיל wakeup אוטומטי!)
|
# צור issue חדש (עם הקצאה לסוכן → מפעיל wakeup אוטומטי!)
|
||||||
~/legal-ai/scripts/pc.sh POST "/api/companies/42a7acd0-30c5-4cbd-ac97-7424f65df294/issues" \
|
# ⚠️ שלוף projectId מה-issue ההורה — אל תקבע UUID ידנית:
|
||||||
'{"title":"...","projectId":"25c1b4a1-2c0e-4a2d-9938-8ae56ccda6f1","assigneeAgentId":"{agent-id}","description":"...","status":"todo"}'
|
PROJECT_ID=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID" | jq -r '.projectId')
|
||||||
|
~/legal-ai/scripts/pc.sh POST "/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
|
||||||
|
"{\"title\":\"...\",\"projectId\":\"$PROJECT_ID\",\"assigneeAgentId\":\"{agent-id}\",\"description\":\"...\",\"status\":\"todo\"}"
|
||||||
|
|
||||||
# עדכן issue
|
# עדכן issue
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ tools:
|
|||||||
- mcp__legal-ai__revise_draft
|
- mcp__legal-ai__revise_draft
|
||||||
- mcp__legal-ai__get_style_guide
|
- mcp__legal-ai__get_style_guide
|
||||||
- mcp__legal-ai__validate_decision
|
- mcp__legal-ai__validate_decision
|
||||||
|
- mcp__legal-ai__case_update
|
||||||
---
|
---
|
||||||
|
|
||||||
# מייצא טיוטה — סוכן ייצוא סופי
|
# מייצא טיוטה — סוכן ייצוא סופי
|
||||||
@@ -40,14 +41,14 @@ tools:
|
|||||||
## סקייל ייצוא
|
## סקייל ייצוא
|
||||||
|
|
||||||
**חובה לקרוא לפני כל ייצוא:**
|
**חובה לקרוא לפני כל ייצוא:**
|
||||||
- `/home/chaim/.paperclip/instances/default/skills/42a7acd0-30c5-4cbd-ac97-7424f65df294/legal-docx/SKILL.md`
|
- `/home/chaim/.paperclip/instances/default/skills/$PAPERCLIP_COMPANY_ID/legal-docx/SKILL.md`
|
||||||
- `/home/chaim/.paperclip/instances/default/skills/42a7acd0-30c5-4cbd-ac97-7424f65df294/legal-docx/references/document-types.md`
|
- `/home/chaim/.paperclip/instances/default/skills/$PAPERCLIP_COMPANY_ID/legal-docx/references/document-types.md`
|
||||||
|
|
||||||
**סקריפט ייצוא:**
|
**סקריפט ייצוא:**
|
||||||
- `/home/chaim/.paperclip/instances/default/skills/42a7acd0-30c5-4cbd-ac97-7424f65df294/legal-docx/scripts/create-legal-doc.js`
|
- `/home/chaim/.paperclip/instances/default/skills/$PAPERCLIP_COMPANY_ID/legal-docx/scripts/create-legal-doc.js`
|
||||||
|
|
||||||
**תבנית:**
|
**תבנית:**
|
||||||
- `/home/chaim/.paperclip/instances/default/skills/42a7acd0-30c5-4cbd-ac97-7424f65df294/legal-docx/references/docx template.docx`
|
- `/home/chaim/.paperclip/instances/default/skills/$PAPERCLIP_COMPANY_ID/legal-docx/references/docx template.docx`
|
||||||
|
|
||||||
## תהליך עבודה
|
## תהליך עבודה
|
||||||
|
|
||||||
@@ -102,12 +103,13 @@ tools:
|
|||||||
|
|
||||||
### שלב 4: שמירה מגורסת
|
### שלב 4: שמירה מגורסת
|
||||||
1. צור תיקייה `~/legal-ai/data/cases/{מספר-ערר}/exports/` (אם לא קיימת)
|
1. צור תיקייה `~/legal-ai/data/cases/{מספר-ערר}/exports/` (אם לא קיימת)
|
||||||
2. בדוק כמה טיוטות כבר קיימות בתיקייה (קבצים שמתחילים ב-`טיוטה-V`)
|
2. בדוק כמה טיוטות כבר קיימות בתיקייה (קבצים שמתחילים ב-`טיוטה-v`)
|
||||||
3. שמור כ-`טיוטה-V{N}.docx` כאשר N = המספר הבא בתור
|
3. שמור כ-`טיוטה-v{N}.docx` כאשר N = המספר הבא בתור
|
||||||
- אם אין טיוטות: `טיוטה-V1.docx`
|
- אם אין טיוטות: `טיוטה-v1.docx`
|
||||||
- אם יש V1: `טיוטה-V2.docx`
|
- אם יש v1: `טיוטה-v2.docx`
|
||||||
- וכן הלאה
|
- וכן הלאה
|
||||||
4. ודא שהקובץ נוצר ושגודלו סביר
|
4. ודא שהקובץ נוצר ושגודלו סביר
|
||||||
|
5. עדכן סטטוס תיק ל-`exported` דרך `case_update(case_number, {"status": "exported"})`
|
||||||
|
|
||||||
### שלב 5: דיווח
|
### שלב 5: דיווח
|
||||||
דווח למשתמש:
|
דווח למשתמש:
|
||||||
@@ -145,6 +147,6 @@ fi
|
|||||||
## כללים קריטיים
|
## כללים קריטיים
|
||||||
|
|
||||||
1. **לעולם אל תייצא בלי בדיקה** — תמיד הרץ validate_decision קודם
|
1. **לעולם אל תייצא בלי בדיקה** — תמיד הרץ validate_decision קודם
|
||||||
2. **לא לדרוס טיוטות קודמות** — תמיד גרסה חדשה (V1, V2, V3...)
|
2. **לא לדרוס טיוטות קודמות** — תמיד גרסה חדשה (v1, v2, v3...)
|
||||||
3. **שמות קבצים בעברית** — `טיוטה-V1.docx`, לא `draft-V1.docx`
|
3. **שמות קבצים בעברית** — `טיוטה-v1.docx`, לא `draft-v1.docx`
|
||||||
4. **קרא את הסקייל** — לפני כל ייצוא, קרא את legal-docx SKILL.md
|
4. **קרא את הסקייל** — לפני כל ייצוא, קרא את legal-docx SKILL.md
|
||||||
|
|||||||
@@ -92,11 +92,11 @@ tools:
|
|||||||
|
|
||||||
**אם הכל עבר בהצלחה:**
|
**אם הכל עבר בהצלחה:**
|
||||||
```bash
|
```bash
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "done"}'```
|
||||||
|
|
||||||
**אם נכשלו תיקונים קריטיים או יש markers `[?]` רבים:**
|
**אם נכשלו תיקונים קריטיים או יש markers `[?]` רבים:**
|
||||||
```bash
|
```bash
|
||||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "blocked"}'```
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם נכשל, סטטוס = `blocked` + comment עם פירוט.
|
**אסור** לסיים `done` עם פלט חסר — אם נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ tools:
|
|||||||
- mcp__legal-ai__workflow_status
|
- mcp__legal-ai__workflow_status
|
||||||
- mcp__legal-ai__search_case_documents
|
- mcp__legal-ai__search_case_documents
|
||||||
- mcp__legal-ai__search_precedent_library
|
- mcp__legal-ai__search_precedent_library
|
||||||
|
- mcp__legal-ai__search_internal_decisions
|
||||||
- mcp__legal-ai__precedent_library_get
|
- mcp__legal-ai__precedent_library_get
|
||||||
|
- mcp__legal-ai__precedent_list
|
||||||
- mcp__legal-ai__halacha_review
|
- mcp__legal-ai__halacha_review
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,6 +85,8 @@ tools:
|
|||||||
|
|
||||||
ה-analyst וה-researcher חייבים לתעד queries לקורפוסים שלהם. בלי תיעוד — אין דרך לוודא שתקדימי עליון רלוונטיים לא הוחמצו.
|
ה-analyst וה-researcher חייבים לתעד queries לקורפוסים שלהם. בלי תיעוד — אין דרך לוודא שתקדימי עליון רלוונטיים לא הוחמצו.
|
||||||
|
|
||||||
|
**שיטת בדיקה:** grep ידני — קרא את קבצי המחקר וחפש בהם את הסעיפים הנ"ל. `validate_decision` **לא** בודק זאת אוטומטית. הצלבה עם MCP (סעיף 4 למטה) היא אופציונלית ומשלימה.
|
||||||
|
|
||||||
בדוק:
|
בדוק:
|
||||||
1. **קיום סעיף "שאילתות לקורפוסים"**:
|
1. **קיום סעיף "שאילתות לקורפוסים"**:
|
||||||
- ב-`{case_dir}/documents/research/analysis-and-research.md` — סעיף **7א** (לפי שלב 5ד של ה-analyst)
|
- ב-`{case_dir}/documents/research/analysis-and-research.md` — סעיף **7א** (לפי שלב 5ד של ה-analyst)
|
||||||
@@ -143,6 +147,39 @@ tools:
|
|||||||
- האם יש תקדים אישי שלה רלוונטי? אם כן — האם הופנה אליו (חיסכון / דחייה / הבחנה)?
|
- האם יש תקדים אישי שלה רלוונטי? אם כן — האם הופנה אליו (חיסכון / דחייה / הבחנה)?
|
||||||
- **ציטוטי פסיקה חיצונית בבלוק י** — לכל ציטוט (`citation` + `supporting_quote`) שמופיע, חפש ב-`search_precedent_library` (subject_tag הרלוונטי) וודא שהציטוט קיים בקורפוס ושהלכה אושרה. ציטוט שלא תואם להלכה מאושרת = critical.
|
- **ציטוטי פסיקה חיצונית בבלוק י** — לכל ציטוט (`citation` + `supporting_quote`) שמופיע, חפש ב-`search_precedent_library` (subject_tag הרלוונטי) וודא שהציטוט קיים בקורפוס ושהלכה אושרה. ציטוט שלא תואם להלכה מאושרת = critical.
|
||||||
|
|
||||||
|
### 9. צירוף פסיקה ל-DB (`precedent_attach`) — critical
|
||||||
|
|
||||||
|
לכל ציטוט פסיקה בבלוק י (חיצוני או internal_committee), **חייב להיות רישום ב-`case_precedents`** דרך `precedent_attach` של ה-researcher.
|
||||||
|
|
||||||
|
**שיטת בדיקה:**
|
||||||
|
1. הרץ `precedent_list(case_number)` — קבל רשימת כל הציטוטים שנרשמו ל-DB.
|
||||||
|
2. סרוק את בלוק י (וטענות סף) וזהה כל ציטוט פסיקה (citation + quote).
|
||||||
|
3. **לכל ציטוט**: ודא שהוא מופיע ב-`precedent_list`. אם חסר → `qa = fail` (critical, חוסם ייצוא). דווח אילו ציטוטים לא נרשמו.
|
||||||
|
|
||||||
|
**למה זה חשוב:** ה-DOCX exporter ו-Hermes curator קוראים מ-`case_precedents`. ציטוט שנמצא רק בטקסט ולא ב-DB יחמיץ at-export-time validation וניתוח Hermes.
|
||||||
|
|
||||||
|
### 10. מראה מקום מלא בציטוטים — warning
|
||||||
|
|
||||||
|
לכל ציטוט פסיקה בבלוק י, ודא שהוא כולל:
|
||||||
|
- **מספר תיק מלא** (לא רק "פלוני נ' פלמוני")
|
||||||
|
- **ערכאה** (עליון / מנהלי / מחוזי / שלום / ועדת ערר)
|
||||||
|
- **תאריך / `פורסם בנבו`** או `פורסם ב-`
|
||||||
|
- **`page_reference`** כשמדובר בציטוט ארוך מתוך פס"ד
|
||||||
|
|
||||||
|
אם חסר אחד מהשלושה הראשונים → **`qa = warning`**, דווח לחיים בcomment + הצע למלא. (לא חוסם — לא כל פסק דין יש לו פאג'ינציה.)
|
||||||
|
|
||||||
|
### 11. תקפות סטטוס תיק (status_validity) — sanity check
|
||||||
|
|
||||||
|
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
|
||||||
|
|
||||||
|
```
|
||||||
|
new → proofread → documents_ready → analyst_verified → research_complete (legacy/optional)
|
||||||
|
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
||||||
|
→ drafted (אתה כאן!) → qa_passed / qa_failed → exported
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **`research_complete` הוא valid status** (לא bug, לא legacy ערומה). ב-`legal-researcher.md` שלב 5 הוא הסטטוס שהחוקר מגדיר בסיום מחקר. אם תיק במצב זה נשלח אליך לפני `drafted` — דווח, אל תכשיל.
|
||||||
|
|
||||||
#### תבנית קבלה (מ-`daphna-acceptance-architecture.md` — אם תוצאה = קבלה)
|
#### תבנית קבלה (מ-`daphna-acceptance-architecture.md` — אם תוצאה = קבלה)
|
||||||
- האם הסיבה לקבלה ברורה: פגם פנימי / החזרה / תיקונים / 8xxx מהותית / שומה?
|
- האם הסיבה לקבלה ברורה: פגם פנימי / החזרה / תיקונים / 8xxx מהותית / שומה?
|
||||||
- האם התבנית הנבחרת (A/B/C/D/E) מתאימה לסיבה?
|
- האם התבנית הנבחרת (A/B/C/D/E) מתאימה לסיבה?
|
||||||
@@ -163,6 +200,9 @@ tools:
|
|||||||
| **שאילתות לקורפוסים** | **critical** | **חוסם ייצוא** |
|
| **שאילתות לקורפוסים** | **critical** | **חוסם ייצוא** |
|
||||||
| מתודולוגיה | critical | חוסם ייצוא |
|
| מתודולוגיה | critical | חוסם ייצוא |
|
||||||
| **קול דפנה** | **critical** | **חוסם ייצוא** |
|
| **קול דפנה** | **critical** | **חוסם ייצוא** |
|
||||||
|
| **צירוף פסיקה ל-DB** | **critical** | **חוסם ייצוא** |
|
||||||
|
| מראה מקום מלא | warning | מדווח, לא חוסם |
|
||||||
|
| תקפות סטטוס | sanity | דיווח בלבד |
|
||||||
|
|
||||||
## תהליך עבודה
|
## תהליך עבודה
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ tools:
|
|||||||
- mcp__legal-ai__document_get_text
|
- mcp__legal-ai__document_get_text
|
||||||
- mcp__legal-ai__search_case_documents
|
- mcp__legal-ai__search_case_documents
|
||||||
- mcp__legal-ai__search_decisions
|
- mcp__legal-ai__search_decisions
|
||||||
|
- mcp__legal-ai__search_internal_decisions
|
||||||
- mcp__legal-ai__find_similar_cases
|
- mcp__legal-ai__find_similar_cases
|
||||||
- mcp__legal-ai__extract_references
|
- mcp__legal-ai__extract_references
|
||||||
- mcp__legal-ai__precedent_attach
|
- mcp__legal-ai__precedent_attach
|
||||||
- mcp__legal-ai__precedent_list
|
- mcp__legal-ai__precedent_list
|
||||||
- mcp__legal-ai__precedent_search_library
|
- mcp__legal-ai__precedent_search_library
|
||||||
- mcp__legal-ai__search_precedent_library
|
- mcp__legal-ai__search_precedent_library
|
||||||
|
- mcp__legal-ai__internal_decision_upload
|
||||||
|
- mcp__legal-ai__precedent_library_upload
|
||||||
- mcp__legal-ai__precedent_library_get
|
- mcp__legal-ai__precedent_library_get
|
||||||
- mcp__legal-ai__precedent_library_list
|
- mcp__legal-ai__precedent_library_list
|
||||||
- mcp__legal-ai__precedent_extract_halachot
|
- mcp__legal-ai__precedent_extract_halachot
|
||||||
@@ -27,9 +30,14 @@ tools:
|
|||||||
- mcp__legal-ai__precedent_process_pending
|
- mcp__legal-ai__precedent_process_pending
|
||||||
- mcp__legal-ai__halacha_review
|
- mcp__legal-ai__halacha_review
|
||||||
- mcp__legal-ai__halachot_pending
|
- mcp__legal-ai__halachot_pending
|
||||||
|
- mcp__legal-ai__missing_precedent_create
|
||||||
|
- mcp__legal-ai__missing_precedent_list
|
||||||
|
- mcp__legal-ai__missing_precedent_close
|
||||||
- mcp__legal-ai__workflow_status
|
- mcp__legal-ai__workflow_status
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> ראה גם: [HEARTBEAT.md](HEARTBEAT.md) לכללי הפעלה כלליים — routing, company filtering, wakeup API
|
||||||
|
|
||||||
# חוקר תקדימים — סוכן מחקר משפטי
|
# חוקר תקדימים — סוכן מחקר משפטי
|
||||||
|
|
||||||
אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים.
|
אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים.
|
||||||
@@ -67,6 +75,92 @@ tools:
|
|||||||
|
|
||||||
כתבי ערר, תשובות, תגובות — אלה בטיפול סוכן "מנתח משפטי".
|
כתבי ערר, תשובות, תגובות — אלה בטיפול סוכן "מנתח משפטי".
|
||||||
|
|
||||||
|
## ⚠️ חובה לקרוא — איזה כלי upload להשתמש לכל סוג פסיקה
|
||||||
|
|
||||||
|
כשאתה מעלה פסיקה לקורפוס הסמכותי, **יש שני זרמים שונים** והם **לא ניתנים להחלפה**. שגיאה כאן פוגעת בכל המערכת.
|
||||||
|
|
||||||
|
### Flowchart החלטה — איזה כלי?
|
||||||
|
|
||||||
|
```
|
||||||
|
האם ה-citation מתחיל ב-"ערר" או "בל"מ" (החלטת ועדת ערר)?
|
||||||
|
├── כן → internal_decision_upload ✅ (חובה chair_name + district)
|
||||||
|
└── לא →
|
||||||
|
האם מתחיל ב-עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ
|
||||||
|
(פסיקת בית משפט מנהלי/עליון/מחוזי/שלום)?
|
||||||
|
├── כן → precedent_library_upload ✅ (external_upload)
|
||||||
|
└── לא → דווח לחיים: citation לא מוכר, אל תעלה
|
||||||
|
```
|
||||||
|
|
||||||
|
### זרם A — `precedent_library_upload` (external)
|
||||||
|
|
||||||
|
לפסיקת ערכאות שיפוטיות: עליון (בג"ץ/ע"א/רע"א/ע"פ/רע"פ/דנ"א), מנהלי (עע"מ/בר"מ/עמ"נ), מחוזי (ת"א/ת"מ), שלום.
|
||||||
|
|
||||||
|
```python
|
||||||
|
mcp__legal-ai__precedent_library_upload(
|
||||||
|
file_path="/path/to/file.pdf",
|
||||||
|
citation="עע\"מ 3911/19 פלוני נ' הוועדה המקומית רמת גן (פורסם בנבו, 12.07.2023)",
|
||||||
|
case_name="פלוני נ' הוועדה המקומית רמת גן",
|
||||||
|
court="בית המשפט העליון",
|
||||||
|
decision_date="2023-07-12",
|
||||||
|
practice_area="rishuy_uvniya", # Axis B בלבד
|
||||||
|
subject_tags=["שימוש חורג", "מגרש מסחרי"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**הכלי שומר `source_kind='external_upload'`.** Citation guard: אם תנסה להעלות citation שמתחיל ב-"ערר" או "בל\"מ" — הכלי **ידחה** עם שגיאה ויפנה ל-`internal_decision_upload`.
|
||||||
|
|
||||||
|
### זרם B — `internal_decision_upload` (internal_committee) — **חובה לחלק מהפסיקה**
|
||||||
|
|
||||||
|
להחלטות **ועדות ערר** מכל המחוזות (ירושלים, מרכז, תל אביב, צפון, דרום, חיפה, ארצי). כולל גם ערר רגיל וגם בל"מ.
|
||||||
|
|
||||||
|
```python
|
||||||
|
mcp__legal-ai__internal_decision_upload(
|
||||||
|
file_path="/path/to/file.pdf",
|
||||||
|
case_number="ערר (ועדות ערר - תכנון ובנייה ירושלים) 1110/20",
|
||||||
|
chair_name="שרית אריאלי", # חובה!
|
||||||
|
district="ירושלים", # חובה! אחד מ-7
|
||||||
|
case_name="פלוני נ' הוועדה המקומית מודיעין",
|
||||||
|
court="ועדת הערר לתכנון ובנייה — מחוז ירושלים",
|
||||||
|
decision_date="2020-11-15",
|
||||||
|
practice_area="rishuy_uvniya", # Axis B
|
||||||
|
appeal_subtype="building_permit",
|
||||||
|
proceeding_type="ערר", # 'ערר' / 'בל"מ' — ראה מטה
|
||||||
|
subject_tags=["שימוש חורג"],
|
||||||
|
is_binding=False, # תמיד False — שכנוע אופקי, לא חוב
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**שדות חובה (הכלי דוחה בלעדיהם):**
|
||||||
|
- `file_path`
|
||||||
|
- `case_number`
|
||||||
|
- `chair_name` — בלעדיו אי-אפשר לחפש סלקטיבית לפי הרכב
|
||||||
|
- `district` — ערכים תקפים: **ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי** (גם "תל-אביב" עם מקף נקלט)
|
||||||
|
|
||||||
|
**שדה מומלץ — `proceeding_type`:**
|
||||||
|
- `"ערר"` — הליך ערר עיקרי (כותרת ב-PDF: "ערר (ועדות ערר ...) NNNN/YY")
|
||||||
|
- `'בל"מ'` — בקשה להארכת מועד להגשת ערר (כותרת: "בל\"מ NNNN/YY" או נושא "בקשה להארכת מועד להגשת ערר")
|
||||||
|
- שני הסוגים יכולים לחלוק אותו מספר תיק (למשל 8047/23 קיים גם כערר וגם כבל"מ).
|
||||||
|
- בכותרת הראשית של ה-PDF זה תמיד מפורש — לקרוא משם ולא לנחש.
|
||||||
|
- אם תשאיר ריק — הכלי גוזר אוטומטית מ-appeal_subtype (`extension_request_*` → 'בל"מ') או מתבנית הטקסט. עדיף מפורש.
|
||||||
|
|
||||||
|
**הכלי שומר `source_kind='internal_committee'`.** DB constraint `case_law_internal_district_check` אוכף ש-`district NOT NULL` כשמדובר ב-internal_committee.
|
||||||
|
|
||||||
|
### אם chair_name או district חסר ב-PDF
|
||||||
|
|
||||||
|
- חפש בתוך הטקסט: "בפני: עו\"ד X" / "יו\"ר הוועדה: X" / "מחוז ירושלים" / שם המחוז בכותרת
|
||||||
|
- אם לא מצליח לזהות — **אל תנחש**. דווח לחיים ב-comment: "נמצא PDF של החלטת ערר ללא chair_name/district ברורים — נדרש מילוי ידני". המשך עם שאר העבודה.
|
||||||
|
|
||||||
|
### 2 שכבות חיפוש מקבילות
|
||||||
|
|
||||||
|
לאחר ההעלאות הנכונות:
|
||||||
|
|
||||||
|
| כלי | מטרה | מתי |
|
||||||
|
|-----|------|-----|
|
||||||
|
| `search_precedent_library` | חיפוש פסיקה **חיצונית** (עליון/מנהלי/מחוזי) | כל סוגיה מרכזית — חובה |
|
||||||
|
| `search_internal_decisions` | חיפוש בהחלטות **ועדות ערר** (כל המחוזות) | כשהסוגיה דיונית או כשאין הלכת עליון |
|
||||||
|
|
||||||
|
שניהם מקבלים את אותם הפילטרים: `practice_area` (Axis B), `subject_tag`, וכו'. `search_internal_decisions` מקבל בנוסף `district` ו-`chair_name`.
|
||||||
|
|
||||||
## תהליך עבודה
|
## תהליך עבודה
|
||||||
|
|
||||||
### שלב 1: התמצאות
|
### שלב 1: התמצאות
|
||||||
@@ -101,8 +195,8 @@ tools:
|
|||||||
| סיווג תיק | practice_area |
|
| סיווג תיק | practice_area |
|
||||||
|------------|---------------|
|
|------------|---------------|
|
||||||
| 1xxx (רישוי ובניה) | `rishuy_uvniya` |
|
| 1xxx (רישוי ובניה) | `rishuy_uvniya` |
|
||||||
| 8xxx (היטל השבחה) | `histael_hashbacha` |
|
| 8xxx (היטל השבחה) | `betterment_levy` |
|
||||||
| 9xxx (פיצויים ס' 197) | `pitsuim_197` |
|
| 9xxx (פיצויים ס' 197) | `compensation_197` |
|
||||||
|
|
||||||
אם הסוגיה ב-`appeal_subtype` ידוע (כמו "שימוש חורג", "סטייה ניכרת") — הוסף `appeal_subtype` לפילטר.
|
אם הסוגיה ב-`appeal_subtype` ידוע (כמו "שימוש חורג", "סטייה ניכרת") — הוסף `appeal_subtype` לפילטר.
|
||||||
|
|
||||||
@@ -121,6 +215,27 @@ search_precedent_library(
|
|||||||
- אם תוצאה דומה: תקדם לחיסכון דוקטרינרי ("כפי שקבענו ב-X")
|
- אם תוצאה דומה: תקדם לחיסכון דוקטרינרי ("כפי שקבענו ב-X")
|
||||||
- אם תוצאה הפוכה: ציין כי **חובה** הבחנה (distinguishing)
|
- אם תוצאה הפוכה: ציין כי **חובה** הבחנה (distinguishing)
|
||||||
|
|
||||||
|
#### 2ב.2א — ועדות ערר אחרות (`search_internal_decisions`) — לפי שיקול דעת
|
||||||
|
|
||||||
|
**ההבדל מ-`search_decisions`:** `search_decisions` מחפש **רק בהחלטות של דפנה**. `search_internal_decisions` מחפש בהחלטות **כל ועדות הערר** בכל המחוזות (ירושלים, מרכז, תל אביב, צפון, דרום, ארצי).
|
||||||
|
|
||||||
|
**מתי להשתמש:**
|
||||||
|
- כשהסוגיה היא חדשנית ודפנה לא הכריעה בה → בדוק אם ועדת ערר אחרת כבר הכריעה
|
||||||
|
- כשרוצים לבדוק האם יש גישות שונות בין מחוזות (ועדות ערר שונות)
|
||||||
|
- **אל תשתמש** אם `search_decisions` כבר מצא את התשובה — אין צורך לחפש פעמיים
|
||||||
|
|
||||||
|
```
|
||||||
|
search_internal_decisions(
|
||||||
|
query="...",
|
||||||
|
practice_area="betterment_levy", # rishuy_uvniya / betterment_levy / compensation_197
|
||||||
|
district="ירושלים", # ריק = כל המחוזות
|
||||||
|
chair_name="", # ריק = כל היו"רים; "דפנה תמיר" = דפנה בלבד (שווה ל-search_decisions)
|
||||||
|
limit=5
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **שים לב להיררכיה:** החלטת ועדת ערר נמוכה מבית משפט מחוזי. אל תציג ועדת ערר אחרת כ"הלכה מחייבת".
|
||||||
|
|
||||||
#### 2ב.3 — בדיקה מצטלבת מול `daphna-precedent-network.md`
|
#### 2ב.3 — בדיקה מצטלבת מול `daphna-precedent-network.md`
|
||||||
|
|
||||||
לכל סוגיה — בדוק במסמך:
|
לכל סוגיה — בדוק במסמך:
|
||||||
@@ -154,6 +269,42 @@ search_precedent_library(
|
|||||||
|
|
||||||
**מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו.
|
**מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו.
|
||||||
|
|
||||||
|
#### 2ב.4א — איתור החלטה ספציפית לפי שם — פרוטוקול לפני "לא בקורפוס" ⚠️
|
||||||
|
|
||||||
|
שם תיק לבדו (למשל `"אגסי"`) **אינו מפתח חיפוש אמין**. ההטמעה הסמנטית והאינדקס הלקסיקלי בנויים על תוכן ההלכה/הפסקה — כך ששאילתת-שם עלולה להחזיר דווקא החלטות ש**מצטטות** את התיק, ולא את התיק עצמו. לפני שמכריזים שהחלטה אינה בקורפוס:
|
||||||
|
|
||||||
|
1. **הוסף הקשר לשאילתה** — לא `"אגסי"` אלא `"אגסי פטור 19(ג)(1) שתי דירות 140 מ"ר"`, או חפש לפי **מספר התיק** (`"ערר 81002-01-21"`).
|
||||||
|
2. **חפש בשני הקורפוסים** — `search_precedent_library` **וגם** `search_internal_decisions`. החלטות ערר/בל"מ שהיו"ר מעלה נשמרות כ-`internal_committee` ומתגלות בחיפוש הפנימי.
|
||||||
|
3. **לאימות קיום / דפדוף** — `precedent_library_list(search="<שם>", source_kind="all_committees")`. ברירת המחדל `external_upload` **מסתירה** החלטות ועדת ערר שהועלו — חובה `all_committees` או `internal_committee`.
|
||||||
|
4. רק אם **כל** הניסיונות לעיל ריקים — הכרז "לא בקורפוס" ועבור ל-2ב.5.
|
||||||
|
|
||||||
|
#### 2ב.5 — תיעוד פסיקה חסרה (`missing_precedent_create`) — חובה
|
||||||
|
|
||||||
|
**מתי לקרוא:** לכל ציטוט שהצדדים הביאו (בכתב ערר / תגובה / תגובת ועדה) **שלא נמצא בקורפוס** אחרי חיפוש מובנה לפי פרוטוקול 2ב.4א (`search_precedent_library` + `search_internal_decisions` + `precedent_search_library`, כולל שאילתה עם הקשר/מספר תיק).
|
||||||
|
|
||||||
|
**למה זה חשוב:**
|
||||||
|
- ה-writer יודע שלא להסתמך על פסיקה שלא ב-DB ("טוענים שמופיע" ≠ "אומת")
|
||||||
|
- היו"ר רואה בדף ייחודי `/missing-precedents` מה ממתין להעלאה ויכול לסגור פערים בקליק
|
||||||
|
- ההיסטוריה נשמרת: ראינו את הציטוט, לא מצאנו, חיכינו להעלאה, הועלה, נסגר
|
||||||
|
|
||||||
|
```python
|
||||||
|
mcp__legal-ai__missing_precedent_create(
|
||||||
|
citation = "עע\"מ 1461/20 אנטרים אינווסטמנטס נ' הועדה המקומית ירושלים (נבו 4.5.2021)",
|
||||||
|
case_number = "1017-03-26", # תיק הערר שבו הצד ציטט
|
||||||
|
cited_by_party = "permit_applicant", # appellant/respondent/committee/permit_applicant/unknown
|
||||||
|
cited_by_party_name = "לינדאב בע\"מ",
|
||||||
|
legal_topic = "זכות עמידה",
|
||||||
|
legal_issue = "זכות ערר על בקשה להיתר מוקנית רק לבעל זכות במקרקעין",
|
||||||
|
claim_quote = "...הציטוט המדויק מכתב הטענות...",
|
||||||
|
case_name = "אנטרים", # שם קצר
|
||||||
|
notes = "אופציונלי"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
הכלי deduplicates: ציטוט+תיק זהים → מחזיר את הרשומה הקיימת. אם הציטוט כבר תויג (אפילו ב-status='closed' כי היו"ר העלה אותו בינתיים) — אל תיצור כפילות.
|
||||||
|
|
||||||
|
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
|
||||||
|
|
||||||
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||||
|
|
||||||
### שלב 3: מיפוי תכנית
|
### שלב 3: מיפוי תכנית
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ tools:
|
|||||||
- mcp__legal-ai__write_block
|
- mcp__legal-ai__write_block
|
||||||
- mcp__legal-ai__search_decisions
|
- mcp__legal-ai__search_decisions
|
||||||
- mcp__legal-ai__search_precedent_library
|
- mcp__legal-ai__search_precedent_library
|
||||||
|
- mcp__legal-ai__search_internal_decisions
|
||||||
- mcp__legal-ai__precedent_library_get
|
- mcp__legal-ai__precedent_library_get
|
||||||
- mcp__legal-ai__precedent_library_list
|
- mcp__legal-ai__precedent_library_list
|
||||||
- mcp__legal-ai__halacha_review
|
- mcp__legal-ai__halacha_review
|
||||||
@@ -59,6 +60,9 @@ tools:
|
|||||||
### חובה לפני בלוק ז (טענות הצדדים):
|
### חובה לפני בלוק ז (טענות הצדדים):
|
||||||
- **בלוק ז: `docs/daphna-block-zayin-claims.md`** — מבנה, סדר הצדדים, ביטויי קישור, ניטרליות מלאה, אנטי-דפוסים. בלוק ז הוא **דוח עובדתי** של הטענות — לא הערכה.
|
- **בלוק ז: `docs/daphna-block-zayin-claims.md`** — מבנה, סדר הצדדים, ביטויי קישור, ניטרליות מלאה, אנטי-דפוסים. בלוק ז הוא **דוח עובדתי** של הטענות — לא הערכה.
|
||||||
|
|
||||||
|
### חובה אם זוהתה תבנית פרוצדורלית (החלטת ביניים — 8xxx בלבד):
|
||||||
|
- **תבניות פרוצדורליות: `docs/daphna-procedural-patterns.md`** — אם CEO סימן `pattern_tag: appraiser_clarification_request` או שעץ ההחלטה הראה התקיימות של כל 5 התנאים ב-§0.5, יש לחקות את **המבנה** (לא את הניסוח) של ההחלטה. כולל ביטויי מעבר קנוניים ובדיקת QA לפני שימוש. ⚠️ **אסור** לחקות את הניסוח של ערר 8174-24 — היא דוגמת outlier.
|
||||||
|
|
||||||
### תשתית כללית:
|
### תשתית כללית:
|
||||||
5. **מתודולוגיה אנליטית: `docs/decision-methodology.md`** — איך לחשוב על החלטה
|
5. **מתודולוגיה אנליטית: `docs/decision-methodology.md`** — איך לחשוב על החלטה
|
||||||
6. מדריך סגנון: `skills/decision/SKILL.md` — איך דפנה כותבת
|
6. מדריך סגנון: `skills/decision/SKILL.md` — איך דפנה כותבת
|
||||||
@@ -347,6 +351,28 @@ fi
|
|||||||
|
|
||||||
חפש לפי `practice_area` (rishuy_uvniya / betterment_levy / compensation_197) ולפי `subject_tag` רלוונטי. הלכות שלא אושרו ע"י דפנה לא מוחזרות מהכלי — אם החיפוש ריק, חזור ל-`search_decisions` בלבד.
|
חפש לפי `practice_area` (rishuy_uvniya / betterment_levy / compensation_197) ולפי `subject_tag` רלוונטי. הלכות שלא אושרו ע"י דפנה לא מוחזרות מהכלי — אם החיפוש ריק, חזור ל-`search_decisions` בלבד.
|
||||||
|
|
||||||
|
**איתור החלטה לפי שם:** אם אתה מחפש החלטה ספציפית בשמה (למשל "אגסי"), אל תחפש בשם לבדו — צרף מונחי תוכן או מספר תיק (`"אגסי 19(ג)(1) 140 מ"ר"` / `"ערר 81002-01-21"`). שאילתת-שם בלבד עלולה להחזיר את מי שמצטט את ההחלטה ולא את ההחלטה עצמה.
|
||||||
|
|
||||||
|
### ⚠️ ניסוח ציטוטי פסיקה בקול ההחלטה — לפי `source_kind`
|
||||||
|
|
||||||
|
כל רשומה בקורפוס נושאת `source_kind` (ראה בפלט של `precedent_library_get` / `search_precedent_library` / `search_internal_decisions`). הניסוח בבלוק י **משתנה לפי הסוג** — לא רק הציטוט, אלא **התפקיד הרטורי** של פסק הדין בהנמקה:
|
||||||
|
|
||||||
|
| source_kind | מקור | מעמד | תבנית ניסוח בבלוק י |
|
||||||
|
|-------------|------|------|----------------------|
|
||||||
|
| `external_upload` | בית משפט (עליון/מנהלי/מחוזי/שלום) | **סמכותי — מחייב או משכנע גבוה** | "בהתאם להלכת **X** ב-עע\"מ NNNN/YY, נקבע כי..." / "כפי שהבהיר בית המשפט העליון ב-בג\"ץ NNN/YY, '...'" |
|
||||||
|
| `internal_committee` (אחר) | ועדת ערר אחרת | **שכנוע אופקי בלבד — לא מחייב** | "כפי שנקבע על-ידי כב' היו\"ר **Y** במחוז Z בערר NNNN/YY, '...'. סוגיה זו עלתה בפנינו, ואנו מסכימים עם הניתוח הנ\"ל..." |
|
||||||
|
| `internal_committee` של דפנה עצמה | החלטה קודמת של דפנה | **עקביות עצמית (ג'וריספרודנציה אישית)** | "כפי שקבעתי בעבר בערר NNNN/YY, '...'. אין מקום לסטות מכך גם בעניין שלפנינו." (קול אישי "אנחנו"/"אני" — לפי מה שמופיע בקורפוס המקור) |
|
||||||
|
|
||||||
|
**עקרון CREAC (Rule + Explanation):**
|
||||||
|
- **Rule (כלל)**: רק מ-`external_upload` (פסיקת ערכאות) או מחוקקה. **אסור** להציג ועדת ערר אחרת כ"כלל מחייב".
|
||||||
|
- **Explanation (הרחבה/שכנוע)**: `internal_committee` יכול לתפוס כאן — אבל **בנפרד** מהכלל, כשכנוע נוסף.
|
||||||
|
- **אם אין הלכת עליון** ויש רק ועדת ערר תומכת — נסח: "לעת הזו, סוגיה זו טרם נדונה בערכאות עליונות. עם זאת, כפי שנקבע ב<ערר>... מצאנו את ההנמקה משכנעת ואנו אומצים אותה."
|
||||||
|
|
||||||
|
**בדיקה לפני שאתה כותב ציטוט:**
|
||||||
|
1. הוצא את ה-`source_kind` מהפלט של `search_precedent_library` או `search_internal_decisions`.
|
||||||
|
2. אם `internal_committee` — בדוק את `chair_name`. אם זו דפנה תמיר → סגנון "כפי שקבעתי בעבר". אחרת → סגנון אופקי עם ציון מחוז.
|
||||||
|
3. אל תערבב — שלוש קטגוריות שונות, שלוש תבניות שונות.
|
||||||
|
|
||||||
### אנטי-דפוסים — בדיקה אחרי כתיבה (חובה)
|
### אנטי-דפוסים — בדיקה אחרי כתיבה (חובה)
|
||||||
|
|
||||||
- [ ] **אין רשימות ממוספרות בתוך פסקה** (`(1)... (2)... (3)...`) — דפנה מעולם לא משתמשת
|
- [ ] **אין רשימות ממוספרות בתוך פסקה** (`(1)... (2)... (3)...`) — דפנה מעולם לא משתמשת
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
data/
|
data/
|
||||||
.claude/
|
.claude/
|
||||||
|
!.claude/agents/
|
||||||
|
!.claude/agents/hermes-curator.md
|
||||||
mcp-server/.venv/
|
mcp-server/.venv/
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -11,7 +13,11 @@ scripts/
|
|||||||
skills/
|
skills/
|
||||||
!skills/docx/
|
!skills/docx/
|
||||||
!skills/docx/decision_template.docx
|
!skills/docx/decision_template.docx
|
||||||
|
!skills/decision/
|
||||||
|
!skills/decision/SKILL.md
|
||||||
docs/
|
docs/
|
||||||
|
!docs/legal-decision-lessons.md
|
||||||
|
!docs/corpus-analysis.md
|
||||||
legacy/
|
legacy/
|
||||||
node_modules/
|
node_modules/
|
||||||
.next/
|
.next/
|
||||||
|
|||||||
@@ -1146,7 +1146,7 @@
|
|||||||
"description": "After deploy: PATCH 403-17 to set case_name='ערר 403/17', then trigger precedent_extract_halachot to test the dual-mode extraction on a non-binding committee decision.",
|
"description": "After deploy: PATCH 403-17 to set case_name='ערר 403/17', then trigger precedent_extract_halachot to test the dual-mode extraction on a non-binding committee decision.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"9",
|
"9",
|
||||||
"10",
|
"10",
|
||||||
@@ -1154,7 +1154,8 @@
|
|||||||
"12"
|
"12"
|
||||||
],
|
],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T10:38:07.071897Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "14",
|
"id": "14",
|
||||||
@@ -1325,13 +1326,14 @@
|
|||||||
"description": "ה-plugin שלנו: @paperclipai/plugin-sdk@^2026.325.0, apiVersion: 1, minimumHostVersion: 2026.325.0. ה-host: 2026.428.0. ייתכן capabilities חדשות (issue.interactions.create, וכו').",
|
"description": "ה-plugin שלנו: @paperclipai/plugin-sdk@^2026.325.0, apiVersion: 1, minimumHostVersion: 2026.325.0. ה-host: 2026.428.0. ייתכן capabilities חדשות (issue.interactions.create, וכו').",
|
||||||
"details": "פעולה (Phase 4 — אחרי שדרוג Paperclip stable):\n1. cd /home/chaim/plugin-legal-ai && npm view @paperclipai/plugin-sdk version\n2. אם חדשה: npm install @paperclipai/plugin-sdk@latest\n3. קריאת adapter-plugin.md המעודכן ב-paperclip repo\n4. בדיקה אם apiVersion: 2 קיים\n5. הוספת capabilities חדשות אם רלוונטי (בעיקר issue.interactions.create אחרי gap #4)\n6. npm run build && reinstall plugin\n\nתלוי בgap #19 (interactions API) — אם אנחנו רוצים שהplugin יוכל ליצור interactions, חייב capability חדש.",
|
"details": "פעולה (Phase 4 — אחרי שדרוג Paperclip stable):\n1. cd /home/chaim/plugin-legal-ai && npm view @paperclipai/plugin-sdk version\n2. אם חדשה: npm install @paperclipai/plugin-sdk@latest\n3. קריאת adapter-plugin.md המעודכן ב-paperclip repo\n4. בדיקה אם apiVersion: 2 קיים\n5. הוספת capabilities חדשות אם רלוונטי (בעיקר issue.interactions.create אחרי gap #4)\n6. npm run build && reinstall plugin\n\nתלוי בgap #19 (interactions API) — אם אנחנו רוצים שהplugin יוכל ליצור interactions, חייב capability חדש.",
|
||||||
"testStrategy": "אחרי npm install: בדיקה ש-plugin עולה ב-Paperclip בלי last_error. SELECT status, last_error FROM plugins WHERE plugin_key='marcusgroup.legal-ai'.",
|
"testStrategy": "אחרי npm install: בדיקה ש-plugin עולה ב-Paperclip בלי last_error. SELECT status, last_error FROM plugins WHERE plugin_key='marcusgroup.legal-ai'.",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"27",
|
"27",
|
||||||
"19"
|
"19"
|
||||||
],
|
],
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T12:19:16.180163Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "27",
|
"id": "27",
|
||||||
@@ -1339,10 +1341,11 @@
|
|||||||
"description": "כרגע אנחנו על 2026.428.0 — הגרסה היציבה האחרונה. כשיופיע stable חדש (כנראה 2026.5xx.x), לבצע שדרוג מבוקר.",
|
"description": "כרגע אנחנו על 2026.428.0 — הגרסה היציבה האחרונה. כשיופיע stable חדש (כנראה 2026.5xx.x), לבצע שדרוג מבוקר.",
|
||||||
"details": "טריגר: npm view paperclipai dist-tags.latest מחזיר משהו ≠ 2026.428.0.\n\nפעולה:\n1. קריאת releases/v2026.5xx.x.md ב-GitHub\n2. בדיקת שינויים שעלולים להשפיע (CUSTOMIZATIONS.md סעיפים: hebrew, RTL, plugin driver, heartbeat)\n3. גיבוי: pg_dump של paperclip DB + cp -r ~/.npm/_npx/43414d9b790239bb /tmp/\n4. pm2 stop paperclip\n5. rm -rf ~/.npm/_npx/43414d9b790239bb\n6. npx paperclipai@latest run (יוריד גרסה חדשה)\n7. הרצה מחדש: ~/.paperclip/hebrew/apply-hebrew.sh && ~/.paperclip/issue-link-fix/apply-issue-link-fix.sh\n8. pm2 restart paperclip\n9. בדיקה ב-pc.nautilus.marcusgroup.org: עברית + plugin פעיל + סוכן מתעורר על comment\n\nתלוי בלי dependencies (יכול להיות מבוצע בכל עת אחרי שיש stable חדש).",
|
"details": "טריגר: npm view paperclipai dist-tags.latest מחזיר משהו ≠ 2026.428.0.\n\nפעולה:\n1. קריאת releases/v2026.5xx.x.md ב-GitHub\n2. בדיקת שינויים שעלולים להשפיע (CUSTOMIZATIONS.md סעיפים: hebrew, RTL, plugin driver, heartbeat)\n3. גיבוי: pg_dump של paperclip DB + cp -r ~/.npm/_npx/43414d9b790239bb /tmp/\n4. pm2 stop paperclip\n5. rm -rf ~/.npm/_npx/43414d9b790239bb\n6. npx paperclipai@latest run (יוריד גרסה חדשה)\n7. הרצה מחדש: ~/.paperclip/hebrew/apply-hebrew.sh && ~/.paperclip/issue-link-fix/apply-issue-link-fix.sh\n8. pm2 restart paperclip\n9. בדיקה ב-pc.nautilus.marcusgroup.org: עברית + plugin פעיל + סוכן מתעורר על comment\n\nתלוי בלי dependencies (יכול להיות מבוצע בכל עת אחרי שיש stable חדש).",
|
||||||
"testStrategy": "אחרי שדרוג: cat ~/.npm/_npx/43414d9b790239bb/node_modules/paperclipai/package.json | grep version → גרסה חדשה. UI עברית. test wakeup על issue.",
|
"testStrategy": "אחרי שדרוג: cat ~/.npm/_npx/43414d9b790239bb/node_modules/paperclipai/package.json | grep version → גרסה חדשה. UI עברית. test wakeup על issue.",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T12:19:16.180163Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "28",
|
"id": "28",
|
||||||
@@ -1371,13 +1374,639 @@
|
|||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-05-04T17:29:25.686Z"
|
"updatedAt": "2026-05-04T17:29:25.686Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "30",
|
||||||
|
"title": "תיקון 3 baגים בקטלוג (practice_area + source_kind + upload route)",
|
||||||
|
"description": "CRITICAL: 3 sub-bugs. (א) יצירת תיקים מתייגת practice_area='appeals_committee' במקום rishuy_uvniya/betterment_levy/compensation_197 לפי קידומת מספר התיק (1xxx/8xxx/9xxx) — audit + migration לכל התיקים הקיימים + תיקון נתיב היצירה. (ב) כל החלטה של ועדת ערר שהועלתה ל-case_law מסומנת כ-source_kind='external_upload' במקום 'internal_committee' — audit ל-case_law עם case_number שמתחיל ב'ערר' → reclassify + מילוי chair_name + district רטרואקטיבית. (ג) POST /api/precedent-library/upload לא מבחין — תיקון: ניתוב לפי תחילית הציטוט (ערר/ועדות ערר → internal_committee, אחרת → external_upload).",
|
||||||
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #1. Pre-requirement: השתמש במחיקה+rerun של halachot אחרי שינוי source_kind. השתמש בpattern של internal_decisions.py (dry_run+log_action).",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Audit + migration practice_area (1xxx→rishuy_uvniya, 8xxx→betterment_levy, 9xxx→compensation_197)",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Audit + reclassify case_law source_kind external_upload → internal_committee עבור 'ערר' prefix",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Delete + re-extract halachot עבור רשומות שעברו reclassification",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "תיקון נתיב יצירת תיק לתיוג practice_area נכון מההתחלה",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "תיקון /api/precedent-library/upload לניתוב לפי תחילית הציטוט",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "מבחני רגרסיה לכל 3 הbaגים",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"title": "תיקון MCP `case_update` + API `PUT /api/cases/{case_number}` לתמוך בעדכון practice_area + appeal_subtype",
|
||||||
|
"status": "done",
|
||||||
|
"details": "התגלה ב-26/05/2026: MCP tool case_update והAPI לא מקבלים את השדה practice_area, ולכן אי-אפשר לתקן תיוג שגוי דרך הממשק. נאלצתי לעדכן ידנית ב-SQL. צריך להוסיף את השדות ל-CaseUpdateRequest ב-web/app.py וב-cases_tools.case_update בmcp-server.",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"title": "[prevention] DB CHECK constraints: source_kind='internal_committee' ⇒ chair_name NOT NULL; cases.practice_area enum",
|
||||||
|
"status": "done",
|
||||||
|
"description": "מיגרציה: ALTER TABLE case_law ADD CONSTRAINT chair_required_for_internal CHECK (source_kind <> 'internal_committee' OR (chair_name IS NOT NULL AND chair_name <> '')); וכן CHECK על cases.practice_area לערכים תקינים. חייב לרוץ אחרי subtask #2 (backfill) אחרת constraint creation ייכשל.",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"title": "[prevention] Unify practice_area taxonomy — מיפוי או מחיקה של appeals_committee מ-practice_area.py",
|
||||||
|
"status": "done",
|
||||||
|
"description": "ב-mcp-server/src/legal_mcp/services/practice_area.py:21 יש PRACTICE_AREAS={appeals_committee,national_insurance,labor_law} שסותר את ה-DB constraint של case_law (rishuy_uvniya/betterment_levy/compensation_197). grep מקיף לכל caller של 'appeals_committee'; להחליף במיפוי מפורש או למחוק.",
|
||||||
|
"parentId": "undefined"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "31",
|
||||||
|
"title": "מיצוי chair_name + district בהעלאת ועדת ערר",
|
||||||
|
"description": "תוספת לטופס + חילוץ אוטומטי מהציטוט/text הפסיקה. רטרואקטיבי לכל הרשומות הקיימות עם source_kind='internal_committee' שהשדות בהן ריקים.",
|
||||||
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #2. תלוי במשימה #30 (sub-bug ב).",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"30"
|
||||||
|
],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Backfill chair_name + district לכל 7 הרשומות החסרות (LLM extraction)",
|
||||||
|
"status": "done",
|
||||||
|
"description": "psql query: SELECT id, case_number FROM case_law WHERE source_kind='internal_committee' AND (chair_name IS NULL OR chair_name=''); לכל אחת — חילוץ ע\"י precedent_metadata_extractor.extract_and_apply.",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "[prevention] Validation: chair_name+district required ב-internal_decisions_upload (API+MCP)",
|
||||||
|
"status": "done",
|
||||||
|
"description": "ב-web/app.py:4607-4680 כיום chair_name/district = Form(\"\") (default ריק). שנה ל-required עם validation שדוחה ריק כשsource_kind='internal_committee'. הוסף enum של 6 ערכי district (ירושלים/מרכז/תל אביב/צפון/דרום/ארצי).",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "[prevention] UI dropdown ל-district בטופס העלאת החלטות ועדה (web-ui)",
|
||||||
|
"status": "done",
|
||||||
|
"description": "במקום free-text — Select של 6 הערכים. גם בטופס חיפוש (search_internal_decisions).",
|
||||||
|
"parentId": "undefined"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "32",
|
||||||
|
"title": "UI — דף עריכת פסיקה ייפתח רחב-במרכז (לא צר-בצד)",
|
||||||
|
"description": "חוסר נוחות בעריכה. שינוי ה-Dialog/Sheet ל-Modal רחב מרכזי. רלוונטי גם להוספת שדות chair_name + district מהמשימה #31.",
|
||||||
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #3.",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T10:38:07.071897Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "33",
|
||||||
|
"title": "UI — הסתרת עמודת 'שם' (case_name) בדף רשימת פסיקה",
|
||||||
|
"description": "רוב הערכים זהים למספר התיק. להסתיר את העמודה ב-UI, לשמור עמודה ב-DB לשימוש עתידי.",
|
||||||
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #4.",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "low",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "34",
|
||||||
|
"title": "חילוץ ציטוטי-פנים מהחלטות דפנה (internal_committee + ירושלים)",
|
||||||
|
"description": "פאטרן: 'ונפנה להחלטות של ועדת ערר זו...', 'כפי שקבעתי בערר X', 'בדומה לעמדתי בהחלטה Y'. חילוץ אוטומטי + שמירה ב-precedent_internal_citations table שיאפשר ל-search_internal_decisions להחזיר גם את הפסיקה המוזכרת.",
|
||||||
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #5. תלוי במשימה #30 (sub-bug ב) ובמשימה #31.",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"30",
|
||||||
|
"31"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T10:38:07.071897Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "35",
|
||||||
|
"title": "דף/דוח 'פסיקה חסרה בקורפוס' — פיצ'ר מלא",
|
||||||
|
"description": "טבלת DB missing_precedents (id, citation, case_name, cited_in_case_id, cited_in_document_id, cited_by_party, legal_topic, legal_issue, claim_quote, status, linked_case_law_id, closed_at, created_at), API endpoints (POST/GET/upload/PATCH), MCP tools (missing_precedent_create/list/close), דף Next.js /missing-precedents, הוק אוטומטי במחקר ע\"י legal-researcher.",
|
||||||
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #6.",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Migration + model missing_precedents",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "API endpoints POST/GET/upload/PATCH /api/missing-precedents",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "MCP tools missing_precedent_create/list/close",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Next.js page /missing-precedents עם list + detail + upload form",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "Auto-creation hook במחקר (legal-researcher יוצר רשומה כשמזהה ציטוט חסר)",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "Webhook עדכון לפלאגין Paperclip + Comment לחיים",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "36",
|
||||||
|
"title": "כינוס פרופוזיציות לטיעונים משפטיים אמיתיים (de-dup/aggregation)",
|
||||||
|
"description": "extract_claims מחזיר ~60 פרופוזיציות לתיק, צריך לאגד ל-~10 טיעונים משפטיים אמיתיים. טבלה חדשה legal_arguments + טבלת קישור legal_argument_propositions (M:M ל-case_claims). LLM aggregation job (Hermes/DeepSeek). API + MCP + UI display שמציג 'X טיעונים משפטיים' במקום 'Y טענות'.",
|
||||||
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #7.",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Migration + models legal_arguments + legal_argument_propositions",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "LLM aggregation job (Hermes/DeepSeek profile)",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "API + MCP tool aggregate_claims_to_arguments",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "UI display update — case detail page מציג טיעונים אמיתיים",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "Backfill לכל התיקים הקיימים",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "37",
|
||||||
|
"title": "הפרדת תתי-סוגי בל\"מ לפי practice_area",
|
||||||
|
"description": "3 ערכי appeal_subtype חדשים: extension_request_building_permit (1xxx, ס'152 - 30 ימים), extension_request_betterment_levy (8xxx, ס'14 לתוספת ג' - 45 ימים), extension_request_compensation (9xxx, ס'198(ד) - 30 ימים). 3 templates מתודולוגיים נפרדים. אוטו-זיהוי מהsubject. UI badge + filter.",
|
||||||
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #8. Pre-requirement: עדכון mcp-server/src/legal_mcp/services/practice_area.py APPEALS_COMMITTEE_SUBTYPES + עדכון web/paperclip_client.py mapping appeal_subtype → company.",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "הוספת 3 ערכי enum ל-practice_area.py APPEALS_COMMITTEE_SUBTYPES",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "כתיבת 3 templates מתודולוגיים ב-docs/methodology/extension-request-{type}.md",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "אוטו-זיהוי בקוד יצירת תיק (subject='בקשה להארכת מועד' → קביעת subtype לפי practice_area)",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "UI badge + filter ייעודי לבל\"מ",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "עדכון web/paperclip_client.py mapping ל-company עבור 3 הערכים החדשים",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "38",
|
||||||
|
"title": "שדרוג סוכני Paperclip להכרת השינויים מ-#30-#37",
|
||||||
|
"description": "עדכון 7 הגדרות סוכן (CEO/analyst/researcher/writer/QA/proofreader/exporter) + HEARTBEAT.md לזיהוי המבנים החדשים. בלי זה כל הפיצ'רים נשארים זמינים אבל הסוכנים לא יודעים להשתמש בהם. כולל הוספת research_complete כ-valid case_status.",
|
||||||
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #9. תלוי במשימות #30-#37.",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"30",
|
||||||
|
"31",
|
||||||
|
"34",
|
||||||
|
"35",
|
||||||
|
"36",
|
||||||
|
"37"
|
||||||
|
],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "עדכון .claude/agents/legal-ceo.md — routing + statuses + wake reasons",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "עדכון .claude/agents/legal-analyst.md — practice_area, legal_arguments, בל\"מ detection",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "עדכון .claude/agents/legal-researcher.md — 2 layers, missing_precedents, citations, בל\"מ templates",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "עדכון .claude/agents/legal-writer.md — legal_arguments view, בל\"מ templates",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "עדכון .claude/agents/legal-qa.md — בל\"מ-aware validation",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "עדכון .claude/agents/HEARTBEAT.md — כללי routing משותפים + research_complete status",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"title": "סנכרון לכל החברות CMPA mirror — sync_agents_across_companies.py",
|
||||||
|
"status": "done",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"title": "[alignment] researcher docs: דרישה מפורשת שכל 'ערר' → internal_decision_upload, לעולם לא precedent_library_upload",
|
||||||
|
"status": "done",
|
||||||
|
"description": "בלגל-researcher.md: דוגמת קוד מפורשת + flowchart החלטה: לפי תחילית הציטוט. הסבר על השלילה של precedent_library_upload כשמדובר ב-ערר. תלוי במשימה #39 (MCP tool חדש).",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"title": "[alignment] analyst docs: הסבר על 2 taxonomies של practice_area + מתי משתמשים בכל אחת",
|
||||||
|
"status": "done",
|
||||||
|
"description": "בלגל-analyst.md: טבלה ברורה — practice_area (case_law) vs practice_area (cases). מתי להעביר rishuy_uvniya ומתי appeals_committee. אחרי משימה #30.9 (taxonomy unification) — סביר שזה ייפשט.",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"title": "[alignment] writer docs: הבחנה בין source_kind בציטוט (binding vs persuasive)",
|
||||||
|
"status": "done",
|
||||||
|
"description": "בלגל-writer.md: 'החלטת ועדת ערר אחרת ⇒ עקביות אופקית, לא הלכה מחייבת'. 'פס\"ד עליון/מנהלי ⇒ סמכותי בינדינג'. דוגמאות פרזיולוגיה מ-skills/decision/SKILL.md.",
|
||||||
|
"parentId": "undefined"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updatedAt": "2026-05-26T07:41:47.880478Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "39",
|
||||||
|
"title": "[ROOT CAUSE] MCP tool חדש: internal_decision_upload",
|
||||||
|
"description": "הוספת @mcp.tool() עם chair_name+district חובה ו-source_kind='internal_committee' אוטומטי. סוגר את ה-root cause של Bug (ב) ב-#30. בלעדיו 44 רשומות חדשות יחזרו כ-external_upload תוך חודש.",
|
||||||
|
"details": "מיקום: mcp-server/src/legal_mcp/tools/internal_decisions.py (אם לא קיים — ליצור). רישום ב-server.py סביב שורה 169 (ליד precedent_library_upload). הקריאה מנותבת ל-int_decisions_service.ingest_internal_decision (קיים ב-internal_decisions.py).",
|
||||||
|
"testStrategy": "1. שלח tool call ללא chair_name → JSON error. 2. שלח עם chair+district → רשומה נוצרת עם source_kind='internal_committee'. 3. precedent_chunks נוצרים עם source_kind ירש.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"30"
|
||||||
|
],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T07:41:37.260868Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "40",
|
||||||
|
"title": "[שלב B - ROI מיידי] הפעלת VOYAGE_RERANK_ENABLED=true ב-Coolify",
|
||||||
|
"description": "Cross-encoder rerank-2 ממומש ב-mcp-server/src/legal_mcp/services/rerank.py אבל כבוי בייצור (default=false). POC הוכיח +4.5% mean@3 ו-+11.6% practical queries (latency +702ms acceptable לזרימה האסינכרונית). 5 דקות עבודה — env change ב-Coolify.",
|
||||||
|
"details": "mcp__coolify__env_vars set VOYAGE_RERANK_ENABLED=true. ראה web/mcp_env_catalog.py:71-72 לdescription. אופציה: rampup רק על search_precedent_library (לא על find_similar_cases — latency-sensitive).",
|
||||||
|
"testStrategy": "search_precedent_library('היקף הסמכות') לפני/אחרי — לראות שינוי ב-ordering. בדוק שלא יותר מ-1000ms latency.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "41",
|
||||||
|
"title": "[שלב B] BM25/tsvector hybrid retrieval על precedent_chunks + halachot",
|
||||||
|
"description": "כיום כל החיפוש הוא 100% dense (cosine). ציטוטים מספריים ('עע\"מ 1461/20') נכשלים כי semantic לא מצליח בהם. הוספת tsvector GIN + RRF merge dense+lexical = +15-25% recall על ציטוטים — קריטי לאימות פסיקה ב-3-glimmering-oasis שלב 3.",
|
||||||
|
"details": "ALTER TABLE precedent_chunks ADD COLUMN content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED; CREATE INDEX ... USING gin (content_tsv). באותו אופן על halachot.rule_statement. ב-db.py:2357 (search_precedent_library_semantic) — להוסיף שאילתה מקבילה של websearch_to_tsquery → RRF merge עם cosine. אזהרה: postgres אינו תומך ב-'hebrew' config — simple config יעבוד אבל בלי stemming.",
|
||||||
|
"testStrategy": "שאילתה 'עע\"מ 1461/20' לפני/אחרי. לפני: 0 hits. אחרי: 1 hit מדויק על הציטוט. וגם — שאילתות סמנטיות לא מאבדות recall.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"40"
|
||||||
|
],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "42",
|
||||||
|
"title": "[שלב B] Query expansion via Claude Haiku — 2-3 variants per query",
|
||||||
|
"description": "שאילתות עם abbreviations משפטיות ('בל\"מ'/'בקשה להארכת מועד') חוטפות recall. LLM expansion: שאילתה → 2-3 variants → union retrieval. +10-15% recall.",
|
||||||
|
"details": "להוסיף שכבה ב-search_precedent_library_semantic: אם query מכיל abbreviations נפוצים (mapping פנימי) — להריץ Haiku להרחבה. cache תוצאות לפי query hash (Redis TTL 24h).",
|
||||||
|
"testStrategy": "Eval על 20 שאילתות עם abbreviations: לפני/אחרי recall@10. צפוי +10-15%.",
|
||||||
|
"status": "deferred",
|
||||||
|
"dependencies": [
|
||||||
|
"41"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "43",
|
||||||
|
"title": "[שלב B] MMR / diversity penalty — limit 2 chunks per case_law_id",
|
||||||
|
"description": "תוצאות חיפוש דומות מאוד זו לזו (אותה פסיקה, chunks סמוכים) — פסיקות חוזרות תופסות slots → diversity@10 נמוך. הוספת cap per case_law_id (2-3 max) או MMR אמיתי.",
|
||||||
|
"details": "פתרון קל: SQL DISTINCT ON (case_law_id) + 2 בpost-processing. פתרון איכותי: MMR — לכל candidate, score = λ*relevance - (1-λ)*max_similarity_to_selected. λ=0.7 דיפולט.",
|
||||||
|
"testStrategy": "כל שאילתה ב-top-10: <= 2 chunks per case_law_id. diversity score עולה.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"40"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "44",
|
||||||
|
"title": "[שלב B] HNSW migration (or lists=68 IVFFlat) + REINDEX",
|
||||||
|
"description": "IVFFlat lists=50 עם 4,595 vectors — sub-optimal. sqrt(4595)≈68. HNSW עדיף ל-recall (אבל יותר זיכרון). שיפור +3-5% recall@10.",
|
||||||
|
"details": "אופציה 1: REINDEX עם lists=68 (פשוט, idempotent). אופציה 2: DROP+CREATE עם HNSW (m=16, ef_construction=64) — דורש pgvector ≥0.5 ובדיקת זמן build. בדוק SELECT extversion FROM pg_extension WHERE extname='vector'.",
|
||||||
|
"testStrategy": "EXPLAIN ANALYZE לפני/אחרי על 5 שאילתות מייצגות. בנצ'מרק recall@10 על blind set.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "45",
|
||||||
|
"title": "[שלב B] Halacha auto-approve sweep — בדיקת 219 pending + הורדת סף ל-0.78",
|
||||||
|
"description": "219 halachot pending review (17%) חסומות מ-search. אם dafna לא מסקר ידנית — הם מתבזבזים. dashboard batch + הורדת auto-approve threshold.",
|
||||||
|
"details": "1. בדוק 20 דגימות אקראיות של pending — אם רובן ראויות לאישור, הורד HALACHA_AUTO_APPROVE_THRESHOLD מ-0.80 ל-0.78. 2. הוסף UI batch approval ב-/halachot עם filter pending+confidence>0.75. 3. one-shot SQL לאישור 200 halachot שעמדו בקריטריונים החדשים.",
|
||||||
|
"testStrategy": "אחרי sweep: halachot approved יעלה מ-1,064 ל-~1,260. search_precedent_library יחזיר יותר rule-level results.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "46",
|
||||||
|
"title": "[שלב B] Dynamic halacha boost — לפי query-rule similarity",
|
||||||
|
"description": "כיום halacha boost = +0.05 קבוע. דינמי לפי query similarity ירוץ דייקנות (5% precision על שאילתות ספציפיות).",
|
||||||
|
"details": "ב-db.py:2479 — score = float(d['score']) + 0.05. החלף ב-boost = 0.10 * d['score'] (proportional). או — אם rerank ON, השתמש בrerank score כbaseline (אין צורך ב-boost כלל).",
|
||||||
|
"testStrategy": "A/B test על 10 שאילתות: precision@5 לפני/אחרי.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"40"
|
||||||
|
],
|
||||||
|
"priority": "low",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "47",
|
||||||
|
"title": "[שלב C - prevention] Audit script periodic: detect new external_upload עם case_number של ערר",
|
||||||
|
"description": "Drift detection: שגיאה דומה ל-Bug (ב) יכולה לחזור בעתיד. periodic check (יומי?) + alert ל-Slack/comment.",
|
||||||
|
"details": "scripts/audit_corpus_consistency.py — בודק: 1. case_law WHERE source_kind='external_upload' AND case_number ~ '^ערר|^ARAR'. 2. case_law WHERE source_kind='internal_committee' AND chair_name IS NULL. הרצה דרך cron או scheduled task ב-Paperclip.",
|
||||||
|
"testStrategy": "להריץ אחרי כל העלאה חדשה (וגם פעם ביום). אם מוצא drift — comment ב-Paperclip ל-CEO.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"30",
|
||||||
|
"39"
|
||||||
|
],
|
||||||
|
"priority": "low",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "48",
|
||||||
|
"title": "[שלב C] Parent-doc retrieval (child=300, parent=1500 tokens)",
|
||||||
|
"description": "chunk_size=600 חותך חלק מהלכות ארוכות. parent-doc: חיפוש על child קטן (300 tokens), החזרת parent גדול (1500 tokens) ל-LLM context.",
|
||||||
|
"details": "מיגרציה DB: precedent_chunks.parent_chunk_id (FK self). chunking pipeline משתנה ל-2 רמות. retrieval: SELECT distinct parent_chunk WHERE child_chunk matches.",
|
||||||
|
"testStrategy": "Eval: writer מקבל context מלא יותר, לא חתוך באמצע משפט/ציטוט.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"41"
|
||||||
|
],
|
||||||
|
"priority": "low",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "49",
|
||||||
|
"title": "[שלב C] Multimodal backfill ל-77 רשומות שנותרו",
|
||||||
|
"description": "כיום 40/117 precedent_image_embeddings (34%). 77 רשומות נותרו ללא image embeddings. ערך נמוך כשהמסמכים digital-native, אבל קריטי לscanned PDFs.",
|
||||||
|
"details": "scripts/multimodal_backfill.py כבר קיים. להריץ עם batch size 10 כדי לא לדפוק את Voyage rate limits. אומדן: 77×~10K tokens = ~770K tokens ($10-15).",
|
||||||
|
"testStrategy": "אחרי backfill: COUNT(*) FROM precedent_image_embeddings ≥ 117 (מותר יותר אם יש כמה pages per pdf).",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "low",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "50",
|
||||||
|
"title": "[שלב C] Closed-loop retrieval feedback + ndcg dashboard",
|
||||||
|
"description": "אין tracking של 'what was retrieved → what writer cited'. בלי זה — אי אפשר לעדכן את ה-RAG בצורה מדודה לאורך זמן.",
|
||||||
|
"details": "טבלה חדשה retrieval_feedback (query, candidates_retrieved JSONB, cited_in_final_decision UUID[], created_at). hooks ב-writer לדווח. dashboard חודשי עם ndcg@10.",
|
||||||
|
"testStrategy": "אחרי 3 החלטות סופיות: SELECT count FROM retrieval_feedback ≥ 3. dashboard מציג ndcg trend.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "low",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "51",
|
||||||
|
"title": "[שלב C] Halacha quality monitoring — confidence drift, alert",
|
||||||
|
"description": "אם prompt או model משתנה — confidence distribution יכול לזוז. בלי monitoring — דרדור איכות עובר תחת הראדר.",
|
||||||
|
"details": "scheduled job: weekly mean confidence per practice_area. אם זז ביותר מ-0.05 — alert. dashboard ב-/halachot עם histogram.",
|
||||||
|
"testStrategy": "אחרי 4 שבועות — לבדוק שיש 4 datapoints + alert עובד על נתון synthetic.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "low",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "52",
|
||||||
|
"title": "[Retrieval RC-A] הוספת case_name + case_number ל-tsvector הלקסיקלי",
|
||||||
|
"description": "השורש האמיתי לכך שסוכן לא מאתר החלטה לפי שם (אגסי). ה-tsvector הלקסיקלי (SCHEMA_V12_SQL ב-db.py) בנוי רק מ-precedent_chunks.content ומ-halachot rule/quote/reasoning — לא משם התיק/הצד או ממספר התיק. לכן שאילתת-שם מחזירה את מי שמצטט את ההחלטה, לא את ההחלטה עצמה. לשלב את case_law.case_name + case_number באינדקס הלקסיקלי (tsvector ייעודי על case_law או setweight) כך שחיפוש לפי שם יפגע ברשומה עצמה.",
|
||||||
|
"status": "done",
|
||||||
|
"priority": "high",
|
||||||
|
"dependencies": [],
|
||||||
|
"details": "קבצים: mcp-server/src/legal_mcp/services/db.py (SCHEMA_V12_SQL ~שורה 774, search_precedent_library_lexical), hybrid_search.py (_merge_sem_lex). דורש ALTER TABLE + migration על Postgres (localhost:5433) + restart MCP server. בדיקה: search_internal_decisions('אגסי') ו-search_precedent_library('אגסי') חייבים להחזיר את אגסי (1a87efe5) בעמוד הראשון.",
|
||||||
|
"testStrategy": "reproduction test: query='אגסי' → expect case_law_id 1a87efe5 in top-3. regression: substantive query עדיין מחזיר 0.6+ score.",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-30T11:05:36.307Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "53",
|
||||||
|
"title": "[Retrieval RC-B] חיפוש/רשימה מאוחדים — לא לחתוך internal_committee",
|
||||||
|
"description": "החלטות ערר/בל\"מ שמועלות נשמרות source_kind='internal_committee'. precedent_library_list ברירת מחדל external_upload ומסתיר אותן; כלי ה-MCP precedent_library_list אפילו לא חושף פרמטר source_kind, כך שסוכן לעולם לא יכול לדפדף בהן. לחשוף source_kind/all_committees בכלי ה-MCP ובמידת הצורך לאחד את שכבת ה-list/search.",
|
||||||
|
"status": "done",
|
||||||
|
"priority": "high",
|
||||||
|
"dependencies": [
|
||||||
|
"52"
|
||||||
|
],
|
||||||
|
"details": "קבצים: web/app.py (precedent_library_list ~5194, all_committees expansion ב-db.list_external_case_law ~2708), mcp-server tool def ל-precedent_library_list. בדיקה: precedent_library_list יכול להחזיר את אגסי כשמבקשים committees; חיפוש סמנטי כבר מאוחד (אומת).",
|
||||||
|
"testStrategy": "precedent_library_list(source_kind='all_committees', practice_area='betterment_levy') כולל את אגסי+וינפלד. regression: ברירת מחדל external_upload עדיין מחזירה 14 ולא שוברת UI.",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-30T11:09:44.511Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "54",
|
||||||
|
"title": "[Retrieval RC-3] הנחיית סוכנים — איתור לפי שם + שני קורפוסים",
|
||||||
|
"description": "לעדכן הנחיות legal-analyst/researcher/writer: לאיתור החלטה ספציפית לפי שם להוסיף מונחי תוכן או מספר תיק, ולחפש בשני הקורפוסים (search_internal_decisions + search_precedent_library) לפני שמסיקים 'לא קיים בקורפוס'. כולל יצירת missing_precedent רק אחרי חיפוש כפול.",
|
||||||
|
"status": "done",
|
||||||
|
"priority": "medium",
|
||||||
|
"dependencies": [
|
||||||
|
"53"
|
||||||
|
],
|
||||||
|
"details": "קבצים: .claude/agents/legal-analyst.md, legal-researcher.md, legal-writer.md. אחרי שינוי skills/agent config — להריץ sync_agents_across_companies.py.",
|
||||||
|
"testStrategy": "קריאת ההנחיות מאשרת fallback ברור; (אם אפשר) הרצת סוכן על שאילתת-שם מחזירה את ההחלטה.",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-30T11:12:44.727Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "55",
|
||||||
|
"title": "[Retrieval RC-4] תיקון chunking — פרגמנטים זעירים",
|
||||||
|
"description": "בתוצאות החיפוש מופיעים chunks של מילה-שתיים ('דיון','דיון וב','סיכום ו') כתוצאות מובילות. מציפים תוצאות ומורידים דירוג תוכן אמיתי. לחקור את chunker.py (פיצול לפי כותרת-סעיף שיוצר chunks ריקים) ולתקן: מינימום אורך chunk / מיזוג כותרת לגוף.",
|
||||||
|
"status": "done",
|
||||||
|
"priority": "medium",
|
||||||
|
"dependencies": [
|
||||||
|
"54"
|
||||||
|
],
|
||||||
|
"details": "קבצים: mcp-server/src/legal_mcp/services/chunker.py (SECTION_PATTERNS). דורש שיקול re-chunk לרשומות קיימות — לבדוק עלות מול feedback_no_reocr_retrofit (להשתמש בטקסט שמור, לא re-OCR).",
|
||||||
|
"testStrategy": "אין chunks < N תווים בקורפוס אחרי תיקון; search_internal_decisions('אגסי') לא מציג פרגמנטי 'דיון'.",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-30T11:19:23.923Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "56",
|
||||||
|
"title": "[Retrieval finding] halacha_filters לא מסננים source_kind — דליפה חוצת-קורפוסים",
|
||||||
|
"description": "התגלה תוך כדי משימה 53. ב-search_precedent_library_semantic וב-search_precedent_library_lexical (db.py): chunk_filters כוללים cl.source_kind=$sk אבל halacha_filters כוללים רק review_status. תוצאה: search_precedent_library(external) מחזיר גם הלכות internal_committee, ו-search_internal_decisions(internal) מחזיר גם הלכות external. אי-עקביות: chunks מסוננים, halachot לא. כרגע זה דווקא מסייע למציאוּת (לכן לא רגרסיה), אבל לא עקבי. דורש החלטת מדיניות: או לסנן halachot גם לפי source_kind (עקבי, אך 'מסתיר' שכבות), או להשאיר מאוחד במכוון + לתעד. אם משאירים מאוחד — לעדכן docstrings של שני הכלים שזה לא 'corpus נפרד'.",
|
||||||
|
"status": "pending",
|
||||||
|
"priority": "low",
|
||||||
|
"dependencies": [],
|
||||||
|
"details": "db.py: search_precedent_library_semantic (~שורה הקודמת ל-3311), search_precedent_library_lexical (3346). שתי הפונקציות: halacha_filters=['h.review_status IN ...'] — חסר cl.source_kind. נמצא בעת בדיקת רגרסיה למשימה 53.",
|
||||||
|
"testStrategy": "לאחר החלטה: אם מסננים — search_precedent_library('...substantive...', external) לא מחזיר case_law_id internal; אם משאירים — docstring מעודכן + טסט מאשר התנהגות מכוונת.",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-30T11:09:30.257989+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "57",
|
||||||
|
"title": "[Retrieval #55 follow-up] re-chunk+re-embed של פסיקה שהוטמעה לפני תיקון ה-chunker",
|
||||||
|
"description": "משימה 55 תיקנה את ה-chunker (עיגון כותרות + מיזוג) ומסננת את 484 הפרגמנטים בזמן query. הרמדיאציה המלאה: re-chunk מ-full_text השמור (ללא re-OCR — תואם feedback_no_reocr_retrofit) + re-embed, כדי שהתוכן יהיה נכון ולא רק מוסתר. נדחה כי זו מיגרציית-נתונים עם עלות Voyage API על ~13+ תיקים — דורש אישור עלות מ-chaim לפני הרצה. לבדוק כמה תיקים מושפעים (יש להם chunk<50) ולהריץ בקבוצות.",
|
||||||
|
"status": "pending",
|
||||||
|
"priority": "low",
|
||||||
|
"dependencies": [
|
||||||
|
"55"
|
||||||
|
],
|
||||||
|
"details": "מקור: case_law.full_text קיים. נתיב: chunker.chunk_document(_hierarchical) → embeddings → החלפת precedent_chunks לתיק. למחוק chunks ישנים של התיק לפני הוספה. אחרי הרצה — ניתן להסיר את פילטר ה->=50 query (אופציונלי). תיקים מושפעים: SELECT DISTINCT case_law_id WHERE length(trim(content))<50.",
|
||||||
|
"testStrategy": "אחרי re-chunk לתיק לדוגמה: 0 chunks<50 לאותו case_law_id; search_internal_decisions עדיין מחזיר את התיק; ספירת chunks סבירה.",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-30T11:19:06.142606+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "58",
|
||||||
|
"title": "[Case access] get_case_by_number שביר לפורמט — סוכן 'עיוור' למסמכי תיק",
|
||||||
|
"description": "דווח ע\"י chaim: סוכן כתב שחסרים מסמכי תיק כי document_list החזיר ריק, אך המסמכים קיימים. שורש: get_case_by_number (db.py) עושה 'WHERE case_number=$1' התאמה מדויקת בלבד. אומת — 8137-24 מחזיר 9 מסמכים, אבל 8137/24 / 'ערר 8137-24' / רווחים / zero-pad → 'תיק לא נמצא'. הסוכן מקבל את המספר בפורמט שונה (כותרת issue, לוכסן, תחילית ערר/בל\"מ) → התאמה נכשלת → 'אין מסמכים'. משפיע על כל הכלים מבוססי case_number (document_list, extract_references, search_case_documents, get_claims, draft, וכו'). תיקון: נורמליזציה (strip prefix לתחילת ספרה, trim, '/'→'-') + fallback בשאילתה. תיקון נקודה-אחת מתקן את כל הכלים.",
|
||||||
|
"status": "done",
|
||||||
|
"priority": "high",
|
||||||
|
"dependencies": [],
|
||||||
|
"details": "db.py: get_case_by_number (~שורה לאחר get_case). להוסיף _normalize_case_number + שאילתה עם OR על replace(trim(case_number),'/','-')=norm, ORDER BY exact-first. בדיקה: כל הווריאציות של 8137-24 מחזירות 9 מסמכים.",
|
||||||
|
"testStrategy": "document_list על 7 וריאציות פורמט של תיק קיים → כולן מחזירות את אותם מסמכים; תיק לא-קיים אמיתי עדיין מחזיר 'לא נמצא'.",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-30T11:54:34.291Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-05-04T17:29:25.687Z",
|
"lastModified": "2026-05-30T11:54:34.291Z",
|
||||||
"taskCount": 29,
|
"taskCount": 58,
|
||||||
"completedCount": 24,
|
"completedCount": 53,
|
||||||
"tags": [
|
"tags": [
|
||||||
"legal-ai"
|
"legal-ai"
|
||||||
]
|
]
|
||||||
|
|||||||
79
CLAUDE.md
79
CLAUDE.md
@@ -56,6 +56,8 @@
|
|||||||
| [`docs/decision-block-mapping.md`](docs/decision-block-mapping.md) | מיפוי בלוקים להחלטות — איך 12 הבלוקים משתקפים ב-DOCX | להתמצאות במבנה |
|
| [`docs/decision-block-mapping.md`](docs/decision-block-mapping.md) | מיפוי בלוקים להחלטות — איך 12 הבלוקים משתקפים ב-DOCX | להתמצאות במבנה |
|
||||||
| [`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) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
|
||||||
|
| [`.claude/agents/HEARTBEAT.md`](.claude/agents/HEARTBEAT.md) | checklist הפעלת סוכן — routing, company filtering, quirks, wakeup עם UUID נכון | **לפני כל עבודה על סוכנים** |
|
||||||
|
| [`skills/dafna-decision-template/SKILL.md`](skills/dafna-decision-template/SKILL.md) | export DOCX לפי styles של תבנית Word של דפנה — line classification, dash policy, placeholder handling | לפני export DOCX |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -89,6 +91,16 @@
|
|||||||
- שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip`
|
- שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip`
|
||||||
- **אין צורך ב-Docker או Coolify**
|
- **אין צורך ב-Docker או Coolify**
|
||||||
|
|
||||||
|
**legal-chat-service** — רץ **מקומית דרך pm2** (חדש, מאפריל 2026):
|
||||||
|
- פורט: `localhost:8770` (loopback בלבד)
|
||||||
|
- שירות aiohttp קצר שעוטף את `claude` CLI ב-streaming + session continuation, ומשרת את הטאב "שיחה" בדף `/training`. הקונטיינר משדל אליו proxy דרך `host.docker.internal:8770`.
|
||||||
|
- קוד: [mcp-server/src/legal_mcp/chat_service/](mcp-server/src/legal_mcp/chat_service/)
|
||||||
|
- התקנה: `pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs && pm2 save`
|
||||||
|
- בריאות: `curl http://127.0.0.1:8770/health` → `{"ok":true,...}`
|
||||||
|
- שינויי קוד: `pm2 restart legal-chat-service`
|
||||||
|
- **אפס עלות API** — claude CLI משתמש ב-claude.ai subscription של chaim. הנחת היסוד של `claude_session.py` (claude CLI מקומי בלבד) נשמרת — השירות הזה הוא הגשר הרשמי בין הקונטיינר לחוץ.
|
||||||
|
- Coolify dependency: ה-Service Definition של legal-ai חייב להכיל `extra_hosts: host.docker.internal:host-gateway` (אחרת ה-proxy יקבל ConnectError).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## מבנה תיקיות
|
## מבנה תיקיות
|
||||||
@@ -106,18 +118,34 @@
|
|||||||
├── skills/ ← כלי עבודה ומדריכים
|
├── skills/ ← כלי עבודה ומדריכים
|
||||||
│ ├── decision/ מדריך סגנון + references + 12 בלוקים
|
│ ├── decision/ מדריך סגנון + references + 12 בלוקים
|
||||||
│ ├── assistant/ קטלוג מסמכים
|
│ ├── assistant/ קטלוג מסמכים
|
||||||
│ └── docx/ עיצוב DOCX
|
│ ├── docx/ עיצוב DOCX
|
||||||
|
│ ├── dafna-decision-template/ export DOCX לפי תבנית Word של דפנה
|
||||||
|
│ └── new-company-setup/ blueprint הוספת חברה חדשה
|
||||||
|
├── .claude/
|
||||||
|
│ └── agents/ ← הוראות סוכנים + HEARTBEAT.md (symlinks ב-Paperclip)
|
||||||
|
│ ├── HEARTBEAT.md checklist הפעלה משותף לכל הסוכנים
|
||||||
|
│ ├── legal-ceo.md תזמורן + בקרת זרימה
|
||||||
|
│ ├── legal-writer.md כתיבת בלוקים בסגנון דפנה
|
||||||
|
│ ├── legal-analyst.md ניתוח משפטי + חילוץ טענות
|
||||||
|
│ ├── legal-researcher.md חיפוש תקדימים
|
||||||
|
│ ├── legal-qa.md 7 שערי איכות
|
||||||
|
│ ├── legal-proofreader.md תיקון OCR
|
||||||
|
│ ├── legal-exporter.md ייצוא DOCX סופי
|
||||||
|
│ └── hermes-curator.md סוכן Hermes לניתוח סגנון post-export
|
||||||
├── data/
|
├── data/
|
||||||
│ ├── training/ ← 4 החלטות לאימון (DOCX)
|
│ ├── training/ ← 4 החלטות לאימון (DOCX)
|
||||||
│ ├── exports/ ← טיוטות DOCX מיוצאות
|
│ ├── exports/ ← טיוטות DOCX מיוצאות
|
||||||
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
|
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
|
||||||
├── web/ ← FastAPI backend (Python): 75 API endpoints
|
├── web/ ← FastAPI backend (Python): 75+ API endpoints
|
||||||
│ ├── app.py ← API ראשי
|
│ ├── app.py ← API ראשי
|
||||||
│ ├── paperclip_client.py ← אינטגרציית Paperclip
|
│ ├── paperclip_api.py ← אינטגרציית Paperclip: `pc_request()` + `emit_case_status_webhook()`
|
||||||
|
│ ├── paperclip_client.py ← legacy client (ישן — השתמש ב-paperclip_api.py)
|
||||||
│ └── gitea_client.py ← אינטגרציית Gitea
|
│ └── gitea_client.py ← אינטגרציית Gitea
|
||||||
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
|
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
|
||||||
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
|
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
|
||||||
├── mcp-server/ ← MCP server + services + tools
|
├── mcp-server/ ← MCP server + services + tools
|
||||||
|
├── adapters/ ← Paperclip external adapters (ראה למטה)
|
||||||
|
│ └── deepseek-paperclip-adapter/ ← `deepseek_local` (Hermes-pinned ל-DeepSeek profile)
|
||||||
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
|
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
|
||||||
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
|
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
|
||||||
```
|
```
|
||||||
@@ -135,12 +163,14 @@
|
|||||||
|
|
||||||
הפרויקט משתמש ב-**TaskMaster AI** (MCP server) לניהול משימות מובנה:
|
הפרויקט משתמש ב-**TaskMaster AI** (MCP server) לניהול משימות מובנה:
|
||||||
- **תמיד** להשתמש ב-TaskMaster לפירוק, מעקב וניהול משימות — לא ב-TASKS.md ידני
|
- **תמיד** להשתמש ב-TaskMaster לפירוק, מעקב וניהול משימות — לא ב-TASKS.md ידני
|
||||||
- קובץ המשימות: `tasks/tasks.json`
|
- קובץ המשימות הקנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (יחסי ל-project root, **לא** `~/.taskmaster/tasks/tasks.json`). מכיל את כל ה-tags של legal-ai (`master`, `legal-ai`).
|
||||||
- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`
|
- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`
|
||||||
- לפני התחלת עבודה → `next_task` כדי לדעת מה הבא לפי תלויות
|
- לפני התחלת עבודה → `next_task` כדי לדעת מה הבא לפי תלויות
|
||||||
- אחרי סיום משימה → `update_task` עם status=done
|
- אחרי סיום משימה → `update_task` עם status=done
|
||||||
- משימה מורכבת → `expand_task` לפירוק לתתי-משימות
|
- משימה מורכבת → `expand_task` לפירוק לתתי-משימות
|
||||||
|
|
||||||
|
> **⚠️ מלכוד cwd ב-CLI:** הדגל `--tag` בוחר קבוצה לוגית *בתוך* הקובץ — הוא **לא** בוחר לאיזה `tasks.json` לכתוב. ה-CLI מאתר את הקובץ לפי ה-cwd (`<cwd>/.taskmaster/tasks/tasks.json`). תמיד `cd ~/legal-ai` לפני `task-master add-task` או כל פקודה משנה, ואז אמת ב-MCP `get_tasks` שהשינוי נחת. הרצה מ-`~/` כותבת לקובץ נטוש והמשימה לא תופיע בשאילתות MCP. כשלא בטוחים — לערוך את `~/legal-ai/.taskmaster/tasks/tasks.json` ישירות.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Paperclip — כללי אינטגרציה קריטיים
|
## Paperclip — כללי אינטגרציה קריטיים
|
||||||
@@ -180,6 +210,47 @@
|
|||||||
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
|
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
|
||||||
- שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`.
|
- שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`.
|
||||||
|
|
||||||
|
### Webhook יוצא — עדכון סטטוס תיק לפלאגין
|
||||||
|
|
||||||
|
כשסטטוס תיק משתנה דרך `PUT /api/cases/{case_number}`, הבקאנד שולח webhook אסינכרוני לפלאגין:
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/cases/{case_number} → emit_case_status_webhook() [BackgroundTask]
|
||||||
|
→ POST /api/plugins/marcusgroup.legal-ai/webhooks/case-status
|
||||||
|
→ plugin-legal-ai/onWebhook()
|
||||||
|
→ comment בעברית על issue + CEO wakeup (כשסטטוס = qa_failed)
|
||||||
|
```
|
||||||
|
|
||||||
|
- הקוד ב-`web/paperclip_api.py` (`emit_case_status_webhook`), fire-and-forget, timeout 5s
|
||||||
|
- הפלאגין שומר idempotency key ב-state עם TTL 5 דקות למניעת spam על retry
|
||||||
|
- `GET /api/cases/stale?days=N` — תיקים שלא עודכנו N ימים; מוחרגים: `new`, `final`, `exported`
|
||||||
|
- `GET /api/chair-feedback/weekly-summary` — סיכום פידבק YU"R לשבוע האחרון
|
||||||
|
|
||||||
|
### Scheduled Jobs (plugin-legal-ai)
|
||||||
|
|
||||||
|
| Job | לוח זמנים | מה עושה |
|
||||||
|
|-----|-----------|---------|
|
||||||
|
| `stale-case-reminder` | יומי 08:00 | שולח comment אזהרה על תיקים תקועים >3 ימים |
|
||||||
|
| `weekly-feedback-analysis` | ראשון 19:00 | מעיר CEO לניתוח פידבק YU"R ועדכון `docs/legal-decision-lessons.md` |
|
||||||
|
| `sync-case-status` | כל 30 דק' | מסנכרן סטטוסי תיקים בין legal-ai ל-Paperclip |
|
||||||
|
|
||||||
|
CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **אין לו issueId, אל תנסה לפרסם comment או לסגור issue**.
|
||||||
|
|
||||||
|
### External adapters — `deepseek_local`
|
||||||
|
- מיקום ה-package: [adapters/deepseek-paperclip-adapter/](adapters/deepseek-paperclip-adapter/) (לא ב-`node_modules`).
|
||||||
|
- רישום ב-Paperclip: רשומה ב-`~/.paperclip/adapter-plugins.json` (נטען אוטומטית ב-startup דרך `buildExternalAdapters`). אין צורך בעריכת `node_modules`.
|
||||||
|
- **מה ה-adapter עושה**: spawnל-`hermes chat` עם `HERMES_HOME=/home/chaim/.hermes/profiles/deepseek` כך שה-CLI טוען את `config.yaml` (`base_url=https://api.deepseek.com/v1`, `provider=custom`, `key_env=DEEPSEEK_API_KEY`) ואת `.env` (שמכיל את ה-key).
|
||||||
|
- **מודלים זמינים** (lookup ב-DeepSeek `/v1/models`): `deepseek-v4-pro` (default), `deepseek-v4-flash`. יופיעו כדרופ-דאון ב-UI.
|
||||||
|
- **התקנה מחדש / עדכון**: `curl -X POST -H "Authorization: Bearer pcapi_legal_install_key_2026" -H "Content-Type: application/json" -d '{"packageName":"/home/chaim/legal-ai/adapters/deepseek-paperclip-adapter","isLocalPath":true}' http://localhost:3100/api/adapters/install`. לעדכון hot — `POST /api/adapters/deepseek_local/reload`.
|
||||||
|
- **⚠ Cross-company sync**: `sync_agents_across_companies.py` **מדלג** על סוכנים עם `adapter_type` שונה בין CMP ל-CMPA. כשעוברים סוכן ל-`deepseek_local` חובה להחיל ידנית בשתי החברות לפני sync.
|
||||||
|
- **תוספת adapters עתידיים** (OpenAI ישיר, Anthropic ישיר, וכו'): אותו דפוס. ה-package הראשי חייב לייצא `createServerAdapter()` שמחזיר `{ type, label, models, agentConfigurationDoc, execute, testEnvironment, sessionCodec, listSkills, syncSkills, ... }`. ראה את [adapters/deepseek-paperclip-adapter/dist/index.js](adapters/deepseek-paperclip-adapter/dist/index.js) כתבנית.
|
||||||
|
|
||||||
|
### External adapters — Hermes Curator (`curator-cmp` / `curator-cmpa`)
|
||||||
|
- פרופילי Hermes נפרדים לסוכן `hermes-curator` — מנתח החלטות סופיות ומציע עדכוני SKILL.md/lessons.md
|
||||||
|
- מיקום: `~/.hermes/profiles/curator-cmp/` + `~/.hermes/profiles/curator-cmpa/`
|
||||||
|
- מופעל אחרי export סופי; אינו מעדכן קבצים ישירות
|
||||||
|
- **תהליך אישור הצעות:** הצעות ה-curator מגיעות כ-comment ב-Paperclip → חיים בוחן ומאשר ידנית → commits ל-`SKILL.md` ו-`docs/legal-decision-lessons.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## עקרונות כתיבה קריטיים
|
## עקרונות כתיבה קריטיים
|
||||||
|
|||||||
12
Dockerfile
12
Dockerfile
@@ -61,6 +61,18 @@ COPY mcp-server/src/ ./mcp-server/src/
|
|||||||
# (Path(__file__).resolve().parents[4] / "skills/docx/decision_template.docx")
|
# (Path(__file__).resolve().parents[4] / "skills/docx/decision_template.docx")
|
||||||
COPY skills/docx/decision_template.docx ./skills/docx/decision_template.docx
|
COPY skills/docx/decision_template.docx ./skills/docx/decision_template.docx
|
||||||
|
|
||||||
|
# Reference content the /training tab reads at runtime:
|
||||||
|
# - .claude/agents/hermes-curator.md → GET /api/training/curator/prompt
|
||||||
|
# - skills/decision/SKILL.md → system prompt for the chat
|
||||||
|
# - docs/legal-decision-lessons.md → system prompt for the chat
|
||||||
|
# - docs/corpus-analysis.md → system prompt for the chat
|
||||||
|
#
|
||||||
|
# These are read-only at runtime; chair edits go through git, not the container.
|
||||||
|
COPY .claude/agents/hermes-curator.md ./.claude/agents/hermes-curator.md
|
||||||
|
COPY skills/decision/SKILL.md ./skills/decision/SKILL.md
|
||||||
|
COPY docs/legal-decision-lessons.md ./docs/legal-decision-lessons.md
|
||||||
|
COPY docs/corpus-analysis.md ./docs/corpus-analysis.md
|
||||||
|
|
||||||
# Make mcp-server source available to web/app.py (it does sys.path.insert for legal_mcp)
|
# Make mcp-server source available to web/app.py (it does sys.path.insert for legal_mcp)
|
||||||
ENV PYTHONPATH=/app/mcp-server/src
|
ENV PYTHONPATH=/app/mcp-server/src
|
||||||
|
|
||||||
|
|||||||
99
adapters/deepseek-paperclip-adapter/dist/index.js
vendored
Normal file
99
adapters/deepseek-paperclip-adapter/dist/index.js
vendored
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* DeepSeek (via Hermes) — external Paperclip adapter.
|
||||||
|
*
|
||||||
|
* Loaded by Paperclip's plugin-loader. Contract:
|
||||||
|
* The package's main module must export createServerAdapter() returning
|
||||||
|
* a single ServerAdapterModule object with all fields wired in.
|
||||||
|
*
|
||||||
|
* Runtime: spawns the local `hermes` CLI with HERMES_HOME pinned to a
|
||||||
|
* DeepSeek profile that defines model.base_url=https://api.deepseek.com/v1
|
||||||
|
* and model.key_env=DEEPSEEK_API_KEY.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ADAPTER_TYPE,
|
||||||
|
ADAPTER_LABEL,
|
||||||
|
DEEPSEEK_MODELS,
|
||||||
|
DEFAULT_PROFILE_HOME,
|
||||||
|
} from "./shared/constants.js";
|
||||||
|
import { execute } from "./server/execute.js";
|
||||||
|
import { testEnvironment } from "./server/test.js";
|
||||||
|
import { sessionCodec } from "./server/session-codec.js";
|
||||||
|
import { listSkills, syncSkills } from "./server/skills.js";
|
||||||
|
|
||||||
|
const AGENT_CONFIGURATION_DOC = `# DeepSeek (via Hermes) — Agent Configuration
|
||||||
|
|
||||||
|
DeepSeek-pinned variant of the Hermes adapter. Runs the local \`hermes\` CLI
|
||||||
|
with \`HERMES_HOME\` pointed at a DeepSeek profile (\`config.yaml\` declares
|
||||||
|
\`base_url=https://api.deepseek.com/v1\` and \`key_env=DEEPSEEK_API_KEY\`).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Hermes Agent installed (\`pip install hermes-agent\`) — \`hermes --version\` works.
|
||||||
|
- DeepSeek profile dir exists (default: \`/home/chaim/.hermes/profiles/deepseek\`)
|
||||||
|
with \`config.yaml\` + \`.env\` (containing \`DEEPSEEK_API_KEY\`).
|
||||||
|
|
||||||
|
## Core Configuration
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| model | string | \`deepseek-v4-pro\` | DeepSeek model id (\`deepseek-v4-pro\` or \`deepseek-v4-flash\`). |
|
||||||
|
| provider | string | \`custom\` | Hermes provider name. The DeepSeek profile defines \`provider: custom\` so \`custom\` is the right value. |
|
||||||
|
| hermesProfileHome | string | \`/home/chaim/.hermes/profiles/deepseek\` | Absolute path to a Hermes profile dir. Set per-agent if you maintain multiple DeepSeek profiles. |
|
||||||
|
| timeoutSec | number | 1800 | Execution timeout in seconds. |
|
||||||
|
| graceSec | number | 30 | SIGTERM grace period in seconds. |
|
||||||
|
|
||||||
|
## Tools / Workspace
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| toolsets | string | (profile default) | Comma-separated toolsets to enable. |
|
||||||
|
| persistSession | boolean | true | Resume sessions across heartbeats via \`--resume\`. |
|
||||||
|
| worktreeMode | boolean | false | Use git worktree for isolated changes. |
|
||||||
|
| checkpoints | boolean | false | Enable filesystem checkpoints. |
|
||||||
|
|
||||||
|
## Advanced
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| hermesCommand | string | \`hermes\` | Path to the hermes binary. |
|
||||||
|
| verbose | boolean | false | Enable verbose Hermes logs. |
|
||||||
|
| extraArgs | string[] | [] | Extra CLI args appended after standard flags. |
|
||||||
|
| env | object | {} | Extra environment variables passed to Hermes. \`HERMES_HOME\` here overrides \`hermesProfileHome\`. |
|
||||||
|
| promptTemplate | string | (default) | Override the default Paperclip wakeup prompt. |
|
||||||
|
| paperclipApiUrl | string | \`http://127.0.0.1:3100/api\` | Paperclip API URL injected into the prompt template. |
|
||||||
|
|
||||||
|
## Available template variables
|
||||||
|
|
||||||
|
\`{{agentId}}\`, \`{{agentName}}\`, \`{{companyId}}\`, \`{{companyName}}\`,
|
||||||
|
\`{{runId}}\`, \`{{taskId}}\`, \`{{taskTitle}}\`, \`{{taskBody}}\`,
|
||||||
|
\`{{commentId}}\`, \`{{wakeReason}}\`, \`{{projectName}}\`, \`{{paperclipApiUrl}}\`.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function createServerAdapter() {
|
||||||
|
return {
|
||||||
|
type: ADAPTER_TYPE,
|
||||||
|
label: ADAPTER_LABEL,
|
||||||
|
models: DEEPSEEK_MODELS,
|
||||||
|
agentConfigurationDoc: AGENT_CONFIGURATION_DOC,
|
||||||
|
|
||||||
|
execute,
|
||||||
|
testEnvironment,
|
||||||
|
sessionCodec,
|
||||||
|
listSkills,
|
||||||
|
syncSkills,
|
||||||
|
|
||||||
|
// Capability flags
|
||||||
|
supportsLocalAgentJwt: true,
|
||||||
|
supportsInstructionsBundle: false,
|
||||||
|
requiresMaterializedRuntimeSkills: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also export the loose constants for any caller that wants to inspect
|
||||||
|
// the package without invoking createServerAdapter (e.g., test harnesses).
|
||||||
|
export const type = ADAPTER_TYPE;
|
||||||
|
export const label = ADAPTER_LABEL;
|
||||||
|
export const models = DEEPSEEK_MODELS;
|
||||||
|
export const agentConfigurationDoc = AGENT_CONFIGURATION_DOC;
|
||||||
|
export const defaultProfileHome = DEFAULT_PROFILE_HOME;
|
||||||
352
adapters/deepseek-paperclip-adapter/dist/server/execute.js
vendored
Normal file
352
adapters/deepseek-paperclip-adapter/dist/server/execute.js
vendored
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
/**
|
||||||
|
* Server-side execution for the DeepSeek-via-Hermes adapter.
|
||||||
|
*
|
||||||
|
* Spawns `hermes chat -q "..." -Q -m <model> --provider custom` with
|
||||||
|
* HERMES_HOME pinned to a DeepSeek-configured profile so the same machine
|
||||||
|
* can run other Hermes-based agents on different providers in parallel.
|
||||||
|
*
|
||||||
|
* The Hermes CLI loads model.base_url, model.key_env (DEEPSEEK_API_KEY),
|
||||||
|
* and toolsets from <HERMES_HOME>/config.yaml + <HERMES_HOME>/.env.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
runChildProcess,
|
||||||
|
buildPaperclipEnv,
|
||||||
|
renderTemplate,
|
||||||
|
ensureAbsoluteDirectory,
|
||||||
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import {
|
||||||
|
HERMES_CLI,
|
||||||
|
DEFAULT_PROFILE_HOME,
|
||||||
|
DEFAULT_MODEL,
|
||||||
|
DEFAULT_PROVIDER,
|
||||||
|
DEFAULT_TIMEOUT_SEC,
|
||||||
|
DEFAULT_GRACE_SEC,
|
||||||
|
SESSION_ID_REGEX,
|
||||||
|
SESSION_ID_REGEX_LEGACY,
|
||||||
|
TOKEN_USAGE_REGEX,
|
||||||
|
COST_REGEX,
|
||||||
|
} from "../shared/constants.js";
|
||||||
|
|
||||||
|
function cfgString(v) {
|
||||||
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||||
|
}
|
||||||
|
function cfgNumber(v) {
|
||||||
|
return typeof v === "number" ? v : undefined;
|
||||||
|
}
|
||||||
|
function cfgBoolean(v) {
|
||||||
|
return typeof v === "boolean" ? v : undefined;
|
||||||
|
}
|
||||||
|
function cfgStringArray(v) {
|
||||||
|
return Array.isArray(v) && v.every((i) => typeof i === "string") ? v : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROMPT_TEMPLATE = `You are "{{agentName}}", an AI agent employee in a Paperclip-managed company powered by DeepSeek.
|
||||||
|
|
||||||
|
IMPORTANT: Use the \`terminal\` tool with \`curl\` for ALL Paperclip API calls (web_extract and browser cannot access localhost).
|
||||||
|
|
||||||
|
Your Paperclip identity:
|
||||||
|
Agent ID: {{agentId}}
|
||||||
|
Company ID: {{companyId}}
|
||||||
|
API Base: {{paperclipApiUrl}}
|
||||||
|
|
||||||
|
{{#taskId}}
|
||||||
|
## Assigned Task
|
||||||
|
|
||||||
|
Issue ID: {{taskId}}
|
||||||
|
Title: {{taskTitle}}
|
||||||
|
|
||||||
|
{{taskBody}}
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Work on the task using your tools.
|
||||||
|
2. When done, mark the issue completed:
|
||||||
|
\`curl -s -X PATCH "{{paperclipApiUrl}}/issues/{{taskId}}" -H "Content-Type: application/json" -d '{"status":"done"}'\`
|
||||||
|
3. Post a completion comment summarizing what you did:
|
||||||
|
\`curl -s -X POST "{{paperclipApiUrl}}/issues/{{taskId}}/comments" -H "Content-Type: application/json" -d '{"body":"DONE: <your summary here>"}'\`
|
||||||
|
{{/taskId}}
|
||||||
|
|
||||||
|
{{#commentId}}
|
||||||
|
## Comment on This Issue
|
||||||
|
|
||||||
|
Someone commented. Read it:
|
||||||
|
\`curl -s "{{paperclipApiUrl}}/issues/{{taskId}}/comments/{{commentId}}" | python3 -m json.tool\`
|
||||||
|
Address the comment, POST a reply if needed, then continue working.
|
||||||
|
{{/commentId}}
|
||||||
|
|
||||||
|
{{#noTask}}
|
||||||
|
## Heartbeat Wake — Check for Work
|
||||||
|
|
||||||
|
1. List your open issues:
|
||||||
|
\`curl -s "{{paperclipApiUrl}}/companies/{{companyId}}/issues?assigneeAgentId={{agentId}}"\`
|
||||||
|
2. Pick the highest priority and work on it. When done, follow steps 2-3 above.
|
||||||
|
3. If nothing to do, report briefly what you checked.
|
||||||
|
{{/noTask}}`;
|
||||||
|
|
||||||
|
function buildPrompt(ctx, config) {
|
||||||
|
const template = cfgString(config.promptTemplate) || DEFAULT_PROMPT_TEMPLATE;
|
||||||
|
const taskId = cfgString(ctx.context?.taskId);
|
||||||
|
const taskTitle = cfgString(ctx.context?.taskTitle) || "";
|
||||||
|
const taskBody = cfgString(ctx.context?.taskBody) || "";
|
||||||
|
const commentId = cfgString(ctx.context?.commentId) || "";
|
||||||
|
const wakeReason = cfgString(ctx.context?.wakeReason) || "";
|
||||||
|
const agentName = ctx.agent?.name || "DeepSeek Agent";
|
||||||
|
const companyName = cfgString(ctx.context?.companyName) || "";
|
||||||
|
const projectName = cfgString(ctx.context?.projectName) || "";
|
||||||
|
|
||||||
|
let paperclipApiUrl =
|
||||||
|
cfgString(config.paperclipApiUrl) ||
|
||||||
|
process.env.PAPERCLIP_API_URL ||
|
||||||
|
"http://127.0.0.1:3100/api";
|
||||||
|
if (!paperclipApiUrl.endsWith("/api")) {
|
||||||
|
paperclipApiUrl = paperclipApiUrl.replace(/\/+$/, "") + "/api";
|
||||||
|
}
|
||||||
|
|
||||||
|
const vars = {
|
||||||
|
agentId: ctx.agent?.id || "",
|
||||||
|
agentName,
|
||||||
|
companyId: ctx.agent?.companyId || "",
|
||||||
|
companyName,
|
||||||
|
runId: ctx.runId || "",
|
||||||
|
taskId: taskId || "",
|
||||||
|
taskTitle,
|
||||||
|
taskBody,
|
||||||
|
commentId,
|
||||||
|
wakeReason,
|
||||||
|
projectName,
|
||||||
|
paperclipApiUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rendered = template;
|
||||||
|
rendered = rendered.replace(/\{\{#taskId\}\}([\s\S]*?)\{\{\/taskId\}\}/g, taskId ? "$1" : "");
|
||||||
|
rendered = rendered.replace(/\{\{#noTask\}\}([\s\S]*?)\{\{\/noTask\}\}/g, taskId ? "" : "$1");
|
||||||
|
rendered = rendered.replace(/\{\{#commentId\}\}([\s\S]*?)\{\{\/commentId\}\}/g, commentId ? "$1" : "");
|
||||||
|
return renderTemplate(rendered, vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanResponse(raw) {
|
||||||
|
return raw
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) return true;
|
||||||
|
if (t.startsWith("[tool]") || t.startsWith("[hermes]") || t.startsWith("[paperclip]") || t.startsWith("[deepseek]")) return false;
|
||||||
|
if (t.startsWith("session_id:")) return false;
|
||||||
|
if (/^\[\d{4}-\d{2}-\d{2}T/.test(t)) return false;
|
||||||
|
if (/^\[done\]\s*┊/.test(t)) return false;
|
||||||
|
if (/^┊\s*[\p{Emoji_Presentation}]/u.test(t) && !/^┊\s*💬/.test(t)) return false;
|
||||||
|
if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(t)) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((line) => {
|
||||||
|
let t = line.replace(/^[\s]*┊\s*💬\s*/, "").trim();
|
||||||
|
t = t.replace(/^\[done\]\s*/, "").trim();
|
||||||
|
return t;
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHermesOutput(stdout, stderr) {
|
||||||
|
const combined = stdout + "\n" + stderr;
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
const sessionMatch = stdout.match(SESSION_ID_REGEX);
|
||||||
|
if (sessionMatch?.[1]) {
|
||||||
|
result.sessionId = sessionMatch[1];
|
||||||
|
const sessionLineIdx = stdout.lastIndexOf("\nsession_id:");
|
||||||
|
if (sessionLineIdx > 0) {
|
||||||
|
result.response = cleanResponse(stdout.slice(0, sessionLineIdx));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const legacyMatch = combined.match(SESSION_ID_REGEX_LEGACY);
|
||||||
|
if (legacyMatch?.[1]) result.sessionId = legacyMatch[1];
|
||||||
|
const cleaned = cleanResponse(stdout);
|
||||||
|
if (cleaned.length > 0) result.response = cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageMatch = combined.match(TOKEN_USAGE_REGEX);
|
||||||
|
if (usageMatch) {
|
||||||
|
result.usage = {
|
||||||
|
inputTokens: parseInt(usageMatch[1], 10) || 0,
|
||||||
|
outputTokens: parseInt(usageMatch[2], 10) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const costMatch = combined.match(COST_REGEX);
|
||||||
|
if (costMatch?.[1]) result.costUsd = parseFloat(costMatch[1]);
|
||||||
|
|
||||||
|
if (stderr.trim()) {
|
||||||
|
const errorLines = stderr
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => /error|exception|traceback|failed/i.test(line))
|
||||||
|
.filter((line) => !/INFO|DEBUG|warn/i.test(line));
|
||||||
|
if (errorLines.length > 0) result.errorMessage = errorLines.slice(0, 5).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function execute(ctx) {
|
||||||
|
const config = ctx.agent?.adapterConfig ?? {};
|
||||||
|
|
||||||
|
const hermesCmd = cfgString(config.hermesCommand) || HERMES_CLI;
|
||||||
|
const model = cfgString(config.model) || DEFAULT_MODEL;
|
||||||
|
const provider = cfgString(config.provider) || DEFAULT_PROVIDER;
|
||||||
|
const profileHome = cfgString(config.hermesProfileHome) || DEFAULT_PROFILE_HOME;
|
||||||
|
const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC;
|
||||||
|
const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC;
|
||||||
|
const toolsets = cfgString(config.toolsets) || cfgStringArray(config.enabledToolsets)?.join(",");
|
||||||
|
const extraArgs = cfgStringArray(config.extraArgs);
|
||||||
|
const persistSession = cfgBoolean(config.persistSession) !== false;
|
||||||
|
const worktreeMode = cfgBoolean(config.worktreeMode) === true;
|
||||||
|
const checkpoints = cfgBoolean(config.checkpoints) === true;
|
||||||
|
const useQuiet = cfgBoolean(config.quiet) !== false;
|
||||||
|
|
||||||
|
const prompt = buildPrompt(ctx, config);
|
||||||
|
|
||||||
|
const args = ["chat", "-q", prompt];
|
||||||
|
if (useQuiet) args.push("-Q");
|
||||||
|
if (model) args.push("-m", model);
|
||||||
|
args.push("--provider", provider);
|
||||||
|
if (toolsets) args.push("-t", toolsets);
|
||||||
|
if (worktreeMode) args.push("-w");
|
||||||
|
if (checkpoints) args.push("--checkpoints");
|
||||||
|
if (cfgBoolean(config.verbose) === true) args.push("-v");
|
||||||
|
args.push("--source", "tool");
|
||||||
|
args.push("--yolo");
|
||||||
|
|
||||||
|
const prevSessionId = cfgString(ctx.runtime?.sessionParams?.sessionId);
|
||||||
|
if (persistSession && prevSessionId) args.push("--resume", prevSessionId);
|
||||||
|
if (extraArgs?.length) args.push(...extraArgs);
|
||||||
|
|
||||||
|
// Pin Hermes to the DeepSeek profile by default. The agent can override
|
||||||
|
// by setting adapter_config.hermesProfileHome or adapter_config.env.HERMES_HOME.
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
...buildPaperclipEnv(ctx.agent),
|
||||||
|
HERMES_HOME: profileHome,
|
||||||
|
};
|
||||||
|
if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId;
|
||||||
|
const taskId = cfgString(ctx.context?.taskId);
|
||||||
|
if (taskId) env.PAPERCLIP_TASK_ID = taskId;
|
||||||
|
|
||||||
|
// Parity with hermes_local (paperclip-src/server/src/adapters/registry.ts:267):
|
||||||
|
// inject the per-run agent auth token so the agent can call the Paperclip API.
|
||||||
|
// Without this, every Paperclip API write from the running agent fails with 401.
|
||||||
|
//
|
||||||
|
// Resolve env from the runtime-resolved config (ctx.config.env contains plain
|
||||||
|
// strings — Paperclip's secrets service unwraps {type:"plain"|"secret_ref", ...}
|
||||||
|
// bindings before invocation in services/heartbeat.ts:5433-5437).
|
||||||
|
// Fall back to agent.adapterConfig.env with manual unwrapping for older paths.
|
||||||
|
function unwrapEnvValue(v) {
|
||||||
|
if (typeof v === "string") return v;
|
||||||
|
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||||
|
if (v.type === "plain" && typeof v.value === "string") return v.value;
|
||||||
|
}
|
||||||
|
return undefined; // skip secret_ref / unknown types — let resolver handle them
|
||||||
|
}
|
||||||
|
const resolvedUserEnv =
|
||||||
|
ctx.config && typeof ctx.config === "object" && ctx.config.env && typeof ctx.config.env === "object" && !Array.isArray(ctx.config.env)
|
||||||
|
? ctx.config.env
|
||||||
|
: null;
|
||||||
|
const rawUserEnv =
|
||||||
|
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||||
|
? config.env
|
||||||
|
: {};
|
||||||
|
// Prefer pre-resolved values from ctx.config.env when available; fall back to
|
||||||
|
// unwrapping raw bindings from agent.adapterConfig.env.
|
||||||
|
const flattenedUserEnv = {};
|
||||||
|
for (const [k, v] of Object.entries(rawUserEnv)) {
|
||||||
|
const resolved = resolvedUserEnv && typeof resolvedUserEnv[k] === "string" ? resolvedUserEnv[k] : unwrapEnvValue(v);
|
||||||
|
if (typeof resolved === "string") flattenedUserEnv[k] = resolved;
|
||||||
|
}
|
||||||
|
const userEnvApiKey = flattenedUserEnv.PAPERCLIP_API_KEY;
|
||||||
|
const explicitApiKey =
|
||||||
|
typeof userEnvApiKey === "string" && userEnvApiKey.trim().length > 0;
|
||||||
|
if (ctx.authToken && !explicitApiKey) env.PAPERCLIP_API_KEY = ctx.authToken;
|
||||||
|
|
||||||
|
// Apply unwrapped user env (may override HERMES_HOME, OPENAI_API_KEY, etc.).
|
||||||
|
Object.assign(env, flattenedUserEnv);
|
||||||
|
|
||||||
|
const cwd = cfgString(config.cwd) || cfgString(ctx.config?.workspaceDir) || ".";
|
||||||
|
try {
|
||||||
|
await ensureAbsoluteDirectory(cwd);
|
||||||
|
} catch {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[deepseek] Starting Hermes (model=${model}, provider=${provider}, profileHome=${env.HERMES_HOME}, timeout=${timeoutSec}s)\n`,
|
||||||
|
);
|
||||||
|
if (prevSessionId) {
|
||||||
|
await ctx.onLog("stdout", `[deepseek] Resuming session: ${prevSessionId}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reclassify benign Hermes stderr lines as stdout so the UI doesn't paint them red.
|
||||||
|
const wrappedOnLog = async (stream, chunk) => {
|
||||||
|
if (stream === "stderr") {
|
||||||
|
const trimmed = chunk.trimEnd();
|
||||||
|
const isBenign =
|
||||||
|
/^\[?\d{4}[-/]\d{2}[-/]\d{2}T/.test(trimmed) ||
|
||||||
|
/^[A-Z]+:\s+(INFO|DEBUG|WARN|WARNING)\b/.test(trimmed) ||
|
||||||
|
/Successfully registered all tools/.test(trimmed) ||
|
||||||
|
/MCP [Ss]erver/.test(trimmed) ||
|
||||||
|
/tool registered successfully/.test(trimmed) ||
|
||||||
|
/Application initialized/.test(trimmed);
|
||||||
|
if (isBenign) return ctx.onLog("stdout", chunk);
|
||||||
|
}
|
||||||
|
return ctx.onLog(stream, chunk);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forward ctx.onSpawn so Paperclip persists processPid/processGroupId to the
|
||||||
|
// heartbeat_runs row. Without it, the reaper cannot verify the child is alive
|
||||||
|
// (run.processPid is null) and treats the run as orphaned during long quiet
|
||||||
|
// phases (DeepSeek V4-Pro thinking can be silent for 60-90s per turn).
|
||||||
|
const result = await runChildProcess(ctx.runId, hermesCmd, args, {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec,
|
||||||
|
graceSec,
|
||||||
|
onLog: wrappedOnLog,
|
||||||
|
onSpawn: ctx.onSpawn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = parseHermesOutput(result.stdout || "", result.stderr || "");
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[deepseek] Exit code: ${result.exitCode ?? "null"}, timed out: ${result.timedOut}\n`,
|
||||||
|
);
|
||||||
|
if (parsed.sessionId) {
|
||||||
|
await ctx.onLog("stdout", `[deepseek] Session: ${parsed.sessionId}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionResult = {
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
signal: result.signal,
|
||||||
|
timedOut: result.timedOut,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
if (parsed.errorMessage) executionResult.errorMessage = parsed.errorMessage;
|
||||||
|
if (parsed.usage) executionResult.usage = parsed.usage;
|
||||||
|
if (parsed.costUsd !== undefined) executionResult.costUsd = parsed.costUsd;
|
||||||
|
if (parsed.response) executionResult.summary = parsed.response.slice(0, 2000);
|
||||||
|
|
||||||
|
executionResult.resultJson = {
|
||||||
|
result: parsed.response || "",
|
||||||
|
session_id: parsed.sessionId || null,
|
||||||
|
usage: parsed.usage || null,
|
||||||
|
cost_usd: parsed.costUsd ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (persistSession && parsed.sessionId) {
|
||||||
|
executionResult.sessionParams = { sessionId: parsed.sessionId };
|
||||||
|
executionResult.sessionDisplayId = parsed.sessionId.slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
return executionResult;
|
||||||
|
}
|
||||||
29
adapters/deepseek-paperclip-adapter/dist/server/session-codec.js
vendored
Normal file
29
adapters/deepseek-paperclip-adapter/dist/server/session-codec.js
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Session codec — Hermes uses a single sessionId for cross-heartbeat continuity
|
||||||
|
* via the --resume CLI flag. Same shape as the Hermes adapter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function readNonEmptyString(value) {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionCodec = {
|
||||||
|
deserialize(raw) {
|
||||||
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||||
|
const sessionId =
|
||||||
|
readNonEmptyString(raw.sessionId) ?? readNonEmptyString(raw.session_id);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
return { sessionId };
|
||||||
|
},
|
||||||
|
serialize(params) {
|
||||||
|
if (!params) return null;
|
||||||
|
const sessionId =
|
||||||
|
readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
return { sessionId };
|
||||||
|
},
|
||||||
|
getDisplayId(params) {
|
||||||
|
if (!params) return null;
|
||||||
|
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||||
|
},
|
||||||
|
};
|
||||||
171
adapters/deepseek-paperclip-adapter/dist/server/skills.js
vendored
Normal file
171
adapters/deepseek-paperclip-adapter/dist/server/skills.js
vendored
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* Skill snapshot for the DeepSeek-via-Hermes adapter.
|
||||||
|
*
|
||||||
|
* Hermes manages its own skills under ~/.hermes/skills/ (global; not per-profile).
|
||||||
|
* Paperclip-managed skills declared in adapter config are surfaced as
|
||||||
|
* "company_managed" entries — same behavior as the upstream Hermes adapter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
readPaperclipRuntimeSkillEntries,
|
||||||
|
resolvePaperclipDesiredSkillNames,
|
||||||
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import { ADAPTER_TYPE } from "../shared/constants.js";
|
||||||
|
|
||||||
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function asString(value) {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSkillFrontmatter(content) {
|
||||||
|
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||||
|
if (!match) return {};
|
||||||
|
const fm = {};
|
||||||
|
for (const line of match[1].split("\n")) {
|
||||||
|
const idx = line.indexOf(":");
|
||||||
|
if (idx === -1) continue;
|
||||||
|
const key = line.slice(0, idx).trim();
|
||||||
|
let val = line.slice(idx + 1).trim();
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
fm[key] = val;
|
||||||
|
}
|
||||||
|
return fm;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSkillEntry(key, skillMdPath, categoryPath) {
|
||||||
|
let description = null;
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(skillMdPath, "utf8");
|
||||||
|
description = parseSkillFrontmatter(content).description ?? null;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
runtimeName: key,
|
||||||
|
desired: true,
|
||||||
|
managed: false,
|
||||||
|
state: "installed",
|
||||||
|
origin: "user_installed",
|
||||||
|
originLabel: "Hermes skill",
|
||||||
|
locationLabel: `~/.hermes/skills/${categoryPath}`,
|
||||||
|
readOnly: true,
|
||||||
|
sourcePath: skillMdPath,
|
||||||
|
targetPath: null,
|
||||||
|
detail: description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanHermesSkills(skillsHome) {
|
||||||
|
const entries = [];
|
||||||
|
try {
|
||||||
|
const cats = await fs.readdir(skillsHome, { withFileTypes: true });
|
||||||
|
for (const cat of cats) {
|
||||||
|
if (!cat.isDirectory()) continue;
|
||||||
|
const catPath = path.join(skillsHome, cat.name);
|
||||||
|
const topSkill = path.join(catPath, "SKILL.md");
|
||||||
|
if (await fs.stat(topSkill).catch(() => null)) {
|
||||||
|
entries.push(await buildSkillEntry(cat.name, topSkill, cat.name));
|
||||||
|
}
|
||||||
|
const items = await fs.readdir(catPath, { withFileTypes: true }).catch(() => []);
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.isDirectory()) continue;
|
||||||
|
const skillMd = path.join(catPath, item.name, "SKILL.md");
|
||||||
|
if (await fs.stat(skillMd).catch(() => null)) {
|
||||||
|
entries.push(await buildSkillEntry(item.name, skillMd, `${cat.name}/${item.name}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ~/.hermes/skills/ doesn't exist
|
||||||
|
}
|
||||||
|
return entries.sort((a, b) => a.key.localeCompare(b.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSnapshot(config) {
|
||||||
|
const homedir =
|
||||||
|
asString(config.env?.HOME) ??
|
||||||
|
process.env.HOME ??
|
||||||
|
"/home/chaim";
|
||||||
|
const hermesSkillsHome = path.join(homedir, ".hermes", "skills");
|
||||||
|
|
||||||
|
const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
|
const desiredSkills = resolvePaperclipDesiredSkillNames(config, paperclipEntries);
|
||||||
|
const desiredSet = new Set(desiredSkills);
|
||||||
|
const availableByKey = new Map(paperclipEntries.map((e) => [e.key, e]));
|
||||||
|
|
||||||
|
const hermesSkillEntries = await scanHermesSkills(hermesSkillsHome);
|
||||||
|
const hermesKeys = new Set(hermesSkillEntries.map((e) => e.key));
|
||||||
|
|
||||||
|
const entries = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
for (const entry of paperclipEntries) {
|
||||||
|
const desired = desiredSet.has(entry.key);
|
||||||
|
entries.push({
|
||||||
|
key: entry.key,
|
||||||
|
runtimeName: entry.runtimeName,
|
||||||
|
desired,
|
||||||
|
managed: true,
|
||||||
|
state: desired ? "configured" : "available",
|
||||||
|
origin: entry.required ? "paperclip_required" : "company_managed",
|
||||||
|
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
||||||
|
readOnly: false,
|
||||||
|
sourcePath: entry.source,
|
||||||
|
targetPath: null,
|
||||||
|
detail: desired ? "Will be available on the next run via Hermes skill loading." : null,
|
||||||
|
required: Boolean(entry.required),
|
||||||
|
requiredReason: entry.requiredReason ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of hermesSkillEntries) {
|
||||||
|
if (availableByKey.has(entry.key)) continue;
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const desired of desiredSkills) {
|
||||||
|
if (availableByKey.has(desired) || hermesKeys.has(desired)) continue;
|
||||||
|
warnings.push(`Desired skill "${desired}" is not available in Paperclip or Hermes skills.`);
|
||||||
|
entries.push({
|
||||||
|
key: desired,
|
||||||
|
runtimeName: null,
|
||||||
|
desired: true,
|
||||||
|
managed: true,
|
||||||
|
state: "missing",
|
||||||
|
origin: "external_unknown",
|
||||||
|
originLabel: "External or unavailable",
|
||||||
|
readOnly: false,
|
||||||
|
sourcePath: null,
|
||||||
|
targetPath: null,
|
||||||
|
detail: "Cannot find this skill in Paperclip or ~/.hermes/skills/.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
adapterType: ADAPTER_TYPE,
|
||||||
|
supported: true,
|
||||||
|
mode: "persistent",
|
||||||
|
desiredSkills,
|
||||||
|
entries,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSkills(ctx) {
|
||||||
|
return buildSnapshot(ctx.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncSkills(ctx, _desired) {
|
||||||
|
return buildSnapshot(ctx.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDesiredSkillNames(config, availableEntries) {
|
||||||
|
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
|
}
|
||||||
164
adapters/deepseek-paperclip-adapter/dist/server/test.js
vendored
Normal file
164
adapters/deepseek-paperclip-adapter/dist/server/test.js
vendored
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Environment test for the DeepSeek (via Hermes) adapter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
HERMES_CLI,
|
||||||
|
ADAPTER_TYPE,
|
||||||
|
DEFAULT_PROFILE_HOME,
|
||||||
|
} from "../shared/constants.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
function asString(v) {
|
||||||
|
return typeof v === "string" ? v : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkCliInstalled(command) {
|
||||||
|
try {
|
||||||
|
await execFileAsync(command, ["--version"], { timeout: 10_000 });
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code === "ENOENT") {
|
||||||
|
return {
|
||||||
|
level: "error",
|
||||||
|
message: `Hermes CLI "${command}" not found in PATH`,
|
||||||
|
hint: "Install Hermes Agent: pip install hermes-agent",
|
||||||
|
code: "deepseek_hermes_cli_not_found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkProfile(profileHome) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(profileHome);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return {
|
||||||
|
level: "error",
|
||||||
|
message: `Profile path is not a directory: ${profileHome}`,
|
||||||
|
hint: "Create the directory or override hermesProfileHome in adapter config.",
|
||||||
|
code: "deepseek_profile_not_dir",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
level: "error",
|
||||||
|
message: `Hermes profile dir does not exist: ${profileHome}`,
|
||||||
|
hint: "Create the profile dir with config.yaml + .env (DEEPSEEK_API_KEY).",
|
||||||
|
code: "deepseek_profile_missing",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = path.join(profileHome, "config.yaml");
|
||||||
|
try {
|
||||||
|
await fs.stat(configPath);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
level: "error",
|
||||||
|
message: `Profile is missing config.yaml: ${configPath}`,
|
||||||
|
hint: "Add config.yaml with model.default + model.base_url + model.key_env.",
|
||||||
|
code: "deepseek_profile_no_config",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
level: "info",
|
||||||
|
message: `Profile resolved: ${profileHome}`,
|
||||||
|
code: "deepseek_profile_ok",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkApiKey(profileHome, configEnv) {
|
||||||
|
// 1. config.env (resolved by Paperclip from secrets)
|
||||||
|
if (configEnv && typeof configEnv === "object" && asString(configEnv.DEEPSEEK_API_KEY)) {
|
||||||
|
return {
|
||||||
|
level: "info",
|
||||||
|
message: "DEEPSEEK_API_KEY found in adapter env config",
|
||||||
|
code: "deepseek_api_key_in_config",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 2. Profile-local .env
|
||||||
|
try {
|
||||||
|
const envFile = path.join(profileHome, ".env");
|
||||||
|
const text = await fs.readFile(envFile, "utf-8");
|
||||||
|
if (/^\s*DEEPSEEK_API_KEY=/m.test(text)) {
|
||||||
|
return {
|
||||||
|
level: "info",
|
||||||
|
message: `DEEPSEEK_API_KEY found in ${envFile}`,
|
||||||
|
code: "deepseek_api_key_in_profile",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
// 3. Process env
|
||||||
|
if (process.env.DEEPSEEK_API_KEY) {
|
||||||
|
return {
|
||||||
|
level: "info",
|
||||||
|
message: "DEEPSEEK_API_KEY found in Paperclip process env",
|
||||||
|
code: "deepseek_api_key_in_process",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
level: "error",
|
||||||
|
message: "DEEPSEEK_API_KEY not found in adapter env, profile .env, or process env",
|
||||||
|
hint: "Add DEEPSEEK_API_KEY to <HERMES_HOME>/.env or to the agent's env secrets.",
|
||||||
|
code: "deepseek_api_key_missing",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testEnvironment(ctx) {
|
||||||
|
const config = ctx.config ?? {};
|
||||||
|
const command = asString(config.hermesCommand) || HERMES_CLI;
|
||||||
|
const profileHome = asString(config.hermesProfileHome) || DEFAULT_PROFILE_HOME;
|
||||||
|
const checks = [];
|
||||||
|
|
||||||
|
const cliCheck = await checkCliInstalled(command);
|
||||||
|
if (cliCheck) {
|
||||||
|
checks.push(cliCheck);
|
||||||
|
if (cliCheck.level === "error") {
|
||||||
|
return {
|
||||||
|
adapterType: ADAPTER_TYPE,
|
||||||
|
status: "fail",
|
||||||
|
checks,
|
||||||
|
testedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileCheck = await checkProfile(profileHome);
|
||||||
|
checks.push(profileCheck);
|
||||||
|
if (profileCheck.level === "error") {
|
||||||
|
return {
|
||||||
|
adapterType: ADAPTER_TYPE,
|
||||||
|
status: "fail",
|
||||||
|
checks,
|
||||||
|
testedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyCheck = await checkApiKey(profileHome, config.env);
|
||||||
|
checks.push(apiKeyCheck);
|
||||||
|
|
||||||
|
const model = asString(config.model);
|
||||||
|
checks.push({
|
||||||
|
level: "info",
|
||||||
|
message: model ? `Model: ${model}` : "Using profile default model",
|
||||||
|
code: "deepseek_model",
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasErrors = checks.some((c) => c.level === "error");
|
||||||
|
const hasWarnings = checks.some((c) => c.level === "warn");
|
||||||
|
return {
|
||||||
|
adapterType: ADAPTER_TYPE,
|
||||||
|
status: hasErrors ? "fail" : hasWarnings ? "warn" : "pass",
|
||||||
|
checks,
|
||||||
|
testedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
36
adapters/deepseek-paperclip-adapter/dist/shared/constants.js
vendored
Normal file
36
adapters/deepseek-paperclip-adapter/dist/shared/constants.js
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Shared constants for the DeepSeek (via Hermes) Paperclip adapter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ADAPTER_TYPE = "deepseek_local";
|
||||||
|
export const ADAPTER_LABEL = "DeepSeek (via Hermes)";
|
||||||
|
|
||||||
|
/** Default Hermes CLI binary name. */
|
||||||
|
export const HERMES_CLI = "hermes";
|
||||||
|
|
||||||
|
/** Default profile directory used as HERMES_HOME if the agent does not override it. */
|
||||||
|
export const DEFAULT_PROFILE_HOME = "/home/chaim/.hermes/profiles/deepseek";
|
||||||
|
|
||||||
|
/** Default model — V4-Pro is the strongest DeepSeek model currently exposed. */
|
||||||
|
export const DEFAULT_MODEL = "deepseek-v4-pro";
|
||||||
|
|
||||||
|
/** DeepSeek profiles in this stack use Hermes' "custom" provider (user-defined in profile config.yaml). */
|
||||||
|
export const DEFAULT_PROVIDER = "custom";
|
||||||
|
|
||||||
|
/** Default timeout (seconds) for one CLI invocation. */
|
||||||
|
export const DEFAULT_TIMEOUT_SEC = 1800;
|
||||||
|
|
||||||
|
/** Grace period (seconds) after SIGTERM before SIGKILL. */
|
||||||
|
export const DEFAULT_GRACE_SEC = 30;
|
||||||
|
|
||||||
|
/** Models that DeepSeek's API currently exposes (verified via /v1/models). */
|
||||||
|
export const DEEPSEEK_MODELS = [
|
||||||
|
{ id: "deepseek-v4-pro", label: "DeepSeek V4 Pro" },
|
||||||
|
{ id: "deepseek-v4-flash", label: "DeepSeek V4 Flash" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Regex for extracting session_id from quiet-mode Hermes output. */
|
||||||
|
export const SESSION_ID_REGEX = /^session_id:\s*(\S+)/m;
|
||||||
|
export const SESSION_ID_REGEX_LEGACY = /session[_ ](?:id|saved)[:\s]+([a-zA-Z0-9_-]+)/i;
|
||||||
|
export const TOKEN_USAGE_REGEX = /tokens?[:\s]+(\d+)\s*(?:input|in)\b.*?(\d+)\s*(?:output|out)\b/i;
|
||||||
|
export const COST_REGEX = /(?:cost|spent)[:\s]*\$?([\d.]+)/i;
|
||||||
25
adapters/deepseek-paperclip-adapter/package-lock.json
generated
Normal file
25
adapters/deepseek-paperclip-adapter/package-lock.json
generated
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "deepseek-paperclip-adapter",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "deepseek-paperclip-adapter",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@paperclipai/adapter-utils": "^2026.325.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@paperclipai/adapter-utils": {
|
||||||
|
"version": "2026.428.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.428.0.tgz",
|
||||||
|
"integrity": "sha512-kGHpE7rhePPCbnG3OwXbNuHZZuI+XyuFgNSiDnrEeiSbkI2c5XHM2WnWDCZ/NGHULfJW3lWhSxGMFoYqiy38vQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
adapters/deepseek-paperclip-adapter/package.json
Normal file
21
adapters/deepseek-paperclip-adapter/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "deepseek-paperclip-adapter",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Paperclip adapter for DeepSeek (V4-Pro / V4-Flash) — runs Hermes Agent locally pinned to a DeepSeek profile",
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@paperclipai/adapter-utils": "^2026.325.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
414
docs/agent-audit-2026-05-17.md
Normal file
414
docs/agent-audit-2026-05-17.md
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
# דו"ח Audit סוכנים — 2026-05-17
|
||||||
|
|
||||||
|
> נוצר על-ידי 7 sub-agents מקבילים שחקרו כל סוכן בנפרד.
|
||||||
|
> כיסוי: קבצי הנחיות, תצורת DB, skills, MCP tools, freshness, drift CMP↔CMPA.
|
||||||
|
>
|
||||||
|
> **עדכון 2026-05-17:** כל 12 הבעיות טופלו באותו יום. ראה סעיף "סטטוס תיקונים" למטה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## סיכום מנהלים
|
||||||
|
|
||||||
|
### טבלת מצב כללית — לאחר תיקונים (2026-05-17)
|
||||||
|
|
||||||
|
| סוכן | מודל (instructions = DB) | Skills CMP | Skills CMPA | סטטוס |
|
||||||
|
|------|--------------------------|-----------|-----------|--------|
|
||||||
|
| עוזר משפטי (CEO) | claude-opus-4-7 ✅ | 9 | 6 | ✅ תקין |
|
||||||
|
| מנתח משפטי | claude-opus-4-7 ✅ | 9 | 6 | ✅ תקין |
|
||||||
|
| חוקר תקדימים | claude-sonnet-4-6 ✅ | 9 | 6 | ✅ תקין |
|
||||||
|
| כותב החלטה | claude-opus-4-7 ✅ | 9 | 6 | ✅ תקין |
|
||||||
|
| בודק איכות (QA) | claude-sonnet-4-6 ✅ | 9 | 6 | ✅ תקין |
|
||||||
|
| מייצא טיוטה | claude-sonnet-4-6 ✅ | 9 | 6 | ✅ תקין |
|
||||||
|
| מגיה מסמכים | claude-opus-4-7 ✅ | 9 | 6 | ✅ תקין |
|
||||||
|
| מנהל ידע (Curator) | deepseek-v4-pro ✅ | 9 | 6 | ✅ תקין |
|
||||||
|
|
||||||
|
> Skills CMPA=6 הוא עיצוב מכוון (6 shared-only skills). verify script מאשר "0 agents need sync".
|
||||||
|
|
||||||
|
### סטטוס תיקונים — כל 12 הבעיות טופלו
|
||||||
|
|
||||||
|
| # | חומרה | סוכן | בעיה | סטטוס | commit |
|
||||||
|
|---|-------|------|------|-------|--------|
|
||||||
|
| 1 | 🔴 | מייצא | `טיוטה-V` → `טיוטה-v` — דורס גרסאות | ✅ תוקן | `a584dc3` |
|
||||||
|
| 2 | 🔴 | מייצא | case.status לא מעודכן ל-`exported` + case_update חסר מ-tools | ✅ תוקן | `a584dc3` |
|
||||||
|
| 3 | 🔴 | חוקר | §ז (query log) חסר בתיק 8174-24 | ✅ תוקן | data (gitignored) |
|
||||||
|
| 4 | 🟠 | כולם | Skills asymmetry CMPA | ✅ לא נדרש — verify: "0 need sync" (עיצוב מכוון) | — |
|
||||||
|
| 5 | 🟠 | חוקר | `search_internal_decisions` לא מתועד | ✅ תוקן — tool + סעיף 2ב.2א | `35423ea` |
|
||||||
|
| 6 | 🟠 | מייצא | נתיב legal-docx hardcoded ל-CMP UUID | ✅ תוקן → `$PAPERCLIP_COMPANY_ID` | `a584dc3` |
|
||||||
|
| 7 | 🟠 | CEO | Project ID + company UUID hardcoded | ✅ תוקן → דינמי מ-$PAPERCLIP_TASK_ID | `35423ea` |
|
||||||
|
| 8 | 🟡 | רוב | Model drift instructions↔DB | ✅ תוקן + שודרג ל-opus-4-7 | `1608ea5`, `c3ce0e7` |
|
||||||
|
| 9 | 🟡 | QA | corpus_queries_logged: ידני או אוטומטי? | ✅ תוקן — הבהרה מפורשת: grep ידני | `1608ea5` |
|
||||||
|
| 10 | 🟡 | CEO | maxConcurrentRuns=NULL | ✅ לא נדרש — DB כבר maxConcurrentRuns=2 | — |
|
||||||
|
| 11 | 🟡 | מגיה | {issue-id} placeholder בקוד | ✅ תוקן → `$PAPERCLIP_TASK_ID` | `1608ea5` |
|
||||||
|
| 12 | 🟢 | מנהל ידע | ownership הצעות curator לא מוגדר | ✅ תוקן — הוסף ל-CLAUDE.md | `1608ea5` |
|
||||||
|
|
||||||
|
### שינויים נוספים שבוצעו באותו סשן
|
||||||
|
|
||||||
|
| שינוי | קובץ | commit |
|
||||||
|
|-------|------|--------|
|
||||||
|
| weekly-feedback-job: כתיבה לקובץ בלבד, לא Paperclip comment | legal-ceo.md | `ea0532b` |
|
||||||
|
| try-catch על agents.invoke בפידבק שבועי | worker.ts | `73e37df` |
|
||||||
|
| try-catch על http.fetch ב-stale-case-reminder | worker.ts | `73e37df` |
|
||||||
|
| HEARTBEAT.md reference בראש legal-researcher.md | legal-researcher.md | `1608ea5` |
|
||||||
|
| search_internal_decisions הוסף ל-legal-researcher tools | legal-researcher.md | `35423ea` |
|
||||||
|
| opus-4-6 → opus-4-7 ב-DB: CEO, מנתח, כותב, מגיה (16 סוכנים) | DB | `c3ce0e7` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ממצאים לפי סוכן
|
||||||
|
|
||||||
|
### 1. עוזר משפטי (CEO)
|
||||||
|
|
||||||
|
**קובץ:** `.claude/agents/legal-ceo.md` — 796 שורות, עודכן 2026-05-17
|
||||||
|
|
||||||
|
**תצורה:**
|
||||||
|
| חברה | ID | Model | Budget |
|
||||||
|
|------|-----|-------|--------|
|
||||||
|
| CMP | `752cebdd-6748-4a04-aacd-c7ab0294ef33` | claude-opus-4-6 | 1500¢ |
|
||||||
|
| CMPA | `cdbfa8bc-3d61-41a4-a2e7-677ec7d34562` | claude-opus-4-6 | 1500¢ |
|
||||||
|
|
||||||
|
**routing conditions:** `user_commented`, `agent_completion`, `precedent_extraction_*`, `weekly-feedback-job`, fallback→heartbeat רגיל
|
||||||
|
|
||||||
|
**MCP tools מוזכרים (41):** case_get/list/update, document_list, get_claims, get_chair_directions, record/list_chair_feedback, approve_direction, brainstorm_directions, search_case_documents, search_precedent_library, workflow_status, processing_status, get_metrics, validate_decision, set_outcome, export_docx, apply_user_edit, list_bookmarks, revise_draft, precedent_process_pending, extract_halachot/metadata, library_get/list, halacha_review, halachot_pending, extract_appraiser_facts, write_interim_draft, export_interim_draft
|
||||||
|
|
||||||
|
**✅ תקין:**
|
||||||
|
- Routing logic מלא ועדכני (כולל weekly-feedback-job שתוקן לאחרונה)
|
||||||
|
- Company filtering ברור (טבלה עם UUIDs וטווחי תיקים)
|
||||||
|
- Wakeup דרך API בלבד (לא DB ישיר) — מוגדר במפורש
|
||||||
|
- HEARTBEAT.md references נכונים (§0, §1, §1.7)
|
||||||
|
- weekly-feedback-job: כתיבה לקובץ בלבד, ללא issueId — נכון
|
||||||
|
|
||||||
|
**⚠️ בעיות:**
|
||||||
|
- 🟠 **Model drift:** instructions = claude-sonnet-4-6, DB = claude-opus-4-6
|
||||||
|
- 🟠 **Hardcoded Project ID:** `25c1b4a1-2c0e-4a2d-9938-8ae56ccda6f1` (תיק 1130-25) — צריך להיות דינמי
|
||||||
|
- 🟡 **maxConcurrentRuns = NULL** ב-DB (שאר הסוכנים = 1)
|
||||||
|
- 🟡 **MCP startup race:** הוראות מדברות על sleep+retry אבל לא כ-code אוטומטי
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. מנתח משפטי
|
||||||
|
|
||||||
|
**קובץ:** `.claude/agents/legal-analyst.md` — 498 שורות, עודכן 2026-05-04
|
||||||
|
|
||||||
|
**תצורה:**
|
||||||
|
| חברה | ID | Model | Budget |
|
||||||
|
|------|-----|-------|--------|
|
||||||
|
| CMP | `c26e9439-a88a-49dc-9e67-2262c95db65c` | claude-opus-4-6 | 1500¢ |
|
||||||
|
| CMPA | `f70fd353-...` | claude-opus-4-6 | 1500¢ |
|
||||||
|
|
||||||
|
**MCP tools (18):** case_get/list/update, document_list/get_text, extract_claims, extract_appraiser_facts, get_claims, search_case_documents, search_decisions, search_precedent_library, precedent_library_get/list, halacha_review, halachot_pending, find_similar_cases, workflow_status, processing_status
|
||||||
|
|
||||||
|
**Output artifacts:** `{case_dir}/documents/research/analysis-and-research.md`
|
||||||
|
|
||||||
|
**Query logging (§5ד/§7א):** לרשום כל `search_precedent_library`, `search_decisions`, `find_similar_cases` כולל ניסיונות עם 0 תוצאות
|
||||||
|
|
||||||
|
**✅ תקין:**
|
||||||
|
- כל 18 כלי MCP מוזכרים ומיושמים
|
||||||
|
- סיווג claim_type ברור (claim/response/reply)
|
||||||
|
- Wakeup CEO בפורמט נכון
|
||||||
|
- reference files קיימים
|
||||||
|
|
||||||
|
**⚠️ בעיות:**
|
||||||
|
- 🟠 **Model drift:** instructions = claude-opus-4-7, DB = claude-opus-4-6
|
||||||
|
- 🟡 **CMPA sync gap:** עדכון אחרון CMPA = 2026-05-04 (13 ימים לפני CMP)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. חוקר תקדימים
|
||||||
|
|
||||||
|
**קובץ:** `.claude/agents/legal-researcher.md` — 240 שורות, עודכן 2026-05-04
|
||||||
|
|
||||||
|
**תצורה:**
|
||||||
|
| חברה | ID | Model | Budget |
|
||||||
|
|------|-----|-------|--------|
|
||||||
|
| CMP | `35022af0-0498-4c3d-90ca-b0ab9e987198` | claude-sonnet-4-6 | 1500¢ |
|
||||||
|
| CMPA | `5dd06843-...` | claude-sonnet-4-6 | 1500¢ |
|
||||||
|
|
||||||
|
**MCP tools (29):** case_get/update, document_list/get_text, search_case_documents, search_decisions, find_similar_cases, extract_references, precedent_attach, precedent_list, precedent_search_library, search_precedent_library, library_get/list, extract_halachot/metadata, precedent_process_pending, halacha_review, halachot_pending, workflow_status
|
||||||
|
|
||||||
|
**Output artifact:** `{case_dir}/documents/research/precedent-research.md`
|
||||||
|
|
||||||
|
**Query logging (§ז):** חובה — כל query עם פילטרים, תוצאות, בחירה/דחייה, negative evidence
|
||||||
|
|
||||||
|
**✅ תקין:**
|
||||||
|
- שלושת הקורפוסים מוגדרים בבירור (פסיקה חיצונית / קאנון דפנה / ציטוטים ידניים)
|
||||||
|
- precedent_attach עם הוראות מלאות
|
||||||
|
- Wakeup CEO דינמי לפי חברה
|
||||||
|
|
||||||
|
**⚠️ בעיות:**
|
||||||
|
- 🔴 **§ז חסר בתיק 8174-24** — 1 מתוך 3 תיקים בדיסק חסר את תיעוד השאילתות. QA אמור לחסום ייצוא.
|
||||||
|
- 🟠 **`search_internal_decisions` לא מתועד** — הכלי ב-header אבל לא מוסבר בגוף ההנחיות. מתי להשתמש בו?
|
||||||
|
- 🟠 **Skills asymmetry CMPA** — CMPA חסרה: legal-assistant, legal-decision, legal-docx, diagnose-why-work-stopped, appendix-expert-intern, terminal-bench-loop
|
||||||
|
- 🟡 **`daphna-precedent-network.md` עדכון אחרון 27 אפריל** — עשוי להיות לפני תקדימים חדשים
|
||||||
|
- 🟡 **HEARTBEAT.md לא מוזכר בפירוש** — אין link ישיר בתחילת ההנחיות
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. כותב החלטה
|
||||||
|
|
||||||
|
**קובץ:** `.claude/agents/legal-writer.md` — 410 שורות, עודכן 2026-05-04
|
||||||
|
|
||||||
|
**תצורה:**
|
||||||
|
| חברה | ID | Model | Budget |
|
||||||
|
|------|-----|-------|--------|
|
||||||
|
| CMP | `7ed8686f-24bc-49a3-bc02-67ca15b895a9` | claude-opus-4-6 | 1500¢ |
|
||||||
|
| CMPA | `99289cb1-...` | claude-opus-4-6 | 1500¢ |
|
||||||
|
|
||||||
|
**Block range:** ה-יא (5-11), כותב בסדר; א-ד (אוטומטי), יב (אוטומטי)
|
||||||
|
|
||||||
|
**5 style docs לפני בלוק י (כולם קיימים):**
|
||||||
|
- `docs/daphna-voice-fingerprint.md` ✅ (עודכן 10 מאי)
|
||||||
|
- `docs/daphna-precedent-network.md` ✅ (עודכן 27 אפריל)
|
||||||
|
- `docs/daphna-architecture-by-outcome.md` ✅ (עודכן 28 אפריל)
|
||||||
|
- `docs/daphna-acceptance-architecture.md` ✅ (עודכן 28 אפריל)
|
||||||
|
- `docs/voice-1130-25.md` ✅ (עודכן 26 אפריל)
|
||||||
|
|
||||||
|
**MCP tools (18):** case_get/update, document_list/get_text, get_claims, get_chair_directions, get_decision_template, get_block_context, save_block_content, write_block, search_decisions, search_precedent_library, library_get/list, search_case_documents, get_style_guide, halacha_review, workflow_status, apply_user_edit
|
||||||
|
|
||||||
|
**✅ תקין:**
|
||||||
|
- 4 statuses של get_chair_directions מוגדרים (missing/empty/partial/complete)
|
||||||
|
- Revision mode ברור (לא לשמור ב-DB בעריכה)
|
||||||
|
- 10 anti-patterns ברורים
|
||||||
|
- Company filtering נכון (CEO IDs שונים לפי חברה)
|
||||||
|
|
||||||
|
**⚠️ בעיות:**
|
||||||
|
- 🟠 **Model drift:** instructions = claude-opus-4-7, DB = claude-opus-4-6
|
||||||
|
- 🟡 **חסר שלב 0 מפורש:** בדיקת `issue.description` (ההוראה הראשית מה-CEO)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. בודק איכות (QA)
|
||||||
|
|
||||||
|
**קובץ:** `.claude/agents/legal-qa.md` — 219 שורות, עודכן 2026-05-04
|
||||||
|
|
||||||
|
**תצורה:**
|
||||||
|
| חברה | ID | Model | Budget |
|
||||||
|
|------|-----|-------|--------|
|
||||||
|
| CMP | `1a5b229e-9220-4b13-940c-f8eb7285fc29` | claude-sonnet-4-6 | 1500¢ |
|
||||||
|
| CMPA | `7191ff77-...` | claude-sonnet-4-6 | 1500¢ |
|
||||||
|
|
||||||
|
**9 בדיקות (לא 8 — §7א הוא נפרד):**
|
||||||
|
1. שלמות מבנית — critical
|
||||||
|
2. רקע ניטרלי — critical
|
||||||
|
3. כיסוי טענות — critical
|
||||||
|
4. משקלות — warning
|
||||||
|
5. ללא כפילות — warning
|
||||||
|
6. מספור רציף — warning
|
||||||
|
7א. שאילתות קורפוס (corpus_queries_logged) — **critical blocker**
|
||||||
|
7. תאימות מתודולוגיה — critical
|
||||||
|
8. קול דפנה — critical
|
||||||
|
|
||||||
|
**Reference files (כולם קיימים):**
|
||||||
|
- `docs/daphna-decision-tree.md` ✅ (521 שורות)
|
||||||
|
- `docs/daphna-voice-fingerprint.md` ✅ (471 שורות)
|
||||||
|
- `docs/daphna-architecture-by-outcome.md` ✅ (381 שורות)
|
||||||
|
- `docs/daphna-acceptance-architecture.md` ✅ (640 שורות)
|
||||||
|
- `docs/daphna-block-zayin-claims.md` ✅ (385 שורות)
|
||||||
|
- `docs/daphna-precedent-network.md` ✅ (379 שורות)
|
||||||
|
|
||||||
|
**✅ תקין:**
|
||||||
|
- כל reference files קיימים ונגישים
|
||||||
|
- Company filtering מתועד (CEO IDs נכונים)
|
||||||
|
- Decision logic done/blocked מוגדרת
|
||||||
|
|
||||||
|
**⚠️ בעיות:**
|
||||||
|
- 🟡 **בדיקה 7א לא ברורה** — אוטומטית (validate_decision) או ידנית (grep בקובצי markdown)?
|
||||||
|
- 🟡 **בדיקה 8 (קול דפנה) סובייקטיבית** — חסרות דוגמאות anti-patterns מדידות
|
||||||
|
- 🟡 **get_metrics() — אין ספי קבלה** — מה מספר/אחוז שמוגדר כ-pass?
|
||||||
|
- 🟡 **decision tree:** אם רק בדיקות 4-6 (warning) נכשלו — done או blocked?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. מייצא טיוטה (Exporter)
|
||||||
|
|
||||||
|
**קובץ:** `.claude/agents/legal-exporter.md` — 151 שורות, עודכן 2026-05-04
|
||||||
|
|
||||||
|
**תצורה:**
|
||||||
|
| חברה | ID | Model | Budget |
|
||||||
|
|------|-----|-------|--------|
|
||||||
|
| CMP | `d0dc703b-ca83-4883-bca7-c9449e8713cd` | claude-sonnet-4-6 | 1500¢ |
|
||||||
|
| CMPA | `ada99a7d-...` | claude-sonnet-4-6 | 1500¢ |
|
||||||
|
|
||||||
|
**MCP tools (8):** export_docx, apply_user_edit, list_bookmarks, revise_draft, validate_decision, get_claims, get_block_context, workflow_status
|
||||||
|
|
||||||
|
**✅ תקין:**
|
||||||
|
- Git integration לכל ייצוא/עדכון
|
||||||
|
- validate_decision לפני export מוגדר
|
||||||
|
- active_draft detection (עריכה-*.docx) מוגדר
|
||||||
|
|
||||||
|
**⚠️ בעיות:**
|
||||||
|
- 🔴 **Naming mismatch קריטי:** הנחיות → `טיוטה-V{N}.docx` (V גדולה); קוד `revise_draft` → `טיוטה-v{N}.docx` (v קטנה); בדיסק בפועל → `טיוטה-v1.docx` (v קטנה). **הסוכן יחפש V גדולה ולא ימצא — יתחיל מ-v1 בכל הפעלה ויחליף קבצים קיימים!**
|
||||||
|
- 🔴 **case.status לא מעודכן ל-`exported`** — אחרי export מצליח, הסטטוס נשאר `drafted`/`reviewed`; הסטטוס `exported` קיים ב-DB schema ומוחרג מ-stale query
|
||||||
|
- 🟠 **legal-docx SKILL.md path hardcoded לCMP UUID** — CMPA ייכשל בקריאת ה-SKILL.md
|
||||||
|
- נכון: `/home/chaim/.paperclip/instances/default/skills/42a7acd0-.../legal-docx/SKILL.md`
|
||||||
|
- חסר: דינמי לפי `$PAPERCLIP_COMPANY_ID`
|
||||||
|
- 🟡 **Heartbeat grace=60s** — אם export DOCX > 60s, שני instances יתעוררו במקביל
|
||||||
|
- 🟡 **File size validation** — מוזכר בהנחיות אך לא מיושם בקוד
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. מגיה מסמכים (Proofreader)
|
||||||
|
|
||||||
|
**קובץ:** `.claude/agents/legal-proofreader.md` — 115 שורות, עודכן 2026-05-04
|
||||||
|
|
||||||
|
**תצורה:**
|
||||||
|
| חברה | ID | Model | Budget |
|
||||||
|
|------|-----|-------|--------|
|
||||||
|
| CMP | `410c0167-27dc-485c-a51b-7aa8b9ff2217` | claude-opus-4-6 | 1500¢ |
|
||||||
|
| CMPA | `17839fc6-...` | claude-opus-4-6 | 1500¢ |
|
||||||
|
|
||||||
|
**OCR workflow — 5 שלבים:** זיהוי → תיקון אוטומטי (abbreviations.json) → הגהה חכמה → שמירה → דיווח+סגירה
|
||||||
|
|
||||||
|
**abbreviations.json:** קיים ב-`/home/chaim/legal-ai/data/abbreviations.json` (2545 bytes, עודכן אפריל)
|
||||||
|
|
||||||
|
**✅ תקין:**
|
||||||
|
- abbreviations.json קיים
|
||||||
|
- Wakeup CEO דינמי לפי חברה
|
||||||
|
- חיוב סגירת issue
|
||||||
|
|
||||||
|
**⚠️ בעיות:**
|
||||||
|
- 🟠 **Model drift:** instructions = claude-opus-4-7, DB = claude-opus-4-6
|
||||||
|
- 🟡 **MCP write support לתיקיות:** לא אומת שה-tools תומכים בכתיבה ל-`documents/proofread/`
|
||||||
|
- 🟡 **Placeholder `{issue-id}` בקוד:** pc.sh calls משתמשות ב-literal `{issue-id}` — האם הסוכן מחליף עם `$PAPERCLIP_TASK_ID`?
|
||||||
|
- 🟡 **`extraction_status = proofread`:** האם השדה קיים ב-MCP document schema?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. מנהל ידע (Hermes Curator)
|
||||||
|
|
||||||
|
**קובץ:** `.claude/agents/hermes-curator.md` — 147 שורות, עודכן 2026-05-10
|
||||||
|
|
||||||
|
**תצורה:**
|
||||||
|
| חברה | ID | Adapter | Model | Budget |
|
||||||
|
|------|-----|---------|-------|--------|
|
||||||
|
| CMP | `60dce831-5c5b-4bae-bda9-5282d506f0dc` | deepseek_local | deepseek-v4-pro | 1500¢ |
|
||||||
|
| CMPA | `d6f7c55d-570a-46b8-8d72-1286d07da0d8` | deepseek_local | deepseek-v4-pro | 1500¢ |
|
||||||
|
|
||||||
|
**Profiles:** `~/.hermes/profiles/curator-cmp/` ✅ + `curator-cmpa/` ✅ (שניהם קיימים)
|
||||||
|
|
||||||
|
**Trigger:** UI "סמן כסופי" → `web/paperclip_client.py:pc_wake_curator_for_final()` → sub-issue + wakeup
|
||||||
|
|
||||||
|
**MCP tools (6):** case_get, case_get_final_text, document_list, get_style_guide, precedent_library_list, search_internal_decisions, halacha_review
|
||||||
|
|
||||||
|
**✅ תקין:**
|
||||||
|
- deepseek_local מוגדר נכון בשתי החברות
|
||||||
|
- Profiles קיימים ועובדים (MEMORY.md מ-06/05 עם 5 ממצאים)
|
||||||
|
- Read-only design — לא מעדכן קבצים ישירות
|
||||||
|
- env vars נדרשים מתועדים
|
||||||
|
|
||||||
|
**⚠️ בעיות:**
|
||||||
|
- 🟢 **לא מוגדר:** מי מממש הצעות ל-SKILL.md/lessons.md שה-curator מציע ב-comments?
|
||||||
|
- 🟢 **Hermes bias:** DeepSeek V4-Pro עלול לפרש תוצאות בצורה סובייקטיבית — אין oversight layer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## בעיות חוצות-סוכנים
|
||||||
|
|
||||||
|
### 1. Skills Asymmetry CMP vs CMPA (🟠 גבוה)
|
||||||
|
|
||||||
|
**Skills ב-CMP (9):**
|
||||||
|
- משותפים (6): paperclip, paperclip-converting-plans-to-tasks, paperclip-create-agent, paperclip-create-plugin, paperclip-dev, para-memory-files
|
||||||
|
- ייחודיים CMP (3+): legal-assistant, legal-decision, legal-docx, appendix-expert-intern, diagnose-why-work-stopped, terminal-bench-loop
|
||||||
|
|
||||||
|
**Skills ב-CMPA (6):** משותפים בלבד — **חסרים כל ה-legal-* skills**
|
||||||
|
|
||||||
|
**השפעה:** סוכני CMPA לא יכולים להשתמש ב-legal-decision skill (כתיבה), legal-assistant (ניתוח), legal-docx (DOCX). לא ברור אם זו החלטה מכוונת (CMPA עובד אחרת?) או gap בסנכרון.
|
||||||
|
|
||||||
|
**פעולה:** הרץ `sync_agents_across_companies.py --verify` עם PAPERCLIP_BOARD_API_KEY לבדיקה.
|
||||||
|
|
||||||
|
### 2. Model Version Drift (🟡 בינוני)
|
||||||
|
|
||||||
|
ב-DB כל הסוכנים רצים על claude-opus-4-6 או claude-sonnet-4-6, אבל קבצי הנחיות מציינים גרסאות שונות:
|
||||||
|
|
||||||
|
| סוכן | instructions מציין | DB רץ על |
|
||||||
|
|------|-------------------|---------|
|
||||||
|
| CEO | claude-sonnet-4-6 | claude-opus-4-6 |
|
||||||
|
| מנתח | claude-opus-4-7 | claude-opus-4-6 |
|
||||||
|
| כותב | claude-opus-4-7 | claude-opus-4-6 |
|
||||||
|
| מגיה | claude-opus-4-7 | claude-opus-4-6 |
|
||||||
|
| חוקר, QA, מייצא | claude-sonnet-4-6 | claude-sonnet-4-6 ✅ |
|
||||||
|
| מנהל ידע | deepseek-v4-pro | deepseek-v4-pro ✅ |
|
||||||
|
|
||||||
|
**לא ברור:** האם CEO/מנתח/כותב **אמורים** לרוץ על Opus (בחירה מכוונת לאיכות) ורק קבצי instructions לא עודכנו? או שה-DB צריך להתעדכן?
|
||||||
|
|
||||||
|
### 3. HEARTBEAT.md Reference (🟢 נמוך)
|
||||||
|
|
||||||
|
קובץ `legal-researcher.md` לא מפנה ל-`HEARTBEAT.md` בפירוש בתחילת הקובץ. שאר הסוכנים כן עושים זאת.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## רשימת תיקונים לפי עדיפות
|
||||||
|
|
||||||
|
### 🔴 קריטי — לתקן לפני תיק הבא
|
||||||
|
|
||||||
|
1. **`legal-exporter.md` + `web/app.py`/`drafting.py`:** אחד הדברים:
|
||||||
|
- תיקן הנחיות: שנה `טיוטה-V` → `טיוטה-v` (v קטנה) בכל המקומות
|
||||||
|
- **ועוד:** הוסף לקובץ הנחיות שלב: "אחרי export מוצלח — עדכן `case.status = 'exported'` דרך MCP או API"
|
||||||
|
|
||||||
|
2. **תיק 8174-24 — §ז חסר:** בדוק אם שלב המחקר הושלם. אם לא — הפעל חוקר מחדש לתיק זה.
|
||||||
|
|
||||||
|
### 🟠 גבוה — לתקן בשבוע הקרוב
|
||||||
|
|
||||||
|
3. **Skills CMPA:** הרץ:
|
||||||
|
```bash
|
||||||
|
PAPERCLIP_BOARD_API_KEY=$(mcp__infisical__get-secret \
|
||||||
|
--projectId 9a77b161-f70c-4dd3-9d67-b7ab850cef51 \
|
||||||
|
--environmentSlug nautilus --secretPath /paperclip --secretName BOARD_API_KEY) \
|
||||||
|
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify
|
||||||
|
```
|
||||||
|
החלט אם להוסיף legal-* skills ל-CMPA ואם כן — הרץ `--apply`.
|
||||||
|
|
||||||
|
4. **`legal-researcher.md`:** הוסף תת-סעיף עם הוראות ל-`search_internal_decisions`:
|
||||||
|
- מתי להשתמש (החלטות פנימיות דפנה שלא בקורפוס הציבורי)
|
||||||
|
- מה ההבדל מ-`search_decisions`
|
||||||
|
|
||||||
|
5. **`legal-exporter.md` — נתיב legal-docx:** שנה מ-hardcoded UUID ל-דינמי:
|
||||||
|
```
|
||||||
|
אם $PAPERCLIP_COMPANY_ID = 42a7acd0... → CMP path
|
||||||
|
אם $PAPERCLIP_COMPANY_ID = 8639e837... → CMPA path
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **`legal-ceo.md` — Project ID:** הסר את ה-hardcoded ID של 1130-25. החלף בהוראה: "השתמש ב-`projects_list` לקבלת project_id הנכון לפי חברה ולתיק".
|
||||||
|
|
||||||
|
### 🟡 בינוני — לתקן בחודש הקרוב
|
||||||
|
|
||||||
|
7. **Model documentation:** החלט על גרסאות מודל לכל סוכן ועדכן גם הנחיות גם DB. עדיף: שמור הנחיות כ-source of truth ועדכן DB דרך `sync_agents_across_companies.py --apply`.
|
||||||
|
|
||||||
|
8. **`legal-qa.md` — הבהרת corpus_queries_logged:** הוסף: "הבדיקה היא קריאת `validate_decision` עם `check_corpus_log=true` / או grep ידני בקובץ `analysis-and-research.md` לסעיף ז".
|
||||||
|
|
||||||
|
9. **`legal-ceo.md` — maxConcurrentRuns:** עדכן DB ל-maxConcurrentRuns=1 (או 2 אם CEO רוצה מקביליות מכוונת).
|
||||||
|
|
||||||
|
10. **`legal-proofreader.md` — {issue-id} placeholder:** שנה ל-`$PAPERCLIP_TASK_ID` באופן מפורש.
|
||||||
|
|
||||||
|
11. **`legal-researcher.md` — HEARTBEAT.md link:** הוסף בשורה 1: `> ראה גם: HEARTBEAT.md לחוקים הכלליים`.
|
||||||
|
|
||||||
|
### 🟢 נמוך — future improvement
|
||||||
|
|
||||||
|
12. **מנהל ידע — ownership:** הוסף ל-CLAUDE.md הנחיה: "Curator proposals ב-comments → חיים מאשר ידנית → commits ל-SKILL.md ו-lessons.md".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## אימות (לאחר תיקונים)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. שלוף API key
|
||||||
|
PAPERCLIP_BOARD_API_KEY=$(mcp__infisical__get-secret \
|
||||||
|
--projectId 9a77b161-f70c-4dd3-9d67-b7ab850cef51 \
|
||||||
|
--environmentSlug nautilus --secretPath /paperclip --secretName BOARD_API_KEY)
|
||||||
|
|
||||||
|
# 2. בדוק drift
|
||||||
|
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify
|
||||||
|
|
||||||
|
# 3. בדוק freshness של הנחיות
|
||||||
|
python ~/legal-ai/scripts/sync_agents_across_companies.py --check-instructions
|
||||||
|
|
||||||
|
# 4. בדוק שסוכני CMPA עובדים עם skills נכונים
|
||||||
|
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
|
||||||
|
SELECT a.name, array_agg(s.name ORDER BY s.name) as skills
|
||||||
|
FROM agents a
|
||||||
|
JOIN companies c ON a.company_id = c.id
|
||||||
|
LEFT JOIN agent_skills ask ON ask.agent_id = a.id
|
||||||
|
LEFT JOIN skills s ON ask.skill_id = s.id
|
||||||
|
WHERE c.name LIKE '%השבחה%' AND (a.is_deleted = false OR a.is_deleted IS NULL)
|
||||||
|
GROUP BY a.id ORDER BY a.name;
|
||||||
|
"
|
||||||
|
```
|
||||||
@@ -29,6 +29,38 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.5. שאלת סף — האם בכלל להכריע עכשיו?
|
||||||
|
|
||||||
|
לפני המעבר לעץ ההחלטה הראשי (§1), שאל:
|
||||||
|
|
||||||
|
> **האם יש פתח להחלטת ביניים שתחסוך הכרעה מלאה?**
|
||||||
|
|
||||||
|
הרוב המכריע של התיקים — לא. אבל בעררי שומה מכרעת (8xxx), קיים כלי שלישי שאינו "דחייה / קבלה / קבלה חלקית" — **החלטת ביניים שמחזירה שאלה ספציפית לשמאי המכריע**.
|
||||||
|
|
||||||
|
| תנאי | מתקיים? |
|
||||||
|
|-------|----------|
|
||||||
|
| השומה המכרעת מנומקת וסדורה ברמה הכללית (הצהרת אמון בגלר אפשרית) | □ |
|
||||||
|
| יש פרט עובדתי קונקרטי (לא טענה משפטית) שדורש מענה | □ |
|
||||||
|
| הפרט לא הוצג בצורה ישירה לשמאי בעת ההכרעה הראשונה (התחדד בדיון / בהשלמת מסמכים) | □ |
|
||||||
|
| דחייה ללא טיפול בפרט תיראה כעודף שמרנות; קבלה תיראה כעודף התערבות | □ |
|
||||||
|
| השמאי המכריע זמין ומסוגל להשיב | □ |
|
||||||
|
|
||||||
|
```
|
||||||
|
כל התנאים מתקיימים?
|
||||||
|
│
|
||||||
|
├─ כן → ⏸️ החלטת ביניים — חזרה לשמאי
|
||||||
|
│ → daphna-procedural-patterns.md §1
|
||||||
|
│ → דלג על §1-§7 של מסמך זה; חזור אליהם רק אחרי שיגיע מענה השמאי
|
||||||
|
│
|
||||||
|
└─ לא → המשך ל-§1 (עץ ההחלטה הראשי)
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **אזהרה:** התבנית הזו רלוונטית כמעט אך ורק ל-8xxx (היטל השבחה). ב-1xxx (רישוי) אין מקבילה — הוועדה היא הסמכות העליונה לעניין, אין שמאי מכריע להחזיר אליו.
|
||||||
|
|
||||||
|
⚠️ **אזהרת איכות:** דוגמת המקור (ערר 8174-24) הוא **דוגמת מבנה בלבד, לא דוגמת ניסוח**. ראה `daphna-procedural-patterns.md` לפרטי הסימנים שיש לתקן בעת חיקוי.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. עץ החלטה ראשי — בחירת סוג ארכיטקטורה
|
## 1. עץ החלטה ראשי — בחירת סוג ארכיטקטורה
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -517,5 +549,6 @@
|
|||||||
| `daphna-architecture-by-outcome.md` | §1 (עץ ראשי), §2 (משני), §4 (מודי פתיחה) |
|
| `daphna-architecture-by-outcome.md` | §1 (עץ ראשי), §2 (משני), §4 (מודי פתיחה) |
|
||||||
| `daphna-acceptance-architecture.md` | §1 (עץ ראשי — קבלה), §3.7 (פורמטי סיום) |
|
| `daphna-acceptance-architecture.md` | §1 (עץ ראשי — קבלה), §3.7 (פורמטי סיום) |
|
||||||
| `daphna-block-zayin-claims.md` | §3.3 (בלוק ז) |
|
| `daphna-block-zayin-claims.md` | §3.3 (בלוק ז) |
|
||||||
|
| `daphna-procedural-patterns.md` | §0.5 (שאלת סף — החלטת ביניים) |
|
||||||
|
|
||||||
ראה את הקבצים המקוריים לדוגמאות ולפירוט מלא. **המסמך הזה אינו תחליף** — הוא **מצביע** איזה סעיף ואיזה מסמך לקרוא לפי השאלה.
|
ראה את הקבצים המקוריים לדוגמאות ולפירוט מלא. **המסמך הזה אינו תחליף** — הוא **מצביע** איזה סעיף ואיזה מסמך לקרוא לפי השאלה.
|
||||||
|
|||||||
148
docs/daphna-procedural-patterns.md
Normal file
148
docs/daphna-procedural-patterns.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# קטלוג תבניות פרוצדורליות של דפנה
|
||||||
|
|
||||||
|
מסמך זה מקטלג **כלים פרוצדורליים** שדפנה משתמשת בהם **במקום** הכרעה מלאה — לא תבניות סגנון, אלא מהלכים שמתבצעים כשהתיק לא מבשיל להחלטה סופית.
|
||||||
|
|
||||||
|
⚠️ **הבחנה קריטית:**
|
||||||
|
- `daphna-architecture-by-outcome.md` + `daphna-acceptance-architecture.md` = **תבניות תוצאה** (דחייה / קבלה — דפנה הכריעה).
|
||||||
|
- מסמך זה = **תבניות אי-הכרעה / הכרעה דחויה** (דפנה בחרה לא להכריע עכשיו).
|
||||||
|
|
||||||
|
⚠️ **אזהרת קורפוס:**
|
||||||
|
החלטות תחת תבניות אלה הן בדרך כלל **outliers סגנוניים** — קצרות, חסרות, לפעמים רשלניות בניסוח. הן אינן מתאימות ל-voice corpus או ל-structure corpus. הן מתאימות **רק** למטרת זיהוי-תבנית בעתיד.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## תבנית 1: החלטת ביניים — חזרה לשמאי המכריע
|
||||||
|
|
||||||
|
### מתי להשתמש
|
||||||
|
|
||||||
|
כשמתקיימים **כל** התנאים הבאים:
|
||||||
|
|
||||||
|
1. **השומה המכרעת מנומקת וסדורה ברמה הכללית** — הצהרת אמון בגלר חייבת להישאר תקפה. אם השומה רעועה מיסודה, לא משתמשים בתבנית זו — הולכים לקבלה (תבנית E ב-acceptance).
|
||||||
|
2. **יש פרט עובדתי קונקרטי, לא טענה משפטית, שדורש מענה** — למשל: "12 מתוך 15 עסקאות ההשוואה הן בקיר משותף", "הנכס בבעלות יחיד ולא במושע", "השמאי לא חישב מקדם דחייה".
|
||||||
|
3. **הפרט הזה לא הוצג בצורה ישירה לשמאי בעת ההכרעה הראשונה** — או שהעורר חידד אותו בדיון / בהשלמת מסמכים.
|
||||||
|
4. **דחיית הערר בלעדיו תיראה כעודף שמרנות; קבלת הערר תיראה כעודף התערבות** — היא נקודת איזון שהחלטת ביניים פותרת.
|
||||||
|
5. **השמאי המכריע זמין ומסוגל להשיב להבהרה** (לא פרש, לא נפטר, לא נמצא בניגוד עניינים מתעורר).
|
||||||
|
|
||||||
|
### מה התבנית עושה
|
||||||
|
|
||||||
|
הוועדה **אינה מכריעה** את הערר. במקום זאת, היא:
|
||||||
|
- מציגה את הרקע (בלוק ה+ו)
|
||||||
|
- מציגה את ההליכים שכבר נערכו (בלוק ח)
|
||||||
|
- מצמצמת את בלוק ז לטענה המרכזית הרלוונטית (לא 47 טענות מקור)
|
||||||
|
- בבלוק י: מצטטת את גלר/אשקלוני, מצהירה על אמון בשומה, ואז מזהה פרט שדורש הבהרה
|
||||||
|
- בבלוק יא: פונה לשמאי המכריע עם **שאלה ספציפית וצרה אחת**
|
||||||
|
|
||||||
|
התוצאה היא **לא** "הערר נדחה" ו**לא** "הערר מתקבל" — אלא: **"לאחר קבלת הבהרת השמאי המכריע תתקבל החלטה סופית בערר"**.
|
||||||
|
|
||||||
|
### מבנה קנוני
|
||||||
|
|
||||||
|
| בלוק | תוכן | חריגה מהסטנדרט |
|
||||||
|
|------|-------|-----------------|
|
||||||
|
| ה | פתיחה — זיהוי הצדדים, השומה, הנכס, התכנית | כותרת: "החלטת ביניים" (לא "החלטה") |
|
||||||
|
| ו | רקע עובדתי — הנכס, היסטוריה קניינית, השומה, הסוגיות שהמכריע הכריע | סטנדרטי |
|
||||||
|
| ז | טענות הצדדים — **רק** הטענה הרלוונטית להבהרה, לא כל הטענות מהמקור | מקוצר באופן דרמטי |
|
||||||
|
| ח | הליכים — הדיון + השלמת מסמכים + תגובות נוספות | חשוב לתעד את ההליך שגרם להבהרת הטענה |
|
||||||
|
| י | דיון — ציטוט גלר/אשקלוני, הצהרת אמון, זיהוי הפרט, "למשנה זהירות" | קצר יחסית — אין הכרעה מלאה |
|
||||||
|
| יא | פנייה לשמאי המכריע + צמצום השאלה ("נדייק כי...") + הוראת מזכירות | תחליף לפסקת "סוף דבר" |
|
||||||
|
| יב | "לאחר קבלת הבהרת השמאי המכריע תתקבל החלטה סופית בערר" | חתימה רגילה (פה אחד + תאריך) |
|
||||||
|
|
||||||
|
### ביטויי מעבר קנוניים
|
||||||
|
|
||||||
|
| ביטוי | תפקיד |
|
||||||
|
|--------|--------|
|
||||||
|
| **"בנקודה זו יכולנו לסיים ולדחות את הערר אלא..."** | מסמן שהעמדה הראשונית היא דחייה; מכין דחייה סופית |
|
||||||
|
| **"לאחר בחינת טענות העורר במלואן בכל זאת לא נוכל להתעלם מכך כי..."** | מצביע על פרט עובדתי קונקרטי שדורש מענה |
|
||||||
|
| **"למשנה זהירות נכון יהיה לקבל הבהרה"** | מילת מפתח — מגן משפטי מפני טענת קלות דעת |
|
||||||
|
| **"אנו פונים לשמאי המכריע להבהרה במסגרתה יתבקש להבהיר..."** | הפעולה האופרטיבית |
|
||||||
|
| **"נדייק כי השמאי המכריע יבדוק את [X] בהתייחס ל[Y]"** | צמצום השאלה — שולל הבנה רחבה מדי |
|
||||||
|
| **"לשם מתן ההבהרה מזכירות הוועדה תעביר לשמאי המכריע את כתבי הטענות..."** | הוראה מינהלית |
|
||||||
|
| **"לאחר קבלת הבהרת השמאי המכריע תתקבל החלטה סופית בערר"** | סיום — לא הכרעה |
|
||||||
|
|
||||||
|
### תקדים-מקור
|
||||||
|
|
||||||
|
**ערר 8174-24 (גולדמן / בית מדרש)** — החלטה מ-11.05.2026.
|
||||||
|
|
||||||
|
⚠️ **אזהרה:** התקדים הזה הוא **דוגמת תבנית בלבד**, לא דוגמת איכות. בהחלטה זו זוהו 7 סימני "זריקה":
|
||||||
|
1. משפט run-on ב-§46 (3 חיבורים בלי פיסוק)
|
||||||
|
2. כפילות לקסיקלית ב-§40 ("כאמור סדורה")
|
||||||
|
3. בלוק ז מקוצץ — רק טענה אחת מתוך 47 מהמקור
|
||||||
|
4. סוגיות נוספות (טבצ'ניק/דייר מוגן; טענת סף) נזנחו לחלוטין
|
||||||
|
5. רטוריקת "במלואן" שלא מתיישבת עם הטקסט
|
||||||
|
6. תאריך מאוחר ביחס לתיק (שנה וחצי)
|
||||||
|
7. אזכור פסיקה מינימלי (רק גלר + אשקלוני)
|
||||||
|
|
||||||
|
לכן: **חיקוי המבנה** של תבנית זו לגיטימי; **חיקוי הניסוח** של 8174-24 — לא. בעת חיקוי, יש לתקן את הסימנים לעיל (במיוחד 1, 2, 5).
|
||||||
|
|
||||||
|
### מתי **לא** להשתמש
|
||||||
|
|
||||||
|
- כשהפגם בשומה הוא **משפטי-עקרוני** (שאלת פרשנות חוק/תכנית) — שם לוועדה יתרון (אשקלוני), ועליה להכריע בעצמה.
|
||||||
|
- כשהפגם הוא **מתודולוגי-יסודי** (השמאי בחר שיטה שגויה) — שם מקומה של תבנית E ב-acceptance ("השומה תושב לתיקון" + רשימת הוראות).
|
||||||
|
- כשעברו זמן רב מההכרעה הראשונה והשמאי כבר אינו זמין — אז ועדת הערר חייבת להכריע בעצמה.
|
||||||
|
- כשהעורר ויתר על ההליך או נמשך / נדחה.
|
||||||
|
|
||||||
|
### בדיקת איכות לפני שימוש (QA)
|
||||||
|
|
||||||
|
- [ ] שאלה ספציפית אחת, לא רשימה.
|
||||||
|
- [ ] הצהרת אמון בשמאי לפני זיהוי הפרט (סדר חשוב).
|
||||||
|
- [ ] "למשנה זהירות" מופיע — מגן משפטי.
|
||||||
|
- [ ] הבלוק ז כולל **רק** את הטענה הרלוונטית (לא ניסיון לסקור 47 טענות בקיצור).
|
||||||
|
- [ ] אין run-on של 3+ חיבורים בלי פיסוק.
|
||||||
|
- [ ] אין "במלואן" כשבפועל בחנת רק קטע.
|
||||||
|
- [ ] בלוק יב מסמן בבירור שזו לא הכרעה סופית.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## תבנית 2: (שמורה) — דחיית סף עם דיון "למען הסדר הטוב"
|
||||||
|
|
||||||
|
> טופלה ב-`daphna-architecture-by-outcome.md §3` (מוד F). מקושר כאן לשם שלמות הקטלוג.
|
||||||
|
|
||||||
|
זוהי תבנית קרובה אבל **אינה** החלטת ביניים — היא הכרעה מלאה (דחייה), עם דיון מהותי שאינו דרוש משפטית. ההבדל:
|
||||||
|
- **דחיית סף + מהות** = "אני דוחה, ולמרות זאת אדון לרווחת הצדדים"
|
||||||
|
- **החלטת ביניים** = "אני לא דוחה ולא מקבלת — שלחתי שאלה אחורה"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## תבנית 3: (עתידית) — החלטה מותנית
|
||||||
|
|
||||||
|
> מקום שמור לתבנית של "הערר מתקבל בכפוף ל-X תוך Y ימים, אחרת ייחשב כנדחה" — אם תזוהה כתבנית חוזרת בקורפוס.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## תיעוד תבניות חדשות
|
||||||
|
|
||||||
|
כאשר מזוהה החלטה שאינה מתיישבת עם תבניות תוצאה (`acceptance-architecture` / `architecture-by-outcome`):
|
||||||
|
1. בדוק אם היא נכנסת לקטלוג זה.
|
||||||
|
2. אם כן — עדכן כאן.
|
||||||
|
3. אם לא — שמור אותה כ-outlier (`case-tags.json` בתיק עצמו, `pattern_corpus: false`) עד שמתגלה תבנית שניה דומה.
|
||||||
|
4. **אסור** להוסיף החלטות outlier ל-voice corpus או ל-structure corpus — הן יזהמו את הקול של דפנה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## מטא-data — תיוג מסמכי outlier
|
||||||
|
|
||||||
|
כל החלטה שנכנסת לתבנית פרוצדורלית (בניגוד לתבנית תוצאה) מסומנת בקובץ `case-tags.json` בתיק עצמו:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"case_number": "8174-24",
|
||||||
|
"document_role": "interim_decision",
|
||||||
|
"voice_corpus": false,
|
||||||
|
"structure_corpus": false,
|
||||||
|
"pattern_corpus": true,
|
||||||
|
"pattern_tag": "appraiser_clarification_request",
|
||||||
|
"quality_signal": "pragmatic_disposition",
|
||||||
|
"comments": "תבנית פרוצדורלית — חזרה לשמאי. לא ייצוג של החלטה מלאה."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **TODO עתידי:** כשנמיגרר את שדות אלו ל-DB schema (`documents.tags` או `cases.metadata`), ה-API יוכל לסנן אוטומטית בעת בניית קורפוס לאימון Hermes. כיום זה ידני.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## עדכון המסמך
|
||||||
|
|
||||||
|
עדכן את הקובץ הזה רק כאשר:
|
||||||
|
1. מזוהה החלטה שנייה (לפחות) עם אותה תבנית פרוצדורלית — מאשר שזו תבנית ולא אקראיות.
|
||||||
|
2. נוסף ביטוי-מעבר חדש בתבנית קיימת.
|
||||||
|
3. נוסף קריטריון "מתי להשתמש" / "מתי לא" — לרוב על בסיס feedback מהיו"ר.
|
||||||
|
|
||||||
@@ -400,6 +400,54 @@
|
|||||||
- **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md)
|
- **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md)
|
||||||
- **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה
|
- **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 6.11 לקחים מערר 1200-25 (קרית ענבים, מאי 2026)
|
||||||
|
|
||||||
|
השוואה בין טיוטת הכותב לעריכת דפנה חשפה 7 דפוסי סגנון שלא היו מתועדים:
|
||||||
|
|
||||||
|
### א. סדר בלוקים — תכניות לפני טענות (1xxx)
|
||||||
|
בתיקי רישוי, דפנה מעדיפה שבלוק ט (תכניות חלות) יופיע **לפני** בלוק ז (טענות). הרציונל: הקורא צריך להכיר את המסגרת הנורמטיבית לפני שהוא קורא את טענות הצדדים.
|
||||||
|
|
||||||
|
**סדר נכון ל-1xxx:** ה → ו → **ט** → ו.ב (רקע מורחב) → ז → ח → י → יא → יב
|
||||||
|
|
||||||
|
### ב. תבנית "להלן מתוך" — חובה
|
||||||
|
כל התייחסות למסמך מקור מלווה ב-"להלן מתוך [שם המסמך]:" כ-placeholder לציטוט/צילום. **12 מופעים** בעריכה, **0** בטיוטה. זהו דפוס סגנוני מרכזי שחייב להיות אוטומטי.
|
||||||
|
|
||||||
|
דוגמאות:
|
||||||
|
- "להלן מתוך הוראות התכנית:"
|
||||||
|
- "להלן מתוך פרוטוקול הדיון בוועדה המקומית:"
|
||||||
|
- "להלן מתוך הבקשה להיתר:"
|
||||||
|
- "להלן מתוך מטרת התכנית:"
|
||||||
|
- "להלן מתוך תשריט מצב מוצע:"
|
||||||
|
|
||||||
|
### ג. רקע עובדתי מורחב — ציר זמן מלא
|
||||||
|
בלוק ו חייב לספר את "הסיפור" של התיק: הגשת בקשה → פרסום → מספר התנגדויות → ישיבות ועדה מקומית (תאריך + תוצאה לכל אחת) → החלטה סופית → הגשת ערר. הטיוטה נתנה שורה אחת (90 מילים); דפנה הרחיבה ל-3 ישיבות מפורטות (~420 מילים).
|
||||||
|
|
||||||
|
### ד. ניתוח "גשר תכנוני"
|
||||||
|
כשמבקש שימוש חורג גם מקדם תכנית — דפנה מנתחת: האם השימוש המבוקש **תואם** את התכנון העתידי (→ גשר לגיטימי, כמו בכוכבה תורן)? או **סותר** (→ סטייה כפולה)? מסגרת ניתוח שלמה (249 מילים) שלא הייתה בטיוטה.
|
||||||
|
|
||||||
|
### ה. עיגון כמותי
|
||||||
|
דפנה מוסיפה נתונים מספריים ספציפיים: "4,404.98 מ"ר לכלל היישוב vs 1,425 מ"ר מבוקש — 32%". המספרים מעגנים את ההחלטה במציאות ומקשים על ערעור.
|
||||||
|
|
||||||
|
### ו. כותרות שטוחות (Heading 2 בלבד)
|
||||||
|
דפנה השתמשה ב-Heading 2 לכל הסעיפים, כולל תת-נושאים בדיון. **אין Heading 3**. כל סעיף עומד בפני עצמו.
|
||||||
|
|
||||||
|
### ז. הבחנת תקדימים inline
|
||||||
|
במקום סעיף נפרד "הבחנה מתקדימי העוררת" — ההבחנות מנוסחות inline: "באשר ל-[שם פסק דין]" → מה ההבדל → סיכום. דוגמה: "באשר לבג"ץ 6525/15 עמק שווה... אולם ההבדל מהותי".
|
||||||
|
|
||||||
|
### ביטויי מעבר חדשים (מעריכה 1200-25)
|
||||||
|
| ביטוי | הקשר |
|
||||||
|
|-------|-------|
|
||||||
|
| "עינינו הרואות" | ממצא מתוך מסמך |
|
||||||
|
| "הנה כי כן" | לפיכך (פורמלי) |
|
||||||
|
| "נשוב כאן ונבחין" | חזרה להבחנת תקדים |
|
||||||
|
| "נוסיף ונבהיר" | הוספת הבהרה |
|
||||||
|
| "מסקנת הדברים" | סיכום סעיף |
|
||||||
|
| "משכבר קבענו" | הפניה לקביעה קודמת |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. מה עדיין לא ראינו
|
## 7. מה עדיין לא ראינו
|
||||||
|
|||||||
@@ -385,3 +385,64 @@ The draft's biggest structural error was adding the "נבאר" doctrinal paragra
|
|||||||
- [ ] Update voice-fingerprint: add new transition phrases
|
- [ ] Update voice-fingerprint: add new transition phrases
|
||||||
- [ ] Update architecture-by-outcome: add "clean acceptance" archetype
|
- [ ] Update architecture-by-outcome: add "clean acceptance" archetype
|
||||||
- [ ] Fix agent opening punctuation: "ונפרט;" not "נפרט."
|
- [ ] Fix agent opening punctuation: "ונפרט;" not "נפרט."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lessons from ערר 1200-25 (קרית ענבים — שימוש חורג, דחייה)
|
||||||
|
|
||||||
|
### Source
|
||||||
|
- Our draft: `data/cases/1200-25/exports/טיוטה-v1.docx` (3,181 words)
|
||||||
|
- Daphna's edit: `data/cases/1200-25/exports/עריכה-v1.docx` (4,313 words, +35%)
|
||||||
|
- Date: May 2026
|
||||||
|
|
||||||
|
### What the Edit Changed
|
||||||
|
|
||||||
|
#### 1. Block Order — Plans Before Claims
|
||||||
|
- **Draft:** ה→ו→ז→ח→ט→י→יא→יב (plans after procedures)
|
||||||
|
- **Edit:** ה→ו→**ט**→ו.ב→ז→ח→י→יא→יב (plans BEFORE claims)
|
||||||
|
- **Lesson:** In licensing cases (1xxx), the reader must understand the normative framework (plans) before reading the parties' arguments about those plans. Block ט should precede Block ז. The new order: opening → brief background → **applicable plans** → expanded background (application + committee proceedings) → claims → procedures → discussion.
|
||||||
|
|
||||||
|
#### 2. "להלן מתוך" Document Insertion Pattern
|
||||||
|
- **Draft:** 0 occurrences
|
||||||
|
- **Edit:** 12 occurrences of "להלן מתוך [document name]:"
|
||||||
|
- **Lesson:** Every reference to a source document must be accompanied by "להלן מתוך [שם המסמך]:" as a placeholder for a direct quote/image. This is a MANDATORY pattern, not optional. Examples: "להלן מתוך הוראות התכנית:", "להלן מתוך פרוטוקול הדיון:", "להלן מתוך הבקשה להיתר:"
|
||||||
|
|
||||||
|
#### 3. Expanded Factual Background (Block ו)
|
||||||
|
- **Draft:** ~90 words (3%), one paragraph
|
||||||
|
- **Edit:** ~420 words (10%), covering: (a) the application details, (b) 3 committee meetings with dates and outcomes, (c) the final decision
|
||||||
|
- **Lesson:** Block ו must tell the full "story" of the case: when the application was filed → when it was published → how many objections → when committee meetings were held → what was decided at each meeting → when the appeal was filed. Each meeting should have date + outcome.
|
||||||
|
|
||||||
|
#### 4. Bridge Planning Analysis ("גשר תכנוני")
|
||||||
|
- **Draft:** Not present
|
||||||
|
- **Edit:** 249 words — new analytical framework
|
||||||
|
- **Lesson:** When an applicant for deviation/variance is also promoting a plan for the same land, the decision must analyze: (a) is the pending plan harmonious with the requested use? If yes → the deviation can serve as a "bridge" until the plan is approved (cite כוכבה תורן). If no → the contradiction STRENGTHENS the rejection. The writer must check `search_case_documents` for pending plans and compare them with the requested use.
|
||||||
|
|
||||||
|
#### 5. Competing Plans Analysis
|
||||||
|
- **Draft:** Not present (1,033 words added)
|
||||||
|
- **Edit:** Detailed comparison of the site-specific plan (151-1382787) vs the comprehensive plan (151-1337534)
|
||||||
|
- **Lesson:** When there's a site-specific plan AND a comprehensive plan, the decision must: (a) describe each plan's scope, (b) compare the permitted uses, (c) show quantitative contradictions (e.g., "the comprehensive plan allocates 4,404 m² for ALL commerce in the settlement, while the request alone is for 1,425 m² — 32%"), (d) conclude whether there's harmony or contradiction. This is often the STRONGEST argument in the decision.
|
||||||
|
|
||||||
|
#### 6. Heading Level — Flat Structure
|
||||||
|
- **Draft:** Mixed Heading 2 + Heading 3 (nested subsections)
|
||||||
|
- **Edit:** All Heading 2 (flat structure)
|
||||||
|
- **Lesson:** Each section stands independently. No nesting. In the discussion, each analytical step is a separate Heading 2 section.
|
||||||
|
|
||||||
|
#### 7. Inline Precedent Distinguishing
|
||||||
|
- **Draft:** Separate section "הבחנה מתקדימי העוררת" (Heading 3)
|
||||||
|
- **Edit:** Each precedent distinguished inline with "באשר ל-[case name]" → what's different → conclusion
|
||||||
|
- **Lesson:** Don't create a separate "distinguishing" section. Address each precedent where it naturally comes up in the discussion, using "באשר ל..." as the opener.
|
||||||
|
|
||||||
|
### New Transition Phrases Identified
|
||||||
|
- **"עינינו הרואות"** — introducing a document-based finding ("our eyes see that...")
|
||||||
|
- **"הנה כי כן"** — therefore/accordingly (more formal than "לפיכך")
|
||||||
|
- **"נשוב כאן ונבחין"** — returning to distinguish a case
|
||||||
|
- **"נוסיף ונבהיר"** — adding clarification
|
||||||
|
- **"מסקנת הדברים"** — concluding a subsection
|
||||||
|
- **"משכבר קבענו"** — since we already established
|
||||||
|
|
||||||
|
### Applied To
|
||||||
|
- [x] Update legal-decision-lessons.md with lessons 1-7
|
||||||
|
- [x] Update daphna-voice-fingerprint.md with structural and style findings
|
||||||
|
- [ ] Update block-schema.md: block order for 1xxx cases (ט before ז)
|
||||||
|
- [ ] Update daphna-architecture-by-outcome.md: add "bridge planning" analysis for rejections
|
||||||
|
- [ ] Update writer system prompt: mandatory "להלן מתוך" pattern
|
||||||
|
|||||||
227
docs/methodology/extension-request-betterment_levy.md
Normal file
227
docs/methodology/extension-request-betterment_levy.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# מתודולוגיה — בל"מ בהיטל השבחה (8xxx)
|
||||||
|
|
||||||
|
**appeal_subtype:** `extension_request_betterment_levy`
|
||||||
|
**מסלול:** סעיף 14 לתוספת ג' לחוק התכנון והבנייה, התשכ"ה-1965
|
||||||
|
**מועד סטטוטורי:** **45 ימים** (להבדיל מ-30 ימים ברישוי) מיום קבלת
|
||||||
|
דרישת תשלום היטל ההשבחה (סעיף 14(א) לתוספת ג')
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## א. מבוא — ייחודיות בל"מ בהיטל השבחה
|
||||||
|
|
||||||
|
בל"מ במסלול היטל השבחה שונה משמעותית מבל"מ ברישוי בכמה ממדים:
|
||||||
|
|
||||||
|
| ממד | בל"מ ברישוי | בל"מ בהיטל השבחה |
|
||||||
|
|------|--------------|-------------------|
|
||||||
|
| מועד סטטוטורי | 30 ימים | **45 ימים** |
|
||||||
|
| סעיף בחוק | 152 | סעיף 14 לתוספת ג' |
|
||||||
|
| בעלי דין | רחב — כל בעל זכות גובלת/קרובה | **צר — רק החייב בהיטל** |
|
||||||
|
| מהות הסעד | ביטול היתר / שינוי תנאים | תיקון שומה / ביטול חיוב |
|
||||||
|
| טון | פעמים אנושי (תושב, סביבה) | קר ומקצועי (פיננסי/שמאי) |
|
||||||
|
| הסתמכות נדרשת | של היזם | של הרשות (חלוקת הכנסות) |
|
||||||
|
|
||||||
|
הייחוד הקרדינלי: **בל"מ בהיטל השבחה דורש הוכחת טעות שמאית או בדין** —
|
||||||
|
לא רק "טעם סביר" כמו ברישוי. הסיבה: שומת היטל ההשבחה היא מעשה מנהלי
|
||||||
|
שקיבל תוקף, וכספים שולמו / נדרשו, ולעיתים גם חולקו. שינוי שומה דורש
|
||||||
|
עילה מהותית.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ב. מסגרת נורמטיבית
|
||||||
|
|
||||||
|
### שכבה א — חקיקה ראשית
|
||||||
|
|
||||||
|
**סעיף 14(א) לתוספת ג' לחוק התכנון והבנייה:**
|
||||||
|
> "בעל המקרקעין החייב בהיטל השבחה ... רשאי להגיש ערר על השומה לוועדת הערר
|
||||||
|
> לפיצויים ולהיטל השבחה ... בתוך 45 ימים מיום שהומצאה לו השומה"
|
||||||
|
|
||||||
|
המחוקק קבע מועד ארוך יותר (45 לעומת 30) מתוך הכרה במורכבות הסוגיה השמאית —
|
||||||
|
הצורך לקבל חוו"ד שמאית, להתייעץ עם עו"ד מומחה למיסוי מקרקעין, ולבחון את
|
||||||
|
חישובי השומה.
|
||||||
|
|
||||||
|
### שכבה ב — עליון
|
||||||
|
|
||||||
|
**רע"א 7669/96 עיריית נהריה נ' קמינסקי (פ"ד נב(1) 214):**
|
||||||
|
ביסוס עקרוני של "סופיות שומה" — שינוי שומה לאחר חלוף המועד הסטטוטורי
|
||||||
|
אינו עומד על ערעור "טעם סביר" בלבד; נדרש אינטרס ציבורי מובהק או טעות
|
||||||
|
שמאית מהותית.
|
||||||
|
|
||||||
|
**עע"מ 1832/14 הרשות לפיתוח ירושלים נ' מנהל מס שבח:**
|
||||||
|
היטל השבחה — תשלום הכפוף לסופיות שומה; קביעות שמאי בדבר ערך המקרקעין לפני
|
||||||
|
ואחרי האירוע התכנוני הן עובדתיות-מקצועיות. שינוי דורש הצדקה חזקה.
|
||||||
|
|
||||||
|
### שכבה ג — ועדות ערר לפיצויים ולהיטל השבחה
|
||||||
|
|
||||||
|
(להוסיף תקדימים ספציפיים מקורפוס דפנה תמיר בהיטל השבחה. הקורפוס הקיים
|
||||||
|
כולל את עררי 8xxx — לחפש דפוס "בל\"מ" או "הארכת מועד" בתוכם.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ג. תבחיני בל"מ בהיטל השבחה — חמישה תבחינים
|
||||||
|
|
||||||
|
| # | תבחין | אופי | משקל |
|
||||||
|
|---|--------|------|------|
|
||||||
|
| א | **טעות שמאית או בדין** | **תנאי סף עצמאי — ייחודי להיטל השבחה** | קריטי |
|
||||||
|
| ב | טעם סביר לאיחור | מקדים — בדומה לרישוי, אך מחמיר | גבוה |
|
||||||
|
| ג | אורך השיהוי | כמותי | גבוה |
|
||||||
|
| ד | הסתמכות הרשות (חלוקת כספים) | כמותי | גבוה |
|
||||||
|
| ה | סיכויי הערר המהותי (לכאורה) | מהותי | בינוני |
|
||||||
|
|
||||||
|
תבחין "אינטרס ציבורי" לא מופיע כתבחין עצמאי כאן — בהיטל השבחה האינטרס
|
||||||
|
הציבורי נטוע בתוך הסתמכות הרשות (תבחין ד).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ד. תבחין א — טעות שמאית או טעות בדין
|
||||||
|
|
||||||
|
### מה זו "טעות שמאית"?
|
||||||
|
לא כל מחלוקת על שווי = טעות. נדרש להוכיח אחד מאלה:
|
||||||
|
|
||||||
|
1. **טעות חישובית גלויה** — סכום שגוי, פעולה אריתמטית שגויה.
|
||||||
|
2. **שיטה שמאית פסולה** — שימוש בגישה לא מקובלת (לדוגמה: היוון לפי שיעור
|
||||||
|
שאינו ריאלי, השוואה לעסקאות שאינן מקבילות).
|
||||||
|
3. **התעלמות מנכסים דומים** — עיוורון לנתונים שהיו צריכים להילקח בחשבון.
|
||||||
|
4. **שגיאה במספרי שטח / זכויות / תכנית** — אי-תאמה לנסח / לתב"ע.
|
||||||
|
|
||||||
|
### מה זו "טעות בדין"?
|
||||||
|
שגיאה משפטית בעצם החיוב:
|
||||||
|
- **חיוב על נכס שאינו "מקרקעין" לעניין החוק** (זכויות חוזיות גרידא).
|
||||||
|
- **חיוב בגין השבחה שאינה נכנסת להגדרת "השבחה" בחוק** (לדוגמה: השבחה
|
||||||
|
שנוצרה לפני התקופה הקובעת; השבחה מכוח תכנית שאינה תכנית מתאר).
|
||||||
|
- **חיוב לפני התגבשות העילה** — דרישה לפני מימוש בהיתר או מכר.
|
||||||
|
|
||||||
|
### הוכחה דרושה
|
||||||
|
- **חוות דעת שמאית חתומה** מאת שמאי מקרקעין מוסמך, עם נתוני השוואה.
|
||||||
|
- **תיעוד הליך השומה המקורי** — אילו נתונים נלקחו? אילו לא?
|
||||||
|
- **חישוב חלופי מנומק** — לא רק "אני חולק", אלא "הנה החישוב הנכון".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ה. תבחין ב — טעם סביר לאיחור
|
||||||
|
|
||||||
|
### העקרון
|
||||||
|
בדומה לבל"מ ברישוי, אך **קפדן יותר**:
|
||||||
|
- מועד 45 ימים נחשב "מועד ארוך" — קשה יותר להצדיק החמצתו.
|
||||||
|
- החייב לרוב מקבל את השומה לידיו אישית — אין סוגיית "פרסום באתר".
|
||||||
|
- ערב פניה לעו"ד / שמאי הוא צעד צפוי וסטנדרטי.
|
||||||
|
|
||||||
|
### מצבי "טעם סביר" אופייניים
|
||||||
|
| מצב | קבילות |
|
||||||
|
|------|---------|
|
||||||
|
| מחלת המבקש (מתועדת רפואית) | קבילה |
|
||||||
|
| המצאה פגומה (לא לכתובת הנכונה) | קבילה — אך נטל הוכחה כבד |
|
||||||
|
| תקופה ארוכה של בירורים מקצועיים | חלשה — לוחות זמנים אינם מוקפאים |
|
||||||
|
| המתנה לעמדת שמאי לפני הגשת ערר | חלשה — אפשר להגיש ולתקן |
|
||||||
|
| התכתבות עם הרשות בניסיון פשרה | חלשה — לא מקפיאה מועד |
|
||||||
|
|
||||||
|
### דרישת התצהיר
|
||||||
|
**חובה** תצהיר מפורט — תאריכים, אנשי קשר, מסמכי תמיכה. ללא תצהיר —
|
||||||
|
הטענה ריקה משפטית.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ו. תבחין ג — אורך השיהוי
|
||||||
|
|
||||||
|
### חישוב
|
||||||
|
| תאריך | אירוע | שיהוי מצטבר |
|
||||||
|
|--------|--------|--------------|
|
||||||
|
| יום 0 | המצאת השומה | 0 |
|
||||||
|
| יום 45 | תום המועד הסטטוטורי | תום המועד |
|
||||||
|
| יום X | הגשת הבל"מ | X-45 ימים מעבר למועד |
|
||||||
|
|
||||||
|
### עקרון מנחה
|
||||||
|
- שיהוי של עד 30 ימים מעבר למועד (סה"כ 75 ימים מיום ההמצאה) — מקבל
|
||||||
|
התייחסות עניינית אם יש טעם סביר.
|
||||||
|
- שיהוי של מעל 90 ימים מעבר למועד — נחשב חמור; דורש הוכחה חזקה במיוחד.
|
||||||
|
- שיהוי של מעל שנה — לרוב חוסם אלא אם מדובר בטעות חישובית גלויה.
|
||||||
|
|
||||||
|
### השפעת השיהוי על הסתמכות הרשות
|
||||||
|
ככל שהזמן עובר — הסיכוי שהרשות חילקה את הכספים גבוה יותר. דרישה להחזר
|
||||||
|
שנים לאחר התשלום פוגעת בהסתמכות הרשות בצורה מובהקת.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ז. תבחין ד — הסתמכות הרשות (חלוקת הכנסות)
|
||||||
|
|
||||||
|
### ייחודיות לעומת בל"מ ברישוי
|
||||||
|
ברישוי — ההסתמכות היא של היזם הפרטי. בהיטל השבחה — ההסתמכות היא של
|
||||||
|
**הרשות הציבורית**: הכספים מועברים לקרן השבחה, מתוכננים לפרויקטים
|
||||||
|
ציבוריים, ולעיתים אף חולקו או הוצאו.
|
||||||
|
|
||||||
|
### טבלת בדיקה
|
||||||
|
| שלב | מצב הכספים | השפעה על הבל"מ |
|
||||||
|
|------|------------|-----------------|
|
||||||
|
| לפני תשלום | החייב לא שילם | קלה — אין הסתמכות הרשות |
|
||||||
|
| לאחר תשלום, לפני חלוקה | בקופת הוועדה / קרן | בינונית |
|
||||||
|
| לאחר חלוקה לרשויות | חולק לעירייה, יזם, וכו' | משמעותית |
|
||||||
|
| לאחר ביצוע פרויקטים | כספים הוצאו | מוחשית, קשה להפיך |
|
||||||
|
|
||||||
|
### עיקרון
|
||||||
|
**ככל שהכספים "התרחקו" מהקופה — דרישות הוכחת הטעות מחמירות.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ח. תבחין ה — סיכויי הערר המהותי (לכאורה)
|
||||||
|
|
||||||
|
### הבהרה מתודית
|
||||||
|
בשלב בל"מ — בוחנים סיכויי הערר רק כדי לקבוע האם יש סיבה לפתוח את הדלת.
|
||||||
|
הקריטריון: **האם יש "טענה לכאורה" המבוססת על תיעוד מקצועי?**
|
||||||
|
|
||||||
|
### סוגי טענות אופייניים
|
||||||
|
- חישוב שגוי של "המצב הקודם" / "המצב החדש"
|
||||||
|
- שיטת שיערוך פסולה (השוואה / הפרשי הון / היוון)
|
||||||
|
- התעלמות מ"זכויות מותנות" שטרם התגבשו
|
||||||
|
- חיוב כפול (הון / הכנסה / שבח)
|
||||||
|
- אי-התאמה למיקום, שימוש, או שטח
|
||||||
|
|
||||||
|
### מה לא נספר כ"סיכויי הליך"
|
||||||
|
- "אני לא מסכים לסכום" — בלי חוו"ד נגדית מבוססת.
|
||||||
|
- טענות כלליות על "המצב הכלכלי" של המבקש.
|
||||||
|
- טענות על "תקדים" שלא הוכרע בערכאה גבוהה יותר.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ט. טבלת התאמה לעובדות (placeholder לכל תיק)
|
||||||
|
|
||||||
|
| תבחין | עובדה במקרה הנוכחי | כיוון |
|
||||||
|
|--------|---------------------|-------|
|
||||||
|
| א. טעות שמאית/בדין | [סוג הטעות הנטענת + תיעוד] | [חוסם / מאפשר] |
|
||||||
|
| ב. טעם סביר | [מועד המצאה, פעולות, תצהיר] | [תומך / מחליש] |
|
||||||
|
| ג. אורך השיהוי | [X ימים מעבר ל-45] | [קל / בינוני / חמור] |
|
||||||
|
| ד. הסתמכות הרשות | [מצב הכספים: בקופה / חולק / הוצא] | [קל / משמעותי / מוחשי] |
|
||||||
|
| ה. סיכויי הליך | [חוו"ד שמאית? חישוב חלופי?] | [לכאורה / ספקולטיבי] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## י. סעיף מסקנה — מבנה אופייני
|
||||||
|
|
||||||
|
המבנה האופייני בבל"מ-היטל-השבחה הוא **קר ומקצועי** — מינימום רגש,
|
||||||
|
מקסימום שמאות:
|
||||||
|
|
||||||
|
1. **קביעת מצב השומה.** "השומה הומצאה ביום X. הבל"מ הוגשה ביום Y."
|
||||||
|
2. **תבחין א (טעות שמאית).** "המבקש טוען לטעות בX. בחינת המסמכים מעלה..."
|
||||||
|
3. **אם טעות לא הוכחה — דחייה.** "בהיעדר טעות שמאית או בדין, אין יסוד
|
||||||
|
לסטות ממועד הקבוע בחוק."
|
||||||
|
4. **אם טעות הוכחה — מעבר לתבחינים ב-ה.**
|
||||||
|
5. **מאזן.** "לאור איזון התבחינים..."
|
||||||
|
6. **הכרעה.** דחייה / קבלה / החזרה לשמאי הוועדה לבחינה.
|
||||||
|
|
||||||
|
### לשון אופיינית לדחייה
|
||||||
|
> "הבל"מ הוגשה X ימים לאחר תום המועד הסטטוטורי. המבקש לא הצביע על טעות
|
||||||
|
> שמאית או בדין; הטענות הן בגדר מחלוקת על שיקול דעת מקצועי, שאינה מצדיקה
|
||||||
|
> פתיחת שומה שקיבלה תוקף. לאור אלה, ובהינתן שהכספים שולמו וחולקו, הבל"מ
|
||||||
|
> נדחית."
|
||||||
|
|
||||||
|
### לשון אופיינית לקבלה (חריגה)
|
||||||
|
> "המבקש הצביע על טעות חישובית במספר זכויות התכנון שנלקחו בחשבון. הטעות
|
||||||
|
> מהותית ומשפיעה על השומה. בנסיבות אלה, ועל אף השיהוי, יש מקום לפתוח את
|
||||||
|
> השומה לדיון בערר עצמו."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## יא. הפניות חוצות
|
||||||
|
|
||||||
|
- ראה גם: `docs/methodology/extension-request-building_permit.md` (סעיף 152, 30 ימים)
|
||||||
|
- ראה גם: `docs/methodology/extension-request-compensation.md` (סעיף 198(ד), 30 ימים)
|
||||||
|
- ראה גם: `docs/block-schema.md` — מבנה 12 הבלוקים
|
||||||
|
- ראה גם: `skills/decision/SKILL.md` — מדריך סגנון של דפנה
|
||||||
252
docs/methodology/extension-request-building_permit.md
Normal file
252
docs/methodology/extension-request-building_permit.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# מתודולוגיה — בל"מ ברישוי ובנייה (1xxx)
|
||||||
|
|
||||||
|
**appeal_subtype:** `extension_request_building_permit`
|
||||||
|
**מסלול:** סעיף 152(א) לחוק התכנון והבנייה, התשכ"ה-1965
|
||||||
|
**מועד סטטוטורי:** 30 ימים מיום המצאת ההחלטה (סעיף 152(ב))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## א. מבוא — מהותו של בל"מ ברישוי
|
||||||
|
|
||||||
|
בל"מ ("בקשה להארכת מועד") הוא הליך מקדמי שהמבקש להגיש ערר על החלטת ועדה מקומית
|
||||||
|
לאחר חלוף 30 הימים נדרש לעבור בו לפני שיוכל לפתוח בערר עצמו. הוועדה נדרשת
|
||||||
|
לאזן בין שני אינטרסים נוגדים:
|
||||||
|
|
||||||
|
- **זכות הגישה לערכאות** — שכל בעל זכות עמידה יוכל להעמיד את החלטת הוועדה
|
||||||
|
המקומית במבחן שיפוטי, במיוחד כאשר ההחלטה נטענת כפסולה.
|
||||||
|
- **סופיות החלטות מנהליות + הסתמכות** — היזם זכאי לפעול לפי ההיתר שניתן, להשקיע
|
||||||
|
כספים, להתחיל בעבודות, ולא לחיות בחשש מתמיד שמא ההיתר ייתקף שנים לאחר אישורו.
|
||||||
|
|
||||||
|
לעומת בל"מ בהיטל השבחה (סעיף 14 לתוספת ג', 45 ימים) ובל"מ בפיצויים (סעיף 198(ד),
|
||||||
|
30 ימים אך עם סף קפדני יותר), בל"מ ברישוי משלב טון אנושי יחסית — ההסתמכות מוחשית
|
||||||
|
(חפירה, פינוי שוכרים) והאינטרסים הציבוריים (מיגון, חיזוק) ממשיים.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ב. מסגרת נורמטיבית — שלוש שכבות
|
||||||
|
|
||||||
|
### שכבה א — עליון: בר"מ 2340/02 הוועדה המקומית רמת השרון נ' אגא וכט, פ"ד נז(3) 385 (2003)
|
||||||
|
|
||||||
|
הכיר בסמכותה של ועדת הערר להאריך את המועד, בנסיבות חריגות, וקבע את הבחינה
|
||||||
|
הדו-שלבית:
|
||||||
|
1. **תנאי סף:** טעם סביר לאיחור.
|
||||||
|
2. **שיקול כולל:** השוואה בין נזקי המבקש לבין הסתמכות הצד שכנגד; היקף השיהוי;
|
||||||
|
סיכויי ההליך; אינטרס ציבורי.
|
||||||
|
|
||||||
|
### שכבה ב — עליון: עע"מ 317/10 שפר נ' סקאל יניב (נבו 23.8.2012)
|
||||||
|
|
||||||
|
הלכה מחייבת: מניין 30 הימים מתחיל **מיום הידיעה בפועל**, לא מיום הפרסום הפורמלי.
|
||||||
|
המשמעות: גם איחור-לכאורה של חודשים יכול להיות לגיטימי אם המבקש לא ידע על ההחלטה
|
||||||
|
בזמן אמת.
|
||||||
|
|
||||||
|
> "מתנגד להיתר שניתן, אשר שטח התנגדותו בפני הועדה המקומית וזו נדחתה, או שידע
|
||||||
|
> על מתן ההיתר, צריך יהיה להגיש את הערר תוך 30 יום מיום שנודע לו על מתן ההיתר."
|
||||||
|
|
||||||
|
### שכבה ג — ועדת ערר ירושלים (דפנה תמיר)
|
||||||
|
|
||||||
|
**ערר 1009/25 מפלגת נעם נ' הוועדה המרחבית הראל (נבו 27.3.2025):**
|
||||||
|
> "דיון בערר המבקש לבטל היתר שכבר יצא מחייב עמידה בלוח הזמנים שהדין מחייב,
|
||||||
|
> כל חריגה מכך מחייבת בקשה להארכת מועד ועמידה בכל התנאים לכך (זכות עמידה,
|
||||||
|
> שיהוי, הסתמכות, פגיעה וכיו'). ודוק, מחייבת בקשה להארכת מועד סדורה ומנומקת
|
||||||
|
> ולא בדרך אגב ולא בחסות תקנות הרישוי."
|
||||||
|
|
||||||
|
**ערר 1112/22 ירושלים שקופה נ' ועדה מקומית ירושלים (נבו 11.5.2023):**
|
||||||
|
> "מרחק של פחות מ-100 מ' אינו מקנה זכות התנגדות לתכנית; קל וחומר שמרחק של
|
||||||
|
> למעלה מ-400 מ' אינו מקנה זכות התנגדות לבקשה להיתר, שכן זכות ההתנגדות לבקשה
|
||||||
|
> להיתר (סעיף 149) צרה מזכות ההתנגדות לתכנית (סעיף 100)"
|
||||||
|
|
||||||
|
**בל"מ 1028/20 חלוואני (ועדת ערר ירושלים):**
|
||||||
|
> "המועד להגשת ערר הינו 30 ימים מיום שהומצאה החלטת הועדה המקומית וכי המבקשת
|
||||||
|
> הייתה ערה להליכי הבקשה להיתר"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ג. שישה תבחינים — סדר הבחינה
|
||||||
|
|
||||||
|
על פי הפסיקה המצטברת, להכרעה בבל"מ-רישוי יש לבחון שישה תבחינים. הסדר חשוב:
|
||||||
|
תבחין ו (זכות עמידה) הוא תנאי סף עצמאי — אם אין זכות עמידה אין צורך לבחון
|
||||||
|
יתר התבחינים.
|
||||||
|
|
||||||
|
| # | תבחין | אופי | מקור |
|
||||||
|
|---|--------|------|------|
|
||||||
|
| ו | **זכות עמידה** | **תנאי סף עצמאי** | עע"מ 1461/20 אנטרים; ערר 1112/22 |
|
||||||
|
| א | טעם סביר לאיחור | מקדים — נחוץ לפתיחת הדלת | עע"מ 317/10 שפר; בל"מ 1028/20 |
|
||||||
|
| ב | אורך השיהוי | כמותי — חומרת ההפרה | ערר 1096/24 אנשין |
|
||||||
|
| ג | הסתמכות + שינוי מצב לרעה | כמותי — נזק | בר"מ 2340/02 |
|
||||||
|
| ד | סיכויי ההליך | מהותי — "לכאורה" | בר"מ 2340/02 |
|
||||||
|
| ה | אינטרס ציבורי / חזקת תקינות | ערכי | הלכת חזקת תקינות |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ד. תבחין ו — זכות עמידה (תנאי סף)
|
||||||
|
|
||||||
|
### מקור הזכות
|
||||||
|
זכות הערר לפי סעיף 152 מוקנית רק למי שהוא **בעל זכות במקרקעין נשוא הבקשה
|
||||||
|
להיתר**, לא לכל בעל עניין (עע"מ 1461/20 אנטרים).
|
||||||
|
|
||||||
|
### תבחין מרחק
|
||||||
|
על פי ערר 1112/22, מרחק של מעל 100 מ' (קל וחומר מעל 400 מ') אינו מקנה זכות
|
||||||
|
התנגדות לבקשת היתר, גם בהיעדר נצפות.
|
||||||
|
|
||||||
|
### טבלת בדיקה
|
||||||
|
| פרמטר | להוכיח |
|
||||||
|
|--------|---------|
|
||||||
|
| בעל זכות בנכס נשוא הבקשה? | חוזה רכישה / נסח / שכירות מאומתת |
|
||||||
|
| בעל זכות בנכס גובל? | מפת מדידה / נסח |
|
||||||
|
| מרחק קו אווירי | מודד / Google Maps עם תיעוד |
|
||||||
|
| קיומה של נצפות | תצלום פנורמי / חוו"ד מודד |
|
||||||
|
| מעמד נציג דיירים / פינוי-בינוי | חוזה פנימי — לא יוצר זכות סטטוטורית |
|
||||||
|
|
||||||
|
**אזהרה:** טיעון של "מתנגד מטעם הציבור" או "אינטרס ציבורי כללי" — אינו מקנה
|
||||||
|
זכות עמידה. הזכות נצרכת להיות מעוגנת בזכות במקרקעין.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ה. תבחין א — טעם סביר לאיחור
|
||||||
|
|
||||||
|
### העיקרון
|
||||||
|
המבקש נדרש להוכיח שלא ידע על ההחלטה בזמן אמת **ושאי-הידיעה היא סבירה** — לא רק
|
||||||
|
שלא ידע, אלא שלא היה ניתן לצפות שיֵדע. הכלל הוא **דרך הסטטוס-קוו**: מי שהתעניין
|
||||||
|
בנכס שכן, שהיה מודע לשלטי בנייה, או שהיה לו עניין סדור בנכס — מוחזק כיודע.
|
||||||
|
|
||||||
|
### דרישות הוכחה
|
||||||
|
1. **תצהיר עובדתי** של המבקש — תאריכים מפורטים, מי אמר לו, מתי בדיוק.
|
||||||
|
2. **הוכחת ברירת המחדל של הוועדה** — היכן הפרסום היה צריך להתבצע? האם בוצע?
|
||||||
|
3. **שלושת התנאים המצטברים** (לפי הלכת שפר, כפי שיושמו בפסיקה לאחר מכן):
|
||||||
|
- זכות טיעון בהליך הרישוי וזכאות לקבל פרסום.
|
||||||
|
- פגם בהליך הפרסום בפועל.
|
||||||
|
- הפגם פגע בזכות הטיעון.
|
||||||
|
|
||||||
|
### מלכודות נפוצות
|
||||||
|
- **התכתבות עם "הדרג המקצועי" אינה מקפיאה לוחות זמנים** (בל"מ 1028/22 חמד).
|
||||||
|
- **היעדר תצהיר → גרסת אי-הידיעה חלשה ראייתית.**
|
||||||
|
- **ידיעה קודמת על ההליכים** (התנגדות שהוגשה, נוכחות בדיון, פניות בעבר) שוללת
|
||||||
|
כל תירוץ של אי-ידיעה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ו. תבחין ב — אורך השיהוי
|
||||||
|
|
||||||
|
### שני רכיבים
|
||||||
|
1. **שיהוי מצטבר** — הזמן שחלף מהחלטת הוועדה המקומית עד הגשת הבל"מ.
|
||||||
|
2. **שיהוי סובייקטיבי** — הזמן שחלף מיום הידיעה הנטענת עד הגשת הבל"מ.
|
||||||
|
|
||||||
|
### ציר זמן לדוגמה
|
||||||
|
| תאריך | אירוע | שיהוי מצטבר |
|
||||||
|
|--------|--------|--------------|
|
||||||
|
| יום 0 | פרסום הבקשה | 0 |
|
||||||
|
| יום 30 | החלטת ועדת משנה | — |
|
||||||
|
| יום 120 | אישרור במליאה | — |
|
||||||
|
| יום X | ידיעה נטענת | חודשים-שנה |
|
||||||
|
| יום X+30 | הגשת הבל"מ | +30 ימים סובייקטיבי |
|
||||||
|
|
||||||
|
### עקרון מנחה
|
||||||
|
ערר 1096/24 אנשין (דפנה תמיר, 30.12.2024):
|
||||||
|
> "בהינתן שהערר מוגש במקום בו לא הייתה לעורר זכות קנויה וברורה להגשתו, היה
|
||||||
|
> עליו שלא להתעכב ובוודאי שלא לחכות ליום האחרון להגשת הערר"
|
||||||
|
|
||||||
|
**הכלל:** ככל שזכות העמידה רופפת יותר — דרישות הזריזות מחמירות.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ז. תבחין ג — הסתמכות הצד שכנגד
|
||||||
|
|
||||||
|
### עיקרון בר"מ 2340/02 אגא וכט
|
||||||
|
> "האם שינה הצד האחר את מצבו לרעה, האם ניתן להשיב את המצב לקדמותו"
|
||||||
|
|
||||||
|
### טבלת השקעות לבדיקה
|
||||||
|
| השקעה | תיעוד נדרש |
|
||||||
|
|--------|-----------|
|
||||||
|
| שכר טרחת מתכננים / עו"ד / יועצים | חשבוניות / קבלות / חוזה |
|
||||||
|
| תכנון מפורט (חניון, ממ"דים) | תכניות חתומות |
|
||||||
|
| היתר חפירה / חפירה בפועל | היתר + תצלומים |
|
||||||
|
| הסכמי מימון | חוזה עם בנק / משקיע |
|
||||||
|
| פינוי שוכרים / חתימות דיירים | חוזי פינוי / הסכמות |
|
||||||
|
| התקדמות פיזית (יסודות, שלד) | תצלומים מתועדים |
|
||||||
|
|
||||||
|
### "האם ניתן להשיב למצב הקדמות?"
|
||||||
|
ככל ששלב הביצוע מתקדם יותר — היכולת להפוך פוחתת. לאחר היתר חפירה, פינוי שוכרים,
|
||||||
|
ושלב הכנת יסודות — המצב לרוב בלתי-הפיך פיזית, ולפחות בלתי-הפיך כלכלית.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ח. תבחין ד — סיכויי ההליך (לכאורה)
|
||||||
|
|
||||||
|
### הבהרה מתודית
|
||||||
|
בשלב בל"מ, **בוחנים סיכויי הערר המהותי רק כדי לקבוע האם יש סיבה מספקת לפתוח
|
||||||
|
את הדלת** — לא לפסוק לגוף הערר. אם המחלוקת המהותית היא קשה ומורכבת אבל ברורה
|
||||||
|
שיש בה ממש — תבחין ד תומך בקבלת הבל"מ. אם המחלוקת תיאורטית, ספקולטיבית, או
|
||||||
|
ברורה לזכות המשיבים — תבחין ד תומך בדחייה.
|
||||||
|
|
||||||
|
### סוגים אופייניים של סוגיות מהותיות בבל"מ-רישוי
|
||||||
|
- תחולת תמ"א 38 (תקנים, מבנה קטן, איזורי סיכון רעש)
|
||||||
|
- תוקף תכנית (פקיעה, הוראות מעבר)
|
||||||
|
- חישוב סל זכויות (תיקון 3א, "קומה טיפוסית קיימת")
|
||||||
|
- מעמד תכנית חדשה (102-XXXXXX) — מופקדת? מאושרת? נסיוני?
|
||||||
|
- תנאי היתר (עמידה בתקנות, קווי בניין, חניות)
|
||||||
|
|
||||||
|
### דרך הבחינה
|
||||||
|
לכל סוגיה: (1) האם ההסתמכות על תכנית / תקן בוצעה; (2) האם יש פסיקה מנחה;
|
||||||
|
(3) האם יש מחלוקת מקצועית-עובדתית שתצריך חוות דעת.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ט. תבחין ה — אינטרס ציבורי / חזקת תקינות
|
||||||
|
|
||||||
|
### חזקת תקינות המעשה המנהלי
|
||||||
|
עיקרון יסוד בדין המנהלי: כל פעולת הוועדה נחזית כתקינה, עד שהמוכיח אחרת. נטל
|
||||||
|
ההוכחה על המבקש.
|
||||||
|
|
||||||
|
### שיקולים אופייניים בבל"מ-רישוי
|
||||||
|
| שיקול | כיוון אופייני |
|
||||||
|
|--------|---------------|
|
||||||
|
| חיזוק מבני מפני רעידות אדמה | תומך ביזם |
|
||||||
|
| ממ"דים / מיגון מפני ירי | תומך ביזם |
|
||||||
|
| הרחבת זכויות דרך / זכויות מעבר | תועלת ציבורית |
|
||||||
|
| חניות תת-קרקעיות (פינוי חניה מרחוב) | תועלת ציבורית |
|
||||||
|
| תקינות הליך (פרסום, התנגדויות, דיון) | חזקת תקינות |
|
||||||
|
| מתנגד סדרתי / בעל אינטרס נסתר | מחליש טענות המבקש |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## י. טבלת התאמה לעובדות (placeholder לכל תיק)
|
||||||
|
|
||||||
|
| תבחין | עובדה במקרה הנוכחי | כיוון |
|
||||||
|
|--------|---------------------|-------|
|
||||||
|
| ו. זכות עמידה | [לתאר מרחק, נצפות, זכויות בקרקע] | [חוסם / מאפשר / שאלה] |
|
||||||
|
| א. טעם סביר | [פרסום, ידיעה, תצהיר] | [נוטה לקבלה / לדחייה] |
|
||||||
|
| ב. אורך השיהוי | [שנים / חודשים / ימים] | [קל / בינוני / חמור] |
|
||||||
|
| ג. הסתמכות | [השקעות מצוטטות בש"ח] | [קלה / משמעותית / מוחשית] |
|
||||||
|
| ד. סיכויי הליך | [שאלות פתוחות vs. ברורות] | [לכאורה / ספקולטיבי] |
|
||||||
|
| ה. אינטרס ציבורי | [שיקולים ציבוריים בולטים] | [תומך / ניטרלי / נגד] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## יא. סעיף מסקנה — מבנה אופייני
|
||||||
|
|
||||||
|
המבנה האופייני של סעיף ההכרעה בבל"מ-רישוי הוא:
|
||||||
|
|
||||||
|
1. **פתיחה — איזון התבחינים בקצרה.** "בחנו את ששת התבחינים... ומצאנו..."
|
||||||
|
2. **תבחין ו (סף).** אם זכות העמידה רופפת/חסרה — זהו לרוב המכריע.
|
||||||
|
3. **תבחינים א-ה.** ניתוח כל אחד בקצרה, עם הפניה לפסיקה.
|
||||||
|
4. **מסקנה כוללת.** "לאור כל האמור — הבקשה להארכת מועד נדחית / מתקבלת".
|
||||||
|
5. **הוצאות.** אם רלוונטי — לפי סעיף 1.
|
||||||
|
|
||||||
|
### לשון אופיינית לדחייה (דפנה תמיר)
|
||||||
|
> "מששה התבחינים שנבחנו — חמישה מצביעים על מסקנה אחת, וגם התבחין השישי אינו
|
||||||
|
> תומך בקבלת הבקשה. נסיבות התיק אינן מצדיקות חריגה מהמועד הסטטוטורי."
|
||||||
|
|
||||||
|
### לשון אופיינית לקבלה
|
||||||
|
> "על אף השיהוי, נסיבות אי-הידיעה מתועדות; ההסתמכות בעיקרה תכנונית ולא ביצועית;
|
||||||
|
> ומחלוקת מהותית ממשית עומדת על הפרק. בנסיבות אלה, יש לפתוח את הדלת לערר על
|
||||||
|
> מנת שהסוגיות יתבררו."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## יב. הפניות חוצות
|
||||||
|
|
||||||
|
- ראה גם: `docs/methodology/extension-request-betterment_levy.md` (סעיף 14, 45 ימים)
|
||||||
|
- ראה גם: `docs/methodology/extension-request-compensation.md` (סעיף 198(ד), 30 ימים)
|
||||||
|
- ראה גם: `docs/block-schema.md` — מבנה 12 הבלוקים
|
||||||
|
- ראה גם: `skills/decision/SKILL.md` — מדריך סגנון של דפנה
|
||||||
|
- דוגמאות מעובדות: `data/cases/1017-03-26/`, `data/cases/1018-03-26/`, `data/cases/1019-03-26/`
|
||||||
215
docs/methodology/extension-request-compensation.md
Normal file
215
docs/methodology/extension-request-compensation.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# מתודולוגיה — בל"מ בפיצויים (ס' 197) (9xxx)
|
||||||
|
|
||||||
|
**appeal_subtype:** `extension_request_compensation`
|
||||||
|
**מסלול:** סעיף 198(ד) לחוק התכנון והבנייה, התשכ"ה-1965
|
||||||
|
**מועד סטטוטורי:** 30 ימים מיום החלטת הוועדה המקומית בתביעת הפיצויים
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## א. מבוא — הייחוד של בל"מ בפיצויים
|
||||||
|
|
||||||
|
בל"מ בפיצויים שונה מהותית הן מבל"מ ברישוי והן מבל"מ בהיטל השבחה:
|
||||||
|
|
||||||
|
| ממד | בל"מ ברישוי | בל"מ היטל השבחה | בל"מ פיצויים |
|
||||||
|
|------|--------------|------------------|----------------|
|
||||||
|
| מועד | 30 ימים | 45 ימים | **30 ימים** |
|
||||||
|
| סעיף | 152 | 14 לתוספת ג' | **198(ד)** |
|
||||||
|
| מהות הסעד | ביטול היתר | תיקון שומה | **פיצויי פגיעה בזכויות קניין** |
|
||||||
|
| נטל הוכחה | מקדים | טעות שמאית | **סף קפדני — פגיעה ממונית מוחשית** |
|
||||||
|
| טון אופייני | מעורב | קר/שמאי | **קר, משפטי, חמור** |
|
||||||
|
| הסתמכות | יזם / רשות | רשות (חלוקה) | **רשות + ציבור (תקציבי פיצויים)** |
|
||||||
|
|
||||||
|
### למה הסף הקפדן ביותר?
|
||||||
|
פיצויים לפי סעיף 197 הם **כספים ציבוריים** שמיועדים לפיצוי על פגיעה
|
||||||
|
ממונית מוחשית בקרקעות. הם נושאים שלוש מאפיינים שדורשים אכיפת מועדים
|
||||||
|
מחמירה:
|
||||||
|
|
||||||
|
1. **תקציבים סגורים** — הוועדה המקומית עוזבת תקציב לפיצויי 197; שיהוי
|
||||||
|
מחבל בתכנון פיננסי ובחלוקת התקציב.
|
||||||
|
2. **השפעה על תכנון עתידי** — דחייה ארוכת-טווח בבירור הזכות לפיצוי משבשת
|
||||||
|
את היכולת לתכנן הליכי הפקעה/תכנון נוספים.
|
||||||
|
3. **זכויות קניין** — שני הצדדים (תובע ורשות) נושאים אינטרסים קנייניים
|
||||||
|
ברורים. אכיפת מועדים = הגנה על שני הצדדים.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ב. מסגרת נורמטיבית
|
||||||
|
|
||||||
|
### שכבה א — חקיקה ראשית
|
||||||
|
|
||||||
|
**סעיף 197(א) לחוק התכנון והבנייה:**
|
||||||
|
> "נפגעו על ידי תכנית, שלא בדרך הפקעה, מקרקעין הנמצאים בתחום התכנית או
|
||||||
|
> גובלים עמה, מי שביום תחילתה של התכנית היה בעל המקרקעין או בעל זכות בהם
|
||||||
|
> זכאי לפיצויים מהוועדה המקומית..."
|
||||||
|
|
||||||
|
**סעיף 198(ד) — מועד הערר:**
|
||||||
|
ערר על החלטת הוועדה המקומית בתביעת פיצויים מוגש לוועדת הערר תוך 30 ימים
|
||||||
|
מיום שהומצאה ההחלטה לתובע.
|
||||||
|
|
||||||
|
### שכבה ב — עליון
|
||||||
|
|
||||||
|
**ע"א 210/88 החברה להפצת פרי הארץ נ' הוועדה המקומית כוכב יאיר (פ"ד מו(4) 627):**
|
||||||
|
ביסוס דרישת ההוכחה לפגיעה ממונית מוחשית — לא די בטענה כללית של "ירידת ערך".
|
||||||
|
נדרשת: (א) הוכחת מצב לפני התכנית; (ב) הוכחת מצב אחרי; (ג) הצבעה על קשר סיבתי
|
||||||
|
ישיר; (ד) חוות דעת שמאית כמותית.
|
||||||
|
|
||||||
|
**עע"מ 1968/00 חברת גוש 6195 נ' הוועדה המקומית הרצליה:**
|
||||||
|
חיזוק עקרון הסופיות בפיצויי 197 — שינוי מועדים בהליך פיצויים פוגע באינטרס
|
||||||
|
הציבורי הספציפי של פריסת תקציבים.
|
||||||
|
|
||||||
|
### שכבה ג — ועדות ערר
|
||||||
|
|
||||||
|
(להוסיף תקדימי דפנה תמיר בעררי 9xxx — לחפש בקורפוס "בל\"מ פיצויים" או
|
||||||
|
"הארכת מועד 197".)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ג. ארבעה תבחיני בל"מ בפיצויים
|
||||||
|
|
||||||
|
| # | תבחין | אופי | סף |
|
||||||
|
|---|--------|------|-----|
|
||||||
|
| א | **פגיעה ממונית מוחשית** | תנאי סף עצמאי | קריטי |
|
||||||
|
| ב | טעם סביר לאיחור | מקדים — קפדן | גבוה |
|
||||||
|
| ג | אורך השיהוי | כמותי — קצר במיוחד | גבוה |
|
||||||
|
| ד | הסתמכות הרשות (תקציב) | כמותי | גבוה |
|
||||||
|
|
||||||
|
לעומת בל"מ ברישוי ובהיטל השבחה — אין כאן תבחין נפרד של "סיכויי הליך";
|
||||||
|
תבחין הפגיעה (א) משלב את שני הממדים (סיכויי הליך + עצם הזכות לפיצוי).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ד. תבחין א — פגיעה ממונית מוחשית (סף הקפדני)
|
||||||
|
|
||||||
|
### הדרישה
|
||||||
|
לא די בטענה לפגיעה. נדרש להוכיח, לפחות לכאורה:
|
||||||
|
|
||||||
|
1. **בעלות / זכות במקרקעין נשוא התביעה** — נסח טאבו, חוזה מאומת, או רישום אחר.
|
||||||
|
2. **תכנית מאושרת שנכנסה לתוקף** — לא טיוטה, לא תב"ע מופקדת — תכנית בתוקף.
|
||||||
|
3. **קשר סיבתי בין התכנית לפגיעה הנטענת** — לא "ירידת ערך כללית" של אזור.
|
||||||
|
4. **חוו"ד שמאית כמותית** — מציגה את ערך הקרקע לפני ואחרי, עם נתוני השוואה.
|
||||||
|
|
||||||
|
### הוצאות מן הכלל
|
||||||
|
לא נחשבים "פגיעה ממונית" לעניין סעיף 197:
|
||||||
|
- **פגיעה תיאורטית עתידית** — תכנית שטרם נכנסה לתוקף, אופציות שלא מומשו.
|
||||||
|
- **פגיעה אסתטית/סובייקטיבית** — נוף, שכנים, אווירה.
|
||||||
|
- **פגיעה זמנית בלבד** — שיבושים בשלב בנייה שאינם משפיעים על ערך ארוך-טווח.
|
||||||
|
- **פגיעה במקרקעין מחוץ לתכנית ולא גובלים** — דרישה שטחית של "תחום התכנית
|
||||||
|
או גובלים עמה" — מצומצמת.
|
||||||
|
|
||||||
|
### דרישת ההוכחה לכאורה בשלב הבל"מ
|
||||||
|
בשלב בל"מ אין צורך להוכיח את הפגיעה במלואה; די ב**הצגת לכאורה משכנעת**
|
||||||
|
המבוססת על מסמכים מקצועיים. הצגה זו מאפשרת לבחון: האם יש בכלל מה לדון
|
||||||
|
לאחר חלוף המועד?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ה. תבחין ב — טעם סביר לאיחור
|
||||||
|
|
||||||
|
### העקרון
|
||||||
|
בפיצויים — דרישת הזריזות מחמירה מאוד. סיבות:
|
||||||
|
|
||||||
|
1. **התובע פעל מולן** — בניגוד לבל"מ ברישוי, התובע ידע על התכנית ופעל
|
||||||
|
בה (הגיש תביעה לוועדה המקומית). אי-ידיעה על ההחלטה היא חריג.
|
||||||
|
2. **המצאה אישית** — ההחלטה מומצאת אישית; פחות מקום לטענות "פרסום באתר".
|
||||||
|
3. **התובע מיוצג** — לרוב התובע פיצויים מיוצג עו"ד; "אי-ידיעה" של עו"ד
|
||||||
|
על מועד היא חולשה ראייתית מובהקת.
|
||||||
|
|
||||||
|
### מצבי "טעם סביר" אופייניים
|
||||||
|
| מצב | קבילות |
|
||||||
|
|------|---------|
|
||||||
|
| המצאה פגומה (לא לכתובת עורך הדין) | קבילה — בכפוף לתיעוד |
|
||||||
|
| מחלת התובע (מתועדת) | קבילה |
|
||||||
|
| תקופה ארוכה של "ניסיון להידברות" עם הוועדה | חלשה — לוחות זמנים לא מוקפאים |
|
||||||
|
| המתנה להחלטה שיפוטית במקרה דומה | חלשה — אפשר להגיש "במקרה ש..." |
|
||||||
|
| תקלה במשרד עורך הדין | חלשה — אחריות נשואת ייצוג |
|
||||||
|
|
||||||
|
### דרישות הוכחה
|
||||||
|
- תצהיר מפורט של התובע **וגם** של עורך דינו.
|
||||||
|
- מסמכי תמיכה (כרטיסי רישום בית חולים, אישורים רפואיים, וכו').
|
||||||
|
- תיעוד התכתבות פנימית במשרד עורך הדין (אם רלוונטי).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ו. תבחין ג — אורך השיהוי
|
||||||
|
|
||||||
|
### עקרונות
|
||||||
|
- **30 ימים בלבד** = מועד קצר במיוחד.
|
||||||
|
- כל יום מעבר מקבל ניקוד שלילי.
|
||||||
|
- שיהוי של מעל 14 ימים מעבר למועד (סה"כ 44 ימים) — נחשב מובהק.
|
||||||
|
- שיהוי של מעל 60 ימים מעבר (סה"כ 90 ימים) — דורש הצדקה חזקה במיוחד.
|
||||||
|
- שיהוי של מעל 180 ימים — חוסם אלא בנסיבות חריגות (טעות בדין, גילוי מאוחר
|
||||||
|
של עובדה מהותית).
|
||||||
|
|
||||||
|
### חישוב
|
||||||
|
| תאריך | אירוע | שיהוי מצטבר |
|
||||||
|
|--------|--------|--------------|
|
||||||
|
| יום 0 | המצאת החלטה | 0 |
|
||||||
|
| יום 30 | תום מועד סטטוטורי | 0 |
|
||||||
|
| יום X | הגשת הבל"מ | X-30 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ז. תבחין ד — הסתמכות הרשות (תקציב פיצויים)
|
||||||
|
|
||||||
|
### ייחוד בפיצויים
|
||||||
|
הוועדה המקומית מקצה תקציב לפיצויי 197 לפי החלטותיה. שיהוי בערר:
|
||||||
|
|
||||||
|
1. **פוגע בפריסה תקציבית** — תקציב עזב מהקצאתו, עבר ליעדים אחרים.
|
||||||
|
2. **מסבך הליכים שלא הוכרעו עדיין** — בעלי מקרקעין אחרים פעלו על סמך
|
||||||
|
התקציב הקיים.
|
||||||
|
3. **משפיע על מכרזים / חוזי תכנון** — שינוי בגובה הפיצויים משפיע על
|
||||||
|
החלטות פיתוח עתידיות.
|
||||||
|
|
||||||
|
### טבלת בדיקה
|
||||||
|
| שלב | מצב התקציב | השפעה |
|
||||||
|
|------|-----------|--------|
|
||||||
|
| לפני סוף שנת כספים | תקציב פעיל, ניתן לשנות הקצאה | קלה |
|
||||||
|
| לאחר סגירת שנת כספים | תקציב חלוק | בינונית |
|
||||||
|
| לאחר העברה ליעדים אחרים | פיצוי דורש מקור חדש | משמעותית |
|
||||||
|
| לאחר ביצוע פרויקטים | בלתי הפיך כלכלית | מוחשית |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ח. טבלת התאמה לעובדות (placeholder לכל תיק)
|
||||||
|
|
||||||
|
| תבחין | עובדה במקרה הנוכחי | כיוון |
|
||||||
|
|--------|---------------------|-------|
|
||||||
|
| א. פגיעה ממונית | [חוו"ד שמאית? קשר סיבתי? תכנית בתוקף?] | [חוסם / מאפשר] |
|
||||||
|
| ב. טעם סביר | [המצאה, ייצוג, תצהיר] | [תומך / מחליש] |
|
||||||
|
| ג. אורך השיהוי | [X ימים מעבר ל-30] | [קל / מובהק / חמור] |
|
||||||
|
| ד. הסתמכות הרשות | [מצב התקציב] | [קל / משמעותי / מוחשי] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ט. סעיף מסקנה — מבנה אופייני
|
||||||
|
|
||||||
|
המבנה האופייני הוא **קפדן, מבוסס מסמכים, ללא רגש**:
|
||||||
|
|
||||||
|
1. **קביעת עובדות.** "ההחלטה הומצאה ביום X. הבל"מ הוגשה ביום Y. השיהוי
|
||||||
|
הוא Z ימים מעבר למועד הסטטוטורי."
|
||||||
|
2. **תבחין א (פגיעה).** "המבקש הציג חוו"ד / לא הציג חוו"ד. הקרקע
|
||||||
|
נמצאת בתחום התכנית / גובלת בה / מחוץ לה."
|
||||||
|
3. **אם לא הוצגה פגיעה לכאורה — דחייה מיידית.** "בהיעדר הצגה לכאורה של
|
||||||
|
פגיעה ממונית, אין יסוד לסטות ממועד הקבוע בחוק."
|
||||||
|
4. **אם הוצגה פגיעה — מעבר לתבחינים ב-ד.**
|
||||||
|
5. **מאזן והכרעה.** דחייה / קבלה / החזרה לוועדה המקומית.
|
||||||
|
|
||||||
|
### לשון אופיינית לדחייה
|
||||||
|
> "המבקש לא הציג ראיה לכאורית לפגיעה ממונית מוחשית בקרקע שבבעלותו. הקרקע
|
||||||
|
> נמצאת מחוץ לתחום התכנית ואינה גובלת עמה. בנסיבות אלה, ובהינתן שהשיהוי
|
||||||
|
> הוא של X ימים מעבר למועד הסטטוטורי הקצר של 30 הימים, אין מקום לסטייה
|
||||||
|
> מהמועד. הבל"מ נדחית."
|
||||||
|
|
||||||
|
### לשון אופיינית לקבלה (חריגה ביותר)
|
||||||
|
> "המבקש הציג חוו"ד שמאית מקצועית המראה ירידת ערך של כ-X% בקרקע הגובלת
|
||||||
|
> בתחום התכנית. ההצגה לכאורה משכנעת. בנסיבות החריגות של [פירוט], ועל אף
|
||||||
|
> הסף הקפדני שמטיל סעיף 198(ד), יש לפתוח את הדלת לדיון מהותי."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## י. הפניות חוצות
|
||||||
|
|
||||||
|
- ראה גם: `docs/methodology/extension-request-building_permit.md` (סעיף 152, 30 ימים)
|
||||||
|
- ראה גם: `docs/methodology/extension-request-betterment_levy.md` (סעיף 14, 45 ימים)
|
||||||
|
- ראה גם: `docs/block-schema.md` — מבנה 12 הבלוקים
|
||||||
|
- ראה גם: `skills/decision/SKILL.md` — מדריך סגנון של דפנה
|
||||||
13
mcp-server/src/legal_mcp/chat_service/__init__.py
Normal file
13
mcp-server/src/legal_mcp/chat_service/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""legal-chat-service — host-side SSE bridge to ``claude`` CLI.
|
||||||
|
|
||||||
|
Runs as a pm2-managed process on the host (port 127.0.0.1:8770 by default).
|
||||||
|
The legal-ai FastAPI container proxies chat requests to it via
|
||||||
|
``host.docker.internal:8770``.
|
||||||
|
|
||||||
|
Why a separate service:
|
||||||
|
The chat needs real-time streaming + multi-turn session continuation
|
||||||
|
(``claude --resume <session_id>``). The container can't run the
|
||||||
|
claude CLI (no binary, no claude.ai credentials). Splitting this out
|
||||||
|
keeps the architectural rule of ``claude_session.py`` intact while
|
||||||
|
enabling the new chat feature for free (no API key).
|
||||||
|
"""
|
||||||
210
mcp-server/src/legal_mcp/chat_service/server.py
Normal file
210
mcp-server/src/legal_mcp/chat_service/server.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""HTTP+SSE bridge from FastAPI (in container) to local claude CLI.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /chat/start — body: {prompt, system?, resume_session_id?}
|
||||||
|
returns SSE stream of events from
|
||||||
|
``claude_session.query_streaming``.
|
||||||
|
REQUIRES Authorization: Bearer <secret>.
|
||||||
|
GET /health — liveness probe (no auth — used by FastAPI for status).
|
||||||
|
|
||||||
|
Run with pm2:
|
||||||
|
pm2 start scripts/legal-chat-service.config.cjs
|
||||||
|
|
||||||
|
Standalone for dev:
|
||||||
|
cd ~/legal-ai/mcp-server
|
||||||
|
LEGAL_CHAT_SHARED_SECRET=... .venv/bin/python -m legal_mcp.chat_service.server \
|
||||||
|
--port 8770 --host 10.0.1.1
|
||||||
|
|
||||||
|
Security posture
|
||||||
|
----------------
|
||||||
|
1. Bind defaults to ``10.0.1.1`` — the host's docker0 bridge gateway.
|
||||||
|
Containers on docker bridges (including the legal-ai container, which
|
||||||
|
sits on the ``coolify`` network but routes to docker0 at the host)
|
||||||
|
can reach this address; processes outside the host cannot. Binding to
|
||||||
|
``0.0.0.0`` is permitted but discouraged (relies on the cloud-level
|
||||||
|
firewall as the sole perimeter).
|
||||||
|
2. ``/chat/start`` requires a ``Authorization: Bearer <LEGAL_CHAT_SHARED_SECRET>``
|
||||||
|
header. The secret is loaded from the environment; without it set,
|
||||||
|
the server refuses to start (no fallback to "open" mode, by design —
|
||||||
|
the claude CLI it spawns can run arbitrary tool calls, so an
|
||||||
|
unauthenticated /chat/start is RCE-equivalent).
|
||||||
|
3. ``/health`` is intentionally unauthenticated so the FastAPI proxy
|
||||||
|
can probe liveness with no token. It returns only a static OK and
|
||||||
|
never spawns subprocesses, so it can't be abused.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
# Run-via-CLI bootstrap so ``python -m legal_mcp.chat_service.server``
|
||||||
|
# works even when the package isn't installed (it is in the venv, but
|
||||||
|
# this safeguard keeps the entrypoint robust).
|
||||||
|
_pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
if _pkg_root not in sys.path:
|
||||||
|
sys.path.insert(0, _pkg_root)
|
||||||
|
|
||||||
|
from legal_mcp.services import claude_session # noqa: E402
|
||||||
|
|
||||||
|
logger = logging.getLogger("legal_chat_service")
|
||||||
|
|
||||||
|
|
||||||
|
# Loaded once at startup. Validated to be non-empty in main(); the handler
|
||||||
|
# uses a constant-time compare to avoid timing oracles on a short input.
|
||||||
|
_SHARED_SECRET: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
async def health(request: web.Request) -> web.Response:
|
||||||
|
return web.json_response({"ok": True, "service": "legal-chat-service"})
|
||||||
|
|
||||||
|
|
||||||
|
def _check_bearer(request: web.Request) -> web.Response | None:
|
||||||
|
"""Validate ``Authorization: Bearer <secret>``. Returns 401 response on failure."""
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
expected = "Bearer " + _SHARED_SECRET
|
||||||
|
# ``compare_digest`` defends against timing attacks. Strings of different
|
||||||
|
# length still leak length, but for a 43-char urlsafe token that's
|
||||||
|
# uninteresting and the auth scheme prefix anchors it anyway.
|
||||||
|
import hmac
|
||||||
|
if not auth or not hmac.compare_digest(auth, expected):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "unauthorized: missing or invalid Bearer token"},
|
||||||
|
status=401,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_start(request: web.Request) -> web.StreamResponse:
|
||||||
|
"""Drive ``claude_session.query_streaming`` and forward events as SSE.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
prompt: str — required, user message
|
||||||
|
system: str | None — system instructions (ignored if resuming)
|
||||||
|
resume_session_id: str | None — continue a prior CLI session
|
||||||
|
timeout: int = 3600 — hard timeout for the subprocess
|
||||||
|
"""
|
||||||
|
unauth = _check_bearer(request)
|
||||||
|
if unauth is not None:
|
||||||
|
return unauth
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.json_response({"error": "invalid JSON body"}, status=400)
|
||||||
|
|
||||||
|
prompt = body.get("prompt") or ""
|
||||||
|
if not prompt.strip():
|
||||||
|
return web.json_response({"error": "prompt is required"}, status=400)
|
||||||
|
system = body.get("system")
|
||||||
|
resume_session_id = body.get("resume_session_id")
|
||||||
|
timeout = int(body.get("timeout") or 3600)
|
||||||
|
|
||||||
|
response = web.StreamResponse(
|
||||||
|
status=200,
|
||||||
|
reason="OK",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
# X-Accel-Buffering=no defeats nginx/traefik buffering — the
|
||||||
|
# FastAPI container proxies via httpx and forwards bytes as
|
||||||
|
# they arrive, but the inner header is harmless and makes
|
||||||
|
# browser-direct testing easier.
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await response.prepare(request)
|
||||||
|
|
||||||
|
async def send_event(payload: dict[str, Any]) -> None:
|
||||||
|
line = f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||||
|
await response.write(line.encode("utf-8"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for event in claude_session.query_streaming(
|
||||||
|
prompt,
|
||||||
|
system=system,
|
||||||
|
resume_session_id=resume_session_id,
|
||||||
|
timeout=timeout,
|
||||||
|
):
|
||||||
|
await send_event(event)
|
||||||
|
if event.get("type") == "done" or event.get("type") == "error":
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Client disconnected — bail cleanly.
|
||||||
|
logger.info("chat_start: client disconnected")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("chat_start: streaming failed")
|
||||||
|
try:
|
||||||
|
await send_event({"type": "error", "message": str(e)})
|
||||||
|
except ConnectionResetError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
await response.write_eof()
|
||||||
|
except ConnectionResetError:
|
||||||
|
pass
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def build_app() -> web.Application:
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_get("/health", health)
|
||||||
|
app.router.add_post("/chat/start", chat_start)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="legal-chat-service")
|
||||||
|
parser.add_argument("--port", type=int, default=8770)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host", default="10.0.1.1",
|
||||||
|
help=(
|
||||||
|
"bind address. Default 10.0.1.1 = docker0 bridge gateway — "
|
||||||
|
"reachable from containers, invisible to non-host networks. "
|
||||||
|
"Use 127.0.0.1 for host-local dev; do not bind 0.0.0.0 "
|
||||||
|
"without a separate perimeter firewall."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("--log-level", default="INFO")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=args.log_level.upper(),
|
||||||
|
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
secret = os.environ.get("LEGAL_CHAT_SHARED_SECRET", "").strip()
|
||||||
|
if not secret:
|
||||||
|
logger.error(
|
||||||
|
"LEGAL_CHAT_SHARED_SECRET is empty; refusing to start. "
|
||||||
|
"Set it in /home/chaim/.legal-chat-service.env (loaded by "
|
||||||
|
"pm2) and mirror it as a Coolify env var on the legal-ai app."
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
if len(secret) < 24:
|
||||||
|
logger.error(
|
||||||
|
"LEGAL_CHAT_SHARED_SECRET is too short (got %d chars); "
|
||||||
|
"refusing to start. Use >=32 chars (e.g. python3 -c "
|
||||||
|
"'import secrets; print(secrets.token_urlsafe(32))').",
|
||||||
|
len(secret),
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
global _SHARED_SECRET
|
||||||
|
_SHARED_SECRET = secret
|
||||||
|
|
||||||
|
app = build_app()
|
||||||
|
logger.info("legal-chat-service listening on %s:%d", args.host, args.port)
|
||||||
|
web.run_app(app, host=args.host, port=args.port, print=lambda _msg: None)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -87,6 +87,20 @@ MULTIMODAL_TEXT_WEIGHT = float(
|
|||||||
# concentrate weight at top ranks; higher values flatten the curve.
|
# concentrate weight at top ranks; higher values flatten the curve.
|
||||||
MULTIMODAL_RRF_K = int(os.environ.get("MULTIMODAL_RRF_K", "60"))
|
MULTIMODAL_RRF_K = int(os.environ.get("MULTIMODAL_RRF_K", "60"))
|
||||||
|
|
||||||
|
# BM25/lexical hybrid — fuse ``ts_rank_cd`` over ``content_tsv``/
|
||||||
|
# ``rule_tsv`` (DB schema V12) with the semantic cosine layer via RRF.
|
||||||
|
# Recovers recall on exact-string queries that voyage embeddings blur
|
||||||
|
# (e.g. case-number citations like "1461/20", "317/10"; rare planning
|
||||||
|
# vocabulary). Hebrew uses the ``simple`` text-search config — no
|
||||||
|
# stemmer needed, and numeric/punctuation tokens stay intact. When
|
||||||
|
# disabled, hybrid search falls back to semantic-only (the previous
|
||||||
|
# behaviour). On by default — the lexical leg is cheap (GIN index) and
|
||||||
|
# only ever *adds* candidates to RRF, it can't down-rank a strong
|
||||||
|
# semantic hit.
|
||||||
|
BM25_HYBRID_ENABLED = (
|
||||||
|
os.environ.get("BM25_HYBRID_ENABLED", "true").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
# Halacha extraction — auto-approve threshold. Halachot with extractor
|
# Halacha extraction — auto-approve threshold. Halachot with extractor
|
||||||
# confidence >= this value are inserted with review_status='approved'
|
# confidence >= this value are inserted with review_status='approved'
|
||||||
# instead of 'pending_review' (so they immediately appear in
|
# instead of 'pending_review' (so they immediately appear in
|
||||||
@@ -118,6 +132,43 @@ def find_case_dir(case_number: str) -> Path:
|
|||||||
CHUNK_SIZE_TOKENS = 600
|
CHUNK_SIZE_TOKENS = 600
|
||||||
CHUNK_OVERLAP_TOKENS = 100
|
CHUNK_OVERLAP_TOKENS = 100
|
||||||
|
|
||||||
|
# Parent-doc retrieval (TaskMaster #48) — hierarchical chunking + lookup.
|
||||||
|
# When enabled:
|
||||||
|
# - The ingest pipeline emits two tiers of precedent_chunks: small
|
||||||
|
# "child" chunks (~300 tokens) for high-recall semantic/lexical
|
||||||
|
# matching, and larger "parent" chunks (~1500 tokens) that contain
|
||||||
|
# ~5 children each. Children are embedded and indexed; parents
|
||||||
|
# carry the broader text the LLM gets back.
|
||||||
|
# - Search runs against children, then swaps each hit for its parent
|
||||||
|
# row before returning — so the writer sees a coherent passage
|
||||||
|
# instead of a 300-token sliver.
|
||||||
|
#
|
||||||
|
# Off by default: the schema (V17) is safe to apply even when the flag
|
||||||
|
# is false (the chunker still emits single-tier chunks and search just
|
||||||
|
# returns them unchanged). Flip to true ONLY after the corpus has been
|
||||||
|
# re-ingested with the hierarchical chunker — see precedent_library
|
||||||
|
# ingest pipeline + the backfill plan in TaskMaster #48.
|
||||||
|
PARENT_DOC_RETRIEVAL_ENABLED = (
|
||||||
|
os.environ.get("PARENT_DOC_RETRIEVAL_ENABLED", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
# Child chunks are what get embedded + matched. Smaller = higher recall,
|
||||||
|
# more rows. 300 tokens (~600 chars Hebrew) is the empirical sweet spot
|
||||||
|
# referenced in the original parent-doc literature (Anthropic, LlamaIndex).
|
||||||
|
PARENT_DOC_CHILD_SIZE_TOKENS = int(
|
||||||
|
os.environ.get("PARENT_DOC_CHILD_SIZE_TOKENS", "300")
|
||||||
|
)
|
||||||
|
# Parent chunks are what get returned to the LLM. Large enough to hold
|
||||||
|
# a full rule statement plus the surrounding paragraph and any cited
|
||||||
|
# authority. 1500 tokens = ~5 children at 300 each.
|
||||||
|
PARENT_DOC_PARENT_SIZE_TOKENS = int(
|
||||||
|
os.environ.get("PARENT_DOC_PARENT_SIZE_TOKENS", "1500")
|
||||||
|
)
|
||||||
|
# Child overlap — keeps neighbouring children sharing ~50 tokens so a
|
||||||
|
# sentence on a chunk boundary still matches the natural phrasing.
|
||||||
|
PARENT_DOC_CHILD_OVERLAP_TOKENS = int(
|
||||||
|
os.environ.get("PARENT_DOC_CHILD_OVERLAP_TOKENS", "50")
|
||||||
|
)
|
||||||
|
|
||||||
# External service allowlist — case materials may ONLY be sent to these domains
|
# External service allowlist — case materials may ONLY be sent to these domains
|
||||||
ALLOWED_EXTERNAL_SERVICES = {
|
ALLOWED_EXTERNAL_SERVICES = {
|
||||||
"api.voyageai.com", # Voyage AI (embeddings)
|
"api.voyageai.com", # Voyage AI (embeddings)
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ mcp = FastMCP(
|
|||||||
from legal_mcp.tools import ( # noqa: E402
|
from legal_mcp.tools import ( # noqa: E402
|
||||||
cases, documents, search, drafting, workflow, precedents,
|
cases, documents, search, drafting, workflow, precedents,
|
||||||
precedent_library as plib,
|
precedent_library as plib,
|
||||||
|
internal_decisions as int_tools,
|
||||||
|
legal_arguments as la_tools,
|
||||||
|
missing_precedents as mp_tools,
|
||||||
|
citations as cit_tools,
|
||||||
|
training_enrichment as train_tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -196,11 +201,20 @@ async def precedent_library_list(
|
|||||||
precedent_level: str = "",
|
precedent_level: str = "",
|
||||||
source_type: str = "",
|
source_type: str = "",
|
||||||
search: str = "",
|
search: str = "",
|
||||||
|
source_kind: str = "external_upload",
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""רשימת הפסיקה בקורפוס הסמכותי, עם פילטרים."""
|
"""רשימת הפסיקה בקורפוס, עם פילטרים.
|
||||||
|
|
||||||
|
source_kind: 'external_upload' (ברירת מחדל — פס"ד בתי משפט) /
|
||||||
|
'internal_committee' (החלטות ועדות ערר ערר/בל"מ שהועלו) /
|
||||||
|
'all_committees' (שתיהן — internal + appeals_committee).
|
||||||
|
החלטות ערר/בל"מ שמעלים נשמרות כ-internal_committee — כדי לראותן
|
||||||
|
ברשימה השתמש ב-source_kind='internal_committee' או 'all_committees'.
|
||||||
|
"""
|
||||||
return await plib.precedent_library_list(
|
return await plib.precedent_library_list(
|
||||||
practice_area, court, precedent_level, source_type, search, limit,
|
practice_area, court, precedent_level, source_type, search,
|
||||||
|
source_kind, limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -216,6 +230,22 @@ async def precedent_library_delete(case_law_id: str) -> str:
|
|||||||
return await plib.precedent_library_delete(case_law_id)
|
return await plib.precedent_library_delete(case_law_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def precedent_link_cases(
|
||||||
|
case_law_id_a: str,
|
||||||
|
case_law_id_b: str,
|
||||||
|
relation_type: str = "same_case_chain",
|
||||||
|
) -> str:
|
||||||
|
"""קישור שתי פסיקות כקשורות (דו-כיווני, idempotent). relation_type: same_case_chain | overruled_by | distinguished."""
|
||||||
|
return await plib.precedent_link_cases(case_law_id_a, case_law_id_b, relation_type)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
|
||||||
|
"""הסרת קישור בין שתי פסיקות (דו-כיווני)."""
|
||||||
|
return await plib.precedent_unlink_cases(case_law_id_a, case_law_id_b)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def precedent_extract_halachot(case_law_id: str) -> str:
|
async def precedent_extract_halachot(case_law_id: str) -> str:
|
||||||
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
|
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
|
||||||
@@ -228,6 +258,18 @@ async def precedent_extract_metadata(case_law_id: str) -> str:
|
|||||||
return await plib.precedent_extract_metadata(case_law_id)
|
return await plib.precedent_extract_metadata(case_law_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def style_corpus_enrich(corpus_id: str, overwrite: bool = False) -> str:
|
||||||
|
"""חילוץ מטא-דאטה (summary, outcome, key_principles, appeal_subtype) להחלטה בקורפוס הסגנון של דפנה. ברירת מחדל: ממלא רק שדות ריקים. שלח `overwrite=true` כדי לרענן."""
|
||||||
|
return await train_tools.extract_decision_metadata(corpus_id, overwrite=overwrite)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def style_corpus_pending_enrichment(limit: int = 50) -> str:
|
||||||
|
"""רשימת החלטות בקורפוס הסגנון שעדיין חסרות summary/outcome/key_principles — מועמדות לחילוץ."""
|
||||||
|
return await train_tools.list_corpus_pending_enrichment(limit)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
|
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
|
||||||
"""ריקון תור בקשות חילוץ שנשלחו מ-UI. kind: 'metadata' או 'halacha'. מריץ extractor מקומית עם CLI על כל פריט בתור, ומנקה את הסימון אחרי הצלחה."""
|
"""ריקון תור בקשות חילוץ שנשלחו מ-UI. kind: 'metadata' או 'halacha'. מריץ extractor מקומית עם CLI על כל פריט בתור, ומנקה את הסימון אחרי הצלחה."""
|
||||||
@@ -347,6 +389,28 @@ async def get_claims(
|
|||||||
return await documents.get_claims(case_number, party_role)
|
return await documents.get_claims(case_number, party_role)
|
||||||
|
|
||||||
|
|
||||||
|
# Legal arguments — aggregated (de-duped) propositions
|
||||||
|
@mcp.tool()
|
||||||
|
async def aggregate_claims_to_arguments(
|
||||||
|
case_number: str,
|
||||||
|
force: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""כינוס פרופוזיציות גולמיות (claims) לטיעונים משפטיים מובחנים — ~6-12 לכל צד.
|
||||||
|
|
||||||
|
משתמש ב-Claude headless לסיווג ואיגוד. force=True מוחק טיעונים קיימים לפני חישוב מחדש.
|
||||||
|
"""
|
||||||
|
return await la_tools.aggregate_claims_to_arguments(case_number, force=force)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_legal_arguments(
|
||||||
|
case_number: str,
|
||||||
|
party: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""שליפת טיעונים משפטיים מאוגדים. party: appellant/respondent/committee/permit_applicant (ריק=הכל)."""
|
||||||
|
return await la_tools.get_legal_arguments(case_number, party)
|
||||||
|
|
||||||
|
|
||||||
# References
|
# References
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def extract_references(
|
async def extract_references(
|
||||||
@@ -406,6 +470,7 @@ async def search_internal_decisions(
|
|||||||
chair_name: str = "",
|
chair_name: str = "",
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
include_halachot: bool = True,
|
include_halachot: bool = True,
|
||||||
|
include_cited_by: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
|
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
|
||||||
|
|
||||||
@@ -420,9 +485,13 @@ async def search_internal_decisions(
|
|||||||
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
|
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
|
||||||
limit: מספר תוצאות מקסימלי
|
limit: מספר תוצאות מקסימלי
|
||||||
include_halachot: האם לכלול הלכות שחולצו
|
include_halachot: האם לכלול הלכות שחולצו
|
||||||
|
include_cited_by: True = הוסף תוצאות עקיפות — לכל hit הוסף גם החלטות
|
||||||
|
שהוא מצטט (מתוך citation graph). שימושי לחיפוש "כל הקשור ל-X"
|
||||||
|
כשרוצים להרחיב מעבר לטקסט המקורי. default False.
|
||||||
"""
|
"""
|
||||||
return await search.search_internal_decisions(
|
return await search.search_internal_decisions(
|
||||||
query, practice_area, appeal_subtype, district, chair_name, limit, include_halachot,
|
query, practice_area, appeal_subtype, district, chair_name, limit, include_halachot,
|
||||||
|
include_cited_by=include_cited_by,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -646,6 +715,183 @@ async def internal_decision_enrich(
|
|||||||
return _json.dumps(result, ensure_ascii=False, indent=2)
|
return _json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def internal_decision_upload(
|
||||||
|
file_path: str,
|
||||||
|
case_number: str,
|
||||||
|
chair_name: str,
|
||||||
|
district: str,
|
||||||
|
case_name: str = "",
|
||||||
|
court: str = "",
|
||||||
|
decision_date: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
summary: str = "",
|
||||||
|
is_binding: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""העלאת החלטה של ועדת ערר (internal_committee) לקורפוס הסמכותי.
|
||||||
|
|
||||||
|
שדות חובה: file_path, case_number, chair_name, district.
|
||||||
|
שמירת ההחלטה עוברת דרך ingest_internal_decision — תויג source_kind='internal_committee' אוטומטית.
|
||||||
|
district תקין: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
|
||||||
|
|
||||||
|
בניגוד ל-precedent_library_upload (שתמיד שומר external_upload),
|
||||||
|
הכלי הזה הוא הנתיב המוסמך להחלטות ועדת ערר ומכריח chair_name+district.
|
||||||
|
"""
|
||||||
|
return await int_tools.internal_decision_upload(
|
||||||
|
file_path=file_path,
|
||||||
|
case_number=case_number,
|
||||||
|
chair_name=chair_name,
|
||||||
|
district=district,
|
||||||
|
case_name=case_name,
|
||||||
|
court=court,
|
||||||
|
decision_date=decision_date,
|
||||||
|
practice_area=practice_area,
|
||||||
|
appeal_subtype=appeal_subtype,
|
||||||
|
subject_tags=subject_tags,
|
||||||
|
summary=summary,
|
||||||
|
is_binding=is_binding,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Missing precedents (TaskMaster #35) ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def missing_precedent_create(
|
||||||
|
citation: str,
|
||||||
|
case_number: str = "",
|
||||||
|
cited_in_document_id: str = "",
|
||||||
|
cited_by_party: str = "unknown",
|
||||||
|
cited_by_party_name: str = "",
|
||||||
|
legal_topic: str = "",
|
||||||
|
legal_issue: str = "",
|
||||||
|
claim_quote: str = "",
|
||||||
|
case_name: str = "",
|
||||||
|
notes: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""תיעוד פסיקה שצוטטה בכתבי הטענות אך אינה בקורפוס.
|
||||||
|
|
||||||
|
שימוש: סוכן המחקר (legal-researcher) קורא לזה כשהוא מזהה ציטוט שלא
|
||||||
|
ניתן לאמת מול הקורפוס. הרשומה נשארת 'open' עד שהיו"ר מעלה את הפסיקה.
|
||||||
|
cited_by_party: appellant / respondent / committee / permit_applicant / unknown.
|
||||||
|
דה-דופ אוטומטי: ציטוט+תיק זהים → מחזיר את הרשומה הקיימת.
|
||||||
|
"""
|
||||||
|
return await mp_tools.missing_precedent_create(
|
||||||
|
citation=citation,
|
||||||
|
case_number=case_number,
|
||||||
|
cited_in_document_id=cited_in_document_id,
|
||||||
|
cited_by_party=cited_by_party,
|
||||||
|
cited_by_party_name=cited_by_party_name,
|
||||||
|
legal_topic=legal_topic,
|
||||||
|
legal_issue=legal_issue,
|
||||||
|
claim_quote=claim_quote,
|
||||||
|
case_name=case_name,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def missing_precedent_list(
|
||||||
|
case_number: str = "",
|
||||||
|
status: str = "open",
|
||||||
|
legal_topic: str = "",
|
||||||
|
limit: int = 50,
|
||||||
|
) -> str:
|
||||||
|
"""רשימת פסיקות חסרות לתיק או בכלל. status: open/uploaded/closed/irrelevant.
|
||||||
|
|
||||||
|
שימוש: היו"ר רואה מה ממתין להעלאה; הסוכן מאשר שלא יוצר כפילויות.
|
||||||
|
"""
|
||||||
|
return await mp_tools.missing_precedent_list(
|
||||||
|
case_number=case_number,
|
||||||
|
status=status,
|
||||||
|
legal_topic=legal_topic,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def missing_precedent_close(
|
||||||
|
id: str,
|
||||||
|
linked_case_law_id: str = "",
|
||||||
|
notes: str = "",
|
||||||
|
status: str = "closed",
|
||||||
|
) -> str:
|
||||||
|
"""סגירת רשומת פסיקה חסרה לאחר העלאה לקורפוס.
|
||||||
|
|
||||||
|
status: closed (הועלה ונקשר) / uploaded (הועלה, ממתין לקישור) /
|
||||||
|
irrelevant (היו"ר החליט שזה לא רלוונטי לקורפוס).
|
||||||
|
"""
|
||||||
|
return await mp_tools.missing_precedent_close(
|
||||||
|
id=id,
|
||||||
|
linked_case_law_id=linked_case_law_id,
|
||||||
|
notes=notes,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Internal citations graph (TaskMaster #34) ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def extract_internal_citations(
|
||||||
|
case_law_id: str = "",
|
||||||
|
chair_name: str = "",
|
||||||
|
limit: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""חילוץ ציטוטים פנימיים מהחלטות ועדת ערר ושמירה ב-citation graph.
|
||||||
|
|
||||||
|
משתמש בדפוסי regex עבריים ("ונפנה ל…", "כפי שקבעתי…", "ראה החלטתי…")
|
||||||
|
לזיהוי הפניות בין החלטות. אם case_law_id סופק — מריץ על שורה אחת
|
||||||
|
(שימושי אחרי upload). אם chair_name סופק — מריץ על כל ההחלטות של
|
||||||
|
אותו יו"ר. אם שניהם ריקים — מריץ על כל ה-internal_committee corpus.
|
||||||
|
|
||||||
|
איידמפוטנטי: ניתן להריץ שוב ושוב בלי כפילויות. ציטוטים שמופנים
|
||||||
|
להחלטות שעדיין לא בקורפוס נשמרים כ-unlinked (cited_case_law_id=NULL)
|
||||||
|
ויראו ב-list_internal_citations כשהיו"ר יחליט אם להעלות אותן.
|
||||||
|
"""
|
||||||
|
return await cit_tools.extract_internal_citations(
|
||||||
|
case_law_id=case_law_id,
|
||||||
|
chair_name=chair_name,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_internal_citations(
|
||||||
|
case_law_id: str = "",
|
||||||
|
linked_only: bool = False,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> str:
|
||||||
|
"""רשימת ציטוטים יוצאים מהחלטה (מה ההחלטה מצטטת).
|
||||||
|
|
||||||
|
משתמש לקבלת תמונה של בסיס הפסיקה שהחלטה הסתמכה עליו.
|
||||||
|
linked_only=True מסנן רק ציטוטים שזוהו ב-case_law של הקורפוס.
|
||||||
|
"""
|
||||||
|
return await cit_tools.list_internal_citations(
|
||||||
|
case_law_id=case_law_id,
|
||||||
|
linked_only=linked_only,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_incoming_citations(
|
||||||
|
case_law_id: str = "",
|
||||||
|
limit: int = 50,
|
||||||
|
) -> str:
|
||||||
|
"""רשימת ציטוטים נכנסים אל החלטה (אילו החלטות מצטטות אותה).
|
||||||
|
|
||||||
|
שימוש: רוצים לדעת אילו החלטות של דפנה (או של ועדות אחרות) הסתמכו
|
||||||
|
על פסק דין מסוים — מעבירים את ה-case_law_id של פסק הדין.
|
||||||
|
"""
|
||||||
|
return await cit_tools.list_incoming_citations(
|
||||||
|
case_law_id=case_law_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def record_chair_feedback(
|
async def record_chair_feedback(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
|
|||||||
@@ -250,8 +250,19 @@ async def extract_appraiser_facts(case_id: UUID) -> dict:
|
|||||||
|
|
||||||
conflicts = await db.detect_appraiser_conflicts(case_id)
|
conflicts = await db.detect_appraiser_conflicts(case_id)
|
||||||
|
|
||||||
|
# Don't swallow extractor failures: if every appraisal errored and no
|
||||||
|
# facts were extracted, surface that as a distinct status instead of
|
||||||
|
# the misleading "completed, 0 facts" we used to return — the caller
|
||||||
|
# (and the UI) need to know that nothing actually ran.
|
||||||
|
all_errored = (
|
||||||
|
total_facts == 0
|
||||||
|
and by_doc
|
||||||
|
and all(d.get("status") == "error" for d in by_doc)
|
||||||
|
)
|
||||||
|
status = "extraction_failed" if all_errored else "completed"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "completed",
|
"status": status,
|
||||||
"appraisal_count": len(appraisals),
|
"appraisal_count": len(appraisals),
|
||||||
"total_facts": total_facts,
|
"total_facts": total_facts,
|
||||||
"conflicts": conflicts,
|
"conflicts": conflicts,
|
||||||
|
|||||||
358
mcp-server/src/legal_mcp/services/argument_aggregator.py
Normal file
358
mcp-server/src/legal_mcp/services/argument_aggregator.py
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
"""כינוס פרופוזיציות לטיעונים משפטיים מובחנים — argument de-duplication.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. ``claims_extractor`` extracts ~20-30 raw propositions per litigation
|
||||||
|
brief into the ``claims`` table.
|
||||||
|
2. This module groups those raw propositions, per party, into 6-12
|
||||||
|
distinct legal arguments via Claude headless (`claude_session`).
|
||||||
|
3. The result is stored in ``legal_arguments`` plus ``legal_argument_
|
||||||
|
propositions`` (M:M join) so we keep traceability back to the source
|
||||||
|
claims.
|
||||||
|
|
||||||
|
Manually de-duping 184 propositions in 3 cases yielded 82 arguments
|
||||||
|
(~24/case) — see ``data/cases/{1017,1018,1019}-03-26/documents/research/
|
||||||
|
legal-arguments.md`` for the gold standard.
|
||||||
|
|
||||||
|
**Architectural constraint**: ``claude_session`` only works from the local
|
||||||
|
MCP server (Claude CLI is not installed in the FastAPI container). Calls
|
||||||
|
from ``web/`` must go through MCP tools; calls from MCP tools land here
|
||||||
|
directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import claude_session, db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Allowed enum values mirror the DB CHECK constraints.
|
||||||
|
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
|
||||||
|
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
|
||||||
|
|
||||||
|
# Hebrew labels for the prompt (Claude needs context in the same
|
||||||
|
# language as the source material).
|
||||||
|
PARTY_LABELS_HE = {
|
||||||
|
"appellant": "עוררים",
|
||||||
|
"respondent": "משיבים",
|
||||||
|
"committee": "ועדה מקומית",
|
||||||
|
"permit_applicant": "מבקשי היתר",
|
||||||
|
"unknown": "צד לא מזוהה",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
AGGREGATE_PROMPT_TEMPLATE = """אתה מנתח כתבי טענות בתחום תכנון ובנייה (ועדת ערר).
|
||||||
|
|
||||||
|
לפניך {n} פרופוזיציות גולמיות שחולצו ממסמכי {party_he} בתיק ערר.
|
||||||
|
מטרתך: לקבץ אותן ל-{target_min}-{target_max} **טיעונים משפטיים מובחנים**
|
||||||
|
(ארגומנטים אמיתיים, לא חזרה מילולית של הפרופוזיציות).
|
||||||
|
|
||||||
|
## כללי איגוד:
|
||||||
|
1. **טיעון אמיתי = רעיון משפטי אחד** — לא רשימה של פרופוזיציות, אלא טענה משפטית עצמאית.
|
||||||
|
2. **מקבצים פרופוזיציות שתומכות באותו רעיון משפטי** — גם אם הניסוח שלהן שונה.
|
||||||
|
3. **מפרידים בין סוגי טענות**:
|
||||||
|
- **threshold** = טענות סף (זכות עמידה, סמכות, מועדים, שיהוי)
|
||||||
|
- **substantive** = טענות מהותיות (תחולת חוק, פרשנות, חישוב)
|
||||||
|
- **procedural** = פגמי הליך (פרסום, פרוטוקול, ניגוד עניינים)
|
||||||
|
- **relief** = סעדים מבוקשים / סיכומים
|
||||||
|
4. **כותרת קצרה ובהירה** — תיאורית, לא משפטית מפורטת. 5-15 מילים.
|
||||||
|
5. **גוף הטיעון בפסקה אחת** — 3-7 שורות עברית, נאמן למקור.
|
||||||
|
6. **שמירת ה-claim_ids המקוריים** — לכל טיעון, רשום אילו פרופוזיציות תומכות בו.
|
||||||
|
|
||||||
|
## פלט:
|
||||||
|
החזר JSON בלבד (ללא markdown, ללא הסברים), array של אובייקטים:
|
||||||
|
```
|
||||||
|
[
|
||||||
|
{{
|
||||||
|
"title": "כותרת קצרה של הטיעון",
|
||||||
|
"body": "גוף הטיעון בפסקה אחת",
|
||||||
|
"topic": "סוגיה משפטית קצרה (לדוגמה: 'זכות עמידה', 'תחולת תמ\\"א 38')",
|
||||||
|
"priority": "threshold|substantive|procedural|relief",
|
||||||
|
"claim_ids": ["uuid-1", "uuid-2"]
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## הפרופוזיציות:
|
||||||
|
{propositions_json}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_prompt(party: str, propositions: list[dict]) -> str:
|
||||||
|
"""Compose the per-party aggregation prompt."""
|
||||||
|
n = len(propositions)
|
||||||
|
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
|
||||||
|
target_min = max(4, n // 4)
|
||||||
|
target_max = max(target_min + 1, min(12, n // 2 + 1))
|
||||||
|
|
||||||
|
party_he = PARTY_LABELS_HE.get(party, party)
|
||||||
|
# Strip noise from propositions for the prompt — Claude only needs
|
||||||
|
# the id and the text to do the grouping.
|
||||||
|
compact = [
|
||||||
|
{"id": str(p["id"]), "text": p["claim_text"]}
|
||||||
|
for p in propositions
|
||||||
|
]
|
||||||
|
propositions_json = json.dumps(compact, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
return AGGREGATE_PROMPT_TEMPLATE.format(
|
||||||
|
n=n,
|
||||||
|
party_he=party_he,
|
||||||
|
target_min=target_min,
|
||||||
|
target_max=target_max,
|
||||||
|
propositions_json=propositions_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
|
||||||
|
"""Validate & normalize a single argument dict from Claude.
|
||||||
|
|
||||||
|
Returns None if the row is unusable (missing required fields).
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
title = (raw.get("title") or "").strip()
|
||||||
|
body = (raw.get("body") or "").strip()
|
||||||
|
if not title or not body:
|
||||||
|
return None
|
||||||
|
priority = raw.get("priority", "substantive")
|
||||||
|
if priority not in ALLOWED_PRIORITIES:
|
||||||
|
priority = "substantive"
|
||||||
|
topic = (raw.get("topic") or fallback_topic or "").strip() or None
|
||||||
|
claim_ids_raw = raw.get("claim_ids") or []
|
||||||
|
claim_ids: list[UUID] = []
|
||||||
|
if isinstance(claim_ids_raw, list):
|
||||||
|
for cid in claim_ids_raw:
|
||||||
|
try:
|
||||||
|
claim_ids.append(UUID(str(cid)))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"topic": topic,
|
||||||
|
"priority": priority,
|
||||||
|
"claim_ids": claim_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _aggregate_party(
|
||||||
|
party: str, propositions: list[dict],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Ask Claude to group one party's propositions; return normalized rows."""
|
||||||
|
if not propositions:
|
||||||
|
return []
|
||||||
|
prompt = _build_prompt(party, propositions)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_result = await claude_session.query_json(prompt)
|
||||||
|
except RuntimeError as e:
|
||||||
|
# Surface CLI-unavailable specifically so the caller can report
|
||||||
|
# cleanly instead of crashing the whole job.
|
||||||
|
raise RuntimeError(
|
||||||
|
f"argument_aggregator: claude_session.query_json failed for party "
|
||||||
|
f"'{party}': {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
if not isinstance(raw_result, list):
|
||||||
|
logger.warning(
|
||||||
|
"argument_aggregator: Claude returned non-list (%s) for party '%s'",
|
||||||
|
type(raw_result).__name__, party,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
out: list[dict] = []
|
||||||
|
for entry in raw_result:
|
||||||
|
norm = _normalize_argument(entry)
|
||||||
|
if norm:
|
||||||
|
out.append(norm)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def aggregate_claims_to_arguments(
|
||||||
|
case_id: UUID, force: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""For a given case, group existing claims into distinct legal arguments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_id: The case UUID.
|
||||||
|
force: If True, delete existing ``legal_arguments`` for the case
|
||||||
|
before aggregating. Otherwise short-circuit if any rows exist.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A summary dict:
|
||||||
|
``{"status": "completed"|"skipped"|"no_claims"|"llm_unavailable",
|
||||||
|
"by_party": {party: count}, "total": int, "message": ...}``
|
||||||
|
"""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
existing = await conn.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM legal_arguments WHERE case_id = $1",
|
||||||
|
case_id,
|
||||||
|
)
|
||||||
|
if existing and not force:
|
||||||
|
return {
|
||||||
|
"status": "skipped",
|
||||||
|
"message": f"Found {existing} existing arguments. Use force=True to re-run.",
|
||||||
|
"total": existing,
|
||||||
|
}
|
||||||
|
|
||||||
|
if force and existing:
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM legal_arguments WHERE case_id = $1", case_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pull all claims for this case, grouped by party.
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT id, party_role, claim_text, claim_index, source_document
|
||||||
|
FROM claims
|
||||||
|
WHERE case_id = $1
|
||||||
|
ORDER BY party_role, claim_index""",
|
||||||
|
case_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return {
|
||||||
|
"status": "no_claims",
|
||||||
|
"message": "No claims found for this case. Run extract_claims first.",
|
||||||
|
"total": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Group propositions by party.
|
||||||
|
by_party: dict[str, list[dict]] = {}
|
||||||
|
for r in rows:
|
||||||
|
party = r["party_role"]
|
||||||
|
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
|
||||||
|
if party not in ALLOWED_PARTIES:
|
||||||
|
party = "unknown"
|
||||||
|
by_party.setdefault(party, []).append(dict(r))
|
||||||
|
|
||||||
|
party_counts: dict[str, int] = {}
|
||||||
|
inserted = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for party, props in by_party.items():
|
||||||
|
try:
|
||||||
|
arguments = await _aggregate_party(party, props)
|
||||||
|
except RuntimeError as e:
|
||||||
|
# Most likely cause: Claude CLI not installed (running from
|
||||||
|
# the container). Don't crash — record the gap and continue.
|
||||||
|
msg = str(e)
|
||||||
|
if "Claude CLI not found" in msg:
|
||||||
|
return {
|
||||||
|
"status": "llm_unavailable",
|
||||||
|
"message": (
|
||||||
|
"Claude CLI not available. This service must run from "
|
||||||
|
"the local MCP server (not the FastAPI container)."
|
||||||
|
),
|
||||||
|
"total": 0,
|
||||||
|
}
|
||||||
|
errors.append(f"{party}: {msg}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not arguments:
|
||||||
|
party_counts[party] = 0
|
||||||
|
continue
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
|
for idx, arg in enumerate(arguments):
|
||||||
|
arg_id = await conn.fetchval(
|
||||||
|
"""INSERT INTO legal_arguments
|
||||||
|
(case_id, party, argument_index, argument_title,
|
||||||
|
argument_body, legal_topic, priority)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id""",
|
||||||
|
case_id,
|
||||||
|
party,
|
||||||
|
idx + 1,
|
||||||
|
arg["title"],
|
||||||
|
arg["body"],
|
||||||
|
arg["topic"],
|
||||||
|
arg["priority"],
|
||||||
|
)
|
||||||
|
for cid in arg["claim_ids"]:
|
||||||
|
try:
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO legal_argument_propositions
|
||||||
|
(argument_id, claim_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING""",
|
||||||
|
arg_id, cid,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
# Likely FK violation if the LLM hallucinated
|
||||||
|
# a claim_id. Log and continue.
|
||||||
|
logger.warning(
|
||||||
|
"argument_aggregator: skipped bad claim_id %s for arg %s: %s",
|
||||||
|
cid, arg_id, e,
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
party_counts[party] = len(arguments)
|
||||||
|
|
||||||
|
result: dict = {
|
||||||
|
"status": "completed",
|
||||||
|
"total": inserted,
|
||||||
|
"by_party": party_counts,
|
||||||
|
"propositions_processed": len(rows),
|
||||||
|
}
|
||||||
|
if errors:
|
||||||
|
result["errors"] = errors
|
||||||
|
result["status"] = "completed_with_errors"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_legal_arguments(
|
||||||
|
case_id: UUID, party: str = "",
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return aggregated legal arguments for a case, optionally filtered by party.
|
||||||
|
|
||||||
|
Each row includes ``supporting_claims`` (list of source claim_ids).
|
||||||
|
"""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if party and party in ALLOWED_PARTIES:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT id, case_id, party, argument_index, argument_title,
|
||||||
|
argument_body, legal_topic, priority, cited_precedents,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM legal_arguments
|
||||||
|
WHERE case_id = $1 AND party = $2
|
||||||
|
ORDER BY priority, argument_index""",
|
||||||
|
case_id, party,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT id, case_id, party, argument_index, argument_title,
|
||||||
|
argument_body, legal_topic, priority, cited_precedents,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM legal_arguments
|
||||||
|
WHERE case_id = $1
|
||||||
|
ORDER BY party, priority, argument_index""",
|
||||||
|
case_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pull supporting claim ids for each argument in one round-trip.
|
||||||
|
arg_ids = [r["id"] for r in rows]
|
||||||
|
supporting: dict[UUID, list[str]] = {}
|
||||||
|
if arg_ids:
|
||||||
|
joins = await conn.fetch(
|
||||||
|
"""SELECT argument_id, claim_id
|
||||||
|
FROM legal_argument_propositions
|
||||||
|
WHERE argument_id = ANY($1::uuid[])""",
|
||||||
|
arg_ids,
|
||||||
|
)
|
||||||
|
for j in joins:
|
||||||
|
supporting.setdefault(j["argument_id"], []).append(str(j["claim_id"]))
|
||||||
|
|
||||||
|
out: list[dict] = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
d["id"] = str(d["id"])
|
||||||
|
d["case_id"] = str(d["case_id"])
|
||||||
|
d["supporting_claims"] = supporting.get(r["id"], [])
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
@@ -360,13 +360,9 @@ async def write_block(
|
|||||||
post_hearing_context=post_hearing_context,
|
post_hearing_context=post_hearing_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Restructure: sources first, then instructions
|
# source_context is already embedded inside formatted_prompt via {source_context} in the
|
||||||
prompt = (
|
# template. Do NOT prepend it again — doing so doubles the prompt size (was 465K chars).
|
||||||
f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n"
|
prompt = formatted_prompt
|
||||||
f"{source_context}\n\n"
|
|
||||||
f"---\n\n"
|
|
||||||
f"{formatted_prompt}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if instructions:
|
if instructions:
|
||||||
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||||
@@ -377,6 +373,19 @@ async def write_block(
|
|||||||
if not dir_doc.get("approved"):
|
if not dir_doc.get("approved"):
|
||||||
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
||||||
|
|
||||||
|
# Guard against context overflow before calling claude -p.
|
||||||
|
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
|
||||||
|
# In practice the CLI has crashed on prompts above ~400K chars, so use
|
||||||
|
# that as a conservative ceiling (well below the token limit).
|
||||||
|
_MAX_PROMPT_CHARS = 400_000
|
||||||
|
if len(prompt) > _MAX_PROMPT_CHARS:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Prompt too large for {block_id}: {len(prompt):,} chars "
|
||||||
|
f"(limit {_MAX_PROMPT_CHARS:,}). "
|
||||||
|
f"source_context: {len(source_context):,} chars. "
|
||||||
|
f"Reduce documents or call extract_appraiser_facts first."
|
||||||
|
)
|
||||||
|
|
||||||
# Call Claude via Claude Code session (no API)
|
# Call Claude via Claude Code session (no API)
|
||||||
model_key = block_cfg["model"]
|
model_key = block_cfg["model"]
|
||||||
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
||||||
@@ -414,16 +423,35 @@ def _build_case_context(case: dict, decision: dict | None) -> str:
|
|||||||
- תוצאה: {outcome_heb}"""
|
- תוצאה: {outcome_heb}"""
|
||||||
|
|
||||||
|
|
||||||
|
# Which doc_types are relevant per block.
|
||||||
|
# None → skip source docs entirely (block uses other context, e.g. claims_context)
|
||||||
|
# [] → include all doc types (default for unspecified blocks)
|
||||||
|
# [..] → include only the listed doc_type values
|
||||||
|
_BLOCK_DOC_TYPES: dict[str, list[str] | None] = {
|
||||||
|
"block-he": None, # only case_context needed; no full docs
|
||||||
|
"block-vav": ["appeal", "protocol"], # כתב ערר + פרוטוקול ועדה
|
||||||
|
"block-zayin": None, # claims_context is sufficient
|
||||||
|
"block-chet": ["protocol"], # פרוטוקול + השלמות טיעון
|
||||||
|
"block-tet": ["appraisal"], # שומות בלבד
|
||||||
|
# block-yod, block-yod-alef, block-he etc. default → all docs
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _build_source_context(case_id: UUID, block_id: str) -> str:
|
async def _build_source_context(case_id: UUID, block_id: str) -> str:
|
||||||
"""Get full document texts for the block.
|
"""Get document texts for the block, filtered by relevance.
|
||||||
|
|
||||||
Per Anthropic best practices: send full source documents, not truncated excerpts.
|
Per Anthropic best practices: send full source documents, not truncated excerpts.
|
||||||
Place documents at the TOP of the prompt (before instructions) for 30% better recall.
|
Per-block filtering prevents context overflow on large cases (9+ docs).
|
||||||
For grounding: instruct Claude to cite word-for-word from these documents.
|
|
||||||
"""
|
"""
|
||||||
|
allowed = _BLOCK_DOC_TYPES.get(block_id, []) # [] sentinel = not in map → all docs
|
||||||
|
if allowed is None:
|
||||||
|
return "" # this block doesn't need raw source docs
|
||||||
|
|
||||||
docs = await db.list_documents(case_id)
|
docs = await db.list_documents(case_id)
|
||||||
context_parts = []
|
context_parts = []
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
|
if allowed and doc["doc_type"] not in allowed:
|
||||||
|
continue
|
||||||
text = await db.get_document_text(UUID(doc["id"]))
|
text = await db.get_document_text(UUID(doc["id"]))
|
||||||
if text:
|
if text:
|
||||||
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")
|
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
"""Legal document chunker - splits text into sections and chunks for RAG."""
|
"""Legal document chunker - splits text into sections and chunks for RAG.
|
||||||
|
|
||||||
|
The default :func:`chunk_document` emits a single tier of overlapping
|
||||||
|
chunks (legacy single-tier indexing). :func:`chunk_document_hierarchical`
|
||||||
|
emits two tiers — small "child" chunks for retrieval matching, plus
|
||||||
|
larger "parent" chunks that supply broader context to the LLM (parent-
|
||||||
|
doc retrieval, TaskMaster #48). The hierarchical variant lives
|
||||||
|
alongside the legacy one so callers can opt in via
|
||||||
|
``config.PARENT_DOC_RETRIEVAL_ENABLED`` without breaking existing
|
||||||
|
single-tier code paths.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -87,13 +97,32 @@ def _assign_pages(chunks: list[Chunk], text: str, page_offsets: list[int]) -> No
|
|||||||
pos = idx + max(1, len(c.content) // 2)
|
pos = idx + max(1, len(c.content) // 2)
|
||||||
|
|
||||||
|
|
||||||
|
# A section shorter than this (stripped chars) is not a real section — it's
|
||||||
|
# an artifact of a header keyword matched mid-text. Such a fragment is merged
|
||||||
|
# into the preceding section rather than emitted as its own chunk. See #55:
|
||||||
|
# unanchored keywords like "דיון"/"החלטה"/"מסקנה" appearing inside a sentence
|
||||||
|
# used to carve tiny boundary chunks ("דיון). במסגרת ה") that polluted search.
|
||||||
|
MIN_SECTION_CHARS = 60
|
||||||
|
|
||||||
|
|
||||||
def _split_into_sections(text: str) -> list[tuple[str, str]]:
|
def _split_into_sections(text: str) -> list[tuple[str, str]]:
|
||||||
"""Split text into (section_type, text) pairs based on Hebrew headers."""
|
"""Split text into (section_type, text) pairs based on Hebrew headers.
|
||||||
|
|
||||||
|
Header keywords are matched only at the **start of a line** (after
|
||||||
|
optional whitespace / list numbering like ``5.`` or ``ג.``). A real
|
||||||
|
section header in these decisions sits on its own line; anchoring to
|
||||||
|
the line start prevents common words ("דיון", "החלטה", "מסקנה") that
|
||||||
|
appear mid-sentence from being treated as section boundaries — which
|
||||||
|
previously produced tiny fragment chunks (#55).
|
||||||
|
"""
|
||||||
# Find all section headers and their positions
|
# Find all section headers and their positions
|
||||||
markers: list[tuple[int, str]] = []
|
markers: list[tuple[int, str]] = []
|
||||||
|
|
||||||
for pattern, section_type in SECTION_PATTERNS:
|
for pattern, section_type in SECTION_PATTERNS:
|
||||||
for match in re.finditer(pattern, text):
|
# ^ + MULTILINE: line start only. Optional leading spaces/tabs and an
|
||||||
|
# optional ordinal prefix ("5.", "5)", "ג.") before the keyword.
|
||||||
|
anchored = rf"^[ \t]*(?:\d+[.)]\s*|[א-ת][.)]\s*)?(?:{pattern})"
|
||||||
|
for match in re.finditer(anchored, text, re.MULTILINE):
|
||||||
markers.append((match.start(), section_type))
|
markers.append((match.start(), section_type))
|
||||||
|
|
||||||
if not markers:
|
if not markers:
|
||||||
@@ -110,11 +139,18 @@ def _split_into_sections(text: str) -> list[tuple[str, str]]:
|
|||||||
if intro_text:
|
if intro_text:
|
||||||
sections.append(("intro", intro_text))
|
sections.append(("intro", intro_text))
|
||||||
|
|
||||||
# Each section
|
# Each section. A section whose text is too short to stand alone is
|
||||||
|
# merged into the previous section (keeping the previous type) so a
|
||||||
|
# near-adjacent pair of headers can't produce a fragment chunk.
|
||||||
for i, (pos, section_type) in enumerate(markers):
|
for i, (pos, section_type) in enumerate(markers):
|
||||||
end = markers[i + 1][0] if i + 1 < len(markers) else len(text)
|
end = markers[i + 1][0] if i + 1 < len(markers) else len(text)
|
||||||
section_text = text[pos:end].strip()
|
section_text = text[pos:end].strip()
|
||||||
if section_text:
|
if not section_text:
|
||||||
|
continue
|
||||||
|
if len(section_text) < MIN_SECTION_CHARS and sections:
|
||||||
|
prev_type, prev_text = sections[-1]
|
||||||
|
sections[-1] = (prev_type, f"{prev_text}\n{section_text}")
|
||||||
|
else:
|
||||||
sections.append((section_type, section_text))
|
sections.append((section_type, section_text))
|
||||||
|
|
||||||
return sections
|
return sections
|
||||||
@@ -162,3 +198,152 @@ def _split_section(text: str, chunk_size: int, overlap: int) -> list[str]:
|
|||||||
def _estimate_tokens(text: str) -> int:
|
def _estimate_tokens(text: str) -> int:
|
||||||
"""Rough token estimate for Hebrew text (~1.5 chars per token)."""
|
"""Rough token estimate for Hebrew text (~1.5 chars per token)."""
|
||||||
return max(1, len(text) // 2)
|
return max(1, len(text) // 2)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parent-doc retrieval (TaskMaster #48) ────────────────────────────
|
||||||
|
# Hierarchical chunker — emits a list of (child, parent) pairs:
|
||||||
|
# * each "child" carries the smaller text used for embedding/search
|
||||||
|
# * each "parent" is shared by ~5 consecutive children (1500/300)
|
||||||
|
# The list is FLAT — both parents and children live in the same return
|
||||||
|
# list, distinguished by ``role``. A child's ``parent_local_id`` points
|
||||||
|
# back to its parent's ``local_id``, so the ingest pipeline can resolve
|
||||||
|
# the FK after the parent row is INSERTed and its DB UUID is known.
|
||||||
|
#
|
||||||
|
# Parents are built FIRST (one window of ``parent_size`` tokens per
|
||||||
|
# section, sliding by the parent window — no overlap between parents),
|
||||||
|
# then each parent is sub-divided into overlapping children. This keeps
|
||||||
|
# the parent boundary aligned with semantic sections (so a "discussion"
|
||||||
|
# parent doesn't contain stray "ruling" prose) while still allowing
|
||||||
|
# child overlap for recall.
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HierarchicalChunk:
|
||||||
|
"""One chunk in the two-tier hierarchy.
|
||||||
|
|
||||||
|
Both children and parents share this shape; ``role`` distinguishes
|
||||||
|
them. Children get an embedding at ingest time; parents do not —
|
||||||
|
they exist only to carry context back to the LLM at retrieval time.
|
||||||
|
|
||||||
|
``local_id`` is a stable in-batch identifier (sequential int) used
|
||||||
|
only by the ingest pipeline to wire children to their parent's DB
|
||||||
|
UUID after the parent INSERT returns. It is NOT persisted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
content: str
|
||||||
|
role: str # 'child' | 'parent'
|
||||||
|
section_type: str = "other"
|
||||||
|
page_number: int | None = None
|
||||||
|
chunk_index: int = 0
|
||||||
|
local_id: int = -1
|
||||||
|
parent_local_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_document_hierarchical(
|
||||||
|
text: str,
|
||||||
|
child_size: int = config.PARENT_DOC_CHILD_SIZE_TOKENS,
|
||||||
|
parent_size: int = config.PARENT_DOC_PARENT_SIZE_TOKENS,
|
||||||
|
overlap: int = config.PARENT_DOC_CHILD_OVERLAP_TOKENS,
|
||||||
|
page_offsets: list[int] | None = None,
|
||||||
|
) -> list[HierarchicalChunk]:
|
||||||
|
"""Split a document into a two-tier (child, parent) hierarchy.
|
||||||
|
|
||||||
|
Returns a flat list where each element is either a parent or a
|
||||||
|
child. Children carry ``parent_local_id`` pointing back to their
|
||||||
|
parent's ``local_id``. Caller (ingest pipeline) must insert parents
|
||||||
|
first, capture their DB UUIDs by ``local_id``, then insert children
|
||||||
|
with the resolved UUID in ``parent_chunk_id``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: full document text.
|
||||||
|
child_size: child chunk size in tokens (≈ 300 by default).
|
||||||
|
parent_size: parent chunk size in tokens (≈ 1500 by default).
|
||||||
|
Parents contain ``parent_size // child_size`` children on
|
||||||
|
average.
|
||||||
|
overlap: child-to-child overlap inside a parent (≈ 50 tokens).
|
||||||
|
Parents themselves do not overlap each other.
|
||||||
|
page_offsets: PDF page offsets for tagging chunks with page #.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
* Parents respect section boundaries (header detection from
|
||||||
|
:data:`SECTION_PATTERNS`). A "facts" parent will not include
|
||||||
|
"ruling" text.
|
||||||
|
* Empty text returns an empty list.
|
||||||
|
* Both child and parent rows are tagged with the page of their
|
||||||
|
first character.
|
||||||
|
"""
|
||||||
|
if not text.strip():
|
||||||
|
return []
|
||||||
|
if child_size <= 0 or parent_size <= 0:
|
||||||
|
raise ValueError("child_size and parent_size must be positive")
|
||||||
|
if child_size > parent_size:
|
||||||
|
raise ValueError("child_size must be <= parent_size")
|
||||||
|
|
||||||
|
sections = _split_into_sections(text)
|
||||||
|
out: list[HierarchicalChunk] = []
|
||||||
|
parent_idx = 0 # global parent ordinal (chunk_index for parents)
|
||||||
|
child_idx = 0 # global child ordinal (chunk_index for children)
|
||||||
|
local_id = 0 # sequential id within this document
|
||||||
|
|
||||||
|
for section_type, section_text in sections:
|
||||||
|
# Step 1: split section into parent-sized windows (no overlap).
|
||||||
|
parent_texts = _split_section(section_text, parent_size, overlap=0)
|
||||||
|
for parent_text in parent_texts:
|
||||||
|
parent_local = local_id
|
||||||
|
local_id += 1
|
||||||
|
parent_chunk = HierarchicalChunk(
|
||||||
|
content=parent_text,
|
||||||
|
role="parent",
|
||||||
|
section_type=section_type,
|
||||||
|
chunk_index=parent_idx,
|
||||||
|
local_id=parent_local,
|
||||||
|
parent_local_id=None,
|
||||||
|
)
|
||||||
|
out.append(parent_chunk)
|
||||||
|
parent_idx += 1
|
||||||
|
|
||||||
|
# Step 2: sub-divide this parent into overlapping children.
|
||||||
|
child_texts = _split_section(parent_text, child_size, overlap)
|
||||||
|
for ch_text in child_texts:
|
||||||
|
ch = HierarchicalChunk(
|
||||||
|
content=ch_text,
|
||||||
|
role="child",
|
||||||
|
section_type=section_type,
|
||||||
|
chunk_index=child_idx,
|
||||||
|
local_id=local_id,
|
||||||
|
parent_local_id=parent_local,
|
||||||
|
)
|
||||||
|
out.append(ch)
|
||||||
|
local_id += 1
|
||||||
|
child_idx += 1
|
||||||
|
|
||||||
|
if page_offsets:
|
||||||
|
_assign_pages_hierarchical(out, text, page_offsets)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_pages_hierarchical(
|
||||||
|
chunks: list[HierarchicalChunk],
|
||||||
|
text: str,
|
||||||
|
page_offsets: list[int],
|
||||||
|
) -> None:
|
||||||
|
"""Page-tag both children and parents.
|
||||||
|
|
||||||
|
Same forward-scan strategy as :func:`_assign_pages` but works on
|
||||||
|
the hierarchical list. Parents may span pages; we tag them with
|
||||||
|
the page of their first character (matches how the multimodal
|
||||||
|
retriever joins on page numbers).
|
||||||
|
"""
|
||||||
|
from legal_mcp.services.extractor import page_at_offset
|
||||||
|
pos = 0
|
||||||
|
for c in chunks:
|
||||||
|
idx = text.find(c.content, pos)
|
||||||
|
if idx < 0:
|
||||||
|
idx = text.find(c.content)
|
||||||
|
if idx < 0:
|
||||||
|
continue
|
||||||
|
c.page_number = page_at_offset(idx, page_offsets)
|
||||||
|
# Advance past halfway — children share text with their parent
|
||||||
|
# and with each other (overlap), so a small forward step lets
|
||||||
|
# the next find() still pick up the right occurrence.
|
||||||
|
pos = idx + max(1, len(c.content) // 4)
|
||||||
|
|||||||
434
mcp-server/src/legal_mcp/services/citation_extractor.py
Normal file
434
mcp-server/src/legal_mcp/services/citation_extractor.py
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
"""Internal citation graph extractor (TaskMaster #34).
|
||||||
|
|
||||||
|
When Daphna (or any other internal_committee chair) cites another committee
|
||||||
|
decision inside the body of a ruling, she uses fairly stable phrases:
|
||||||
|
|
||||||
|
"ונפנה לערר 1110/20 ירושלים שקופה …"
|
||||||
|
"כפי שקבעתי בערר 1041/24 …"
|
||||||
|
"בדומה לעמדתי בהחלטה ערר 8048/24 …"
|
||||||
|
"כפי שנקבע במחוז ת\"א בערר 1234/20 …"
|
||||||
|
"ראה החלטתי בערר 1015-01-24 …"
|
||||||
|
|
||||||
|
This module scans the ``full_text`` of internal-committee ``case_law`` rows,
|
||||||
|
extracts those citations via regex, tries to link each cited case_number to a
|
||||||
|
row already in ``case_law`` (any source_kind), and stores the result in
|
||||||
|
``precedent_internal_citations``. Unresolved citations are kept with
|
||||||
|
``cited_case_law_id = NULL`` so the chair can see what's missing from the
|
||||||
|
corpus (and ``search_internal_decisions`` can surface "cited but absent" gaps).
|
||||||
|
|
||||||
|
The result is a *citation graph* that downstream tools (search, researcher
|
||||||
|
agent) can join on to surface "decisions cited by this one" alongside
|
||||||
|
keyword/semantic hits — without re-running an LLM on every query.
|
||||||
|
|
||||||
|
Patterns are *intentionally* permissive: we accept stray Hebrew quote marks
|
||||||
|
(both straight ``"`` and curly ``״``), optional district parens, and several
|
||||||
|
trigger phrases. False positives are de-duplicated downstream by the
|
||||||
|
``UNIQUE (source_case_law_id, cited_case_number)`` constraint and by case-
|
||||||
|
number normalization (see ``_normalize_case_number``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Iterator
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Patterns ─────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Two pattern families:
|
||||||
|
# 1. Appeals-committee citations ("ערר" / "בל\"מ") — primary target.
|
||||||
|
# These are the ones we resolve against ``case_law``.
|
||||||
|
# 2. Court rulings ("עע\"מ", "בר\"מ", "עמ\"נ", "ע\"א", "בג\"ץ", "רע\"א").
|
||||||
|
# Stored as unlinked rows by default, so the researcher knows the
|
||||||
|
# decision quotes a higher court.
|
||||||
|
#
|
||||||
|
# Trigger words ("ונפנה", "כפי שקבעתי", "בדומה ל…", "ראה החלטתי",
|
||||||
|
# "כפי שנקבע") are *optional* — many citations appear without one (Daphna
|
||||||
|
# often introduces a quote with just "כפי שצוין בערר…"). We therefore
|
||||||
|
# match the citation core (prefix + number) and capture the surrounding
|
||||||
|
# sentence as context.
|
||||||
|
#
|
||||||
|
# Regex notes:
|
||||||
|
# * Hebrew gershayim/quotation: both straight (") and curly (״) are
|
||||||
|
# accepted via the character class [\"״].
|
||||||
|
# * Case numbers can be NNNN/YY, NNNN-YY, or NNNN-MM-YY (the third form
|
||||||
|
# is the Nevo "filed" format: 1015-01-24 means file #1015 of Jan 2024).
|
||||||
|
# * Optional district paren: ערר (ועדות ערר - תכנון ובנייה ירושלים)
|
||||||
|
# 1110/20 — we allow up to 60 chars of parenthetical content.
|
||||||
|
# * \b doesn't behave well with Hebrew, so we anchor by whitespace or
|
||||||
|
# punctuation lookarounds.
|
||||||
|
|
||||||
|
_TRIGGER = (
|
||||||
|
r"(?:ונפנה\s+ל|"
|
||||||
|
r"כפי\s+ש(?:קבעתי|נקבע|פסקתי)\s+ב|"
|
||||||
|
r"בדומה\s+ל(?:עמדתי\s+ב)?|"
|
||||||
|
r"ראה\s+(?:את\s+)?(?:החלטתי\s+ב|פסיקת\s+ה?ועדה\s+ב)?|"
|
||||||
|
r"בעניין\s+|"
|
||||||
|
r"בהחלטת(?:י|ה|נו)?\s+ב?)?"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional district / committee parenthetical between the prefix and the
|
||||||
|
# case number. Matches things like "(ועדות ערר - תכנון ובנייה ירושלים)"
|
||||||
|
# or "(ירושלים)" or "(מרכז)". Up to 80 chars to be safe. Required actual
|
||||||
|
# parentheses (the `\(` and `\)` are NOT optional) — otherwise the regex
|
||||||
|
# greedily absorbs the next sentence's content and skips intermediate
|
||||||
|
# citations like "ראה גם ערר 1041/24 …\nכפי שקבעתי בערר (…) 1110/20".
|
||||||
|
_DISTRICT_PAREN = r"(?:\s*\([^)\n]{0,80}\)\s*)?"
|
||||||
|
|
||||||
|
# Case-number core: 3-5 digits, optional separator and 2-4 digits (and
|
||||||
|
# optional third group for the NNNN-MM-YY format).
|
||||||
|
_NUM_RX = r"(\d{3,5}(?:[-/]\d{2,4}(?:[-/]\d{2,4})?)?)"
|
||||||
|
|
||||||
|
_PATTERNS = [
|
||||||
|
# 1. Appeals-committee — ערר / בל"מ
|
||||||
|
(
|
||||||
|
"appeals_committee",
|
||||||
|
re.compile(
|
||||||
|
_TRIGGER
|
||||||
|
+ r"(ערר|בל[\"״]מ)"
|
||||||
|
+ _DISTRICT_PAREN
|
||||||
|
+ r"\s*"
|
||||||
|
+ _NUM_RX,
|
||||||
|
re.UNICODE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# 2. Higher courts — עע"מ, בר"מ, עמ"נ, ע"א, בג"ץ, רע"א, דנ"א, בש"א
|
||||||
|
(
|
||||||
|
"court_ruling",
|
||||||
|
re.compile(
|
||||||
|
_TRIGGER
|
||||||
|
+ r"(עע[\"״]מ|בר[\"״]מ|עמ[\"״]נ|ע[\"״]א|בג[\"״]ץ|רע[\"״]א|דנ[\"״]א|בש[\"״]א)"
|
||||||
|
+ r"\s*"
|
||||||
|
+ _NUM_RX,
|
||||||
|
re.UNICODE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Context window for storing the match (characters before/after).
|
||||||
|
_CTX_BEFORE = 120
|
||||||
|
_CTX_AFTER = 240
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_case_number(raw: str) -> str:
|
||||||
|
"""Normalize a case-number for matching.
|
||||||
|
|
||||||
|
The same case can appear in the corpus as "1110/20", "1110-20",
|
||||||
|
"ערר 1110/20", "1110-01-20" — different rules for the third form,
|
||||||
|
which is the Nevo file format. We canonicalize by:
|
||||||
|
* stripping non-digit/separator chars
|
||||||
|
* unifying "/" → "-"
|
||||||
|
* lowercasing
|
||||||
|
The result is used only for matching, never for display.
|
||||||
|
"""
|
||||||
|
cleaned = re.sub(r"[^\d/\-]", "", raw or "")
|
||||||
|
return cleaned.replace("/", "-").strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_citations_from_text(text: str) -> Iterator[dict]:
|
||||||
|
"""Yield citation dicts extracted from ``text``.
|
||||||
|
|
||||||
|
Each dict has:
|
||||||
|
prefix: matched prefix (ערר / בל\"מ / עע\"מ / …)
|
||||||
|
case_number: raw number as captured
|
||||||
|
case_number_norm: normalized (slashes → dashes, digits only)
|
||||||
|
raw: the full matched span
|
||||||
|
context: ±300 chars surrounding the match (whitespace normalized)
|
||||||
|
pattern_kind: 'appeals_committee' or 'court_ruling'
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for kind, pattern in _PATTERNS:
|
||||||
|
for m in pattern.finditer(text):
|
||||||
|
# The `_TRIGGER` is wrapped in (?:...) so it does not add a
|
||||||
|
# capture group; group(1) is the prefix, group(2) is the number.
|
||||||
|
prefix = (m.group(1) or "").strip()
|
||||||
|
number = (m.group(2) or "").strip()
|
||||||
|
if not prefix or not number:
|
||||||
|
continue
|
||||||
|
norm = _normalize_case_number(number)
|
||||||
|
if not norm:
|
||||||
|
continue
|
||||||
|
key = (kind, norm)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
|
||||||
|
start = max(0, m.start() - _CTX_BEFORE)
|
||||||
|
end = min(len(text), m.end() + _CTX_AFTER)
|
||||||
|
context = text[start:end].replace("\n", " ").strip()
|
||||||
|
context = re.sub(r"\s+", " ", context)
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"prefix": prefix,
|
||||||
|
"case_number": number,
|
||||||
|
"case_number_norm": norm,
|
||||||
|
"raw": m.group(0).strip(),
|
||||||
|
"context": context[:1000],
|
||||||
|
"pattern_kind": kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_case_law_id(case_number_norm: str) -> UUID | None:
|
||||||
|
"""Try to resolve a normalized citation to an existing case_law row.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Exact match on normalized case_number column (after rewriting
|
||||||
|
existing case_numbers the same way).
|
||||||
|
2. Substring match — the corpus often stores the full Nevo header
|
||||||
|
("ערר (ועדות ערר - תכנון ובנייה ירושלים) 1110/20 …"), so we
|
||||||
|
search by ``case_number ILIKE '%1110/20%' OR '%1110-20%'``.
|
||||||
|
|
||||||
|
Returns None if no row matches.
|
||||||
|
"""
|
||||||
|
if not case_number_norm:
|
||||||
|
return None
|
||||||
|
pool = await db.get_pool()
|
||||||
|
# Build the two raw forms (with slash and with dash) for substring match.
|
||||||
|
parts = case_number_norm.split("-")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
slash_form = "/".join(parts[:2]) if len(parts) == 2 else parts[0] + "/" + parts[-1]
|
||||||
|
else:
|
||||||
|
slash_form = case_number_norm
|
||||||
|
dash_form = case_number_norm
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# Substring match on either form (covers full Nevo headers and short forms).
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT id FROM case_law
|
||||||
|
WHERE case_number ILIKE $1 OR case_number ILIKE $2
|
||||||
|
ORDER BY (source_kind = 'internal_committee') DESC,
|
||||||
|
LENGTH(case_number) ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
f"%{slash_form}%",
|
||||||
|
f"%{dash_form}%",
|
||||||
|
)
|
||||||
|
return UUID(str(row["id"])) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_and_store(case_law_id: UUID) -> dict:
|
||||||
|
"""Extract citations from a single ``case_law`` row's ``full_text``,
|
||||||
|
resolve them against the corpus, and INSERT into
|
||||||
|
``precedent_internal_citations`` (ON CONFLICT DO NOTHING).
|
||||||
|
|
||||||
|
Returns: {extracted: N, linked: M, new: K, skipped: S}
|
||||||
|
extracted — total distinct citations found in the text
|
||||||
|
linked — how many resolved to an existing case_law row
|
||||||
|
new — rows actually inserted (not pre-existing)
|
||||||
|
skipped — citations skipped (self-citation, already stored)
|
||||||
|
"""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT id, case_number, full_text FROM case_law WHERE id = $1",
|
||||||
|
case_law_id,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return {"extracted": 0, "linked": 0, "new": 0, "skipped": 0, "error": "not_found"}
|
||||||
|
|
||||||
|
text = row["full_text"] or ""
|
||||||
|
own_norm = _normalize_case_number(row["case_number"] or "")
|
||||||
|
|
||||||
|
extracted = 0
|
||||||
|
linked = 0
|
||||||
|
new_count = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for cit in extract_citations_from_text(text):
|
||||||
|
extracted += 1
|
||||||
|
if cit["case_number_norm"] == own_norm:
|
||||||
|
# Self-citation (e.g. document headers repeating the case number).
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
cited_id = await _resolve_case_law_id(cit["case_number_norm"])
|
||||||
|
if cited_id is not None and cited_id == case_law_id:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if cited_id is not None:
|
||||||
|
linked += 1
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO precedent_internal_citations (
|
||||||
|
source_case_law_id, cited_case_number, cited_case_law_id,
|
||||||
|
match_context, match_pattern, confidence
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (source_case_law_id, cited_case_number) DO NOTHING
|
||||||
|
""",
|
||||||
|
case_law_id,
|
||||||
|
f"{cit['prefix']} {cit['case_number']}",
|
||||||
|
cited_id,
|
||||||
|
cit["context"],
|
||||||
|
cit["pattern_kind"],
|
||||||
|
0.90 if cited_id is not None else 0.75,
|
||||||
|
)
|
||||||
|
# asyncpg execute returns 'INSERT 0 N' — N is rows inserted.
|
||||||
|
try:
|
||||||
|
n_inserted = int(result.split()[-1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
n_inserted = 0
|
||||||
|
if n_inserted == 1:
|
||||||
|
new_count += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"extracted": extracted,
|
||||||
|
"linked": linked,
|
||||||
|
"new": new_count,
|
||||||
|
"skipped": skipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_all_internal_committee(
|
||||||
|
chair_name_filter: str = "",
|
||||||
|
limit: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
"""Run extraction over every internal-committee row in ``case_law``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chair_name_filter: if non-empty, restrict to rows where chair_name
|
||||||
|
matches (exact match). Useful for running on Daphna only.
|
||||||
|
limit: hard cap on number of rows processed (0 = no cap).
|
||||||
|
|
||||||
|
Returns: summary dict with per-row counts and aggregate totals.
|
||||||
|
"""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
conditions = ["source_kind = 'internal_committee'", "full_text <> ''"]
|
||||||
|
params: list = []
|
||||||
|
if chair_name_filter:
|
||||||
|
conditions.append("chair_name = $1")
|
||||||
|
params.append(chair_name_filter)
|
||||||
|
where = " WHERE " + " AND ".join(conditions)
|
||||||
|
limit_clause = f" LIMIT {int(limit)}" if limit and limit > 0 else ""
|
||||||
|
sql = f"SELECT id, case_number FROM case_law{where} ORDER BY created_at{limit_clause}"
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(sql, *params)
|
||||||
|
|
||||||
|
totals = {
|
||||||
|
"processed": 0,
|
||||||
|
"extracted": 0,
|
||||||
|
"linked": 0,
|
||||||
|
"new": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"chair_name_filter": chair_name_filter,
|
||||||
|
"row_count": len(rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
stats = await extract_and_store(UUID(str(r["id"])))
|
||||||
|
totals["processed"] += 1
|
||||||
|
totals["extracted"] += stats.get("extracted", 0)
|
||||||
|
totals["linked"] += stats.get("linked", 0)
|
||||||
|
totals["new"] += stats.get("new", 0)
|
||||||
|
totals["skipped"] += stats.get("skipped", 0)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("citation extraction failed for %s: %s", r["case_number"], e)
|
||||||
|
totals["failed"] += 1
|
||||||
|
|
||||||
|
return totals
|
||||||
|
|
||||||
|
|
||||||
|
async def list_citations_for_case_law(
|
||||||
|
case_law_id: UUID,
|
||||||
|
linked_only: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return all citations *from* the given case_law row (outgoing edges)."""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
where = "pic.source_case_law_id = $1"
|
||||||
|
if linked_only:
|
||||||
|
where += " AND pic.cited_case_law_id IS NOT NULL"
|
||||||
|
sql = f"""
|
||||||
|
SELECT pic.id::text AS id,
|
||||||
|
pic.cited_case_number,
|
||||||
|
pic.cited_case_law_id::text AS cited_case_law_id,
|
||||||
|
pic.match_context,
|
||||||
|
pic.match_pattern,
|
||||||
|
pic.confidence::float AS confidence,
|
||||||
|
pic.created_at,
|
||||||
|
cl.case_number AS target_case_number,
|
||||||
|
cl.case_name AS target_case_name,
|
||||||
|
cl.chair_name AS target_chair_name,
|
||||||
|
cl.district AS target_district
|
||||||
|
FROM precedent_internal_citations pic
|
||||||
|
LEFT JOIN case_law cl ON cl.id = pic.cited_case_law_id
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY pic.created_at
|
||||||
|
"""
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(sql, case_law_id)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_citations_to_case_law(case_law_id: UUID) -> list[dict]:
|
||||||
|
"""Return all citations *to* the given case_law row (incoming edges).
|
||||||
|
|
||||||
|
Useful for "which Daphna decisions cite this ruling?" queries.
|
||||||
|
"""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
sql = """
|
||||||
|
SELECT pic.id::text AS id,
|
||||||
|
pic.source_case_law_id::text AS source_case_law_id,
|
||||||
|
pic.cited_case_number,
|
||||||
|
pic.match_context,
|
||||||
|
pic.match_pattern,
|
||||||
|
pic.confidence::float AS confidence,
|
||||||
|
pic.created_at,
|
||||||
|
cl.case_number AS source_case_number,
|
||||||
|
cl.case_name AS source_case_name,
|
||||||
|
cl.chair_name AS source_chair_name,
|
||||||
|
cl.district AS source_district
|
||||||
|
FROM precedent_internal_citations pic
|
||||||
|
JOIN case_law cl ON cl.id = pic.source_case_law_id
|
||||||
|
WHERE pic.cited_case_law_id = $1
|
||||||
|
ORDER BY pic.created_at DESC
|
||||||
|
"""
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(sql, case_law_id)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_cited_case_law_ids(source_case_law_ids: list[UUID]) -> dict[str, list[str]]:
|
||||||
|
"""Bulk-fetch outgoing citation case_law_ids for the given source rows.
|
||||||
|
|
||||||
|
Returns: {source_case_law_id (str): [cited_case_law_id (str), ...]} —
|
||||||
|
only including linked (resolved) citations.
|
||||||
|
|
||||||
|
Used by search.search_internal_decisions(include_cited_by=True) to
|
||||||
|
expand result sets with the precedents the hits themselves cite,
|
||||||
|
without running a separate roundtrip per row.
|
||||||
|
"""
|
||||||
|
if not source_case_law_ids:
|
||||||
|
return {}
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT source_case_law_id::text AS source_id,
|
||||||
|
cited_case_law_id::text AS cited_id
|
||||||
|
FROM precedent_internal_citations
|
||||||
|
WHERE source_case_law_id = ANY($1::uuid[])
|
||||||
|
AND cited_case_law_id IS NOT NULL
|
||||||
|
""",
|
||||||
|
list(source_case_law_ids),
|
||||||
|
)
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for r in rows:
|
||||||
|
out.setdefault(r["source_id"], []).append(r["cited_id"])
|
||||||
|
return out
|
||||||
@@ -72,6 +72,9 @@ async def query(
|
|||||||
"""
|
"""
|
||||||
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||||
|
|
||||||
|
if len(full_prompt) > 150_000:
|
||||||
|
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"claude", "-p",
|
"claude", "-p",
|
||||||
"--output-format", "json",
|
"--output-format", "json",
|
||||||
@@ -110,7 +113,8 @@ async def query(
|
|||||||
|
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
||||||
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}")
|
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
|
||||||
|
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}{size_info}")
|
||||||
|
|
||||||
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
||||||
if not stdout:
|
if not stdout:
|
||||||
@@ -138,3 +142,175 @@ async def query_json(
|
|||||||
"""
|
"""
|
||||||
raw = await query(prompt, timeout=timeout, system=system)
|
raw = await query(prompt, timeout=timeout, system=system)
|
||||||
return parse_llm_json(raw)
|
return parse_llm_json(raw)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Streaming + session continuation ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def query_streaming(
|
||||||
|
prompt: str,
|
||||||
|
*,
|
||||||
|
system: str | None = None,
|
||||||
|
resume_session_id: str | None = None,
|
||||||
|
timeout: int = LONG_TIMEOUT,
|
||||||
|
cwd: str | None = None,
|
||||||
|
):
|
||||||
|
"""Stream Claude's response as an async iterator of events.
|
||||||
|
|
||||||
|
Wraps `claude -p --output-format=stream-json` (newline-delimited JSON
|
||||||
|
objects from the CLI) and translates each line into a small, stable
|
||||||
|
shape that the chat service / SSE proxy can forward without leaking
|
||||||
|
CLI internals to the browser.
|
||||||
|
|
||||||
|
Event shapes yielded:
|
||||||
|
{"type": "session_id", "value": "<uuid>"} # first event, used for resume
|
||||||
|
{"type": "text_delta", "text": "<partial>"} # incremental assistant text
|
||||||
|
{"type": "tool_use", "name": "...", "input": {...}}
|
||||||
|
{"type": "error", "message": "..."}
|
||||||
|
{"type": "done", "text": "<full response>"}
|
||||||
|
|
||||||
|
The CLI emits a richer stream; we project to this minimal set so the
|
||||||
|
front-end can stay stable across CLI upgrades.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: The user message to send.
|
||||||
|
system: Optional system instructions (used only when starting a
|
||||||
|
fresh conversation — when resume_session_id is set, the
|
||||||
|
session already carries its system prompt).
|
||||||
|
resume_session_id: Continue a prior conversation. When given,
|
||||||
|
we don't re-send the system prompt; the CLI loads the
|
||||||
|
entire conversation history from disk.
|
||||||
|
timeout: Hard ceiling on the subprocess.
|
||||||
|
cwd: Working directory for the subprocess — defaults to the
|
||||||
|
host's HOME so claude.ai credentials resolve correctly.
|
||||||
|
"""
|
||||||
|
if resume_session_id:
|
||||||
|
# When resuming, system is already baked into the on-disk session
|
||||||
|
# — sending it again would be a no-op at best and confuse the
|
||||||
|
# conversation at worst.
|
||||||
|
full_prompt = prompt
|
||||||
|
cmd = [
|
||||||
|
"claude", "-p",
|
||||||
|
"--output-format", "stream-json",
|
||||||
|
"--verbose",
|
||||||
|
"--resume", resume_session_id,
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||||
|
cmd = [
|
||||||
|
"claude", "-p",
|
||||||
|
"--output-format", "stream-json",
|
||||||
|
"--verbose",
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(full_prompt) > 200_000:
|
||||||
|
logger.warning(
|
||||||
|
"Streaming: large prompt (%d chars) — may hit CLI input limits",
|
||||||
|
len(full_prompt),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=cwd,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
yield {
|
||||||
|
"type": "error",
|
||||||
|
"message": (
|
||||||
|
"Claude CLI not found on host — legal-chat-service must "
|
||||||
|
"run where the `claude` binary is installed (Daphna's host, "
|
||||||
|
"not the legal-ai container)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
assert proc.stdin is not None # for type checkers
|
||||||
|
assert proc.stdout is not None
|
||||||
|
|
||||||
|
# Send the prompt and close stdin so the CLI knows the user message
|
||||||
|
# is complete.
|
||||||
|
try:
|
||||||
|
proc.stdin.write(full_prompt.encode("utf-8"))
|
||||||
|
await proc.stdin.drain()
|
||||||
|
proc.stdin.close()
|
||||||
|
except BrokenPipeError:
|
||||||
|
# CLI exited before reading the prompt — drain stderr and bail.
|
||||||
|
stderr_b = await proc.stderr.read() if proc.stderr else b""
|
||||||
|
yield {
|
||||||
|
"type": "error",
|
||||||
|
"message": f"Claude CLI closed stdin early: {stderr_b.decode('utf-8', errors='replace')[:300]}",
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
accumulated_text: list[str] = []
|
||||||
|
session_id_emitted = False
|
||||||
|
deadline = asyncio.get_event_loop().time() + timeout
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
remaining = deadline - asyncio.get_event_loop().time()
|
||||||
|
if remaining <= 0:
|
||||||
|
yield {"type": "error", "message": f"timed out after {timeout}s"}
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
line_b = await asyncio.wait_for(proc.stdout.readline(), timeout=remaining)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
yield {"type": "error", "message": f"stream timed out after {timeout}s"}
|
||||||
|
break
|
||||||
|
if not line_b:
|
||||||
|
break
|
||||||
|
line = line_b.decode("utf-8", errors="replace").strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
event = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Stray non-JSON line from CLI — surface a snippet for debug.
|
||||||
|
logger.debug("non-JSON stream line: %s", line[:120])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# The CLI's stream-json emits several event types. We only
|
||||||
|
# care about the ones the chat service forwards.
|
||||||
|
t = event.get("type")
|
||||||
|
if not session_id_emitted:
|
||||||
|
sid = event.get("session_id")
|
||||||
|
if sid:
|
||||||
|
session_id_emitted = True
|
||||||
|
yield {"type": "session_id", "value": sid}
|
||||||
|
|
||||||
|
if t == "assistant":
|
||||||
|
# event["message"]["content"] is a list of blocks; we extract
|
||||||
|
# text blocks and tool_use blocks.
|
||||||
|
msg = event.get("message") or {}
|
||||||
|
for block in msg.get("content") or []:
|
||||||
|
btype = block.get("type")
|
||||||
|
if btype == "text":
|
||||||
|
text = block.get("text") or ""
|
||||||
|
if text:
|
||||||
|
accumulated_text.append(text)
|
||||||
|
yield {"type": "text_delta", "text": text}
|
||||||
|
elif btype == "tool_use":
|
||||||
|
yield {
|
||||||
|
"type": "tool_use",
|
||||||
|
"name": block.get("name") or "",
|
||||||
|
"input": block.get("input") or {},
|
||||||
|
}
|
||||||
|
elif t == "result":
|
||||||
|
# Final synthesized result line from the CLI — we already
|
||||||
|
# delivered the deltas, so just stop here.
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
if proc.returncode is None:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await proc.wait()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield {"type": "done", "text": "".join(accumulated_text)}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -109,16 +109,30 @@ _HEBREW_ABBREV_FIXES: dict[str, str] = {
|
|||||||
'מייר': 'מ"ר',
|
'מייר': 'מ"ר',
|
||||||
'יחייד': 'יח"ד',
|
'יחייד': 'יח"ד',
|
||||||
'בייכ': 'ב"כ',
|
'בייכ': 'ב"כ',
|
||||||
|
# Patterns where double-yod (יי) substitutes for gershayim (״) in born-digital PDFs
|
||||||
|
'בליימ': 'בל"מ', # בקשה להארכת מועד — appears in RTL legal docs
|
||||||
|
'תמייא': 'תמ"א', # תכנית מתאר ארצית
|
||||||
}
|
}
|
||||||
|
|
||||||
_ABBREV_PATTERN = re.compile(
|
_ABBREV_PATTERN = re.compile(
|
||||||
'|'.join(re.escape(k) for k in sorted(_HEBREW_ABBREV_FIXES, key=len, reverse=True))
|
'|'.join(re.escape(k) for k in sorted(_HEBREW_ABBREV_FIXES, key=len, reverse=True))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Matches Hebrew law year abbreviations where gershayim was encoded as double-yod.
|
||||||
|
# e.g. תשכייה → תשכ"ה, תשנייב → תשנ"ב
|
||||||
|
_HEBREW_YEAR_RE = re.compile(r'(תש[א-ת]+)יי([א-ת])')
|
||||||
|
|
||||||
|
|
||||||
def _fix_hebrew_quotes(text: str) -> str:
|
def _fix_hebrew_quotes(text: str) -> str:
|
||||||
"""Fix known Hebrew abbreviation quote replacements from Google Vision OCR."""
|
"""Fix known Hebrew abbreviation quote replacements.
|
||||||
return _ABBREV_PATTERN.sub(lambda m: _HEBREW_ABBREV_FIXES[m.group()], text)
|
|
||||||
|
Applied to both Google Vision OCR output and direct PyMuPDF extraction —
|
||||||
|
some born-digital PDFs encode gershayim (״) as double-yod (יי), producing
|
||||||
|
the same corruption patterns as OCR.
|
||||||
|
"""
|
||||||
|
text = _ABBREV_PATTERN.sub(lambda m: _HEBREW_ABBREV_FIXES[m.group()], text)
|
||||||
|
text = _HEBREW_YEAR_RE.sub(r'\1"\2', text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
# ── Extraction ───────────────────────────────────────────────────
|
# ── Extraction ───────────────────────────────────────────────────
|
||||||
@@ -189,7 +203,7 @@ async def _extract_pdf(path: Path) -> tuple[str, int, list[int]]:
|
|||||||
text = page.get_text().strip()
|
text = page.get_text().strip()
|
||||||
|
|
||||||
if len(text) > 50 and _text_quality_ok(text):
|
if len(text) > 50 and _text_quality_ok(text):
|
||||||
pages_text.append(text)
|
pages_text.append(_fix_hebrew_quotes(text))
|
||||||
logger.debug("Page %d: direct extraction (%d chars, quality OK)", page_num + 1, len(text))
|
logger.debug("Page %d: direct extraction (%d chars, quality OK)", page_num + 1, len(text))
|
||||||
else:
|
else:
|
||||||
reason = "insufficient text" if len(text) <= 50 else "low quality OCR layer"
|
reason = "insufficient text" if len(text) <= 50 else "low quality OCR layer"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Layered on top of ``rerank.maybe_rerank``. When ``MULTIMODAL_ENABLED`` is
|
|||||||
true the result comes from a weighted merge of:
|
true the result comes from a weighted merge of:
|
||||||
|
|
||||||
• text side: cosine on chunks → optional rerank-2 cross-encoder
|
• text side: cosine on chunks → optional rerank-2 cross-encoder
|
||||||
|
(precedent search additionally fuses ``ts_rank_cd`` lexical results
|
||||||
|
via RRF before this step — see ``BM25_HYBRID_ENABLED``)
|
||||||
• image side: cosine on per-page voyage-multimodal-3 embeddings
|
• image side: cosine on per-page voyage-multimodal-3 embeddings
|
||||||
|
|
||||||
rerank-2 is a *text* cross-encoder, so image-side rows are NOT passed
|
rerank-2 is a *text* cross-encoder, so image-side rows are NOT passed
|
||||||
@@ -15,6 +17,14 @@ visual-heavy content still appears in results.
|
|||||||
When ``MULTIMODAL_ENABLED`` is false this module degenerates to plain
|
When ``MULTIMODAL_ENABLED`` is false this module degenerates to plain
|
||||||
``rerank.maybe_rerank`` — callers can wrap unconditionally and let env
|
``rerank.maybe_rerank`` — callers can wrap unconditionally and let env
|
||||||
control behaviour.
|
control behaviour.
|
||||||
|
|
||||||
|
BM25/lexical leg (V12 + ``BM25_HYBRID_ENABLED``):
|
||||||
|
``search_precedent_library_hybrid`` runs ``search_precedent_library_lexical``
|
||||||
|
in parallel with the semantic side and fuses the two by rank via RRF.
|
||||||
|
This recovers exact-string recall (case-number citations like "1461/20",
|
||||||
|
rare planning terms) that voyage embeddings blur. The fused list is
|
||||||
|
then handed to rerank-2 (if enabled) and to the image RRF (if
|
||||||
|
multimodal is enabled) exactly as before.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -91,16 +101,28 @@ async def search_precedent_library_hybrid(
|
|||||||
source_kind: str = "external_upload",
|
source_kind: str = "external_upload",
|
||||||
district: str = "",
|
district: str = "",
|
||||||
chair_name: str = "",
|
chair_name: str = "",
|
||||||
|
max_per_case_law: int = 2,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Hybrid wrapper for precedent-library search.
|
"""Hybrid wrapper for precedent-library search.
|
||||||
|
|
||||||
source_kind='external_upload' → court rulings (default)
|
source_kind='external_upload' → court rulings (default)
|
||||||
source_kind='internal_committee' → appeals-committee decisions
|
source_kind='internal_committee' → appeals-committee decisions
|
||||||
|
max_per_case_law: MMR-style diversity cap — at most N hits per
|
||||||
|
case_law_id in the final ranked list (default 2). Prevents a
|
||||||
|
single precedent from monopolizing the result list when many of
|
||||||
|
its chunks/halachot are individually relevant.
|
||||||
|
|
||||||
|
When ``config.BM25_HYBRID_ENABLED`` is true (default) ``_base`` fuses
|
||||||
|
semantic cosine + lexical ``ts_rank_cd`` via RRF before handing the
|
||||||
|
candidates to rerank-2 (if enabled) and the image merge (if
|
||||||
|
multimodal is enabled).
|
||||||
"""
|
"""
|
||||||
fetch_k = max(limit, config.VOYAGE_RERANK_FETCH_K) if config.MULTIMODAL_ENABLED else limit
|
# Fetch deeper so diversity dedup still leaves enough candidates.
|
||||||
|
fetch_k = max(limit * max(max_per_case_law, 1), config.VOYAGE_RERANK_FETCH_K) \
|
||||||
|
if config.MULTIMODAL_ENABLED else max(limit * max(max_per_case_law, 1), limit)
|
||||||
|
|
||||||
async def _base(limit: int) -> list[dict]:
|
async def _base(limit: int) -> list[dict]:
|
||||||
return await db.search_precedent_library_semantic(
|
sem_rows = await db.search_precedent_library_semantic(
|
||||||
query_embedding=query_text_embedding,
|
query_embedding=query_text_embedding,
|
||||||
practice_area=practice_area,
|
practice_area=practice_area,
|
||||||
court=court,
|
court=court,
|
||||||
@@ -114,12 +136,39 @@ async def search_precedent_library_hybrid(
|
|||||||
district=district,
|
district=district,
|
||||||
chair_name=chair_name,
|
chair_name=chair_name,
|
||||||
)
|
)
|
||||||
|
if not config.BM25_HYBRID_ENABLED:
|
||||||
|
return sem_rows
|
||||||
|
# Fetch lexical with ≥ 2× depth so RRF has reserves at the tail.
|
||||||
|
lex_limit = max(limit * 2, limit)
|
||||||
|
try:
|
||||||
|
lex_rows = await db.search_precedent_library_lexical(
|
||||||
|
query=query,
|
||||||
|
practice_area=practice_area,
|
||||||
|
court=court,
|
||||||
|
precedent_level=precedent_level,
|
||||||
|
appeal_subtype=appeal_subtype,
|
||||||
|
is_binding=is_binding,
|
||||||
|
subject_tag=subject_tag,
|
||||||
|
source_kind=source_kind,
|
||||||
|
district=district,
|
||||||
|
chair_name=chair_name,
|
||||||
|
limit=lex_limit,
|
||||||
|
include_halachot=include_halachot,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Hybrid precedent: lexical side failed, semantic only: %s", e,
|
||||||
|
)
|
||||||
|
return sem_rows
|
||||||
|
if not lex_rows:
|
||||||
|
return sem_rows
|
||||||
|
return _merge_sem_lex(sem_rows, lex_rows, limit=limit)
|
||||||
|
|
||||||
text_results = await rerank.maybe_rerank(
|
text_results = await rerank.maybe_rerank(
|
||||||
query=query, base_search=_base, limit=fetch_k,
|
query=query, base_search=_base, limit=fetch_k,
|
||||||
)
|
)
|
||||||
if not config.MULTIMODAL_ENABLED:
|
if not config.MULTIMODAL_ENABLED:
|
||||||
return text_results[:limit]
|
return _diversify_by_case_law(text_results, limit, max_per_case_law)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
query_img_emb = await embeddings.embed_query_for_multimodal(query)
|
query_img_emb = await embeddings.embed_query_for_multimodal(query)
|
||||||
@@ -134,13 +183,128 @@ async def search_precedent_library_hybrid(
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Hybrid: image side failed, returning text only: %s", e)
|
logger.warning("Hybrid: image side failed, returning text only: %s", e)
|
||||||
return text_results[:limit]
|
return _diversify_by_case_law(text_results, limit, max_per_case_law)
|
||||||
|
|
||||||
merged = _merge(
|
merged = _merge(
|
||||||
text_results, img_rows,
|
text_results, img_rows,
|
||||||
id_field="case_law_id",
|
id_field="case_law_id",
|
||||||
text_weight=config.MULTIMODAL_TEXT_WEIGHT,
|
text_weight=config.MULTIMODAL_TEXT_WEIGHT,
|
||||||
)
|
)
|
||||||
|
return _diversify_by_case_law(merged, limit, max_per_case_law)
|
||||||
|
|
||||||
|
|
||||||
|
def _diversify_by_case_law(
|
||||||
|
rows: list[dict],
|
||||||
|
limit: int,
|
||||||
|
max_per_case_law: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""MMR-style diversity cap: at most ``max_per_case_law`` rows per
|
||||||
|
case_law_id in the final list. Preserves input order (which is the
|
||||||
|
relevance ranking) — for each row, include it only if we haven't
|
||||||
|
reached the cap for its case_law_id yet.
|
||||||
|
|
||||||
|
Set max_per_case_law<=0 to disable (returns rows[:limit] unchanged).
|
||||||
|
"""
|
||||||
|
if max_per_case_law <= 0 or not rows:
|
||||||
|
return rows[:limit]
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
out: list[dict] = []
|
||||||
|
for r in rows:
|
||||||
|
clid = str(r.get("case_law_id") or "")
|
||||||
|
if not clid:
|
||||||
|
out.append(r)
|
||||||
|
if len(out) >= limit:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
n = counts.get(clid, 0)
|
||||||
|
if n < max_per_case_law:
|
||||||
|
out.append(r)
|
||||||
|
counts[clid] = n + 1
|
||||||
|
if len(out) >= limit:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _row_key(r: dict) -> tuple[str, str]:
|
||||||
|
"""Stable identity for sem/lex RRF.
|
||||||
|
|
||||||
|
Halachot rows have ``halacha_id``; chunk rows have ``chunk_id``.
|
||||||
|
Returns ``(type, id)`` so a halacha and a chunk with the same UUID
|
||||||
|
(extremely unlikely, but distinct namespaces) don't collide.
|
||||||
|
"""
|
||||||
|
typ = str(r.get("type") or "")
|
||||||
|
rid = r.get("halacha_id") if typ == "halacha" else r.get("chunk_id")
|
||||||
|
return (typ, str(rid or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_sem_lex(
|
||||||
|
sem_rows: list[dict],
|
||||||
|
lex_rows: list[dict],
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""RRF fusion of semantic + lexical precedent results.
|
||||||
|
|
||||||
|
Why RRF (and not weighted score sum): cosine similarities (~0.4-0.7)
|
||||||
|
and ``ts_rank_cd`` values (often 0.001-0.5, query-length-dependent)
|
||||||
|
live on completely different scales — a weighted sum would let one
|
||||||
|
side dominate by accident. RRF combines by *rank*, so a row that
|
||||||
|
tops one list and is mid-pack in the other gets a robust boost.
|
||||||
|
|
||||||
|
Per row::
|
||||||
|
|
||||||
|
rrf_score = 1 / (k + sem_rank) + 1 / (k + lex_rank)
|
||||||
|
|
||||||
|
A row that appears in only one list contributes that list's term
|
||||||
|
only. Output is sorted by combined score, with extra debug fields
|
||||||
|
(``sem_score``, ``sem_rank``, ``lex_score``, ``lex_rank``) attached
|
||||||
|
so callers and tests can inspect why a row ranked where it did.
|
||||||
|
|
||||||
|
The row payload (``content``, ``rule_statement``, ``case_*`` joins,
|
||||||
|
etc.) is taken from the semantic-side row when available — the two
|
||||||
|
sources return identical column shapes, but semantic rows carry the
|
||||||
|
confidence-boosted ``score`` that the rest of the pipeline expects.
|
||||||
|
"""
|
||||||
|
k = config.MULTIMODAL_RRF_K
|
||||||
|
sem_rank_by_key: dict[tuple, int] = {}
|
||||||
|
sem_row_by_key: dict[tuple, dict] = {}
|
||||||
|
for rank, r in enumerate(sem_rows, 1):
|
||||||
|
key = _row_key(r)
|
||||||
|
if not key[1]:
|
||||||
|
continue
|
||||||
|
sem_rank_by_key[key] = rank
|
||||||
|
sem_row_by_key[key] = r
|
||||||
|
|
||||||
|
lex_rank_by_key: dict[tuple, int] = {}
|
||||||
|
lex_row_by_key: dict[tuple, dict] = {}
|
||||||
|
for rank, r in enumerate(lex_rows, 1):
|
||||||
|
key = _row_key(r)
|
||||||
|
if not key[1]:
|
||||||
|
continue
|
||||||
|
lex_rank_by_key[key] = rank
|
||||||
|
lex_row_by_key[key] = r
|
||||||
|
|
||||||
|
all_keys = set(sem_rank_by_key) | set(lex_rank_by_key)
|
||||||
|
merged: list[dict] = []
|
||||||
|
for key in all_keys:
|
||||||
|
sem_rank = sem_rank_by_key.get(key)
|
||||||
|
lex_rank = lex_rank_by_key.get(key)
|
||||||
|
base = sem_row_by_key.get(key) or lex_row_by_key.get(key)
|
||||||
|
if base is None:
|
||||||
|
continue
|
||||||
|
d = dict(base)
|
||||||
|
sem_term = 1.0 / (k + sem_rank) if sem_rank else 0.0
|
||||||
|
lex_term = 1.0 / (k + lex_rank) if lex_rank else 0.0
|
||||||
|
d["sem_score"] = float(sem_row_by_key[key]["score"]) \
|
||||||
|
if key in sem_row_by_key else 0.0
|
||||||
|
d["sem_rank"] = sem_rank or 0
|
||||||
|
d["lex_score"] = float(lex_row_by_key[key]["score"]) \
|
||||||
|
if key in lex_row_by_key else 0.0
|
||||||
|
d["lex_rank"] = lex_rank or 0
|
||||||
|
d["score"] = sem_term + lex_term
|
||||||
|
merged.append(d)
|
||||||
|
|
||||||
|
merged.sort(key=lambda x: -float(x["score"]))
|
||||||
return merged[:limit]
|
return merged[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from uuid import UUID, uuid4
|
|||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import chunker, db, embeddings, extractor
|
from legal_mcp.services import chunker, db, embeddings, extractor
|
||||||
|
from legal_mcp.services.practice_area import derive_proceeding_type
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -86,11 +87,13 @@ async def ingest_internal_decision(
|
|||||||
text: str | None = None,
|
text: str | None = None,
|
||||||
document_id: UUID | None = None,
|
document_id: UUID | None = None,
|
||||||
queue_halachot: bool = True,
|
queue_halachot: bool = True,
|
||||||
|
proceeding_type: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Ingest an appeals-committee decision into the internal corpus.
|
"""Ingest an appeals-committee decision into the internal corpus.
|
||||||
|
|
||||||
Either file_path or text must be provided.
|
Either file_path or text must be provided.
|
||||||
If district is empty, it is inferred from court.
|
If district is empty, it is inferred from court.
|
||||||
|
If proceeding_type is empty, it is derived from appeal_subtype/case_name.
|
||||||
Returns: {"status": "completed", "case_law_id": "...", "chunks": N}
|
Returns: {"status": "completed", "case_law_id": "...", "chunks": N}
|
||||||
"""
|
"""
|
||||||
if not file_path and not text:
|
if not file_path and not text:
|
||||||
@@ -99,6 +102,9 @@ async def ingest_internal_decision(
|
|||||||
raise ValueError("case_number is required")
|
raise ValueError("case_number is required")
|
||||||
|
|
||||||
resolved_district = district.strip() or _district_from_court(court)
|
resolved_district = district.strip() or _district_from_court(court)
|
||||||
|
resolved_proc = proceeding_type.strip() or derive_proceeding_type(
|
||||||
|
appeal_subtype=appeal_subtype, subject=case_name,
|
||||||
|
)
|
||||||
|
|
||||||
if file_path:
|
if file_path:
|
||||||
src = Path(file_path)
|
src = Path(file_path)
|
||||||
@@ -133,29 +139,68 @@ async def ingest_internal_decision(
|
|||||||
summary=summary.strip(),
|
summary=summary.strip(),
|
||||||
is_binding=is_binding,
|
is_binding=is_binding,
|
||||||
document_id=document_id,
|
document_id=document_id,
|
||||||
|
proceeding_type=resolved_proc,
|
||||||
)
|
)
|
||||||
case_law_id = UUID(str(record["id"]))
|
case_law_id = UUID(str(record["id"]))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
chunks = chunker.chunk_document(raw_text, page_offsets=page_offsets)
|
# Parent-doc retrieval (TaskMaster #48) — same gated branch as
|
||||||
if not chunks:
|
# ingest_precedent. Internal committee decisions are typically
|
||||||
await db.set_case_law_extraction_status(case_law_id, "completed")
|
# longer than external court rulings (full transcript + ruling),
|
||||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
# so the parent-doc benefit is even larger here.
|
||||||
return {"status": "completed", "case_law_id": str(case_law_id), "chunks": 0}
|
if config.PARENT_DOC_RETRIEVAL_ENABLED:
|
||||||
|
h_chunks = chunker.chunk_document_hierarchical(
|
||||||
|
raw_text, page_offsets=page_offsets,
|
||||||
|
)
|
||||||
|
if not h_chunks:
|
||||||
|
await db.set_case_law_extraction_status(case_law_id, "completed")
|
||||||
|
await db.set_case_law_halacha_status(case_law_id, "completed")
|
||||||
|
return {"status": "completed", "case_law_id": str(case_law_id), "chunks": 0}
|
||||||
|
children = [c for c in h_chunks if c.role == "child"]
|
||||||
|
parents = [c for c in h_chunks if c.role == "parent"]
|
||||||
|
child_vectors = await embeddings.embed_texts(
|
||||||
|
[c.content for c in children], input_type="document",
|
||||||
|
)
|
||||||
|
chunk_dicts: list[dict] = []
|
||||||
|
for p in parents:
|
||||||
|
chunk_dicts.append({
|
||||||
|
"role": "parent", "local_id": p.local_id, "parent_local_id": None,
|
||||||
|
"chunk_index": p.chunk_index, "content": p.content,
|
||||||
|
"section_type": p.section_type, "page_number": p.page_number,
|
||||||
|
"embedding": None,
|
||||||
|
})
|
||||||
|
for c, v in zip(children, child_vectors):
|
||||||
|
chunk_dicts.append({
|
||||||
|
"role": "child", "local_id": c.local_id,
|
||||||
|
"parent_local_id": c.parent_local_id,
|
||||||
|
"chunk_index": c.chunk_index, "content": c.content,
|
||||||
|
"section_type": c.section_type, "page_number": c.page_number,
|
||||||
|
"embedding": v,
|
||||||
|
})
|
||||||
|
counts = await db.store_precedent_chunks_hierarchical(
|
||||||
|
case_law_id, chunk_dicts,
|
||||||
|
)
|
||||||
|
stored = counts["children"]
|
||||||
|
else:
|
||||||
|
chunks = chunker.chunk_document(raw_text, page_offsets=page_offsets)
|
||||||
|
if not chunks:
|
||||||
|
await db.set_case_law_extraction_status(case_law_id, "completed")
|
||||||
|
await db.set_case_law_halacha_status(case_law_id, "completed")
|
||||||
|
return {"status": "completed", "case_law_id": str(case_law_id), "chunks": 0}
|
||||||
|
|
||||||
chunk_texts = [c.content for c in chunks]
|
chunk_texts = [c.content for c in chunks]
|
||||||
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
|
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
|
||||||
chunk_dicts = [
|
chunk_dicts = [
|
||||||
{
|
{
|
||||||
"chunk_index": c.chunk_index,
|
"chunk_index": c.chunk_index,
|
||||||
"content": c.content,
|
"content": c.content,
|
||||||
"section_type": c.section_type,
|
"section_type": c.section_type,
|
||||||
"page_number": c.page_number,
|
"page_number": c.page_number,
|
||||||
"embedding": v,
|
"embedding": v,
|
||||||
}
|
}
|
||||||
for c, v in zip(chunks, chunk_vectors)
|
for c, v in zip(chunks, chunk_vectors)
|
||||||
]
|
]
|
||||||
stored = await db.store_precedent_chunks(case_law_id, chunk_dicts)
|
stored = await db.store_precedent_chunks(case_law_id, chunk_dicts)
|
||||||
|
|
||||||
await db.set_case_law_extraction_status(case_law_id, "completed")
|
await db.set_case_law_extraction_status(case_law_id, "completed")
|
||||||
await db.set_case_law_halacha_status(case_law_id, "pending")
|
await db.set_case_law_halacha_status(case_law_id, "pending")
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ SUMMARY_STRATEGIES = {
|
|||||||
|
|
||||||
DISCUSSION_RULES: dict[str, list[str]] = {
|
DISCUSSION_RULES: dict[str, list[str]] = {
|
||||||
"universal": [
|
"universal": [
|
||||||
"פרק הדיון = אסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
"פרק הדיון = מאסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
||||||
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
|
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
|
||||||
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
|
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
|
||||||
],
|
],
|
||||||
@@ -485,6 +485,7 @@ CONTENT_CHECKLISTS: dict[str, str] = {
|
|||||||
- שווי מקרקעין — מצב קודם ומצב חדש (שיטת השוואה / יחידות תועלת)
|
- שווי מקרקעין — מצב קודם ומצב חדש (שיטת השוואה / יחידות תועלת)
|
||||||
- עלויות עודפות (חניה, מטלות ציבוריות, תשתיות)
|
- עלויות עודפות (חניה, מטלות ציבוריות, תשתיות)
|
||||||
- מקדמי זמינות, שיעורי הפקעה
|
- מקדמי זמינות, שיעורי הפקעה
|
||||||
|
- הכרעה מפוצלת (bifurcation) — כשהוועדה מאשרת חבות אך ממנה שמאי מייעץ: ביטויי גישור ("ניתן יהיה לעלות בפני השמאי המייעץ"), נוסחת מינוי, הפניה לתקנות סדרי דין התשס"ט-2008, הוראות המשך (30 יום להשגות). ללא סיכום — ישירות לחתימה. ראה: 8070/25
|
||||||
|
|
||||||
### ד. שאלות משפטיות (לפי רלוונטיות)
|
### ד. שאלות משפטיות (לפי רלוונטיות)
|
||||||
- פטורים — דירת מגורים (ס' 19(ג)(1)), שטח עד 140 מ"ר, תא משפחתי
|
- פטורים — דירת מגורים (ס' 19(ג)(1)), שטח עד 140 מ"ר, תא משפחתי
|
||||||
@@ -493,6 +494,7 @@ CONTENT_CHECKLISTS: dict[str, str] = {
|
|||||||
- מקרקעי ישראל — הסדרים מיוחדים (ס' 21 לתוספת השלישית)
|
- מקרקעי ישראל — הסדרים מיוחדים (ס' 21 לתוספת השלישית)
|
||||||
- שומות מוסכמות — תוקף, משמעות, "בלתי נצפה מראש"
|
- שומות מוסכמות — תוקף, משמעות, "בלתי נצפה מראש"
|
||||||
- פרשנות תכניות — ייעוד, שימושים מותרים, מדיניות ועדה מקומית
|
- פרשנות תכניות — ייעוד, שימושים מותרים, מדיניות ועדה מקומית
|
||||||
|
- טענת "תכנית צל = זכות מוקנית" — ניתוח תלת-שכבתי: (1) נורמטיבית — תכנית צל = המחשה, לא מקור נורמטיבי; (2) פרוצדורלית — הקלה ניתנת פר-מבקש, לא זכות כללית; (3) שמאית — משקל הסתברותי בהערכת ההשבחה, לא במישור המשפטי. ראה: 8070/25
|
||||||
|
|
||||||
### ה. ניתוח שמאי (כשיש שומה מכרעת)
|
### ה. ניתוח שמאי (כשיש שומה מכרעת)
|
||||||
- האם השומה מבוססת על מסד עובדתי הולם?
|
- האם השומה מבוססת על מסד עובדתי הולם?
|
||||||
|
|||||||
@@ -2,14 +2,34 @@
|
|||||||
|
|
||||||
Two orthogonal axes used to separate legal domains across the system:
|
Two orthogonal axes used to separate legal domains across the system:
|
||||||
|
|
||||||
practice_area — top-level domain (multi-tenant axis). Examples:
|
practice_area — top-level domain. **Two taxonomies coexist** (see below).
|
||||||
appeals_committee, national_insurance, labor_law.
|
appeal_subtype — refines within a domain.
|
||||||
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
|
⚠️ TWO TAXONOMIES — DO NOT CONFUSE
|
||||||
so vector searches can filter cheaply.
|
==================================
|
||||||
|
|
||||||
|
A. **Multi-tenant axis** (legacy, used in routing logic):
|
||||||
|
- ``appeals_committee`` — the legal-ai instance for Daphna's committee
|
||||||
|
- ``national_insurance`` — future / hypothetical other tenants
|
||||||
|
- ``labor_law`` — future
|
||||||
|
When this axis is used, ``appeal_subtype`` carries the actual domain:
|
||||||
|
``building_permit`` (1xxx), ``betterment_levy`` (8xxx),
|
||||||
|
``compensation_197`` (9xxx).
|
||||||
|
|
||||||
|
B. **Domain axis** (DB columns ``case_law.practice_area``,
|
||||||
|
``cases.practice_area`` — what tests, validators, and CHECK constraints
|
||||||
|
actually use):
|
||||||
|
- ``rishuy_uvniya`` — רישוי ובנייה (1xxx)
|
||||||
|
- ``betterment_levy`` — היטל השבחה (8xxx)
|
||||||
|
- ``compensation_197`` — פיצויים סעיף 197 (9xxx)
|
||||||
|
|
||||||
|
Use ``to_db_practice_area(multi_tenant_pa, appeal_subtype)`` to convert
|
||||||
|
from axis A to axis B before writing to the DB.
|
||||||
|
|
||||||
|
Background: TaskMaster #30 (sub-bug ב) — many ``case_law`` rows stored
|
||||||
|
``appeals_committee`` (axis A) where they should have stored a domain
|
||||||
|
value (axis B). The migration backfill plus CHECK constraints close the
|
||||||
|
gap, and this module now validates **both** namespaces.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -18,19 +38,58 @@ import re
|
|||||||
|
|
||||||
# ── Enums ──────────────────────────────────────────────────────────
|
# ── Enums ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
PRACTICE_AREAS: set[str] = {
|
# Multi-tenant axis (legacy)
|
||||||
|
MULTI_TENANT_PRACTICE_AREAS: set[str] = {
|
||||||
"appeals_committee",
|
"appeals_committee",
|
||||||
"national_insurance",
|
"national_insurance",
|
||||||
"labor_law",
|
"labor_law",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Domain axis (matches DB constraints on case_law/cases)
|
||||||
|
DOMAIN_PRACTICE_AREAS: set[str] = {
|
||||||
|
"rishuy_uvniya",
|
||||||
|
"betterment_levy",
|
||||||
|
"compensation_197",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Union — what ``validate()`` accepts for backward-compat.
|
||||||
|
# Empty string is permitted because the DB CHECK constraint allows it as
|
||||||
|
# a "not yet classified" sentinel (e.g. when auto-derivation fails on an
|
||||||
|
# unrecognized case_number format).
|
||||||
|
PRACTICE_AREAS: set[str] = MULTI_TENANT_PRACTICE_AREAS | DOMAIN_PRACTICE_AREAS | {""}
|
||||||
|
|
||||||
APPEALS_COMMITTEE_SUBTYPES: set[str] = {
|
APPEALS_COMMITTEE_SUBTYPES: set[str] = {
|
||||||
"building_permit",
|
"building_permit",
|
||||||
"betterment_levy",
|
"betterment_levy",
|
||||||
"compensation_197",
|
"compensation_197",
|
||||||
|
# בל"מ — בקשה להארכת מועד להגשת ערר. מסלולים נפרדים לפי domain:
|
||||||
|
"extension_request_building_permit", # 1xxx — סעיף 152, 30 ימים
|
||||||
|
"extension_request_betterment_levy", # 8xxx — סעיף 14 לתוספת ג', 45 ימים
|
||||||
|
"extension_request_compensation", # 9xxx — סעיף 198(ד), 30 ימים
|
||||||
"unknown",
|
"unknown",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# בל"מ subtypes — קל לזהות ע"י prefix
|
||||||
|
BLAM_SUBTYPES: set[str] = {
|
||||||
|
"extension_request_building_permit",
|
||||||
|
"extension_request_betterment_levy",
|
||||||
|
"extension_request_compensation",
|
||||||
|
}
|
||||||
|
|
||||||
|
# מיפוי domain → בל"מ subtype
|
||||||
|
_DOMAIN_TO_BLAM_SUBTYPE: dict[str, str] = {
|
||||||
|
"rishuy_uvniya": "extension_request_building_permit",
|
||||||
|
"betterment_levy": "extension_request_betterment_levy",
|
||||||
|
"compensation_197": "extension_request_compensation",
|
||||||
|
}
|
||||||
|
|
||||||
|
# מיפוי first-digit → בל"מ subtype (אותו מבנה כמו _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE)
|
||||||
|
_APPEALS_COMMITTEE_DIGIT_TO_BLAM = {
|
||||||
|
"1": "extension_request_building_permit",
|
||||||
|
"8": "extension_request_betterment_levy",
|
||||||
|
"9": "extension_request_compensation",
|
||||||
|
}
|
||||||
|
|
||||||
DEFAULT_PRACTICE_AREA = "appeals_committee"
|
DEFAULT_PRACTICE_AREA = "appeals_committee"
|
||||||
|
|
||||||
# Subtypes per practice_area (extend when adding domains)
|
# Subtypes per practice_area (extend when adding domains)
|
||||||
@@ -38,8 +97,74 @@ SUBTYPES_BY_AREA: dict[str, set[str]] = {
|
|||||||
"appeals_committee": APPEALS_COMMITTEE_SUBTYPES,
|
"appeals_committee": APPEALS_COMMITTEE_SUBTYPES,
|
||||||
"national_insurance": {"unknown"},
|
"national_insurance": {"unknown"},
|
||||||
"labor_law": {"unknown"},
|
"labor_law": {"unknown"},
|
||||||
|
# Domain values — subtype is implicit in the value itself
|
||||||
|
"rishuy_uvniya": {"building_permit", "extension_request_building_permit", "unknown"},
|
||||||
|
"betterment_levy": {"betterment_levy", "extension_request_betterment_levy", "unknown"},
|
||||||
|
"compensation_197": {"compensation_197", "extension_request_compensation", "unknown"},
|
||||||
|
# Empty (unclassified) — allow any of the appeals_committee subtypes
|
||||||
|
"": APPEALS_COMMITTEE_SUBTYPES,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Mapping: (multi_tenant_pa, appeal_subtype) → domain_pa
|
||||||
|
_SUBTYPE_TO_DOMAIN: dict[str, str] = {
|
||||||
|
"building_permit": "rishuy_uvniya",
|
||||||
|
"betterment_levy": "betterment_levy",
|
||||||
|
"compensation_197": "compensation_197",
|
||||||
|
"extension_request_building_permit": "rishuy_uvniya",
|
||||||
|
"extension_request_betterment_levy": "betterment_levy",
|
||||||
|
"extension_request_compensation": "compensation_197",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Regex לזיהוי "בקשה להארכת מועד" בנושא הערר (subject) —
|
||||||
|
# וריאציות נפוצות. case-insensitive, מתחשב במרכאות חכמות/רגילות.
|
||||||
|
_BLAM_SUBJECT_PATTERNS = (
|
||||||
|
re.compile(r"בקשה\s+להארכת\s+מועד", re.IGNORECASE),
|
||||||
|
re.compile(r"בל[\"״״]מ", re.IGNORECASE), # בל"מ עם quote variants
|
||||||
|
re.compile(r"הארכת\s+מועד\s+להגשת", re.IGNORECASE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_blam_subject(subject: str) -> bool:
|
||||||
|
"""True iff subject indicates a בל"מ (extension-of-time request).
|
||||||
|
|
||||||
|
מזהה: "בקשה להארכת מועד", "בל\"מ", "הארכת מועד להגשת..."
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> is_blam_subject("בל\"מ אלחנן ברלינגר נ' לינדאב")
|
||||||
|
True
|
||||||
|
>>> is_blam_subject("בקשה להארכת מועד להגשת ערר")
|
||||||
|
True
|
||||||
|
>>> is_blam_subject("היתר בנייה ברחוב X")
|
||||||
|
False
|
||||||
|
"""
|
||||||
|
if not subject:
|
||||||
|
return False
|
||||||
|
return any(p.search(subject) for p in _BLAM_SUBJECT_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
def to_db_practice_area(practice_area: str, appeal_subtype: str = "") -> str:
|
||||||
|
"""Convert a multi-tenant practice_area + appeal_subtype to the
|
||||||
|
domain value stored in DB columns (case_law/cases).
|
||||||
|
|
||||||
|
Returns ``""`` when the input cannot be mapped — callers should
|
||||||
|
handle this rather than letting ``""`` propagate silently to the DB.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> to_db_practice_area("appeals_committee", "building_permit")
|
||||||
|
'rishuy_uvniya'
|
||||||
|
>>> to_db_practice_area("rishuy_uvniya")
|
||||||
|
'rishuy_uvniya'
|
||||||
|
>>> to_db_practice_area("appeals_committee")
|
||||||
|
''
|
||||||
|
"""
|
||||||
|
pa = (practice_area or "").strip()
|
||||||
|
if pa in DOMAIN_PRACTICE_AREAS:
|
||||||
|
return pa
|
||||||
|
if pa == "appeals_committee":
|
||||||
|
return _SUBTYPE_TO_DOMAIN.get((appeal_subtype or "").strip(), "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
# ── Derivation ─────────────────────────────────────────────────────
|
# ── Derivation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -55,14 +180,28 @@ _CASE_NUM = re.compile(r"(?:ARAR[-\s]*\d{2}[-\s]*(?:\d{2}[-\s]*)?)(\d{4})", re.I
|
|||||||
_PLAIN_NUM = re.compile(r"(\d{4})")
|
_PLAIN_NUM = re.compile(r"(\d{4})")
|
||||||
|
|
||||||
|
|
||||||
|
_DOMAIN_TO_SUBTYPE: dict[str, str] = {
|
||||||
|
"rishuy_uvniya": "building_permit",
|
||||||
|
"betterment_levy": "betterment_levy",
|
||||||
|
"compensation_197": "compensation_197",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) -> str:
|
def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) -> str:
|
||||||
"""Infer the appeal_subtype from case_number.
|
"""Infer the appeal_subtype from case_number.
|
||||||
|
|
||||||
For appeals_committee, the convention is:
|
For appeals_committee (axis A), the convention is:
|
||||||
1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197.
|
1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197.
|
||||||
|
|
||||||
|
For domain values (axis B — rishuy_uvniya/betterment_levy/compensation_197),
|
||||||
|
the subtype is implicit in the practice_area itself — we map directly
|
||||||
|
without parsing the case number.
|
||||||
|
|
||||||
Handles multiple formats: ARAR-25-8126, 8126/25, 1170, ערר 1024-25.
|
Handles multiple formats: ARAR-25-8126, 8126/25, 1170, ערר 1024-25.
|
||||||
"""
|
"""
|
||||||
|
# Axis B: practice_area is already a domain value — map directly.
|
||||||
|
if practice_area in DOMAIN_PRACTICE_AREAS:
|
||||||
|
return _DOMAIN_TO_SUBTYPE.get(practice_area, "unknown")
|
||||||
if practice_area != "appeals_committee":
|
if practice_area != "appeals_committee":
|
||||||
return "unknown"
|
return "unknown"
|
||||||
cn = case_number or ""
|
cn = case_number or ""
|
||||||
@@ -77,6 +216,94 @@ def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA)
|
|||||||
return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit, "unknown")
|
return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit, "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def derive_subtype_with_blam(
|
||||||
|
case_number: str,
|
||||||
|
subject: str = "",
|
||||||
|
practice_area: str = DEFAULT_PRACTICE_AREA,
|
||||||
|
) -> str:
|
||||||
|
"""Like ``derive_subtype()`` but also detects בל"מ from the subject.
|
||||||
|
|
||||||
|
If ``subject`` indicates a בקשה להארכת מועד, the returned subtype is
|
||||||
|
one of the ``extension_request_*`` values (chosen per case_number /
|
||||||
|
practice_area). Otherwise behaviour matches ``derive_subtype()``.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> derive_subtype_with_blam("1017-03-26", "בל\"מ ברלינגר נ' לינדאב")
|
||||||
|
'extension_request_building_permit'
|
||||||
|
>>> derive_subtype_with_blam("8500-25", "בקשה להארכת מועד")
|
||||||
|
'extension_request_betterment_levy'
|
||||||
|
>>> derive_subtype_with_blam("1033-25", "ערר על החלטת ועדה")
|
||||||
|
'building_permit'
|
||||||
|
"""
|
||||||
|
base = derive_subtype(case_number, practice_area)
|
||||||
|
if not is_blam_subject(subject):
|
||||||
|
return base
|
||||||
|
# subject says it's בל"מ — return the matching extension_request_* variant.
|
||||||
|
# For domain practice_area (axis B), use the direct mapping.
|
||||||
|
if practice_area in DOMAIN_PRACTICE_AREAS:
|
||||||
|
return _DOMAIN_TO_BLAM_SUBTYPE.get(practice_area, base)
|
||||||
|
# For appeals_committee (axis A), derive from case_number digit.
|
||||||
|
if practice_area == "appeals_committee":
|
||||||
|
cn = case_number or ""
|
||||||
|
m = _CASE_NUM.search(cn) or _PLAIN_NUM.search(cn)
|
||||||
|
if m:
|
||||||
|
first_digit = m.group(1)[0]
|
||||||
|
blam = _APPEALS_COMMITTEE_DIGIT_TO_BLAM.get(first_digit)
|
||||||
|
if blam:
|
||||||
|
return blam
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def is_blam_subtype(appeal_subtype: str) -> bool:
|
||||||
|
"""True iff appeal_subtype is one of the extension_request_* variants.
|
||||||
|
|
||||||
|
Useful for UI badges and routing logic that need to detect בל"מ cases
|
||||||
|
regardless of which domain they belong to.
|
||||||
|
"""
|
||||||
|
return appeal_subtype in BLAM_SUBTYPES
|
||||||
|
|
||||||
|
|
||||||
|
def derive_proceeding_type(*, appeal_subtype: str = "", subject: str = "") -> str:
|
||||||
|
"""Return 'בל"מ' / 'ערר' for appeals-committee decisions/cases.
|
||||||
|
|
||||||
|
Priority: explicit subtype prefix → subject regex → default 'ערר'.
|
||||||
|
"""
|
||||||
|
if appeal_subtype and appeal_subtype.startswith("extension_request_"):
|
||||||
|
return 'בל"מ'
|
||||||
|
if subject and is_blam_subject(subject):
|
||||||
|
return 'בל"מ'
|
||||||
|
return "ערר"
|
||||||
|
|
||||||
|
|
||||||
|
def derive_domain_practice_area(case_number: str) -> str:
|
||||||
|
"""Map a case_number prefix to a domain practice_area (axis B).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``"rishuy_uvniya"`` for 1xxx, ``"betterment_levy"`` for 8xxx,
|
||||||
|
``"compensation_197"`` for 9xxx, or ``""`` when the prefix is
|
||||||
|
unrecognized (caller decides the fallback).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> derive_domain_practice_area("8126/25")
|
||||||
|
'betterment_levy'
|
||||||
|
>>> derive_domain_practice_area("1170")
|
||||||
|
'rishuy_uvniya'
|
||||||
|
>>> derive_domain_practice_area("ARAR-24-01-9007")
|
||||||
|
'compensation_197'
|
||||||
|
>>> derive_domain_practice_area("foo")
|
||||||
|
''
|
||||||
|
"""
|
||||||
|
cn = case_number or ""
|
||||||
|
m = _CASE_NUM.search(cn) or _PLAIN_NUM.search(cn)
|
||||||
|
if not m:
|
||||||
|
return ""
|
||||||
|
first_digit = m.group(1)[0]
|
||||||
|
subtype = _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit)
|
||||||
|
if not subtype:
|
||||||
|
return ""
|
||||||
|
return _SUBTYPE_TO_DOMAIN.get(subtype, "")
|
||||||
|
|
||||||
|
|
||||||
# ── Validation ─────────────────────────────────────────────────────
|
# ── Validation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -99,6 +326,20 @@ def validate(practice_area: str, appeal_subtype: str | None) -> None:
|
|||||||
|
|
||||||
def is_override(case_number: str, practice_area: str, appeal_subtype: str) -> bool:
|
def is_override(case_number: str, practice_area: str, appeal_subtype: str) -> bool:
|
||||||
"""True iff the user-supplied subtype disagrees with what derive_subtype
|
"""True iff the user-supplied subtype disagrees with what derive_subtype
|
||||||
would have produced (and the derived value is not 'unknown')."""
|
would have produced (and the derived value is not 'unknown').
|
||||||
|
|
||||||
|
Note: בל"מ variants (extension_request_*) are NOT considered overrides
|
||||||
|
of their parent domain — extension_request_building_permit on a 1xxx
|
||||||
|
case is consistent with the case-number convention.
|
||||||
|
"""
|
||||||
derived = derive_subtype(case_number, practice_area)
|
derived = derive_subtype(case_number, practice_area)
|
||||||
return derived != "unknown" and derived != appeal_subtype
|
if derived == "unknown":
|
||||||
|
return False
|
||||||
|
if derived == appeal_subtype:
|
||||||
|
return False
|
||||||
|
# בל"מ variants of the same domain are not overrides.
|
||||||
|
if appeal_subtype in BLAM_SUBTYPES:
|
||||||
|
# extension_request_building_permit ↔ building_permit (1xxx) — same domain
|
||||||
|
if _SUBTYPE_TO_DOMAIN.get(appeal_subtype) == _SUBTYPE_TO_DOMAIN.get(derived):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|||||||
@@ -116,6 +116,18 @@ async def ingest_precedent(
|
|||||||
raise FileNotFoundError(f"file not found: {src}")
|
raise FileNotFoundError(f"file not found: {src}")
|
||||||
if not citation.strip():
|
if not citation.strip():
|
||||||
raise ValueError("citation is required")
|
raise ValueError("citation is required")
|
||||||
|
# Citation guard at service level (catches both MCP and HTTP API paths).
|
||||||
|
# Appeals-committee decisions must go through ingest_internal_decision
|
||||||
|
# which records chair_name+district. The MCP wrapper has the same guard
|
||||||
|
# for an earlier, friendlier error message — but this is the source of
|
||||||
|
# truth. See TaskMaster #30(ב) and DB constraint case_law_external_arar_check.
|
||||||
|
_norm = citation.strip()
|
||||||
|
if _norm.startswith(("ערר ", "ערר(", "בל\"מ ", "בל\"מ(", "ARAR ")):
|
||||||
|
raise ValueError(
|
||||||
|
"ציטוט שמתחיל ב-'ערר' או 'בל\"מ' הוא החלטת ועדת ערר. "
|
||||||
|
"השתמש ב-internal_decision_upload (דורש chair_name + district), "
|
||||||
|
"לא ב-precedent_library_upload."
|
||||||
|
)
|
||||||
if practice_area not in _VALID_PRACTICE_AREAS:
|
if practice_area not in _VALID_PRACTICE_AREAS:
|
||||||
raise ValueError(f"invalid practice_area: {practice_area!r}")
|
raise ValueError(f"invalid practice_area: {practice_area!r}")
|
||||||
if source_type not in _VALID_SOURCE_TYPES:
|
if source_type not in _VALID_SOURCE_TYPES:
|
||||||
@@ -160,34 +172,100 @@ async def ingest_precedent(
|
|||||||
case_law_id = UUID(str(record["id"]))
|
case_law_id = UUID(str(record["id"]))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await progress("chunking", 40, f"מחלק את הטקסט ל-chunks ({page_count} עמ')")
|
# Parent-doc retrieval (TaskMaster #48): when enabled, emit
|
||||||
chunks = chunker.chunk_document(text, page_offsets=page_offsets)
|
# two tiers (parents + children). Only children are embedded
|
||||||
if not chunks:
|
# and indexed; parents carry retrieval context. When disabled,
|
||||||
await db.set_case_law_extraction_status(case_law_id, "completed")
|
# fall back to legacy single-tier chunking — identical
|
||||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
# behaviour to pre-V17.
|
||||||
await progress("completed", 100, "אין טקסט לעיבוד")
|
if config.PARENT_DOC_RETRIEVAL_ENABLED:
|
||||||
return {
|
await progress(
|
||||||
"status": "completed",
|
"chunking", 40,
|
||||||
"case_law_id": str(case_law_id),
|
f"מחלק את הטקסט ל-chunks היררכיים ({page_count} עמ')",
|
||||||
"chunks": 0,
|
)
|
||||||
"halachot": 0,
|
h_chunks = chunker.chunk_document_hierarchical(
|
||||||
}
|
text, page_offsets=page_offsets,
|
||||||
|
)
|
||||||
|
if not h_chunks:
|
||||||
|
await db.set_case_law_extraction_status(case_law_id, "completed")
|
||||||
|
await db.set_case_law_halacha_status(case_law_id, "completed")
|
||||||
|
await progress("completed", 100, "אין טקסט לעיבוד")
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"case_law_id": str(case_law_id),
|
||||||
|
"chunks": 0,
|
||||||
|
"halachot": 0,
|
||||||
|
}
|
||||||
|
|
||||||
await progress("embedding", 55, f"מייצר embeddings ל-{len(chunks)} chunks")
|
children = [c for c in h_chunks if c.role == "child"]
|
||||||
chunk_texts = [c.content for c in chunks]
|
parents = [c for c in h_chunks if c.role == "parent"]
|
||||||
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
|
await progress(
|
||||||
|
"embedding", 55,
|
||||||
|
f"מייצר embeddings ל-{len(children)} children "
|
||||||
|
f"({len(parents)} parents)",
|
||||||
|
)
|
||||||
|
child_texts = [c.content for c in children]
|
||||||
|
child_vectors = await embeddings.embed_texts(
|
||||||
|
child_texts, input_type="document",
|
||||||
|
)
|
||||||
|
# Build flat dict list for the two-pass writer.
|
||||||
|
chunk_dicts: list[dict] = []
|
||||||
|
for p in parents:
|
||||||
|
chunk_dicts.append({
|
||||||
|
"role": "parent",
|
||||||
|
"local_id": p.local_id,
|
||||||
|
"parent_local_id": None,
|
||||||
|
"chunk_index": p.chunk_index,
|
||||||
|
"content": p.content,
|
||||||
|
"section_type": p.section_type,
|
||||||
|
"page_number": p.page_number,
|
||||||
|
"embedding": None,
|
||||||
|
})
|
||||||
|
for c, v in zip(children, child_vectors):
|
||||||
|
chunk_dicts.append({
|
||||||
|
"role": "child",
|
||||||
|
"local_id": c.local_id,
|
||||||
|
"parent_local_id": c.parent_local_id,
|
||||||
|
"chunk_index": c.chunk_index,
|
||||||
|
"content": c.content,
|
||||||
|
"section_type": c.section_type,
|
||||||
|
"page_number": c.page_number,
|
||||||
|
"embedding": v,
|
||||||
|
})
|
||||||
|
counts = await db.store_precedent_chunks_hierarchical(
|
||||||
|
case_law_id, chunk_dicts,
|
||||||
|
)
|
||||||
|
stored_chunks = counts["children"]
|
||||||
|
else:
|
||||||
|
await progress(
|
||||||
|
"chunking", 40, f"מחלק את הטקסט ל-chunks ({page_count} עמ')",
|
||||||
|
)
|
||||||
|
chunks = chunker.chunk_document(text, page_offsets=page_offsets)
|
||||||
|
if not chunks:
|
||||||
|
await db.set_case_law_extraction_status(case_law_id, "completed")
|
||||||
|
await db.set_case_law_halacha_status(case_law_id, "completed")
|
||||||
|
await progress("completed", 100, "אין טקסט לעיבוד")
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"case_law_id": str(case_law_id),
|
||||||
|
"chunks": 0,
|
||||||
|
"halachot": 0,
|
||||||
|
}
|
||||||
|
|
||||||
chunk_dicts = [
|
await progress("embedding", 55, f"מייצר embeddings ל-{len(chunks)} chunks")
|
||||||
{
|
chunk_texts = [c.content for c in chunks]
|
||||||
"chunk_index": c.chunk_index,
|
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
|
||||||
"content": c.content,
|
|
||||||
"section_type": c.section_type,
|
chunk_dicts = [
|
||||||
"page_number": c.page_number,
|
{
|
||||||
"embedding": v,
|
"chunk_index": c.chunk_index,
|
||||||
}
|
"content": c.content,
|
||||||
for c, v in zip(chunks, chunk_vectors)
|
"section_type": c.section_type,
|
||||||
]
|
"page_number": c.page_number,
|
||||||
stored_chunks = await db.store_precedent_chunks(case_law_id, chunk_dicts)
|
"embedding": v,
|
||||||
|
}
|
||||||
|
for c, v in zip(chunks, chunk_vectors)
|
||||||
|
]
|
||||||
|
stored_chunks = await db.store_precedent_chunks(case_law_id, chunk_dicts)
|
||||||
|
|
||||||
# Multimodal page-image embeddings (V9). Gated by feature flag.
|
# Multimodal page-image embeddings (V9). Gated by feature flag.
|
||||||
# Non-fatal: text path already succeeded. Only PDFs.
|
# Non-fatal: text path already succeeded. Only PDFs.
|
||||||
@@ -438,13 +516,14 @@ async def delete_precedent(case_law_id: UUID | str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def get_precedent(case_law_id: UUID | str) -> dict | None:
|
async def get_precedent(case_law_id: UUID | str) -> dict | None:
|
||||||
"""Get a precedent with its halachot attached."""
|
"""Get a precedent with its halachot and related cases attached."""
|
||||||
if isinstance(case_law_id, str):
|
if isinstance(case_law_id, str):
|
||||||
case_law_id = UUID(case_law_id)
|
case_law_id = UUID(case_law_id)
|
||||||
record = await db.get_case_law(case_law_id)
|
record = await db.get_case_law(case_law_id)
|
||||||
if not record:
|
if not record:
|
||||||
return None
|
return None
|
||||||
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
|
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
|
||||||
|
record["related_cases"] = await db.get_case_law_relations(case_law_id)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
@@ -454,6 +533,7 @@ async def list_precedents(
|
|||||||
precedent_level: str = "",
|
precedent_level: str = "",
|
||||||
source_type: str = "",
|
source_type: str = "",
|
||||||
search: str = "",
|
search: str = "",
|
||||||
|
source_kind: str = "external_upload",
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
@@ -463,6 +543,7 @@ async def list_precedents(
|
|||||||
precedent_level=precedent_level,
|
precedent_level=precedent_level,
|
||||||
source_type=source_type,
|
source_type=source_type,
|
||||||
search=search,
|
search=search,
|
||||||
|
source_kind=source_kind,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
Runs after chunking. Reads the precedent's full_text and asks Claude to
|
Runs after chunking. Reads the precedent's full_text and asks Claude to
|
||||||
fill in the metadata fields that an upload form usually leaves empty:
|
fill in the metadata fields that an upload form usually leaves empty:
|
||||||
short case_name, summary, headnote, key_quote, subject_tags,
|
short case_name, summary, headnote, key_quote, subject_tags,
|
||||||
appeal_subtype, decision_date, precedent_level, court.
|
appeal_subtype, decision_date, precedent_level, court — plus
|
||||||
|
chair_name + district for internal_committee rows (which the upload
|
||||||
|
path stamps with PLACEHOLDER_PENDING_EXTRACTION when missing).
|
||||||
|
|
||||||
Caller policy: only empty user-supplied fields are filled. Anything the
|
Caller policy: only empty user-supplied fields are filled. Anything the
|
||||||
chair already typed in the upload form is preserved. This is enforced
|
chair already typed in the upload form is preserved. This is enforced
|
||||||
@@ -22,6 +24,12 @@ from legal_mcp.services import claude_session, db
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Sentinel inserted by the upload endpoint when a committee row is created
|
||||||
|
# without chair_name/district (the DB CHECK forces non-empty). Treated as
|
||||||
|
# empty by ``apply_to_record`` so LLM-extracted values overwrite it.
|
||||||
|
PLACEHOLDER_PENDING_EXTRACTION = "(טרם חולץ)"
|
||||||
|
|
||||||
|
|
||||||
# The prompt is short — we only need the first 12K chars of the ruling
|
# The prompt is short — we only need the first 12K chars of the ruling
|
||||||
# (header + opening of discussion is enough for naming + summary). For
|
# (header + opening of discussion is enough for naming + summary). For
|
||||||
# subject tags we sample the discussion section too.
|
# subject tags we sample the discussion section too.
|
||||||
@@ -50,8 +58,12 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
|
|||||||
"decision_date_iso": "YYYY-MM-DD — תאריך מתן ההחלטה כפי שמופיע בטקסט (בכותרת או בחתימה הסופית). אם לא ניתן לזהות במדויק — מחרוזת ריקה.",
|
"decision_date_iso": "YYYY-MM-DD — תאריך מתן ההחלטה כפי שמופיע בטקסט (בכותרת או בחתימה הסופית). אם לא ניתן לזהות במדויק — מחרוזת ריקה.",
|
||||||
"precedent_level": "אחד מ-4: 'עליון' / 'מנהלי' / 'ועדת_ערר_ארצית' / 'ועדת_ערר_מחוזית'. בחר לפי הערכאה שמסומנת בכותרת הפסק. אם לא ברור — מחרוזת ריקה.",
|
"precedent_level": "אחד מ-4: 'עליון' / 'מנהלי' / 'ועדת_ערר_ארצית' / 'ועדת_ערר_מחוזית'. בחר לפי הערכאה שמסומנת בכותרת הפסק. אם לא ברור — מחרוזת ריקה.",
|
||||||
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
|
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
|
||||||
|
"proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY' → 'ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר' → 'בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.",
|
||||||
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
|
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
|
||||||
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות."
|
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.",
|
||||||
|
"chair_name": "שם יו\\\"ר ההרכב — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. חפש בכותרת/חתימה: 'עו\\\"ד דפנה תמיר, יו\\\"ר ועדת הערר', 'בפני: עו\\\"ד פלוני אלמוני (יו\\\"ר)'. השאר שם פרטי+משפחה בלי תוארים ('עו\\\"ד', 'אדריכל'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
||||||
|
"district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים' → 'ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו' → 'תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
||||||
|
"citation_formatted": "המראה מקום המלא לפי **כללי הציטוט האחיד**, בפורמט Markdown — שמות הצדדים בלבד מוקפים בכפול-כוכבית (`**…**`), הכל השאר רגיל. ראה כללים מפורטים בסעיף 12 למטה."
|
||||||
}
|
}
|
||||||
|
|
||||||
## כללי איכות
|
## כללי איכות
|
||||||
@@ -65,6 +77,24 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
|
|||||||
8. **precedent_level** — קבע לפי הערכאה: בית המשפט העליון = "עליון"; בית משפט מחוזי בשבתו כבית משפט לעניינים מנהליים = "מנהלי"; ועדת ערר ארצית = "ועדת_ערר_ארצית"; ועדת ערר מחוזית (כמו ועדות תכנון ובניה ירושלים/מחוז המרכז וכד') = "ועדת_ערר_מחוזית". השתמש ב-underscore כפי שמופיע — לא ברווח.
|
8. **precedent_level** — קבע לפי הערכאה: בית המשפט העליון = "עליון"; בית משפט מחוזי בשבתו כבית משפט לעניינים מנהליים = "מנהלי"; ועדת ערר ארצית = "ועדת_ערר_ארצית"; ועדת ערר מחוזית (כמו ועדות תכנון ובניה ירושלים/מחוז המרכז וכד') = "ועדת_ערר_מחוזית". השתמש ב-underscore כפי שמופיע — לא ברווח.
|
||||||
9. **source_type** — שני ערכים בלבד: "court_ruling" כשהמסמך הוא פסק דין/החלטה של בית משפט (עליון/בג"ץ/מנהלי/מחוזי); "appeals_committee" כשהמסמך הוא החלטה של ועדת ערר (ארצית או מחוזית). זה משלים את `precedent_level` — שני השדות צריכים להיות תואמים.
|
9. **source_type** — שני ערכים בלבד: "court_ruling" כשהמסמך הוא פסק דין/החלטה של בית משפט (עליון/בג"ץ/מנהלי/מחוזי); "appeals_committee" כשהמסמך הוא החלטה של ועדת ערר (ארצית או מחוזית). זה משלים את `precedent_level` — שני השדות צריכים להיות תואמים.
|
||||||
10. **court** — מהכותרת הראשית של הפסק. ניסוח מלא (לא קיצור). מחרוזת ריקה אם לא ניתן לזהות.
|
10. **court** — מהכותרת הראשית של הפסק. ניסוח מלא (לא קיצור). מחרוזת ריקה אם לא ניתן לזהות.
|
||||||
|
11. **proceeding_type** — חובה לזהות עבור החלטות ועדת ערר; ריק עבור פסיקת בית משפט. הסימן הברור: בכותרת הראשונה של המסמך כתוב "ערר (ועדות ערר ...) NNNN/YY" → 'ערר'; "בל\"מ NNNN/YY" או הנושא "בקשה להארכת מועד להגשת ערר" → 'בל\"מ'. שני הסוגים יכולים לחלוק אותו מספר תיק — לכן חשוב להבחין מפורשות.
|
||||||
|
12. **chair_name / district** — חובה למלא רק עבור החלטות ועדת ערר (source_type='appeals_committee'). chair_name נמצא בכותרת ("בפני: עו\"ד פלוני אלמוני, יו\"ר") או בחתימה. district = מחוז הוועדה, מתוך רשימה סגורה. עבור פסקי בית משפט — שני השדות ריקים.
|
||||||
|
13. **citation_formatted — כללי הציטוט האחיד הישראלי**. הרכב את המראה מקום במחרוזת אחת בפורמט Markdown, **כשרק שמות הצדדים מודגשים** (מוקפים ב-`**…**`). כל השאר — קיצור הערכאה, סוגריים של הרכב/מחוז, מספר תיק, מאגר/תאריך — **רגיל ללא הדגשה**.
|
||||||
|
|
||||||
|
תבניות לסוגי פסיקה:
|
||||||
|
* **בית משפט עליון — לא פורסם:** `ע"א 1234/56 **פלוני נ' אלמוני** (נבו 1.2.3456)`
|
||||||
|
* **בית משפט עליון — פורסם:** `ע"א 1234/56 **פלוני נ' אלמוני**, פ"ד יב(3) 456 (1990)`
|
||||||
|
* **בית משפט מנהלי:** `עת"מ (י-ם) 1234/56 **פלוני נ' הוועדה** (נבו 1.2.3456)` — "(י-ם)" / "(ת"א)" / וכד' = קיצור המחוז
|
||||||
|
* **ועדת ערר תכנון ובנייה (מחוזית):** `ערר (ועדות ערר - תכנון ובנייה ת"א-יפו) 81002-01-21 **אברהם אגסי נ' הועדה המקומית לתכנון ובנייה תל אביב** (נבו 25.9.2025)`
|
||||||
|
* **בל"מ (בקשה להארכת מועד):** `בל"מ (ועדות ערר - ירושלים) 1028/20 **חלוואני ריאד נ' רשות הרישוי - הוועדה המקומית ירושלים** (נבו 7.1.2021)`
|
||||||
|
* **ועדת ערר ארצית:** `ערר ארצי 8047/23 **פלוני נ' אלמוני** (נבו 1.2.3456)`
|
||||||
|
|
||||||
|
כללים:
|
||||||
|
- **הצדדים מודגשים בלבד** — כל השאר רגיל. אל תדגיש את "ע"א" / "ערר" / מספר התיק / "(נבו ...)" / "פ"ד".
|
||||||
|
- הצדדים = מי שמופיע **בין מספר התיק לבין הסוגריים הסופיים** (תאריך/מאגר), כלומר "[עורר/מבקש] נ' [משיב]".
|
||||||
|
- תאריך בסוגריים סופיים בפורמט עברי "(נבו 25.9.2025)" — יום.חודש.שנה ללא אפסים מובילים.
|
||||||
|
- אם המאגר הוא נבו והפסיקה לא פורסמה ב-פ"ד — השתמש ב-"(נבו DATE)". אם פורסמה ב-פ"ד — הוסף את ההפניה הפורמלית אחרי הצדדים: `..., פ"ד יב(3) 456 (1990)`.
|
||||||
|
- אם לא ניתן לזהות איזשהו רכיב במדויק — השאר את **כל** השדה ריק. אל תניח / תמציא.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -160,10 +190,30 @@ async def extract_metadata(case_law_id: UUID | str) -> dict:
|
|||||||
st = result["source_type"].strip()
|
st = result["source_type"].strip()
|
||||||
if st in {"court_ruling", "appeals_committee"}:
|
if st in {"court_ruling", "appeals_committee"}:
|
||||||
out["source_type"] = st
|
out["source_type"] = st
|
||||||
|
if isinstance(result.get("proceeding_type"), str):
|
||||||
|
pt = result["proceeding_type"].strip()
|
||||||
|
if pt in {"ערר", 'בל"מ', ""}:
|
||||||
|
out["proceeding_type"] = pt
|
||||||
if isinstance(result.get("court"), str):
|
if isinstance(result.get("court"), str):
|
||||||
out["court"] = result["court"].strip()
|
out["court"] = result["court"].strip()
|
||||||
if isinstance(result.get("case_number_clean"), str):
|
if isinstance(result.get("case_number_clean"), str):
|
||||||
out["case_number_clean"] = result["case_number_clean"].strip()
|
out["case_number_clean"] = result["case_number_clean"].strip()
|
||||||
|
if isinstance(result.get("chair_name"), str):
|
||||||
|
out["chair_name"] = result["chair_name"].strip()
|
||||||
|
if isinstance(result.get("district"), str):
|
||||||
|
d = result["district"].strip()
|
||||||
|
# Closed enum for districts — anything else is dropped to avoid
|
||||||
|
# silently storing free-text in what callers treat as a filter facet.
|
||||||
|
if d in {"ירושלים", "תל אביב", "מרכז", "חיפה", "צפון", "דרום", "ארצית"}:
|
||||||
|
out["district"] = d
|
||||||
|
if isinstance(result.get("citation_formatted"), str):
|
||||||
|
cf = result["citation_formatted"].strip()
|
||||||
|
# Sanity check: a valid citation should contain at least one bold
|
||||||
|
# marker pair (the parties) AND a closing paren (the reporter/date).
|
||||||
|
# If the LLM returned a half-formed string, drop it rather than
|
||||||
|
# store junk that the UI then has to special-case.
|
||||||
|
if cf.count("**") >= 2 and ")" in cf:
|
||||||
|
out["citation_formatted"] = cf
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -267,11 +317,41 @@ async def apply_to_record(
|
|||||||
if c:
|
if c:
|
||||||
fields_to_update["court"] = c
|
fields_to_update["court"] = c
|
||||||
|
|
||||||
|
# proceeding_type — only fill for internal_committee rows (the field is
|
||||||
|
# meaningless for court rulings, which we keep as '').
|
||||||
|
if not (record.get("proceeding_type") or "").strip():
|
||||||
|
pt = (suggested.get("proceeding_type") or "").strip()
|
||||||
|
if pt and (record.get("source_kind") == "internal_committee"):
|
||||||
|
fields_to_update["proceeding_type"] = pt
|
||||||
|
|
||||||
if overwrite_case_number:
|
if overwrite_case_number:
|
||||||
cn = (suggested.get("case_number_clean") or "").strip()
|
cn = (suggested.get("case_number_clean") or "").strip()
|
||||||
if cn:
|
if cn:
|
||||||
fields_to_update["case_number"] = cn
|
fields_to_update["case_number"] = cn
|
||||||
|
|
||||||
|
# citation_formatted — full citation per Israeli citation rules. Only
|
||||||
|
# fill if empty; user edits in /precedents/[id] are preserved.
|
||||||
|
if not (record.get("citation_formatted") or "").strip():
|
||||||
|
s = (suggested.get("citation_formatted") or "").strip()
|
||||||
|
if s:
|
||||||
|
fields_to_update["citation_formatted"] = s
|
||||||
|
|
||||||
|
# chair_name / district — only for internal_committee rows. The DB CHECK
|
||||||
|
# forces these to be non-empty, so the upload endpoint stamps the row
|
||||||
|
# with "(טרם חולץ)" as a placeholder. Treat that placeholder as empty
|
||||||
|
# so the LLM-extracted value can overwrite it.
|
||||||
|
if record.get("source_kind") == "internal_committee":
|
||||||
|
cur_chair = (record.get("chair_name") or "").strip()
|
||||||
|
if cur_chair in ("", PLACEHOLDER_PENDING_EXTRACTION):
|
||||||
|
s = (suggested.get("chair_name") or "").strip()
|
||||||
|
if s:
|
||||||
|
fields_to_update["chair_name"] = s
|
||||||
|
cur_district = (record.get("district") or "").strip()
|
||||||
|
if cur_district in ("", PLACEHOLDER_PENDING_EXTRACTION):
|
||||||
|
s = (suggested.get("district") or "").strip()
|
||||||
|
if s:
|
||||||
|
fields_to_update["district"] = s
|
||||||
|
|
||||||
if not fields_to_update:
|
if not fields_to_update:
|
||||||
return {"updated": False, "fields": []}
|
return {"updated": False, "fields": []}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,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
|
||||||
|
# Extended placeholders: [ימולא ע"י יו"ר הוועדה — extra descriptive text]
|
||||||
|
if re.match(r'^\[ימולא\b', stripped):
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
195
mcp-server/src/legal_mcp/services/style_metadata_extractor.py
Normal file
195
mcp-server/src/legal_mcp/services/style_metadata_extractor.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""Auto-extract per-decision metadata for a style_corpus row.
|
||||||
|
|
||||||
|
Populates the fields that the upload flow leaves empty — summary, outcome,
|
||||||
|
key_principles, appeal_subtype, practice_area — by asking Claude (via the
|
||||||
|
local CLI session) to read the proofread full_text and return a structured
|
||||||
|
JSON blob.
|
||||||
|
|
||||||
|
Caller policy (``apply_to_corpus``): by default we **only fill empty
|
||||||
|
columns**, so chair-edited values are preserved across re-runs. The chair
|
||||||
|
can force a refresh by passing ``overwrite=True``.
|
||||||
|
|
||||||
|
Why this is a separate module from ``precedent_metadata_extractor``:
|
||||||
|
that one fills the *external* case_law corpus (court rulings, third-party
|
||||||
|
committee decisions). This one fills the *style* corpus — Daphna's own
|
||||||
|
decisions used to teach the writer the in-house voice. The two corpora
|
||||||
|
have different schemas, different prompts, and different downstream
|
||||||
|
consumers, so coupling them would have been the wrong shortcut.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import claude_session, db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# A single decision typically runs 200K-650K chars. We sample the head
|
||||||
|
# (where outcome + parties + framing live) and the tail (where the
|
||||||
|
# operative ruling sits). Picking from both edges keeps the prompt under
|
||||||
|
# 60K chars — comfortable for any Claude tier.
|
||||||
|
_HEAD_CHARS = 25_000
|
||||||
|
_TAIL_CHARS = 15_000
|
||||||
|
|
||||||
|
|
||||||
|
def _build_text_window(full_text: str) -> str:
|
||||||
|
if len(full_text) <= _HEAD_CHARS + _TAIL_CHARS:
|
||||||
|
return full_text
|
||||||
|
head = full_text[:_HEAD_CHARS]
|
||||||
|
tail = full_text[-_TAIL_CHARS:]
|
||||||
|
return (
|
||||||
|
f"{head}\n\n"
|
||||||
|
f"[... חתך: {len(full_text) - _HEAD_CHARS - _TAIL_CHARS:,} תווים מהאמצע "
|
||||||
|
f"הושמטו — שמרנו על ההתחלה (טענות + רקע) ועל הסוף (הכרעה + הוצאות) ...]"
|
||||||
|
f"\n\n{tail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Static instructions — go via ``system`` so the SDK path can cache them
|
||||||
|
# across batch enrichment runs (24+ decisions in one pass).
|
||||||
|
METADATA_PROMPT = """אתה מסייע משפטי שמקטלג את הקורפוס הסגנוני של דפנה תמיר (יו"ר ועדת ערר).
|
||||||
|
|
||||||
|
תפקידך: לקרוא החלטה אחת ולחלץ מטא-דאטה ל-style_corpus — שדות שהמשתמש לא הזין בעת ההעלאה.
|
||||||
|
|
||||||
|
**אל תמציא**. אם המידע לא מופיע בטקסט, השאר מחרוזת ריקה או מערך ריק. אסור להסיק עובדות שלא כתובות.
|
||||||
|
|
||||||
|
## פלט נדרש
|
||||||
|
|
||||||
|
החזר JSON אחד (object אחד — לא array, לא markdown, לא הסברים):
|
||||||
|
|
||||||
|
{
|
||||||
|
"summary": "תקציר עניני ב-2-3 משפטים: מי העורר, מה דרש, מה הוכרע. סגנון יבש, ניטרלי, ללא שיפוט. דוגמה: 'ערר על דחיית בקשה להיתר לתוספת מרפסת בקומה ג׳. דפנה קיבלה את הערר חלקית — אישרה את המרפסת בהקטנה ל-12 מ״ר.'",
|
||||||
|
|
||||||
|
"outcome": "התוצאה התמציתית. אחד מאלה (או צירוף קצר): 'קבלה' / 'קבלה חלקית' / 'דחייה' / 'הסתלקות' / 'החזרה לוועדה המקומית'. אם זה לא ברור — מחרוזת ריקה.",
|
||||||
|
|
||||||
|
"key_principles": [
|
||||||
|
"עיקרון משפטי 1 שעולה מההחלטה — משפט אחד, ניסוח מופשט. למשל 'שיקול דעת מוגבל לחריגות בנייה קטנות'.",
|
||||||
|
"עיקרון 2",
|
||||||
|
"..."
|
||||||
|
],
|
||||||
|
|
||||||
|
"appeal_subtype": "תת-סוג ערר. ערכים מותרים: 'building_permit' (היתר בנייה / רישוי), 'betterment_levy' (היטל השבחה), 'compensation_197' (פיצויים ס׳ 197), 'use_change' (שימוש חורג), 'tama_38' (תמ\\"א 38), או מחרוזת ריקה אם לא ברור.",
|
||||||
|
|
||||||
|
"practice_area": "תחום משפט גנרי. ברירת מחדל: 'appeals_committee'. אם זה במובהק 'planning_law' — סמן.",
|
||||||
|
|
||||||
|
"parties_appellant": "שם העורר/ים המרכזיים בהחלטה (אחד או כמה, מופרדים בפסיק). אם זו החלטה מאוחדת — שם הצד המוביל. השאר ריק אם לא ניתן לזהות במדויק.",
|
||||||
|
|
||||||
|
"parties_respondent": "שם המשיב/ים. ברירת מחדל לעררי 1xxx ו-8xxx: 'הוועדה המקומית לתכנון ובניה ירושלים' או דומה. השאר ריק אם לא ברור."
|
||||||
|
}
|
||||||
|
|
||||||
|
## כללי איכות
|
||||||
|
|
||||||
|
1. **summary** — חייב להזכיר את התוצאה. בלי 'בית המשפט קבע ש...' (אנחנו לא בית משפט). בלי הערכת אישית.
|
||||||
|
2. **outcome** — קבלה / קבלה חלקית / דחייה / הסתלקות / החזרה לוועדה המקומית. אם דפנה הכריעה חלקית — 'קבלה חלקית'. אסור 'התקבל' או 'נדחה' בלשון פעולה — רק שם פעולה.
|
||||||
|
3. **key_principles** — 2-5 עקרונות מקסימום. כל אחד משפט אחד. לא ציטוטים מילוליים, אלא תמצות העיקרון.
|
||||||
|
4. **appeal_subtype** — תמיד פעולה אחת. אם החלטה מערבת כמה תת-סוגים — בחר את העיקרי.
|
||||||
|
5. **parties_appellant / parties_respondent** — שם בלבד, בלי 'נ׳' או 'נגד'.
|
||||||
|
|
||||||
|
החזר רק את ה-JSON. אל תכתוב שום דבר לפניו או אחריו.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_decision_metadata(corpus_id: UUID | str) -> dict:
|
||||||
|
"""Run Claude over the row's full_text and return suggested fields.
|
||||||
|
|
||||||
|
Does NOT touch the DB. The caller decides what to apply.
|
||||||
|
"""
|
||||||
|
if isinstance(corpus_id, str):
|
||||||
|
corpus_id = UUID(corpus_id)
|
||||||
|
row = await db.get_style_corpus_row(corpus_id)
|
||||||
|
if not row:
|
||||||
|
return {}
|
||||||
|
full_text = (row.get("full_text") or "").strip()
|
||||||
|
if not full_text:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
context = (
|
||||||
|
f"מספר החלטה: {row.get('decision_number') or '—'}\n"
|
||||||
|
f"תאריך: {row.get('decision_date') or '—'}\n"
|
||||||
|
f"תת-סוג נוכחי: {row.get('appeal_subtype') or '—'}\n"
|
||||||
|
f"נושאים מתויגים: {row.get('subject_categories') or '—'}"
|
||||||
|
)
|
||||||
|
window = _build_text_window(full_text)
|
||||||
|
user_msg = (
|
||||||
|
f"## הקלט\n{context}\n\n"
|
||||||
|
f"--- תחילת ההחלטה ---\n{window}\n--- סוף ההחלטה ---"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await claude_session.query_json(user_msg, system=METADATA_PROMPT)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("style_metadata_extractor: query failed: %s", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
logger.warning(
|
||||||
|
"style_metadata_extractor: expected JSON object, got %s",
|
||||||
|
type(result).__name__,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
out: dict = {}
|
||||||
|
if isinstance(result.get("summary"), str):
|
||||||
|
out["summary"] = result["summary"].strip()
|
||||||
|
if isinstance(result.get("outcome"), str):
|
||||||
|
out["outcome"] = result["outcome"].strip()
|
||||||
|
kp = result.get("key_principles") or []
|
||||||
|
if isinstance(kp, list):
|
||||||
|
out["key_principles"] = [str(p).strip() for p in kp if str(p).strip()]
|
||||||
|
if isinstance(result.get("appeal_subtype"), str):
|
||||||
|
st = result["appeal_subtype"].strip()
|
||||||
|
# Open enum — but log values outside the documented list so we can
|
||||||
|
# tighten the prompt later if needed.
|
||||||
|
known = {
|
||||||
|
"building_permit", "betterment_levy", "compensation_197",
|
||||||
|
"use_change", "tama_38", "",
|
||||||
|
}
|
||||||
|
if st not in known:
|
||||||
|
logger.info("style_metadata: unknown appeal_subtype=%r (kept)", st)
|
||||||
|
out["appeal_subtype"] = st
|
||||||
|
if isinstance(result.get("practice_area"), str):
|
||||||
|
out["practice_area"] = result["practice_area"].strip()
|
||||||
|
# Parties: not stored in the schema today, but worth surfacing in the
|
||||||
|
# extractor's return value so callers (and the UI's drawer) can display
|
||||||
|
# them. The list endpoint extracts via regex; LLM output is the
|
||||||
|
# higher-quality fallback when regex fails.
|
||||||
|
if isinstance(result.get("parties_appellant"), str):
|
||||||
|
out["parties_appellant"] = result["parties_appellant"].strip()
|
||||||
|
if isinstance(result.get("parties_respondent"), str):
|
||||||
|
out["parties_respondent"] = result["parties_respondent"].strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_and_apply(
|
||||||
|
corpus_id: UUID | str, *, overwrite: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Convenience: extract → apply → return summary of what changed.
|
||||||
|
|
||||||
|
Idempotent under default ``overwrite=False`` — re-runs only fill empty
|
||||||
|
fields. Use ``overwrite=True`` to refresh values the chair (or a prior
|
||||||
|
extraction) already wrote.
|
||||||
|
"""
|
||||||
|
if isinstance(corpus_id, str):
|
||||||
|
corpus_id = UUID(corpus_id)
|
||||||
|
suggested = await extract_decision_metadata(corpus_id)
|
||||||
|
if not suggested:
|
||||||
|
return {"extracted": False, "applied": False, "reason": "no suggestion"}
|
||||||
|
|
||||||
|
update_result = await db.update_style_corpus_metadata(
|
||||||
|
corpus_id,
|
||||||
|
summary=suggested.get("summary"),
|
||||||
|
outcome=suggested.get("outcome"),
|
||||||
|
key_principles=suggested.get("key_principles"),
|
||||||
|
appeal_subtype=suggested.get("appeal_subtype"),
|
||||||
|
practice_area=suggested.get("practice_area"),
|
||||||
|
overwrite=overwrite,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"extracted": True,
|
||||||
|
"applied": update_result.get("updated", False),
|
||||||
|
"fields_set": update_result.get("fields", []),
|
||||||
|
"suggested": suggested,
|
||||||
|
}
|
||||||
391
mcp-server/src/legal_mcp/services/telemetry.py
Normal file
391
mcp-server/src/legal_mcp/services/telemetry.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
"""RAG retrieval telemetry — closed-loop feedback (TaskMaster #50).
|
||||||
|
|
||||||
|
Logs every semantic search call so we can compute nDCG@10 over time,
|
||||||
|
spot retrieval drift, and feed the rerank training set.
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
- **All writes are fire-and-forget**: callers wrap us in ``try/except``
|
||||||
|
but we also swallow our own DB errors so a telemetry hiccup can never
|
||||||
|
fail a search. The log itself is also written via a detached task —
|
||||||
|
the search returns to the caller immediately and the row lands in
|
||||||
|
the DB on the side.
|
||||||
|
|
||||||
|
- **search_decisions / search_case_documents** return document chunks
|
||||||
|
from active cases, not ``case_law`` rows. Their telemetry rows leave
|
||||||
|
``top_case_law_ids`` empty; nDCG aggregation ignores them.
|
||||||
|
|
||||||
|
- **Auto-inferred feedback**: once a final decision is exported, we
|
||||||
|
scan its ``decision_paragraphs.citations`` JSONB, pull the
|
||||||
|
``case_law_id`` values, and mark them as ``relevance_score=3`` on
|
||||||
|
any search_log for the same case where the precedent appeared in
|
||||||
|
the top-K. This gives us a "cited == relevant" ground truth signal
|
||||||
|
without asking the chair to label results by hand.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any, Iterable
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_SOURCES = {"cited_in_decision", "chair_marked", "auto_inferred"}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_case_law_ids(results: Iterable[Any], limit: int = 10) -> list[UUID]:
|
||||||
|
"""Pull up to ``limit`` ``case_law_id`` UUIDs from search results.
|
||||||
|
|
||||||
|
Tolerates rows missing the field, non-UUID strings, and ``None``
|
||||||
|
values. Preserves order (= ranking).
|
||||||
|
"""
|
||||||
|
out: list[UUID] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for r in results:
|
||||||
|
if len(out) >= limit:
|
||||||
|
break
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
|
raw = r.get("case_law_id")
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
s = str(raw)
|
||||||
|
if s in seen:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out.append(UUID(s))
|
||||||
|
seen.add(s)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _insert_log(
|
||||||
|
*,
|
||||||
|
search_type: str,
|
||||||
|
query: str,
|
||||||
|
practice_area: str | None,
|
||||||
|
case_id: UUID | None,
|
||||||
|
user_agent: str | None,
|
||||||
|
result_count: int,
|
||||||
|
top_case_law_ids: list[UUID],
|
||||||
|
duration_ms: int | None,
|
||||||
|
) -> UUID | None:
|
||||||
|
try:
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO search_logs (
|
||||||
|
search_type, query, practice_area, case_id,
|
||||||
|
user_agent, result_count, top_case_law_ids,
|
||||||
|
duration_ms
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
search_type,
|
||||||
|
query[:2000], # guard against pathologically long queries
|
||||||
|
practice_area or None,
|
||||||
|
case_id,
|
||||||
|
user_agent or None,
|
||||||
|
int(result_count),
|
||||||
|
top_case_law_ids or None,
|
||||||
|
duration_ms,
|
||||||
|
)
|
||||||
|
return row["id"] if row else None
|
||||||
|
except Exception:
|
||||||
|
logger.exception("telemetry.log_search: insert failed (swallowed)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def log_search(
|
||||||
|
*,
|
||||||
|
search_type: str,
|
||||||
|
query: str,
|
||||||
|
results: Iterable[dict],
|
||||||
|
duration_ms: int | None = None,
|
||||||
|
practice_area: str | None = None,
|
||||||
|
case_id: UUID | str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
) -> UUID | None:
|
||||||
|
"""Record a search call. Never raises.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_type: one of 'precedent_library', 'internal_decisions',
|
||||||
|
'decisions', 'case_documents', 'similar_cases'.
|
||||||
|
query: the raw user query.
|
||||||
|
results: iterable of result dicts. We pull ``case_law_id`` from
|
||||||
|
the first 10 to populate ``top_case_law_ids``.
|
||||||
|
duration_ms: search latency in milliseconds.
|
||||||
|
practice_area: optional filter applied to the search.
|
||||||
|
case_id: optional case context (when the search was scoped to
|
||||||
|
or triggered from a specific case).
|
||||||
|
user_agent: 'writer' / 'researcher' / 'analyst' / 'manual'.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ``search_logs.id`` UUID if the row was written, else None.
|
||||||
|
Most callers ignore this; auto-inference uses it later via
|
||||||
|
``infer_relevance_from_citations``.
|
||||||
|
"""
|
||||||
|
# Snapshot results immediately — callers may keep iterating.
|
||||||
|
snapshot = list(results) if not isinstance(results, list) else results
|
||||||
|
top_ids = _coerce_case_law_ids(snapshot, limit=10)
|
||||||
|
|
||||||
|
case_uuid: UUID | None
|
||||||
|
if case_id is None:
|
||||||
|
case_uuid = None
|
||||||
|
elif isinstance(case_id, UUID):
|
||||||
|
case_uuid = case_id
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
case_uuid = UUID(str(case_id))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
case_uuid = None
|
||||||
|
|
||||||
|
return await _insert_log(
|
||||||
|
search_type=search_type,
|
||||||
|
query=query,
|
||||||
|
practice_area=practice_area,
|
||||||
|
case_id=case_uuid,
|
||||||
|
user_agent=user_agent,
|
||||||
|
result_count=len(snapshot),
|
||||||
|
top_case_law_ids=top_ids,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def log_search_bg(
|
||||||
|
*,
|
||||||
|
search_type: str,
|
||||||
|
query: str,
|
||||||
|
results: Iterable[dict],
|
||||||
|
duration_ms: int | None = None,
|
||||||
|
practice_area: str | None = None,
|
||||||
|
case_id: UUID | str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fire-and-forget variant. Schedules the insert as a detached task.
|
||||||
|
|
||||||
|
Use this from hot search paths so the caller returns to the user
|
||||||
|
immediately. Errors are logged inside ``log_search``.
|
||||||
|
"""
|
||||||
|
# Snapshot eagerly so the caller can mutate/iterate results freely.
|
||||||
|
snapshot = list(results) if not isinstance(results, list) else list(results)
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
# No running loop — caller is sync. Best-effort: skip telemetry.
|
||||||
|
return
|
||||||
|
loop.create_task(
|
||||||
|
log_search(
|
||||||
|
search_type=search_type,
|
||||||
|
query=query,
|
||||||
|
results=snapshot,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
practice_area=practice_area,
|
||||||
|
case_id=case_id,
|
||||||
|
user_agent=user_agent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# Auto-inferred relevance feedback
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_citations_from_jsonb(citations: Any) -> list[UUID]:
|
||||||
|
"""Parse ``decision_paragraphs.citations`` JSONB into UUID list.
|
||||||
|
|
||||||
|
Stored shape: ``[{"case_law_id": "...", "text": "...", "type": ...}]``.
|
||||||
|
Tolerates string form (asyncpg returns it as JSON string when the
|
||||||
|
column registration didn't auto-decode).
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
if not citations:
|
||||||
|
return []
|
||||||
|
if isinstance(citations, (bytes, bytearray)):
|
||||||
|
try:
|
||||||
|
citations = _json.loads(citations.decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return []
|
||||||
|
elif isinstance(citations, str):
|
||||||
|
try:
|
||||||
|
citations = _json.loads(citations)
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(citations, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
out: list[UUID] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in citations:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
raw = item.get("case_law_id")
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
s = str(raw)
|
||||||
|
if s in seen:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out.append(UUID(s))
|
||||||
|
seen.add(s)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _gather_cited_case_law_ids(case_id: UUID) -> list[UUID]:
|
||||||
|
"""Pull every distinct ``case_law_id`` cited anywhere in the case's
|
||||||
|
decision paragraphs.
|
||||||
|
"""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT dp.citations
|
||||||
|
FROM decision_paragraphs dp
|
||||||
|
JOIN decision_blocks db ON db.id = dp.block_id
|
||||||
|
JOIN decisions d ON d.id = db.decision_id
|
||||||
|
WHERE d.case_id = $1
|
||||||
|
AND dp.citations IS NOT NULL
|
||||||
|
AND jsonb_array_length(dp.citations) > 0
|
||||||
|
""",
|
||||||
|
case_id,
|
||||||
|
)
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[UUID] = []
|
||||||
|
for r in rows:
|
||||||
|
for clid in _extract_citations_from_jsonb(r["citations"]):
|
||||||
|
s = str(clid)
|
||||||
|
if s not in seen:
|
||||||
|
seen.add(s)
|
||||||
|
out.append(clid)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def infer_relevance_from_citations(
|
||||||
|
case_id: UUID | str,
|
||||||
|
*,
|
||||||
|
relevance_score: int = 3,
|
||||||
|
feedback_source: str = "cited_in_decision",
|
||||||
|
) -> dict:
|
||||||
|
"""For each precedent cited in the case's draft, write a relevance
|
||||||
|
row against every search_log where that precedent appeared in the
|
||||||
|
top-K for the same case.
|
||||||
|
|
||||||
|
Idempotent: the ``UNIQUE(search_log_id, case_law_id, feedback_source)``
|
||||||
|
constraint on ``search_relevance_feedback`` prevents duplicates.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``{"cited_precedents": int, "feedback_rows_inserted": int,
|
||||||
|
"searches_matched": int}``.
|
||||||
|
"""
|
||||||
|
if relevance_score not in (0, 1, 2, 3):
|
||||||
|
raise ValueError("relevance_score must be in 0..3")
|
||||||
|
if feedback_source not in _VALID_SOURCES:
|
||||||
|
raise ValueError(f"feedback_source must be one of {_VALID_SOURCES!r}")
|
||||||
|
|
||||||
|
case_uuid = case_id if isinstance(case_id, UUID) else UUID(str(case_id))
|
||||||
|
|
||||||
|
cited = await _gather_cited_case_law_ids(case_uuid)
|
||||||
|
if not cited:
|
||||||
|
return {
|
||||||
|
"cited_precedents": 0,
|
||||||
|
"feedback_rows_inserted": 0,
|
||||||
|
"searches_matched": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
pool = await db.get_pool()
|
||||||
|
inserted = 0
|
||||||
|
matched_searches: set[str] = set()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# For each cited precedent, find all logs where it appeared in
|
||||||
|
# top_case_law_ids for this case, and record its rank.
|
||||||
|
for clid in cited:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT id, top_case_law_ids
|
||||||
|
FROM search_logs
|
||||||
|
WHERE case_id = $1
|
||||||
|
AND top_case_law_ids IS NOT NULL
|
||||||
|
AND $2 = ANY(top_case_law_ids)
|
||||||
|
""",
|
||||||
|
case_uuid,
|
||||||
|
clid,
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
top_ids = row["top_case_law_ids"] or []
|
||||||
|
# asyncpg returns uuid[] as list[UUID]
|
||||||
|
try:
|
||||||
|
rank = top_ids.index(clid) + 1
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
result = await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO search_relevance_feedback (
|
||||||
|
search_log_id, case_law_id, rank,
|
||||||
|
relevance_score, feedback_source
|
||||||
|
) VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (search_log_id, case_law_id, feedback_source)
|
||||||
|
DO NOTHING
|
||||||
|
""",
|
||||||
|
row["id"],
|
||||||
|
clid,
|
||||||
|
rank,
|
||||||
|
relevance_score,
|
||||||
|
feedback_source,
|
||||||
|
)
|
||||||
|
# ``execute`` returns 'INSERT 0 1' or 'INSERT 0 0' for
|
||||||
|
# the no-op path; count only the writes.
|
||||||
|
if result.endswith(" 1"):
|
||||||
|
inserted += 1
|
||||||
|
matched_searches.add(str(row["id"]))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cited_precedents": len(cited),
|
||||||
|
"feedback_rows_inserted": inserted,
|
||||||
|
"searches_matched": len(matched_searches),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def infer_relevance_for_all_finalized_cases(limit: int | None = None) -> dict:
|
||||||
|
"""Bulk-run auto-inference for every case whose draft is final/exported.
|
||||||
|
|
||||||
|
Useful for back-filling after V18 schema lands and a few decisions
|
||||||
|
have already been written. Skips cases with no cited precedents
|
||||||
|
silently (they contribute zero to the totals).
|
||||||
|
"""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
sql = """
|
||||||
|
SELECT DISTINCT c.id
|
||||||
|
FROM cases c
|
||||||
|
JOIN decisions d ON d.case_id = c.id
|
||||||
|
WHERE c.status IN ('final', 'exported')
|
||||||
|
"""
|
||||||
|
if limit is not None and limit > 0:
|
||||||
|
sql += " LIMIT $1"
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(sql, *([limit] if limit else []))
|
||||||
|
|
||||||
|
totals = {
|
||||||
|
"cases_processed": 0,
|
||||||
|
"cited_precedents": 0,
|
||||||
|
"feedback_rows_inserted": 0,
|
||||||
|
"searches_matched": 0,
|
||||||
|
}
|
||||||
|
for r in rows:
|
||||||
|
stats = await infer_relevance_from_citations(r["id"])
|
||||||
|
totals["cases_processed"] += 1
|
||||||
|
totals["cited_precedents"] += stats["cited_precedents"]
|
||||||
|
totals["feedback_rows_inserted"] += stats["feedback_rows_inserted"]
|
||||||
|
totals["searches_matched"] += stats["searches_matched"]
|
||||||
|
return totals
|
||||||
@@ -128,8 +128,9 @@ async def case_create(
|
|||||||
hearing_date: str = "",
|
hearing_date: str = "",
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
expected_outcome: str = "",
|
expected_outcome: str = "",
|
||||||
practice_area: str = "appeals_committee",
|
practice_area: str = "",
|
||||||
appeal_subtype: str = "",
|
appeal_subtype: str = "",
|
||||||
|
proceeding_type: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""יצירת תיק ערר חדש.
|
"""יצירת תיק ערר חדש.
|
||||||
|
|
||||||
@@ -145,9 +146,12 @@ async def case_create(
|
|||||||
hearing_date: תאריך דיון (YYYY-MM-DD)
|
hearing_date: תאריך דיון (YYYY-MM-DD)
|
||||||
notes: הערות
|
notes: הערות
|
||||||
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
|
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
|
||||||
practice_area: תחום משפטי (appeals_committee / national_insurance / labor_law)
|
practice_area: תחום משפטי — domain value (rishuy_uvniya / betterment_levy /
|
||||||
|
compensation_197). ריק או "appeals_committee" = יוסק
|
||||||
|
אוטומטית ממספר התיק (1xxx→רישוי, 8xxx→השבחה, 9xxx→197)
|
||||||
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
|
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
|
||||||
ריק = יוסק אוטומטית ממספר התיק
|
ריק = יוסק אוטומטית ממספר התיק
|
||||||
|
proceeding_type: 'ערר' / 'בל"מ'. ריק = יוסק מ-appeal_subtype/subject.
|
||||||
"""
|
"""
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
|
|
||||||
@@ -155,12 +159,27 @@ async def case_create(
|
|||||||
if hearing_date:
|
if hearing_date:
|
||||||
h_date = date_type.fromisoformat(hearing_date)
|
h_date = date_type.fromisoformat(hearing_date)
|
||||||
|
|
||||||
# Resolve appeal_subtype: explicit override > auto-derive > 'unknown'
|
# Auto-derive practice_area when missing or set to the legacy multi-tenant
|
||||||
derived_subtype = pa.derive_subtype(case_number, practice_area)
|
# value. The DB's cases_practice_area_check rejects 'appeals_committee',
|
||||||
|
# so we MUST map it to a domain value before INSERT. If derivation fails
|
||||||
|
# (unknown case number format), fall back to '' which the constraint allows.
|
||||||
|
if not practice_area or practice_area == "appeals_committee":
|
||||||
|
practice_area = pa.derive_domain_practice_area(case_number)
|
||||||
|
|
||||||
|
# Resolve appeal_subtype: explicit override > auto-derive > 'unknown'.
|
||||||
|
# derive_subtype_with_blam inspects the subject to detect בל"מ
|
||||||
|
# (בקשה להארכת מועד) and returns an extension_request_* variant when
|
||||||
|
# appropriate. Falls back to regular derive_subtype when subject is empty.
|
||||||
|
derived_subtype = pa.derive_subtype_with_blam(case_number, subject, practice_area)
|
||||||
if not appeal_subtype:
|
if not appeal_subtype:
|
||||||
appeal_subtype = derived_subtype
|
appeal_subtype = derived_subtype
|
||||||
pa.validate(practice_area, appeal_subtype)
|
pa.validate(practice_area, appeal_subtype)
|
||||||
|
|
||||||
|
# proceeding_type: explicit override > derived from subtype/subject > 'ערר'
|
||||||
|
resolved_proc = proceeding_type.strip() or pa.derive_proceeding_type(
|
||||||
|
appeal_subtype=appeal_subtype, subject=subject,
|
||||||
|
)
|
||||||
|
|
||||||
case = await db.create_case(
|
case = await db.create_case(
|
||||||
case_number=case_number,
|
case_number=case_number,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -175,6 +194,7 @@ async def case_create(
|
|||||||
expected_outcome=expected_outcome,
|
expected_outcome=expected_outcome,
|
||||||
practice_area=practice_area,
|
practice_area=practice_area,
|
||||||
appeal_subtype=appeal_subtype,
|
appeal_subtype=appeal_subtype,
|
||||||
|
proceeding_type=resolved_proc,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If the user overrode the case-number convention (e.g. case 8500 marked
|
# If the user overrode the case-number convention (e.g. case 8500 marked
|
||||||
@@ -237,7 +257,10 @@ async def case_list(status: str = "", limit: int = 50) -> str:
|
|||||||
"""רשימת תיקי ערר עם אפשרות סינון לפי סטטוס.
|
"""רשימת תיקי ערר עם אפשרות סינון לפי סטטוס.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
status: סינון לפי סטטוס (new, in_progress, drafted, reviewed, final). ריק = הכל
|
status: סינון לפי סטטוס (new, processing, proofread, documents_ready, analyst_verified,
|
||||||
|
research_complete, outcome_set, direction_pending, direction_approved,
|
||||||
|
analysis_enriched, ready_for_writing, drafted, qa_passed, qa_failed,
|
||||||
|
exported, done). ריק = הכל
|
||||||
limit: מספר תוצאות מקסימלי
|
limit: מספר תוצאות מקסימלי
|
||||||
"""
|
"""
|
||||||
cases = await db.list_cases(status=status or None, limit=limit)
|
cases = await db.list_cases(status=status or None, limit=limit)
|
||||||
@@ -271,6 +294,11 @@ async def case_update(
|
|||||||
decision_date: str = "",
|
decision_date: str = "",
|
||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
expected_outcome: str = "",
|
expected_outcome: str = "",
|
||||||
|
appellants: list[str] | None = None,
|
||||||
|
respondents: list[str] | None = None,
|
||||||
|
property_address: str = "",
|
||||||
|
permit_number: str = "",
|
||||||
|
proceeding_type: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""עדכון פרטי תיק.
|
"""עדכון פרטי תיק.
|
||||||
|
|
||||||
@@ -284,6 +312,11 @@ async def case_update(
|
|||||||
decision_date: תאריך החלטה (YYYY-MM-DD)
|
decision_date: תאריך החלטה (YYYY-MM-DD)
|
||||||
tags: תגיות
|
tags: תגיות
|
||||||
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
|
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
|
||||||
|
appellants: רשימת עוררים חדשה
|
||||||
|
respondents: רשימת משיבים חדשה
|
||||||
|
property_address: כתובת נכס חדשה
|
||||||
|
permit_number: מספר תכנית/בקשה חדש
|
||||||
|
proceeding_type: 'ערר' / 'בל"מ' — ריק = ללא שינוי
|
||||||
"""
|
"""
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
|
|
||||||
@@ -315,13 +348,33 @@ async def case_update(
|
|||||||
if notes:
|
if notes:
|
||||||
fields["notes"] = notes
|
fields["notes"] = notes
|
||||||
if hearing_date:
|
if hearing_date:
|
||||||
fields["hearing_date"] = date_type.fromisoformat(hearing_date)
|
try:
|
||||||
|
fields["hearing_date"] = date_type.fromisoformat(hearing_date)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Invalid hearing_date format: {hearing_date!r}") from exc
|
||||||
if decision_date:
|
if decision_date:
|
||||||
fields["decision_date"] = date_type.fromisoformat(decision_date)
|
try:
|
||||||
|
fields["decision_date"] = date_type.fromisoformat(decision_date)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Invalid decision_date format: {decision_date!r}") from exc
|
||||||
if tags is not None:
|
if tags is not None:
|
||||||
fields["tags"] = tags
|
fields["tags"] = tags
|
||||||
if expected_outcome:
|
if expected_outcome:
|
||||||
fields["expected_outcome"] = expected_outcome
|
fields["expected_outcome"] = expected_outcome
|
||||||
|
if appellants is not None:
|
||||||
|
fields["appellants"] = appellants
|
||||||
|
if respondents is not None:
|
||||||
|
fields["respondents"] = respondents
|
||||||
|
if property_address:
|
||||||
|
fields["property_address"] = property_address
|
||||||
|
if permit_number:
|
||||||
|
fields["permit_number"] = permit_number
|
||||||
|
if proceeding_type:
|
||||||
|
if proceeding_type not in {"ערר", 'בל"מ'}:
|
||||||
|
raise ValueError(
|
||||||
|
f"proceeding_type לא תקין: {proceeding_type!r}. ערכים תקפים: ערר / בל\"מ"
|
||||||
|
)
|
||||||
|
fields["proceeding_type"] = proceeding_type
|
||||||
|
|
||||||
updated = await db.update_case(UUID(case["id"]), **fields)
|
updated = await db.update_case(UUID(case["id"]), **fields)
|
||||||
|
|
||||||
|
|||||||
135
mcp-server/src/legal_mcp/tools/citations.py
Normal file
135
mcp-server/src/legal_mcp/tools/citations.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""MCP tools for the internal-decisions citation graph (TaskMaster #34).
|
||||||
|
|
||||||
|
The citation graph captures pointers between Daphna's (and other internal
|
||||||
|
committee chairs') decisions: when one ruling cites another, ``precedent_
|
||||||
|
internal_citations`` records the edge — resolved against ``case_law`` when
|
||||||
|
the cited row exists, kept as a stub when it doesn't.
|
||||||
|
|
||||||
|
Three tools:
|
||||||
|
|
||||||
|
- ``extract_internal_citations`` — run regex extraction on one row (by id) or
|
||||||
|
on every internal-committee row filtered by chair (e.g. Daphna only).
|
||||||
|
Idempotent: re-running does not duplicate rows (ON CONFLICT DO NOTHING).
|
||||||
|
- ``list_internal_citations`` — outgoing edges from a source row. Optional
|
||||||
|
``linked_only`` filter for rows resolved to existing case_law UUIDs.
|
||||||
|
- ``list_incoming_citations`` — incoming edges to a target row ("which
|
||||||
|
Daphna decisions cite this ruling?").
|
||||||
|
|
||||||
|
These tools are *manual triggers*. The pipeline runs them after a new
|
||||||
|
internal-decision upload, but the chair / researcher can also re-run on
|
||||||
|
demand (for example after fixing OCR or after uploading a previously-
|
||||||
|
missing decision so that newer rows now link to it).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import citation_extractor
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(payload) -> str:
|
||||||
|
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _err(msg: str) -> str:
|
||||||
|
return json.dumps({"error": msg}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_internal_citations(
|
||||||
|
case_law_id: str = "",
|
||||||
|
chair_name: str = "",
|
||||||
|
limit: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""חילוץ ציטוטים פנימיים מהחלטות ועדת ערר ושמירה ב-precedent_internal_citations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_law_id: UUID של החלטה ספציפית. אם ריק וגם chair_name ריק — מריץ
|
||||||
|
על כל ההחלטות internal_committee. אם מסופק, חייב לעבור על שורה אחת
|
||||||
|
בלבד (משתמש בזה אחרי upload).
|
||||||
|
chair_name: שם יו"ר (כגון 'דפנה תמיר'). מסנן את האצווה. ריק = כל היו"רים.
|
||||||
|
limit: עליון על מספר רשומות שיעובדו (0 = ללא הגבלה). שימושי לבדיקה.
|
||||||
|
|
||||||
|
הכלי איידמפוטנטי — ON CONFLICT DO NOTHING על (source_case_law_id, cited_case_number).
|
||||||
|
מחזיר סטטיסטיקה: extracted, linked, new, skipped, failed.
|
||||||
|
"""
|
||||||
|
if case_law_id.strip() and chair_name.strip():
|
||||||
|
return _err("יש לספק case_law_id או chair_name, לא שניהם")
|
||||||
|
|
||||||
|
if case_law_id.strip():
|
||||||
|
try:
|
||||||
|
cl_uuid = UUID(case_law_id.strip())
|
||||||
|
except ValueError:
|
||||||
|
return _err("case_law_id לא תקין")
|
||||||
|
try:
|
||||||
|
stats = await citation_extractor.extract_and_store(cl_uuid)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok(stats)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats = await citation_extractor.extract_all_internal_committee(
|
||||||
|
chair_name_filter=chair_name.strip(),
|
||||||
|
limit=int(limit) if limit else 0,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok(stats)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_internal_citations(
|
||||||
|
case_law_id: str = "",
|
||||||
|
linked_only: bool = False,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> str:
|
||||||
|
"""רשימת ציטוטים יוצאים מהחלטה (מה ההחלטה הזו מצטטת).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_law_id: UUID של ה-case_law (חובה).
|
||||||
|
linked_only: True = רק ציטוטים שקושרו ל-case_law קיים בקורפוס.
|
||||||
|
limit: עליון על מספר תוצאות (default 50).
|
||||||
|
|
||||||
|
Returns: JSON עם list של ציטוטים, כולל target_case_number/name/chair
|
||||||
|
כשהם linked. אם linked_only=False, ציטוטים בלתי קושרים יחזרו עם
|
||||||
|
cited_case_law_id=null וניתן להעלות אותם דרך internal_decision_upload.
|
||||||
|
"""
|
||||||
|
if not case_law_id.strip():
|
||||||
|
return _err("case_law_id חובה")
|
||||||
|
try:
|
||||||
|
cl_uuid = UUID(case_law_id.strip())
|
||||||
|
except ValueError:
|
||||||
|
return _err("case_law_id לא תקין")
|
||||||
|
try:
|
||||||
|
rows = await citation_extractor.list_citations_for_case_law(
|
||||||
|
cl_uuid, linked_only=bool(linked_only),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok({"items": rows[: max(1, int(limit))], "count": len(rows)})
|
||||||
|
|
||||||
|
|
||||||
|
async def list_incoming_citations(
|
||||||
|
case_law_id: str = "",
|
||||||
|
limit: int = 50,
|
||||||
|
) -> str:
|
||||||
|
"""רשימת ציטוטים נכנסים אל החלטה (אילו החלטות מצטטות אותה).
|
||||||
|
|
||||||
|
שימוש: רוצים לדעת אילו החלטות של דפנה הסתמכו על פסק דין מסוים?
|
||||||
|
מעבירים את ה-case_law_id של פסק הדין הזה.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_law_id: UUID של ה-target case_law (חובה).
|
||||||
|
limit: עליון על מספר תוצאות.
|
||||||
|
"""
|
||||||
|
if not case_law_id.strip():
|
||||||
|
return _err("case_law_id חובה")
|
||||||
|
try:
|
||||||
|
cl_uuid = UUID(case_law_id.strip())
|
||||||
|
except ValueError:
|
||||||
|
return _err("case_law_id לא תקין")
|
||||||
|
try:
|
||||||
|
rows = await citation_extractor.list_citations_to_case_law(cl_uuid)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok({"items": rows[: max(1, int(limit))], "count": len(rows)})
|
||||||
116
mcp-server/src/legal_mcp/tools/internal_decisions.py
Normal file
116
mcp-server/src/legal_mcp/tools/internal_decisions.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""MCP tools for the Internal Decisions corpus.
|
||||||
|
|
||||||
|
Decisions of appeals committees (ועדות ערר) live in the same physical
|
||||||
|
``case_law`` table as court rulings but are distinguished by
|
||||||
|
``source_kind='internal_committee'`` and must carry ``chair_name`` +
|
||||||
|
``district``.
|
||||||
|
|
||||||
|
The existing ``precedent_library_upload`` MCP tool always stores
|
||||||
|
``source_kind='external_upload'`` and does not accept chair/district —
|
||||||
|
which is why **44+ existing appeals-committee decisions were tagged
|
||||||
|
wrong**. This wrapper is the authoritative ingestion path for committee
|
||||||
|
decisions and enforces the required metadata at the tool boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from legal_mcp.services import internal_decisions as int_svc
|
||||||
|
|
||||||
|
# Valid Hebrew district names (matches _COURT_TO_DISTRICT in service)
|
||||||
|
VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב", "צפון", "דרום", "חיפה", "ארצי"}
|
||||||
|
|
||||||
|
# proceeding_type — ערר vs בל"מ. The service can derive it from
|
||||||
|
# appeal_subtype/subject if left empty, so this stays optional at the API.
|
||||||
|
VALID_PROCEEDING_TYPES = {"ערר", 'בל"מ'}
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(payload) -> str:
|
||||||
|
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _err(msg: str) -> str:
|
||||||
|
return json.dumps({"error": msg}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def internal_decision_upload(
|
||||||
|
file_path: str,
|
||||||
|
case_number: str,
|
||||||
|
chair_name: str,
|
||||||
|
district: str,
|
||||||
|
case_name: str = "",
|
||||||
|
court: str = "",
|
||||||
|
decision_date: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
summary: str = "",
|
||||||
|
is_binding: bool = False,
|
||||||
|
proceeding_type: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""העלאת החלטה של ועדת ערר (internal_committee) לקורפוס הסמכותי.
|
||||||
|
|
||||||
|
Required: file_path, case_number, chair_name, district.
|
||||||
|
The tool enforces chair_name+district so the record cannot be saved
|
||||||
|
in the broken legacy mode (external_upload with empty chair/district).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: נתיב מלא לקובץ PDF/DOCX/RTF/TXT/MD.
|
||||||
|
case_number: מספר הערר ("ערר (ועדות ערר - תכנון ובנייה ירושלים) 1110/20 ...").
|
||||||
|
chair_name: שם יו"ר הוועדה (חובה).
|
||||||
|
district: מחוז (ירושלים/מרכז/תל אביב/צפון/דרום/חיפה/ארצי) — חובה.
|
||||||
|
case_name: שם קצר.
|
||||||
|
court: ערכאה ("ועדת הערר לתכנון ובנייה — מחוז ירושלים").
|
||||||
|
decision_date: ISO date (YYYY-MM-DD), אופציונלי.
|
||||||
|
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
|
||||||
|
appeal_subtype: building_permit / וכו'.
|
||||||
|
subject_tags: תגיות נושא.
|
||||||
|
is_binding: בד"כ False (ועדת ערר לא מחייבת ועדה אחרת — שכנוע אופקי).
|
||||||
|
proceeding_type: 'ערר' או 'בל"מ'. אם ריק — נגזר מ-appeal_subtype/case_name.
|
||||||
|
|
||||||
|
Returns: JSON עם case_law_id, מספר chunks, halachot_pending.
|
||||||
|
"""
|
||||||
|
if not file_path.strip():
|
||||||
|
return _err("file_path חובה")
|
||||||
|
if not case_number.strip():
|
||||||
|
return _err("case_number חובה")
|
||||||
|
if not chair_name.strip():
|
||||||
|
return _err(
|
||||||
|
"chair_name חובה. החלטות ועדת ערר חייבות שם יו\"ר — "
|
||||||
|
"בלעדיו ההחלטה לא ניתנת לחיפוש סלקטיבי לפי הרכב."
|
||||||
|
)
|
||||||
|
if not district.strip():
|
||||||
|
return _err(
|
||||||
|
"district חובה. ערכים תקפים: " + ", ".join(sorted(VALID_DISTRICTS))
|
||||||
|
)
|
||||||
|
if district.strip() not in VALID_DISTRICTS:
|
||||||
|
return _err(
|
||||||
|
f"district לא תקין: {district!r}. ערכים תקפים: "
|
||||||
|
+ ", ".join(sorted(VALID_DISTRICTS))
|
||||||
|
)
|
||||||
|
if proceeding_type.strip() and proceeding_type.strip() not in VALID_PROCEEDING_TYPES:
|
||||||
|
return _err(
|
||||||
|
f"proceeding_type לא תקין: {proceeding_type!r}. ערכים תקפים: "
|
||||||
|
+ ", ".join(sorted(VALID_PROCEEDING_TYPES))
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await int_svc.ingest_internal_decision(
|
||||||
|
case_number=case_number,
|
||||||
|
case_name=case_name,
|
||||||
|
court=court,
|
||||||
|
decision_date=decision_date or None,
|
||||||
|
chair_name=chair_name,
|
||||||
|
district=district,
|
||||||
|
practice_area=practice_area,
|
||||||
|
appeal_subtype=appeal_subtype,
|
||||||
|
subject_tags=subject_tags or [],
|
||||||
|
summary=summary,
|
||||||
|
is_binding=is_binding,
|
||||||
|
file_path=file_path,
|
||||||
|
proceeding_type=proceeding_type,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok(result)
|
||||||
83
mcp-server/src/legal_mcp/tools/legal_arguments.py
Normal file
83
mcp-server/src/legal_mcp/tools/legal_arguments.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""MCP tools — aggregated legal arguments (claim de-duplication)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import argument_aggregator, db
|
||||||
|
|
||||||
|
|
||||||
|
async def aggregate_claims_to_arguments(
|
||||||
|
case_number: str,
|
||||||
|
force: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""כינוס פרופוזיציות גולמיות לטיעונים משפטיים מובחנים.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: מספר תיק הערר.
|
||||||
|
force: True = למחוק טיעונים קיימים ולחשב מחדש.
|
||||||
|
"""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
return json.dumps(
|
||||||
|
{"status": "error", "message": f"תיק {case_number} לא נמצא."},
|
||||||
|
ensure_ascii=False, indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
result = await argument_aggregator.aggregate_claims_to_arguments(
|
||||||
|
case_id, force=force,
|
||||||
|
)
|
||||||
|
result["case_number"] = case_number
|
||||||
|
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_legal_arguments(
|
||||||
|
case_number: str,
|
||||||
|
party: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""שליפת טיעונים משפטיים מאוגדים לתיק.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: מספר תיק הערר.
|
||||||
|
party: סינון לפי צד (appellant/respondent/committee/permit_applicant).
|
||||||
|
ריק = כל הצדדים.
|
||||||
|
"""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
return json.dumps(
|
||||||
|
{"status": "error", "message": f"תיק {case_number} לא נמצא."},
|
||||||
|
ensure_ascii=False, indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
args = await argument_aggregator.get_legal_arguments(case_id, party=party)
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
return json.dumps({
|
||||||
|
"status": "empty",
|
||||||
|
"case_number": case_number,
|
||||||
|
"message": "לא נמצאו טיעונים מאוגדים. הרץ aggregate_claims_to_arguments תחילה.",
|
||||||
|
"arguments": [],
|
||||||
|
}, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# Group by party for nicer display.
|
||||||
|
party_he = {
|
||||||
|
"appellant": "עוררים",
|
||||||
|
"respondent": "משיבים",
|
||||||
|
"committee": "ועדה מקומית",
|
||||||
|
"permit_applicant": "מבקשי היתר",
|
||||||
|
"unknown": "צד לא מזוהה",
|
||||||
|
}
|
||||||
|
by_party: dict[str, list[dict]] = {}
|
||||||
|
for a in args:
|
||||||
|
label = party_he.get(a["party"], a["party"])
|
||||||
|
by_party.setdefault(label, []).append(a)
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"status": "ok",
|
||||||
|
"case_number": case_number,
|
||||||
|
"total": len(args),
|
||||||
|
"by_party": by_party,
|
||||||
|
}, ensure_ascii=False, indent=2, default=str)
|
||||||
210
mcp-server/src/legal_mcp/tools/missing_precedents.py
Normal file
210
mcp-server/src/legal_mcp/tools/missing_precedents.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""MCP tools for the missing-precedents log.
|
||||||
|
|
||||||
|
When a researcher (or chair) finds a citation in a party brief that
|
||||||
|
isn't yet in the precedent_library, they record it here so:
|
||||||
|
|
||||||
|
1. The gap is visible in the UI (the chair can see all open citations
|
||||||
|
that need to be uploaded).
|
||||||
|
2. The writer agent doesn't try to use a precedent that isn't in the
|
||||||
|
corpus — it knows the gap is being tracked.
|
||||||
|
3. The chair has a clean closing workflow: upload the actual decision
|
||||||
|
via the precedent library / internal-decisions, then link it here.
|
||||||
|
|
||||||
|
Three tools:
|
||||||
|
- ``missing_precedent_create`` — log a new gap (researcher / chair).
|
||||||
|
- ``missing_precedent_list`` — list open gaps (optionally filtered).
|
||||||
|
- ``missing_precedent_close`` — close a gap (chair workflow).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import db
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(payload) -> str:
|
||||||
|
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _err(msg: str) -> str:
|
||||||
|
return json.dumps({"error": msg}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_case_id(case_number: str) -> UUID | None:
|
||||||
|
"""Translate a human case_number (e.g. '1017-03-26') to a UUID."""
|
||||||
|
if not case_number or not case_number.strip():
|
||||||
|
return None
|
||||||
|
row = await db.get_case_by_number(case_number.strip())
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return UUID(row["id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def missing_precedent_create(
|
||||||
|
citation: str,
|
||||||
|
case_number: str = "",
|
||||||
|
cited_in_document_id: str = "",
|
||||||
|
cited_by_party: str = "unknown",
|
||||||
|
cited_by_party_name: str = "",
|
||||||
|
legal_topic: str = "",
|
||||||
|
legal_issue: str = "",
|
||||||
|
claim_quote: str = "",
|
||||||
|
case_name: str = "",
|
||||||
|
notes: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""תיעוד פסיקה שצוטטה אך אינה בקורפוס. הסוכן יוצר רשומה כשהוא מזהה ציטוט
|
||||||
|
שלא ניתן לאמת מול הקורפוס; היו"ר יסגור אותה לאחר העלאת המסמך.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
citation: מראה המקום המלא (חובה).
|
||||||
|
case_number: מספר תיק הערר שבו צוטטה הפסיקה (לדוגמה '1017-03-26').
|
||||||
|
cited_in_document_id: UUID של המסמך שבו הציטוט מופיע (אופציונלי).
|
||||||
|
cited_by_party: appellant / respondent / committee / permit_applicant / unknown.
|
||||||
|
cited_by_party_name: שם הצד (כדי שיהיה ברור מי ציטט).
|
||||||
|
legal_topic: נושא משפטי קצר (לדוגמה "זכות עמידה").
|
||||||
|
legal_issue: שאלה משפטית מפורטת.
|
||||||
|
claim_quote: הציטוט בכתב הטענות.
|
||||||
|
case_name: שם קצר של פסק הדין החסר.
|
||||||
|
notes: הערות חופשיות.
|
||||||
|
|
||||||
|
Returns: JSON של הרשומה שנוצרה (כולל id) או error.
|
||||||
|
"""
|
||||||
|
if not citation.strip():
|
||||||
|
return _err("citation חובה")
|
||||||
|
|
||||||
|
case_id = None
|
||||||
|
if case_number:
|
||||||
|
case_id = await _resolve_case_id(case_number)
|
||||||
|
if case_id is None:
|
||||||
|
return _err(f"תיק לא נמצא: {case_number}")
|
||||||
|
|
||||||
|
doc_uuid: UUID | None = None
|
||||||
|
if cited_in_document_id.strip():
|
||||||
|
try:
|
||||||
|
doc_uuid = UUID(cited_in_document_id.strip())
|
||||||
|
except ValueError:
|
||||||
|
return _err("cited_in_document_id לא תקין")
|
||||||
|
|
||||||
|
party = cited_by_party.strip() or "unknown"
|
||||||
|
if party not in db.ALLOWED_MP_PARTIES:
|
||||||
|
return _err(
|
||||||
|
f"cited_by_party לא תקין. ערכים תקפים: "
|
||||||
|
f"{', '.join(sorted(db.ALLOWED_MP_PARTIES))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deduplication: if a row already exists for the same citation in
|
||||||
|
# the same case, return that one rather than creating a duplicate.
|
||||||
|
existing = await db.find_missing_precedent_by_citation(
|
||||||
|
citation=citation.strip(),
|
||||||
|
case_id=case_id,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return _ok({**existing, "_duplicate": True})
|
||||||
|
|
||||||
|
try:
|
||||||
|
row = await db.create_missing_precedent(
|
||||||
|
citation=citation.strip(),
|
||||||
|
case_name=case_name.strip() or None,
|
||||||
|
cited_in_case_id=case_id,
|
||||||
|
cited_in_document_id=doc_uuid,
|
||||||
|
cited_by_party=party,
|
||||||
|
cited_by_party_name=cited_by_party_name.strip() or None,
|
||||||
|
legal_topic=legal_topic.strip() or None,
|
||||||
|
legal_issue=legal_issue.strip() or None,
|
||||||
|
claim_quote=claim_quote.strip() or None,
|
||||||
|
notes=notes.strip() or None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def missing_precedent_list(
|
||||||
|
case_number: str = "",
|
||||||
|
status: str = "open",
|
||||||
|
legal_topic: str = "",
|
||||||
|
limit: int = 50,
|
||||||
|
) -> str:
|
||||||
|
"""רשימת פסיקות חסרות. ברירת מחדל = פתוחות בלבד.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: סינון לפי תיק הערר שבו צוטטו.
|
||||||
|
status: open / uploaded / closed / irrelevant (ריק = הכל).
|
||||||
|
legal_topic: סינון לפי נושא משפטי (substring).
|
||||||
|
limit: מספר תוצאות מקסימלי.
|
||||||
|
|
||||||
|
Returns: JSON עם רשימת רשומות + linked_case_law_number אם נסגרו.
|
||||||
|
"""
|
||||||
|
case_id = None
|
||||||
|
if case_number:
|
||||||
|
case_id = await _resolve_case_id(case_number)
|
||||||
|
if case_id is None:
|
||||||
|
return _err(f"תיק לא נמצא: {case_number}")
|
||||||
|
|
||||||
|
s = status.strip() or None
|
||||||
|
if s and s not in db.ALLOWED_MP_STATUS:
|
||||||
|
return _err(
|
||||||
|
f"status לא תקין. ערכים תקפים: "
|
||||||
|
f"{', '.join(sorted(db.ALLOWED_MP_STATUS))}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
rows = await db.list_missing_precedents(
|
||||||
|
status=s,
|
||||||
|
case_id=case_id,
|
||||||
|
legal_topic=legal_topic.strip() or None,
|
||||||
|
limit=max(1, min(int(limit), 500)),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok({"items": rows, "count": len(rows)})
|
||||||
|
|
||||||
|
|
||||||
|
async def missing_precedent_close(
|
||||||
|
id: str,
|
||||||
|
linked_case_law_id: str = "",
|
||||||
|
notes: str = "",
|
||||||
|
status: str = "closed",
|
||||||
|
) -> str:
|
||||||
|
"""סגירת רשומת פסיקה חסרה. ברירת מחדל = 'closed' + קישור ל-case_law.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: UUID של הרשומה.
|
||||||
|
linked_case_law_id: UUID של הפסיקה שהועלתה ב-precedent_library / internal_decisions.
|
||||||
|
notes: הערות סגירה (לדוגמה "אינו רלוונטי" ל-status='irrelevant').
|
||||||
|
status: closed / uploaded / irrelevant.
|
||||||
|
|
||||||
|
Returns: JSON של הרשומה המעודכנת.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mp_id = UUID(id.strip())
|
||||||
|
except ValueError:
|
||||||
|
return _err("id לא תקין")
|
||||||
|
|
||||||
|
cl_uuid: UUID | None = None
|
||||||
|
if linked_case_law_id.strip():
|
||||||
|
try:
|
||||||
|
cl_uuid = UUID(linked_case_law_id.strip())
|
||||||
|
except ValueError:
|
||||||
|
return _err("linked_case_law_id לא תקין")
|
||||||
|
|
||||||
|
status_clean = status.strip() or "closed"
|
||||||
|
if status_clean not in db.ALLOWED_MP_STATUS:
|
||||||
|
return _err(
|
||||||
|
f"status לא תקין. ערכים תקפים: "
|
||||||
|
f"{', '.join(sorted(db.ALLOWED_MP_STATUS))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
row = await db.close_missing_precedent(
|
||||||
|
mp_id=mp_id,
|
||||||
|
linked_case_law_id=cl_uuid,
|
||||||
|
notes=notes.strip() or None,
|
||||||
|
status=status_clean,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
if row is None:
|
||||||
|
return _err("רשומה לא נמצאה")
|
||||||
|
return _ok(row)
|
||||||
@@ -18,9 +18,10 @@ the chair approves them — per project review policy.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import db, precedent_library
|
from legal_mcp.services import db, precedent_library, telemetry
|
||||||
|
|
||||||
|
|
||||||
def _ok(payload) -> str:
|
def _ok(payload) -> str:
|
||||||
@@ -63,6 +64,18 @@ async def precedent_library_upload(
|
|||||||
"""
|
"""
|
||||||
if not citation.strip():
|
if not citation.strip():
|
||||||
return _err("citation חובה")
|
return _err("citation חובה")
|
||||||
|
# Citation guard: appeals-committee decisions must go through
|
||||||
|
# internal_decision_upload (with chair_name + district). The legacy
|
||||||
|
# path always stored source_kind='external_upload' and left
|
||||||
|
# chair_name/district empty — see TaskMaster #30(ב).
|
||||||
|
_norm = citation.strip()
|
||||||
|
_committee_prefixes = ("ערר ", "ערר(", "ערר ", "בל\"מ ", "בל\"מ(", "ARAR ")
|
||||||
|
if any(_norm.startswith(p) for p in _committee_prefixes):
|
||||||
|
return _err(
|
||||||
|
"ציטוט שמתחיל ב-'ערר' או 'בל\"מ' הוא החלטת ועדת ערר. "
|
||||||
|
"השתמש ב-internal_decision_upload (דורש chair_name + district), "
|
||||||
|
"לא ב-precedent_library_upload."
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
result = await precedent_library.ingest_precedent(
|
result = await precedent_library.ingest_precedent(
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
@@ -90,6 +103,7 @@ async def precedent_library_list(
|
|||||||
precedent_level: str = "",
|
precedent_level: str = "",
|
||||||
source_type: str = "",
|
source_type: str = "",
|
||||||
search: str = "",
|
search: str = "",
|
||||||
|
source_kind: str = "external_upload",
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים."""
|
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים."""
|
||||||
@@ -99,6 +113,7 @@ async def precedent_library_list(
|
|||||||
precedent_level=precedent_level,
|
precedent_level=precedent_level,
|
||||||
source_type=source_type,
|
source_type=source_type,
|
||||||
search=search,
|
search=search,
|
||||||
|
source_kind=source_kind,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
return _ok(rows)
|
return _ok(rows)
|
||||||
@@ -116,6 +131,54 @@ async def precedent_library_get(case_law_id: str) -> str:
|
|||||||
return _ok(record)
|
return _ok(record)
|
||||||
|
|
||||||
|
|
||||||
|
async def precedent_link_cases(
|
||||||
|
case_law_id_a: str,
|
||||||
|
case_law_id_b: str,
|
||||||
|
relation_type: str = "same_case_chain",
|
||||||
|
) -> str:
|
||||||
|
"""קישור שתי פסיקות כקשורות זו לזו (דו-כיווני). idempotent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_law_id_a: UUID של פסיקה ראשונה.
|
||||||
|
case_law_id_b: UUID של פסיקה שנייה.
|
||||||
|
relation_type: same_case_chain | overruled_by | distinguished
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
a = UUID(case_law_id_a)
|
||||||
|
b = UUID(case_law_id_b)
|
||||||
|
except ValueError:
|
||||||
|
return _err("case_law_id לא תקין")
|
||||||
|
rec_a = await db.get_case_law(a)
|
||||||
|
rec_b = await db.get_case_law(b)
|
||||||
|
if not rec_a:
|
||||||
|
return _err(f"פסיקה {case_law_id_a} לא נמצאה")
|
||||||
|
if not rec_b:
|
||||||
|
return _err(f"פסיקה {case_law_id_b} לא נמצאה")
|
||||||
|
await db.add_case_law_relation(a, b, relation_type)
|
||||||
|
return _ok({
|
||||||
|
"linked": True,
|
||||||
|
"relation_type": relation_type,
|
||||||
|
"a": {"id": case_law_id_a, "case_number": rec_a.get("case_number"), "court": rec_a.get("court")},
|
||||||
|
"b": {"id": case_law_id_b, "case_number": rec_b.get("case_number"), "court": rec_b.get("court")},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
|
||||||
|
"""הסרת קישור בין שתי פסיקות (דו-כיווני).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_law_id_a: UUID של פסיקה ראשונה.
|
||||||
|
case_law_id_b: UUID של פסיקה שנייה.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
a = UUID(case_law_id_a)
|
||||||
|
b = UUID(case_law_id_b)
|
||||||
|
except ValueError:
|
||||||
|
return _err("case_law_id לא תקין")
|
||||||
|
await db.remove_case_law_relation(a, b)
|
||||||
|
return _ok({"unlinked": True, "a": case_law_id_a, "b": case_law_id_b})
|
||||||
|
|
||||||
|
|
||||||
async def precedent_library_delete(case_law_id: str) -> str:
|
async def precedent_library_delete(case_law_id: str) -> str:
|
||||||
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
|
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
|
||||||
try:
|
try:
|
||||||
@@ -200,8 +263,10 @@ async def search_precedent_library(
|
|||||||
"""
|
"""
|
||||||
if not query or len(query.strip()) < 2:
|
if not query or len(query.strip()) < 2:
|
||||||
return json.dumps([], ensure_ascii=False)
|
return json.dumps([], ensure_ascii=False)
|
||||||
|
q = query.strip()
|
||||||
|
t0 = time.perf_counter()
|
||||||
results = await precedent_library.search_library(
|
results = await precedent_library.search_library(
|
||||||
query=query.strip(),
|
query=q,
|
||||||
practice_area=practice_area,
|
practice_area=practice_area,
|
||||||
court=court,
|
court=court,
|
||||||
precedent_level=precedent_level,
|
precedent_level=precedent_level,
|
||||||
@@ -211,6 +276,15 @@ async def search_precedent_library(
|
|||||||
limit=limit,
|
limit=limit,
|
||||||
include_halachot=include_halachot,
|
include_halachot=include_halachot,
|
||||||
)
|
)
|
||||||
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
|
telemetry.log_search_bg(
|
||||||
|
search_type="precedent_library",
|
||||||
|
query=q,
|
||||||
|
results=results,
|
||||||
|
duration_ms=elapsed_ms,
|
||||||
|
practice_area=practice_area or None,
|
||||||
|
user_agent="unknown",
|
||||||
|
)
|
||||||
return _ok(results)
|
return _ok(results)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import db, embeddings, hybrid_search
|
from legal_mcp.services import db, embeddings, hybrid_search, telemetry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -30,11 +31,16 @@ async def search_decisions(
|
|||||||
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
|
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
|
||||||
"""
|
"""
|
||||||
# Auto-resolve practice_area from case_number if available
|
# Auto-resolve practice_area from case_number if available
|
||||||
|
resolved_case_id: UUID | None = None
|
||||||
if case_number and not practice_area:
|
if case_number and not practice_area:
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if case:
|
if case:
|
||||||
practice_area = case.get("practice_area") or ""
|
practice_area = case.get("practice_area") or ""
|
||||||
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
|
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
|
||||||
|
try:
|
||||||
|
resolved_case_id = UUID(case["id"])
|
||||||
|
except (KeyError, ValueError, TypeError):
|
||||||
|
resolved_case_id = None
|
||||||
|
|
||||||
if not practice_area:
|
if not practice_area:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -43,6 +49,7 @@ async def search_decisions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
query_emb = await embeddings.embed_query(query)
|
query_emb = await embeddings.embed_query(query)
|
||||||
|
t0 = time.perf_counter()
|
||||||
results = await hybrid_search.search_documents_hybrid(
|
results = await hybrid_search.search_documents_hybrid(
|
||||||
query=query,
|
query=query,
|
||||||
query_text_embedding=query_emb,
|
query_text_embedding=query_emb,
|
||||||
@@ -51,6 +58,16 @@ async def search_decisions(
|
|||||||
practice_area=practice_area or None,
|
practice_area=practice_area or None,
|
||||||
appeal_subtype=appeal_subtype or None,
|
appeal_subtype=appeal_subtype or None,
|
||||||
)
|
)
|
||||||
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
|
telemetry.log_search_bg(
|
||||||
|
search_type="decisions",
|
||||||
|
query=query,
|
||||||
|
results=results,
|
||||||
|
duration_ms=elapsed_ms,
|
||||||
|
practice_area=practice_area or None,
|
||||||
|
case_id=resolved_case_id,
|
||||||
|
user_agent="unknown",
|
||||||
|
)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return "לא נמצאו תוצאות."
|
return "לא נמצאו תוצאות."
|
||||||
@@ -87,13 +104,24 @@ async def search_case_documents(
|
|||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return f"תיק {case_number} לא נמצא."
|
||||||
|
|
||||||
|
case_uuid = UUID(case["id"])
|
||||||
query_emb = await embeddings.embed_query(query)
|
query_emb = await embeddings.embed_query(query)
|
||||||
# Restricted to case_id — practice_area filter would be redundant.
|
# Restricted to case_id — practice_area filter would be redundant.
|
||||||
|
t0 = time.perf_counter()
|
||||||
results = await hybrid_search.search_documents_hybrid(
|
results = await hybrid_search.search_documents_hybrid(
|
||||||
query=query,
|
query=query,
|
||||||
query_text_embedding=query_emb,
|
query_text_embedding=query_emb,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
case_id=UUID(case["id"]),
|
case_id=case_uuid,
|
||||||
|
)
|
||||||
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
|
telemetry.log_search_bg(
|
||||||
|
search_type="case_documents",
|
||||||
|
query=query,
|
||||||
|
results=results,
|
||||||
|
duration_ms=elapsed_ms,
|
||||||
|
case_id=case_uuid,
|
||||||
|
user_agent="unknown",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
@@ -130,11 +158,16 @@ async def find_similar_cases(
|
|||||||
appeal_subtype: סוג ערר לסינון
|
appeal_subtype: סוג ערר לסינון
|
||||||
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
|
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
|
||||||
"""
|
"""
|
||||||
|
resolved_case_id: UUID | None = None
|
||||||
if case_number and not practice_area:
|
if case_number and not practice_area:
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if case:
|
if case:
|
||||||
practice_area = case.get("practice_area") or ""
|
practice_area = case.get("practice_area") or ""
|
||||||
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
|
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
|
||||||
|
try:
|
||||||
|
resolved_case_id = UUID(case["id"])
|
||||||
|
except (KeyError, ValueError, TypeError):
|
||||||
|
resolved_case_id = None
|
||||||
|
|
||||||
if not practice_area:
|
if not practice_area:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -145,6 +178,7 @@ async def find_similar_cases(
|
|||||||
query_emb = await embeddings.embed_query(description)
|
query_emb = await embeddings.embed_query(description)
|
||||||
# Even with rerank we ask for ``limit*3`` so the dedup-by-case
|
# Even with rerank we ask for ``limit*3`` so the dedup-by-case
|
||||||
# step downstream still has enough rows to pick the best per case.
|
# step downstream still has enough rows to pick the best per case.
|
||||||
|
t0 = time.perf_counter()
|
||||||
results = await hybrid_search.search_documents_hybrid(
|
results = await hybrid_search.search_documents_hybrid(
|
||||||
query=description,
|
query=description,
|
||||||
query_text_embedding=query_emb,
|
query_text_embedding=query_emb,
|
||||||
@@ -152,6 +186,16 @@ async def find_similar_cases(
|
|||||||
practice_area=practice_area or None,
|
practice_area=practice_area or None,
|
||||||
appeal_subtype=appeal_subtype or None,
|
appeal_subtype=appeal_subtype or None,
|
||||||
)
|
)
|
||||||
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
|
telemetry.log_search_bg(
|
||||||
|
search_type="similar_cases",
|
||||||
|
query=description,
|
||||||
|
results=results,
|
||||||
|
duration_ms=elapsed_ms,
|
||||||
|
practice_area=practice_area or None,
|
||||||
|
case_id=resolved_case_id,
|
||||||
|
user_agent="unknown",
|
||||||
|
)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return "לא נמצאו תיקים דומים."
|
return "לא נמצאו תיקים דומים."
|
||||||
@@ -189,6 +233,7 @@ async def search_internal_decisions(
|
|||||||
chair_name: str = "",
|
chair_name: str = "",
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
include_halachot: bool = True,
|
include_halachot: bool = True,
|
||||||
|
include_cited_by: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
|
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
|
||||||
|
|
||||||
@@ -200,42 +245,145 @@ async def search_internal_decisions(
|
|||||||
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
|
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
|
||||||
limit: מספר תוצאות מקסימלי
|
limit: מספר תוצאות מקסימלי
|
||||||
include_halachot: האם לכלול הלכות שחולצו
|
include_halachot: האם לכלול הלכות שחולצו
|
||||||
|
include_cited_by: True = אחרי החיפוש הראשי, הוסף החלטות שה-hits
|
||||||
|
הראשיים מצטטים (מתוך precedent_internal_citations). default False
|
||||||
|
כדי לא לשבור caller-ים קיימים. match_type='cited_by' מציין שזו
|
||||||
|
תוצאה משנית.
|
||||||
"""
|
"""
|
||||||
from legal_mcp.services import internal_decisions as int_svc
|
from legal_mcp.services import internal_decisions as int_svc
|
||||||
|
|
||||||
|
# Bump the limit a bit when we're expanding via citations — the
|
||||||
|
# citation step is cheap and a few extra primary hits make the
|
||||||
|
# expansion more useful.
|
||||||
|
primary_limit = limit if not include_cited_by else max(limit, limit * 2)
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
results = await int_svc.search_internal(
|
results = await int_svc.search_internal(
|
||||||
query,
|
query,
|
||||||
practice_area=practice_area,
|
practice_area=practice_area,
|
||||||
appeal_subtype=appeal_subtype,
|
appeal_subtype=appeal_subtype,
|
||||||
district=district,
|
district=district,
|
||||||
chair_name=chair_name,
|
chair_name=chair_name,
|
||||||
limit=limit,
|
limit=primary_limit,
|
||||||
include_halachot=include_halachot,
|
include_halachot=include_halachot,
|
||||||
)
|
)
|
||||||
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
|
telemetry.log_search_bg(
|
||||||
|
search_type="internal_decisions",
|
||||||
|
query=query,
|
||||||
|
results=results,
|
||||||
|
duration_ms=elapsed_ms,
|
||||||
|
practice_area=practice_area or None,
|
||||||
|
user_agent="unknown",
|
||||||
|
)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return "לא נמצאו החלטות ועדת ערר רלוונטיות."
|
return "לא נמצאו החלטות ועדת ערר רלוונטיות."
|
||||||
|
|
||||||
|
# Cap primary results back to ``limit`` (we over-fetched only to seed
|
||||||
|
# the citation expansion below — the user asked for ``limit`` items).
|
||||||
|
primary = results[:limit]
|
||||||
|
|
||||||
formatted = []
|
formatted = []
|
||||||
for r in results:
|
seen_case_law_ids: set[str] = set()
|
||||||
entry = {
|
for r in primary:
|
||||||
"score": round(float(r["score"]), 4),
|
clid = str(r.get("case_law_id") or "")
|
||||||
"type": r.get("type", "passage"),
|
if clid:
|
||||||
"case_number": r.get("case_number"),
|
seen_case_law_ids.add(clid)
|
||||||
"case_name": r.get("case_name"),
|
formatted.append(_format_internal_row(r, match_type="primary"))
|
||||||
"court": r.get("court"),
|
|
||||||
"district": r.get("district"),
|
if include_cited_by and seen_case_law_ids:
|
||||||
"chair_name": r.get("chair_name"),
|
from uuid import UUID
|
||||||
"decision_date": r.get("decision_date"),
|
from legal_mcp.services import citation_extractor
|
||||||
}
|
|
||||||
if r.get("type") == "halacha":
|
try:
|
||||||
entry["rule"] = r.get("rule_statement")
|
source_uuids = [UUID(s) for s in seen_case_law_ids]
|
||||||
entry["quote"] = r.get("supporting_quote")
|
cited_map = await citation_extractor.get_cited_case_law_ids(source_uuids)
|
||||||
entry["rule_type"] = r.get("rule_type")
|
except Exception as e:
|
||||||
else:
|
logger.warning("include_cited_by lookup failed: %s", e)
|
||||||
entry["content"] = r.get("content", "")
|
cited_map = {}
|
||||||
entry["section"] = r.get("section_type")
|
|
||||||
entry["page"] = r.get("page_number")
|
# Flatten + dedup the cited case_law_ids that aren't already in
|
||||||
formatted.append(entry)
|
# the primary set.
|
||||||
|
cited_ids: set[str] = set()
|
||||||
|
for ids in cited_map.values():
|
||||||
|
for cid in ids:
|
||||||
|
if cid and cid not in seen_case_law_ids:
|
||||||
|
cited_ids.add(cid)
|
||||||
|
|
||||||
|
if cited_ids:
|
||||||
|
cited_rows = await _fetch_case_law_summaries(list(cited_ids))
|
||||||
|
for row in cited_rows:
|
||||||
|
formatted.append(_format_internal_row(row, match_type="cited_by"))
|
||||||
|
|
||||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_internal_row(r: dict, *, match_type: str = "primary") -> dict:
|
||||||
|
"""Shape an internal-decision hit (or a cited_by stub) for the MCP response."""
|
||||||
|
entry: dict = {
|
||||||
|
"score": round(float(r.get("score", 0.0)), 4),
|
||||||
|
"type": r.get("type", "passage"),
|
||||||
|
"case_number": r.get("case_number"),
|
||||||
|
"case_name": r.get("case_name"),
|
||||||
|
"court": r.get("court"),
|
||||||
|
"district": r.get("district"),
|
||||||
|
"chair_name": r.get("chair_name"),
|
||||||
|
"decision_date": r.get("decision_date"),
|
||||||
|
"match_type": match_type,
|
||||||
|
}
|
||||||
|
if r.get("type") == "halacha":
|
||||||
|
entry["rule"] = r.get("rule_statement")
|
||||||
|
entry["quote"] = r.get("supporting_quote")
|
||||||
|
entry["rule_type"] = r.get("rule_type")
|
||||||
|
else:
|
||||||
|
entry["content"] = r.get("content", "")
|
||||||
|
entry["section"] = r.get("section_type")
|
||||||
|
entry["page"] = r.get("page_number")
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_case_law_summaries(case_law_ids: list[str]) -> list[dict]:
|
||||||
|
"""Pull lightweight metadata for a set of case_law UUIDs (cited-by stubs).
|
||||||
|
|
||||||
|
Doesn't pull chunks/halachot — the goal is to surface the existence of
|
||||||
|
the related precedent, not to repeat search. The caller can drill in
|
||||||
|
via search_internal_decisions with chair_name+case_number if they want
|
||||||
|
full passages.
|
||||||
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
pool = await db.get_pool()
|
||||||
|
uuid_list = []
|
||||||
|
for s in case_law_ids:
|
||||||
|
try:
|
||||||
|
uuid_list.append(UUID(s))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not uuid_list:
|
||||||
|
return []
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT id::text AS case_law_id,
|
||||||
|
case_number,
|
||||||
|
case_name,
|
||||||
|
court,
|
||||||
|
district,
|
||||||
|
chair_name,
|
||||||
|
date AS decision_date,
|
||||||
|
headnote AS content
|
||||||
|
FROM case_law
|
||||||
|
WHERE id = ANY($1::uuid[])
|
||||||
|
""",
|
||||||
|
uuid_list,
|
||||||
|
)
|
||||||
|
out: list[dict] = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
if d.get("decision_date") is not None:
|
||||||
|
d["decision_date"] = d["decision_date"].isoformat()
|
||||||
|
# Stub rows show up with score 0 — they're not ranked, they're context.
|
||||||
|
d["score"] = 0.0
|
||||||
|
d["type"] = "passage"
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|||||||
85
mcp-server/src/legal_mcp/tools/training_enrichment.py
Normal file
85
mcp-server/src/legal_mcp/tools/training_enrichment.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""MCP tool wrappers for the style_corpus metadata-enrichment flow.
|
||||||
|
|
||||||
|
The actual extractor lives in
|
||||||
|
``legal_mcp.services.style_metadata_extractor``; this module just exposes
|
||||||
|
it as MCP tools that the chair (or a future automation) can call from
|
||||||
|
Claude Code.
|
||||||
|
|
||||||
|
Why these tools matter: the upload pipeline (`/api/training/upload` →
|
||||||
|
`_process_proofread_training`) inserts a style_corpus row with
|
||||||
|
``summary=''``, ``outcome=''``, ``key_principles=[]`` because LLM
|
||||||
|
extraction can't run from the FastAPI container (no claude CLI there).
|
||||||
|
This module fills that gap — call it from the host, where ``claude``
|
||||||
|
CLI is available, and the row gets enriched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import db, style_metadata_extractor
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(payload) -> str:
|
||||||
|
return json.dumps({"ok": True, **payload}, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _err(msg: str) -> str:
|
||||||
|
return json.dumps({"ok": False, "error": msg}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_decision_metadata(corpus_id: str, overwrite: bool = False) -> str:
|
||||||
|
"""חילוץ מטא-דאטה (summary, outcome, key_principles, appeal_subtype) להחלטה בקורפוס הסגנון.
|
||||||
|
|
||||||
|
ברירת מחדל ``overwrite=False`` ממלא רק שדות ריקים. הזן ``overwrite=true``
|
||||||
|
כדי לרענן ערכים שכבר נכתבו.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cid = UUID(corpus_id)
|
||||||
|
except ValueError:
|
||||||
|
return _err("corpus_id לא תקין")
|
||||||
|
try:
|
||||||
|
result = await style_metadata_extractor.extract_and_apply(cid, overwrite=overwrite)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_corpus_pending_enrichment(limit: int = 50) -> str:
|
||||||
|
"""רשימת רשומות style_corpus שחסר להן summary/outcome/key_principles — מועמדות להעשרה."""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT id, decision_number, decision_date,
|
||||||
|
length(full_text) AS chars,
|
||||||
|
coalesce(summary, '') = '' AS missing_summary,
|
||||||
|
coalesce(outcome, '') = '' AS missing_outcome,
|
||||||
|
coalesce(jsonb_array_length(key_principles), 0) = 0 AS missing_principles
|
||||||
|
FROM style_corpus
|
||||||
|
WHERE coalesce(summary, '') = ''
|
||||||
|
OR coalesce(outcome, '') = ''
|
||||||
|
OR coalesce(jsonb_array_length(key_principles), 0) = 0
|
||||||
|
ORDER BY decision_date NULLS LAST
|
||||||
|
LIMIT $1
|
||||||
|
""",
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"corpus_id": str(r["id"]),
|
||||||
|
"decision_number": r["decision_number"] or "",
|
||||||
|
"decision_date": str(r["decision_date"]) if r["decision_date"] else "",
|
||||||
|
"chars": r["chars"],
|
||||||
|
"missing": [
|
||||||
|
f for f, v in (
|
||||||
|
("summary", r["missing_summary"]),
|
||||||
|
("outcome", r["missing_outcome"]),
|
||||||
|
("key_principles", r["missing_principles"]),
|
||||||
|
) if v
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return _ok({"count": len(items), "items": items})
|
||||||
276
mcp-server/tests/test_corpus_constraints.py
Normal file
276
mcp-server/tests/test_corpus_constraints.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"""Regression tests for Stage-A corpus integrity fixes (TaskMaster #30, #31).
|
||||||
|
|
||||||
|
These tests document the bugs that were closed in Stage A so they don't
|
||||||
|
regress quietly. Each test maps to a real bug or constraint:
|
||||||
|
|
||||||
|
1. DB CHECK ``cases_practice_area_check`` rejects the legacy
|
||||||
|
``'appeals_committee'`` value — only domain values (rishuy_uvniya /
|
||||||
|
betterment_levy / compensation_197) and ``''`` are allowed.
|
||||||
|
(Bug: many ``cases`` rows stored ``'appeals_committee'`` instead of
|
||||||
|
the domain.)
|
||||||
|
|
||||||
|
2. DB CHECK ``case_law_internal_chair_check`` and
|
||||||
|
``case_law_internal_district_check`` reject internal_committee rows
|
||||||
|
with empty chair_name/district.
|
||||||
|
(Bug: 6 records had source_kind='external_upload' but were really
|
||||||
|
internal committee decisions; the flip to internal_committee in
|
||||||
|
Stage A.2 surfaced the missing chair/district fields.)
|
||||||
|
|
||||||
|
3. DB CHECK ``case_law_external_arar_check`` rejects external_upload
|
||||||
|
rows whose case_number starts with ``"ערר"`` or ``"בל\\"מ"`` —
|
||||||
|
committee decisions must go through internal_decision_upload, not
|
||||||
|
precedent_library_upload.
|
||||||
|
(Bug: the legacy upload path stored everything as external_upload,
|
||||||
|
including appeal-committee decisions; the citation guard now
|
||||||
|
redirects them.)
|
||||||
|
|
||||||
|
4. MCP tool ``precedent_library_upload`` returns an ``_err`` envelope
|
||||||
|
when the citation starts with ``"ערר"`` (citation guard, not DB
|
||||||
|
constraint — fires before INSERT to surface a helpful error).
|
||||||
|
|
||||||
|
These tests connect to the live local Postgres (port 5433) — they do not
|
||||||
|
mock asyncpg. Run with::
|
||||||
|
|
||||||
|
pytest mcp-server/tests/test_corpus_constraints.py -v
|
||||||
|
|
||||||
|
If you don't have ``DATABASE_URL`` set, the tests are skipped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _dsn() -> str | None:
|
||||||
|
return (
|
||||||
|
os.environ.get("DATABASE_URL")
|
||||||
|
or os.environ.get("LEGAL_AI_DATABASE_URL")
|
||||||
|
or "postgresql://legal_ai:od0ASJZFYibOlWK59krLvvETmgqwlXe8@localhost:5433/legal_ai"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def dsn() -> str:
|
||||||
|
d = _dsn()
|
||||||
|
if not d:
|
||||||
|
pytest.skip("No DATABASE_URL set; skipping live-DB regression tests")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def event_loop():
|
||||||
|
"""Provide a fresh event loop per test so asyncpg doesn't leak across cases."""
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
yield loop
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run(loop, coro):
|
||||||
|
return loop.run_until_complete(coro)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. cases.practice_area CHECK ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_cases_rejects_appeals_committee_practice_area(dsn: str, event_loop) -> None:
|
||||||
|
"""``cases.practice_area = 'appeals_committee'`` must violate the CHECK."""
|
||||||
|
|
||||||
|
async def attempt() -> None:
|
||||||
|
conn = await asyncpg.connect(dsn)
|
||||||
|
try:
|
||||||
|
with pytest.raises(asyncpg.exceptions.CheckViolationError):
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO cases (id, case_number, title, practice_area)
|
||||||
|
VALUES ($1, $2, $3, $4)""",
|
||||||
|
uuid4(), f"TEST-{uuid4().hex[:8]}", "regression-test",
|
||||||
|
"appeals_committee",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
_run(event_loop, attempt())
|
||||||
|
|
||||||
|
|
||||||
|
def test_cases_accepts_domain_practice_area(dsn: str, event_loop) -> None:
|
||||||
|
"""Sanity check: rishuy_uvniya / betterment_levy / compensation_197
|
||||||
|
+ empty string must be accepted."""
|
||||||
|
|
||||||
|
async def attempt() -> None:
|
||||||
|
conn = await asyncpg.connect(dsn)
|
||||||
|
try:
|
||||||
|
tx = conn.transaction()
|
||||||
|
await tx.start()
|
||||||
|
try:
|
||||||
|
for value in ("rishuy_uvniya", "betterment_levy",
|
||||||
|
"compensation_197", ""):
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO cases (id, case_number, title, practice_area)
|
||||||
|
VALUES ($1, $2, $3, $4)""",
|
||||||
|
uuid4(), f"TEST-{uuid4().hex[:8]}",
|
||||||
|
f"regression-{value or 'empty'}", value,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await tx.rollback()
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
_run(event_loop, attempt())
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. case_law internal_committee chair/district CHECK ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_law_internal_requires_chair_and_district(dsn: str, event_loop) -> None:
|
||||||
|
"""``case_law`` rows with ``source_kind='internal_committee'`` must have
|
||||||
|
non-empty ``chair_name`` AND ``district``."""
|
||||||
|
|
||||||
|
async def attempt_missing_chair() -> None:
|
||||||
|
conn = await asyncpg.connect(dsn)
|
||||||
|
try:
|
||||||
|
with pytest.raises(asyncpg.exceptions.CheckViolationError):
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO case_law (id, case_number, case_name,
|
||||||
|
source_kind, district, chair_name)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)""",
|
||||||
|
uuid4(), f"ערר {uuid4().hex[:6]}",
|
||||||
|
"test internal w/o chair",
|
||||||
|
"internal_committee", "ירושלים", "",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
async def attempt_missing_district() -> None:
|
||||||
|
conn = await asyncpg.connect(dsn)
|
||||||
|
try:
|
||||||
|
with pytest.raises(asyncpg.exceptions.CheckViolationError):
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO case_law (id, case_number, case_name,
|
||||||
|
source_kind, district, chair_name)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)""",
|
||||||
|
uuid4(), f"ערר {uuid4().hex[:6]}",
|
||||||
|
"test internal w/o district",
|
||||||
|
"internal_committee", "", "עו\"ד דפנה תמיר",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
_run(event_loop, attempt_missing_chair())
|
||||||
|
_run(event_loop, attempt_missing_district())
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. case_law external_upload + ערר citation CHECK ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_law_external_upload_rejects_arar_citation(dsn: str, event_loop) -> None:
|
||||||
|
"""``case_law`` rows with ``source_kind='external_upload'`` cannot have
|
||||||
|
a ``case_number`` that starts with ``"ערר"`` or ``"בל\"מ"`` — those
|
||||||
|
are committee decisions and must use ``source_kind='internal_committee'``."""
|
||||||
|
|
||||||
|
async def attempt_arar() -> None:
|
||||||
|
conn = await asyncpg.connect(dsn)
|
||||||
|
try:
|
||||||
|
with pytest.raises(asyncpg.exceptions.CheckViolationError):
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO case_law (id, case_number, case_name,
|
||||||
|
source_kind)
|
||||||
|
VALUES ($1, $2, $3, $4)""",
|
||||||
|
uuid4(), "ערר 1170/24 חיים נ' ועדה",
|
||||||
|
"test external arar", "external_upload",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
async def attempt_balam() -> None:
|
||||||
|
conn = await asyncpg.connect(dsn)
|
||||||
|
try:
|
||||||
|
with pytest.raises(asyncpg.exceptions.CheckViolationError):
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO case_law (id, case_number, case_name,
|
||||||
|
source_kind)
|
||||||
|
VALUES ($1, $2, $3, $4)""",
|
||||||
|
uuid4(), 'בל"מ 1234/25 פלוני',
|
||||||
|
"test external balam", "external_upload",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
_run(event_loop, attempt_arar())
|
||||||
|
_run(event_loop, attempt_balam())
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. MCP precedent_library_upload citation guard ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_precedent_upload_rejects_arar_citation() -> None:
|
||||||
|
"""The MCP tool ``precedent_library_upload`` must short-circuit
|
||||||
|
citations that start with ``"ערר"`` / ``"בל\"מ"`` and return an
|
||||||
|
``_err`` envelope (a helpful message redirecting to
|
||||||
|
``internal_decision_upload``), without touching the DB."""
|
||||||
|
|
||||||
|
from legal_mcp.tools import precedent_library as tools
|
||||||
|
|
||||||
|
async def call(citation: str) -> dict:
|
||||||
|
# file_path won't be touched because the guard fires first.
|
||||||
|
return json.loads(
|
||||||
|
await tools.precedent_library_upload(
|
||||||
|
file_path="/nonexistent",
|
||||||
|
citation=citation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
for citation in (
|
||||||
|
"ערר 1170/24 חיים נ' ועדה",
|
||||||
|
'בל"מ 1234/25 פלוני',
|
||||||
|
"ARAR 8126-25 ב. קרן-נכסים",
|
||||||
|
):
|
||||||
|
result = loop.run_until_complete(call(citation))
|
||||||
|
assert "error" in result, (
|
||||||
|
f"expected guard to reject {citation!r}, got {result!r}"
|
||||||
|
)
|
||||||
|
# The error message should mention internal_decision_upload so
|
||||||
|
# the caller knows the alternative path.
|
||||||
|
assert "internal_decision_upload" in result["error"], (
|
||||||
|
f"error message should redirect to internal_decision_upload, "
|
||||||
|
f"got {result['error']!r}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_practice_area_module_invariants() -> None:
|
||||||
|
"""Quick guard that the ``practice_area`` service module exposes the
|
||||||
|
helpers tools and tests depend on, and that derivation is consistent
|
||||||
|
with the case-number convention (1xxx/8xxx/9xxx)."""
|
||||||
|
|
||||||
|
from legal_mcp.services import practice_area as pa
|
||||||
|
|
||||||
|
# Domain mapping is consistent with the case-number prefix convention.
|
||||||
|
assert pa.derive_domain_practice_area("1170") == "rishuy_uvniya"
|
||||||
|
assert pa.derive_domain_practice_area("8126/25") == "betterment_levy"
|
||||||
|
assert pa.derive_domain_practice_area("9001") == "compensation_197"
|
||||||
|
assert pa.derive_domain_practice_area("ARAR-25-8126") == "betterment_levy"
|
||||||
|
# Unparseable input → empty (caller decides fallback).
|
||||||
|
assert pa.derive_domain_practice_area("foo") == ""
|
||||||
|
assert pa.derive_domain_practice_area("") == ""
|
||||||
|
|
||||||
|
# Empty practice_area is valid (DB allows it as 'unclassified').
|
||||||
|
pa.validate("", "unknown")
|
||||||
|
pa.validate("rishuy_uvniya", "building_permit")
|
||||||
|
pa.validate("betterment_levy", "betterment_levy")
|
||||||
|
|
||||||
|
# appeals_committee (axis A) is still recognised for backward-compat.
|
||||||
|
pa.validate("appeals_committee", "building_permit")
|
||||||
|
|
||||||
|
# is_override returns False when subtype matches derivation.
|
||||||
|
assert pa.is_override("1170", "rishuy_uvniya", "building_permit") is False
|
||||||
|
assert pa.is_override("8126", "betterment_levy", "betterment_levy") is False
|
||||||
97
mcp-server/tests/test_precedent_corpus_isolation.py
Normal file
97
mcp-server/tests/test_precedent_corpus_isolation.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Regression test for GAP-10 / INV-RET1: corpus separation enforced on
|
||||||
|
EVERY precedent-library query path — including the halacha sub-query.
|
||||||
|
|
||||||
|
Bug: ``search_precedent_library_semantic`` and
|
||||||
|
``search_precedent_library_lexical`` filtered the *chunk* sub-query by
|
||||||
|
``cl.source_kind`` but NOT the *halacha* sub-query. So an external
|
||||||
|
(``source_kind='external_upload'``) search leaked internal-committee
|
||||||
|
halachot, and an internal search leaked external-ruling halachot — a
|
||||||
|
cross-corpus contamination of the rule-level results.
|
||||||
|
|
||||||
|
Fix: the same ``cl.source_kind = '<kind>'`` predicate that gates the
|
||||||
|
chunk query now also gates the halacha query, in BOTH functions.
|
||||||
|
|
||||||
|
This test runs fully OFFLINE — it monkeypatches ``db.get_pool`` with a
|
||||||
|
fake pool that captures every SQL string passed to ``fetch`` instead of
|
||||||
|
hitting Postgres. It asserts the captured halacha SQL carries the
|
||||||
|
source_kind predicate identical to the chunk SQL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from legal_mcp.services import db
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePool:
|
||||||
|
"""Captures SQL passed to ``fetch``; returns no rows."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.queries: list[str] = []
|
||||||
|
|
||||||
|
async def fetch(self, sql: str, *args) -> list: # noqa: ANN002
|
||||||
|
self.queries.append(sql)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _classify(queries: list[str]) -> tuple[str, str]:
|
||||||
|
"""Return (halacha_sql, chunk_sql) from the captured queries."""
|
||||||
|
halacha = next(q for q in queries if "FROM halachot h" in q)
|
||||||
|
chunk = next(q for q in queries if "FROM precedent_chunks pc" in q)
|
||||||
|
return halacha, chunk
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def fake_pool(monkeypatch: pytest.MonkeyPatch) -> _FakePool:
|
||||||
|
pool = _FakePool()
|
||||||
|
|
||||||
|
async def _get_pool() -> _FakePool:
|
||||||
|
return pool
|
||||||
|
|
||||||
|
monkeypatch.setattr(db, "get_pool", _get_pool)
|
||||||
|
return pool
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("source_kind", ["external_upload", "internal_committee"])
|
||||||
|
def test_semantic_halacha_query_is_source_kind_scoped(
|
||||||
|
fake_pool: _FakePool, source_kind: str
|
||||||
|
) -> None:
|
||||||
|
asyncio.run(
|
||||||
|
db.search_precedent_library_semantic(
|
||||||
|
query_embedding=[0.0] * 8,
|
||||||
|
source_kind=source_kind,
|
||||||
|
include_halachot=True,
|
||||||
|
limit=5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
halacha_sql, chunk_sql = _classify(fake_pool.queries)
|
||||||
|
predicate = f"cl.source_kind = '{source_kind}'"
|
||||||
|
assert predicate in chunk_sql, "chunk query must be source_kind-scoped (precondition)"
|
||||||
|
assert predicate in halacha_sql, (
|
||||||
|
"halacha query MUST carry the same source_kind predicate as the "
|
||||||
|
"chunk query — otherwise cross-corpus halacha leakage (GAP-10)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("source_kind", ["external_upload", "internal_committee"])
|
||||||
|
def test_lexical_halacha_query_is_source_kind_scoped(
|
||||||
|
fake_pool: _FakePool, source_kind: str
|
||||||
|
) -> None:
|
||||||
|
asyncio.run(
|
||||||
|
db.search_precedent_library_lexical(
|
||||||
|
query="zoning setback",
|
||||||
|
source_kind=source_kind,
|
||||||
|
include_halachot=True,
|
||||||
|
limit=5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
halacha_sql, chunk_sql = _classify(fake_pool.queries)
|
||||||
|
predicate = f"cl.source_kind = '{source_kind}'"
|
||||||
|
assert predicate in chunk_sql, "chunk query must be source_kind-scoped (precondition)"
|
||||||
|
assert predicate in halacha_sql, (
|
||||||
|
"halacha query MUST carry the same source_kind predicate as the "
|
||||||
|
"chunk query — otherwise cross-corpus halacha leakage (GAP-10)"
|
||||||
|
)
|
||||||
87
scripts/.archive/run_curator_deepseek_test.sh
Executable file
87
scripts/.archive/run_curator_deepseek_test.sh
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-off A/B test runner: runs the Knowledge Curator (Hermes) on CMP-78 using
|
||||||
|
# DeepSeek V4-Pro instead of the default Sonnet 4.5 (via marcus/sonnet gateway).
|
||||||
|
# Compare against CMP-80 which runs with the default config.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROFILE_HOME="/home/chaim/.hermes/profiles/curator-cmp-deepseek"
|
||||||
|
PAPERCLIP_API_URL="http://localhost:3100/api"
|
||||||
|
# CMP curator agent's Paperclip key (from Infisical: nautilus /legal-ai HERMES_CURATOR_CMP_PAPERCLIP_KEY)
|
||||||
|
PAPERCLIP_API_KEY="pcp_c87edcf306d06fce13fac701bb6d747191d61dba5b51e903"
|
||||||
|
PAPERCLIP_TASK_ID="beb745e5-7195-40c5-9ac0-e9682c2c5184" # CMP-78
|
||||||
|
PAPERCLIP_TASK_KEY="$PAPERCLIP_TASK_ID"
|
||||||
|
PAPERCLIP_TASK_TITLE="[ערר 1130-25] סקירת ידע — Knowledge Curator (DeepSeek A/B test)"
|
||||||
|
PAPERCLIP_RUN_ID="deepseek-ab-$(date +%s)"
|
||||||
|
PAPERCLIP_WAKE_REASON="manual_deepseek_ab_test"
|
||||||
|
|
||||||
|
# Rendered prompt — copy of the curator template with mustache variables resolved
|
||||||
|
# manually for CMP-78. We also add a clear "[ניסוי DeepSeek V4-Pro]" prefix so
|
||||||
|
# the resulting comment is distinguishable from the default-Sonnet run on CMP-80.
|
||||||
|
read -r -d '' PROMPT <<'EOF' || true
|
||||||
|
אתה מנהל ידע (Knowledge Curator) של ועדת הערר. נעור על תיק שדפנה סימנה כסופי.
|
||||||
|
|
||||||
|
תיק: [ערר 1130-25] סקירת ידע — Knowledge Curator
|
||||||
|
issue ID: beb745e5-7195-40c5-9ac0-e9682c2c5184
|
||||||
|
run reason: manual_deepseek_ab_test
|
||||||
|
|
||||||
|
**הקשר חשוב — ניסוי A/B:** זוהי ריצה ידנית באמצעות DeepSeek V4-Pro במקום ה-Sonnet הרגיל. כל ה-comment שתפרסם חייב להתחיל בכותרת `[ניסוי DeepSeek V4-Pro]` כדי שנוכל להבדיל מהריצה המקבילה ב-CMP-80 (שרצה עם Sonnet). אל תעיר סוכנים אחרים. אל תיצור issues חדשים. אל תפתח interaction.
|
||||||
|
|
||||||
|
הוראות:
|
||||||
|
דפנה סימנה את ההחלטה הסופית של תיק 1130-25 כסופית.
|
||||||
|
קובץ סופי: `סופי-1130-25.docx`
|
||||||
|
|
||||||
|
סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.
|
||||||
|
חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, ממוספר.
|
||||||
|
|
||||||
|
# שלבי ביצוע
|
||||||
|
|
||||||
|
## 1. קונטקסט
|
||||||
|
- קרא את MEMORY.md שלך (memory tool) — מה כבר זיהית.
|
||||||
|
- קרא `/home/chaim/legal-ai/skills/decision/SKILL.md` (file tool) — מה כבר תועד.
|
||||||
|
|
||||||
|
## 2. נתונים
|
||||||
|
- `mcp__legal-ai__case_get` עם case_number `1130-25` — מטא-דאטה.
|
||||||
|
- `mcp__legal-ai__case_get_final_text` עם case_number `1130-25` — קרא את הטקסט המלא של ההחלטה הסופית.
|
||||||
|
- אם רלוונטי: `mcp__legal-ai__search_decisions` להשוואה לחלטות קודמות.
|
||||||
|
|
||||||
|
## 3. ניתוח
|
||||||
|
חפש 3-5 דפוסים/פערים. לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת.
|
||||||
|
|
||||||
|
## 4. כתוב comment הממצאים
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg b "$BODY" '{body:$b}')" \
|
||||||
|
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/comments"
|
||||||
|
```
|
||||||
|
|
||||||
|
פורמט ה-body:
|
||||||
|
- שורה ראשונה: `[ניסוי DeepSeek V4-Pro]`
|
||||||
|
- אחר כך פסקה אחת מבוא קצרה
|
||||||
|
- אחר כך הממצאים ממוספרים
|
||||||
|
|
||||||
|
## 5. סגור את ה-issue
|
||||||
|
```bash
|
||||||
|
curl -sS -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"done"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
# כללים
|
||||||
|
- אל תעדכן קבצים (skills/, lessons.py, DB) בעצמך. רק comment.
|
||||||
|
- אל תיצור issues חדשים.
|
||||||
|
- אל תעיר סוכנים אחרים.
|
||||||
|
- אל תפתח interaction.
|
||||||
|
- בעיה? comment קצר עם הסיבה + סגור (status=done).
|
||||||
|
EOF
|
||||||
|
|
||||||
|
export HERMES_HOME="$PROFILE_HOME"
|
||||||
|
export PAPERCLIP_API_URL PAPERCLIP_API_KEY PAPERCLIP_TASK_ID PAPERCLIP_TASK_KEY \
|
||||||
|
PAPERCLIP_TASK_TITLE PAPERCLIP_RUN_ID PAPERCLIP_WAKE_REASON
|
||||||
|
|
||||||
|
echo "=== DeepSeek V4-Pro Curator A/B test on CMP-78 ==="
|
||||||
|
echo "HERMES_HOME=$HERMES_HOME"
|
||||||
|
echo "TASK_ID=$PAPERCLIP_TASK_ID"
|
||||||
|
echo "RUN_ID=$PAPERCLIP_RUN_ID"
|
||||||
|
echo "Starting Hermes..."
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
hermes -z "$PROMPT" --yolo chat 2>&1
|
||||||
116
scripts/.archive/run_curator_deepseek_test_v2.sh
Executable file
116
scripts/.archive/run_curator_deepseek_test_v2.sh
Executable file
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# A/B test runner #2: DeepSeek V4-Pro on CMP-78 — WITH interaction step
|
||||||
|
# (matching the full Sonnet baseline workflow on CMP-80, including ask_user_questions).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROFILE_HOME="/home/chaim/.hermes/profiles/curator-cmp-deepseek"
|
||||||
|
PAPERCLIP_API_URL="http://localhost:3100/api"
|
||||||
|
PAPERCLIP_API_KEY="pcp_c87edcf306d06fce13fac701bb6d747191d61dba5b51e903"
|
||||||
|
PAPERCLIP_TASK_ID="beb745e5-7195-40c5-9ac0-e9682c2c5184" # CMP-78
|
||||||
|
PAPERCLIP_TASK_KEY="$PAPERCLIP_TASK_ID"
|
||||||
|
PAPERCLIP_TASK_TITLE="[ערר 1130-25] סקירת ידע — DeepSeek V4-Pro test #2 (with interaction)"
|
||||||
|
PAPERCLIP_RUN_ID="deepseek-ab2-$(date +%s)"
|
||||||
|
PAPERCLIP_WAKE_REASON="manual_deepseek_ab_test_v2_with_interaction"
|
||||||
|
|
||||||
|
read -r -d '' PROMPT <<'EOF' || true
|
||||||
|
אתה מנהל ידע (Knowledge Curator) של ועדת הערר. נעור על תיק שדפנה סימנה כסופי.
|
||||||
|
|
||||||
|
תיק: [ערר 1130-25] סקירת ידע — Knowledge Curator
|
||||||
|
issue ID: beb745e5-7195-40c5-9ac0-e9682c2c5184
|
||||||
|
run reason: manual_deepseek_ab_test_v2_with_interaction
|
||||||
|
|
||||||
|
**הקשר חשוב — ניסוי A/B #2:** זוהי ריצה שנייה ידנית באמצעות DeepSeek V4-Pro, הפעם **עם interaction מלא** כדי להשוות הוגנת מול ריצת Sonnet ב-CMP-80. כל הפלטים שתפרסם חייבים להתחיל בכותרת `[ניסוי DeepSeek V4-Pro #2 — עם interaction]`. אל תעיר סוכנים אחרים. אל תיצור issues חדשים.
|
||||||
|
|
||||||
|
הוראות:
|
||||||
|
דפנה סימנה את ההחלטה הסופית של תיק 1130-25 כסופית.
|
||||||
|
קובץ סופי: `סופי-1130-25.docx`
|
||||||
|
|
||||||
|
סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.
|
||||||
|
חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, ממוספר.
|
||||||
|
|
||||||
|
# שלבי ביצוע
|
||||||
|
|
||||||
|
## 1. קונטקסט
|
||||||
|
- קרא את MEMORY.md שלך (memory tool) — מה כבר זיהית.
|
||||||
|
- קרא `/home/chaim/legal-ai/skills/decision/SKILL.md` (file tool) — מה כבר תועד.
|
||||||
|
|
||||||
|
## 2. נתונים
|
||||||
|
- `mcp__legal-ai__case_get` עם case_number `1130-25` — מטא-דאטה.
|
||||||
|
- `mcp__legal-ai__case_get_final_text` עם case_number `1130-25` — קרא את הטקסט המלא של ההחלטה הסופית.
|
||||||
|
|
||||||
|
## 3. ניתוח
|
||||||
|
חפש 3-5 דפוסים/פערים. לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת.
|
||||||
|
|
||||||
|
## 4. כתוב comment הממצאים
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg b "$BODY" '{body:$b}')" \
|
||||||
|
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/comments"
|
||||||
|
```
|
||||||
|
|
||||||
|
פורמט ה-body:
|
||||||
|
- שורה ראשונה: `[ניסוי DeepSeek V4-Pro #2 — עם interaction]`
|
||||||
|
- אחר כך פסקה אחת מבוא קצרה
|
||||||
|
- אחר כך הממצאים ממוספרים
|
||||||
|
|
||||||
|
## 5. פתח interaction מסוג ask_user_questions
|
||||||
|
זה השלב שעבד את Sonnet הרבה זמן — בוא נראה כמה זמן יקח לך.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/interactions" \
|
||||||
|
-d '{
|
||||||
|
"kind": "ask_user_questions",
|
||||||
|
"idempotencyKey": "curator-deepseek-v2:'"$PAPERCLIP_TASK_ID"':select",
|
||||||
|
"title": "[DeepSeek] איזה ממצאים שווים עדכון?",
|
||||||
|
"continuationPolicy": "wake_assignee",
|
||||||
|
"payload": {
|
||||||
|
"version": 1,
|
||||||
|
"submitLabel": "אשר בחירה",
|
||||||
|
"questions": [{
|
||||||
|
"id": "findings_to_propose",
|
||||||
|
"prompt": "סמן את הממצאים שאני אכין כהצעת עדכון ל-style guide",
|
||||||
|
"selectionMode": "multi",
|
||||||
|
"options": [
|
||||||
|
{"id":"f1","label":"<מילוי לפי ממצא 1>","description":"<תקציר>"},
|
||||||
|
{"id":"f2","label":"<מילוי לפי ממצא 2>","description":"<תקציר>"}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
מלא את ה-options לפי הממצאים שלך — אופציה אחת לכל ממצא ממוספר.
|
||||||
|
|
||||||
|
## 6. עדכן issue ל-status=in_review (לא done — ממתינים לבחירת חיים)
|
||||||
|
```bash
|
||||||
|
curl -sS -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"in_review"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
# כללים
|
||||||
|
- אל תעדכן קבצים (skills/, lessons.py, DB) בעצמך. רק comment + interaction.
|
||||||
|
- אל תיצור issues חדשים.
|
||||||
|
- אל תעיר סוכנים אחרים.
|
||||||
|
- בעיה? comment קצר עם הסיבה + סגור (status=done).
|
||||||
|
EOF
|
||||||
|
|
||||||
|
export HERMES_HOME="$PROFILE_HOME"
|
||||||
|
export PAPERCLIP_API_URL PAPERCLIP_API_KEY PAPERCLIP_TASK_ID PAPERCLIP_TASK_KEY \
|
||||||
|
PAPERCLIP_TASK_TITLE PAPERCLIP_RUN_ID PAPERCLIP_WAKE_REASON
|
||||||
|
|
||||||
|
echo "=== DeepSeek V4-Pro #2 (with interaction) — CMP-78 ==="
|
||||||
|
echo "HERMES_HOME=$HERMES_HOME"
|
||||||
|
echo "TASK_ID=$PAPERCLIP_TASK_ID"
|
||||||
|
echo "RUN_ID=$PAPERCLIP_RUN_ID"
|
||||||
|
echo "Started: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
START_EPOCH=$(date +%s)
|
||||||
|
hermes -z "$PROMPT" --yolo chat 2>&1
|
||||||
|
END_EPOCH=$(date +%s)
|
||||||
|
DURATION=$((END_EPOCH - START_EPOCH))
|
||||||
|
echo ""
|
||||||
|
echo "=== Run finished ==="
|
||||||
|
echo "Ended: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
echo "Duration: ${DURATION}s ($((DURATION/60))m $((DURATION%60))s)"
|
||||||
106
scripts/.archive/run_curator_sonnet_rerun.sh
Executable file
106
scripts/.archive/run_curator_sonnet_rerun.sh
Executable file
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# A/B test #3: Sonnet 4.5 re-run on CMP-78 — same task as DeepSeek #2 but with Sonnet.
|
||||||
|
# Goal: check if Sonnet is consistent across runs (esp. the case-outcome detection),
|
||||||
|
# given that the original Sonnet baseline on CMP-80 misread the outcome as "דחייה"
|
||||||
|
# while the actual result is "קבלה חלקית".
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROFILE_HOME="/home/chaim/.hermes/profiles/curator-cmp" # default Sonnet profile
|
||||||
|
PAPERCLIP_API_URL="http://localhost:3100/api"
|
||||||
|
PAPERCLIP_API_KEY="pcp_c87edcf306d06fce13fac701bb6d747191d61dba5b51e903"
|
||||||
|
PAPERCLIP_TASK_ID="beb745e5-7195-40c5-9ac0-e9682c2c5184" # CMP-78
|
||||||
|
PAPERCLIP_TASK_KEY="$PAPERCLIP_TASK_ID"
|
||||||
|
PAPERCLIP_TASK_TITLE="[ערר 1130-25] סקירת ידע — Sonnet rerun (consistency check)"
|
||||||
|
PAPERCLIP_RUN_ID="sonnet-rerun-$(date +%s)"
|
||||||
|
PAPERCLIP_WAKE_REASON="manual_sonnet_consistency_rerun"
|
||||||
|
|
||||||
|
read -r -d '' PROMPT <<'EOF' || true
|
||||||
|
אתה מנהל ידע (Knowledge Curator) של ועדת הערר. נעור על תיק שדפנה סימנה כסופי.
|
||||||
|
|
||||||
|
תיק: [ערר 1130-25] סקירת ידע — Knowledge Curator
|
||||||
|
issue ID: beb745e5-7195-40c5-9ac0-e9682c2c5184
|
||||||
|
run reason: manual_sonnet_consistency_rerun
|
||||||
|
|
||||||
|
**הקשר חשוב — ניסוי A/B #3:** זוהי ריצה חוזרת ידנית באמצעות Sonnet 4.5 (אותו מודל שהריץ ב-CMP-80) — בדיקת עקביות. כל הפלטים שתפרסם חייבים להתחיל בכותרת `[ניסוי Sonnet 4.5 — ריצה חוזרת על CMP-78]`. אל תעיר סוכנים אחרים. אל תיצור issues חדשים.
|
||||||
|
|
||||||
|
הוראות:
|
||||||
|
דפנה סימנה את ההחלטה הסופית של תיק 1130-25 כסופית.
|
||||||
|
קובץ סופי: `סופי-1130-25.docx`
|
||||||
|
|
||||||
|
סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.
|
||||||
|
חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, ממוספר.
|
||||||
|
|
||||||
|
# שלבי ביצוע
|
||||||
|
|
||||||
|
## 1. קונטקסט
|
||||||
|
- קרא את MEMORY.md שלך (memory tool) — מה כבר זיהית.
|
||||||
|
- קרא `/home/chaim/legal-ai/skills/decision/SKILL.md` (file tool) — מה כבר תועד.
|
||||||
|
|
||||||
|
## 2. נתונים
|
||||||
|
- `mcp__legal-ai__case_get` עם case_number `1130-25` — מטא-דאטה.
|
||||||
|
- `mcp__legal-ai__case_get_final_text` עם case_number `1130-25` — קרא את הטקסט המלא של ההחלטה הסופית.
|
||||||
|
|
||||||
|
**שים לב במיוחד**: זהה במדויק את **תוצאת ההחלטה** (קבלה / קבלה חלקית / דחייה) על סמך הטקסט עצמו, לא על סמך הנחות.
|
||||||
|
|
||||||
|
## 3. ניתוח
|
||||||
|
חפש 3-5 דפוסים/פערים. לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת.
|
||||||
|
|
||||||
|
## 4. כתוב comment הממצאים
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg b "$BODY" '{body:$b}')" \
|
||||||
|
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/comments"
|
||||||
|
```
|
||||||
|
|
||||||
|
פורמט ה-body:
|
||||||
|
- שורה ראשונה: `[ניסוי Sonnet 4.5 — ריצה חוזרת על CMP-78]`
|
||||||
|
- שורה שנייה: `**תוצאת ההחלטה הזו: <קבלה / קבלה חלקית / דחייה>** — ציין מפורשות
|
||||||
|
- אחר כך פסקה אחת מבוא קצרה
|
||||||
|
- אחר כך הממצאים ממוספרים
|
||||||
|
|
||||||
|
## 5. פתח interaction מסוג ask_user_questions
|
||||||
|
זהה לפלואו של Sonnet באמת. אם תקבל "Agent run id required" — נסה כמה דרכים, ואם לא הולך, פרסם comment עם רשימת אופציות לבחירה.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/interactions" \
|
||||||
|
-d '{
|
||||||
|
"kind": "ask_user_questions",
|
||||||
|
"idempotencyKey": "curator-sonnet-rerun:'"$PAPERCLIP_TASK_ID"':select",
|
||||||
|
"title": "[Sonnet rerun] איזה ממצאים שווים עדכון?",
|
||||||
|
"continuationPolicy": "wake_assignee",
|
||||||
|
"payload": {"version": 1, "submitLabel": "אשר בחירה",
|
||||||
|
"questions": [{"id": "findings_to_propose", "prompt": "סמן ממצאים", "selectionMode": "multi", "options": []}]}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. עדכן issue ל-status=in_review
|
||||||
|
```bash
|
||||||
|
curl -sS -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"in_review"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
# כללים
|
||||||
|
- אל תעדכן קבצים בעצמך. רק comment + interaction.
|
||||||
|
- אל תיצור issues חדשים.
|
||||||
|
- אל תעיר סוכנים אחרים.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
export HERMES_HOME="$PROFILE_HOME"
|
||||||
|
export PAPERCLIP_API_URL PAPERCLIP_API_KEY PAPERCLIP_TASK_ID PAPERCLIP_TASK_KEY \
|
||||||
|
PAPERCLIP_TASK_TITLE PAPERCLIP_RUN_ID PAPERCLIP_WAKE_REASON
|
||||||
|
|
||||||
|
echo "=== Sonnet 4.5 rerun (consistency check) — CMP-78 ==="
|
||||||
|
echo "HERMES_HOME=$HERMES_HOME"
|
||||||
|
echo "TASK_ID=$PAPERCLIP_TASK_ID"
|
||||||
|
echo "RUN_ID=$PAPERCLIP_RUN_ID"
|
||||||
|
echo "Started: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
START_EPOCH=$(date +%s)
|
||||||
|
hermes -z "$PROMPT" --yolo chat 2>&1
|
||||||
|
END_EPOCH=$(date +%s)
|
||||||
|
DURATION=$((END_EPOCH - START_EPOCH))
|
||||||
|
echo ""
|
||||||
|
echo "=== Run finished ==="
|
||||||
|
echo "Ended: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
echo "Duration: ${DURATION}s ($((DURATION/60))m $((DURATION%60))s)"
|
||||||
@@ -10,8 +10,9 @@
|
|||||||
|--------|------|---------|-----------|
|
|--------|------|---------|-----------|
|
||||||
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
|
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
|
||||||
| `sync_missing_agent_skills.py` | python | סקריפט "אל-כשל" להוספת `paperclipSkillSync` ל-`הגהת מסמכים` ו-`מנתח משפטי` שפיספסו את ה-sync ההיסטורי (Gap #28). תומך `--verify`/`--dry-run`/`--apply`. גיבוי אוטומטי ל-`agents-pre-skill-sync-*.sql`. דורש `PAPERCLIP_BOARD_API_KEY` (Infisical /paperclip ב-nautilus env). idempotent. | חד-פעמי (בוצע 2026-05-04). שמור לרפרנס |
|
| `sync_missing_agent_skills.py` | python | סקריפט "אל-כשל" להוספת `paperclipSkillSync` ל-`הגהת מסמכים` ו-`מנתח משפטי` שפיספסו את ה-sync ההיסטורי (Gap #28). תומך `--verify`/`--dry-run`/`--apply`. גיבוי אוטומטי ל-`agents-pre-skill-sync-*.sql`. דורש `PAPERCLIP_BOARD_API_KEY` (Infisical /paperclip ב-nautilus env). idempotent. | חד-פעמי (בוצע 2026-05-04). שמור לרפרנס |
|
||||||
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** | ידני אחרי כל שינוי |
|
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — הסקריפט מדלג על הסוכן עם warning. בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות לפני sync.** | ידני אחרי כל שינוי |
|
||||||
| `fix_paperclipai_skills_drift.py` | python | סקריפט חד-פעמי (בוצע 2026-05-04) שניקה drift על `paperclipai/*` skills בין CMP ל-CMPA. הסיר `paperclip-dev` מכל 14 הסוכנים, ודאג ש-`paperclip-converting-plans-to-tasks` קיים רק על CEO ו-analyst. תומך `--apply` (ברירת מחדל: dry-run). דורש `PAPERCLIP_BOARD_API_KEY`. נשמר לרפרנס למקרה שhdrift חוזר. | חד-פעמי (בוצע) |
|
| `fix_paperclipai_skills_drift.py` | python | סקריפט חד-פעמי (בוצע 2026-05-04) שניקה drift על `paperclipai/*` skills בין CMP ל-CMPA. הסיר `paperclip-dev` מכל 14 הסוכנים, ודאג ש-`paperclip-converting-plans-to-tasks` קיים רק על CEO ו-analyst. תומך `--apply` (ברירת מחדל: dry-run). דורש `PAPERCLIP_BOARD_API_KEY`. נשמר לרפרנס למקרה שhdrift חוזר. | חד-פעמי (בוצע) |
|
||||||
|
| `test_retrieval_by_name.py` | python | בדיקת אחזור-לפי-שם (#52/RC-A) — מאמת ש`search_precedent_library`/`search_internal_decisions` מדרגים את ההחלטה עצמה (אגסי) מעל מי שמצטט אותה, + רגרסיות לשאילתות מהותיות. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/test_retrieval_by_name.py` (exit 0 = עבר). | ידני אחרי שינוי שכבת חיפוש |
|
||||||
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
||||||
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
||||||
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
||||||
@@ -28,6 +29,14 @@
|
|||||||
| `voyage_rerank_corpus_poc.py` | python | POC #5 — voyage-3 vs rerank-2 על קורפוס מלא (785 docs). הכרעה: +4.5% mean@3 כללי, +11.6% על P queries (practical) | בנצ'מרק חד-פעמי, אישר את שלב B |
|
| `voyage_rerank_corpus_poc.py` | python | POC #5 — voyage-3 vs rerank-2 על קורפוס מלא (785 docs). הכרעה: +4.5% mean@3 כללי, +11.6% על P queries (practical) | בנצ'מרק חד-פעמי, אישר את שלב B |
|
||||||
| `multimodal_backfill.py` | python | Backfill voyage-multimodal-3 page embeddings על מסמכי תיקים קיימים. idempotent (skips by default), forces `MULTIMODAL_ENABLED=true` ל-run, רץ מהקונטיינר. שלב C — ראה `docs/voyage-upgrades-plan.md` | ידני per-case (`python multimodal_backfill.py 8174-24 8137-24`) |
|
| `multimodal_backfill.py` | python | Backfill voyage-multimodal-3 page embeddings על מסמכי תיקים קיימים. idempotent (skips by default), forces `MULTIMODAL_ENABLED=true` ל-run, רץ מהקונטיינר. שלב C — ראה `docs/voyage-upgrades-plan.md` | ידני per-case (`python multimodal_backfill.py 8174-24 8137-24`) |
|
||||||
| `backfill_chunk_pages.py` | python | Backfill `page_number` ב-`document_chunks` קיימים. legacy chunker לא tracked עמודים → `page_number=NULL` חוסם boost של multimodal hybrid (text+image join על אותו עמוד). re-extracts כל PDF (re-OCR אם צריך, ~$0.0015/page), מחשב page_offsets, ומעדכן chunks. idempotent | ידני per-case (`python backfill_chunk_pages.py 8174-24 8137-24`) |
|
| `backfill_chunk_pages.py` | python | Backfill `page_number` ב-`document_chunks` קיימים. legacy chunker לא tracked עמודים → `page_number=NULL` חוסם boost של multimodal hybrid (text+image join על אותו עמוד). re-extracts כל PDF (re-OCR אם צריך, ~$0.0015/page), מחשב page_offsets, ומעדכן chunks. idempotent | ידני per-case (`python backfill_chunk_pages.py 8174-24 8137-24`) |
|
||||||
|
| `audit_corpus_integrity.py` | python | בדיקה תקופתית של עקביות הקורפוס — 3 בדיקות SQL read-only על `case_law` ו-`cases`: (A) `external_upload` עם prefix פנימי `ערר`/`בל"מ`; (B) `internal_committee` חסר `chair_name`/`district`; (C) `cases.practice_area` מחוץ ל-{`rishuy_uvniya`, `betterment_levy`, `compensation_197`, `''`}. כותב log מצטבר ל-`data/logs/corpus_integrity_audit.log` ובמצב הפרות שולח wakeup ל-CEO ב-Paperclip (best-effort, רק אם `PAPERCLIP_API_URL`+`PAPERCLIP_API_KEY` מוגדרים). דגל: `--no-notify`. Idempotent, יוצא 0. **Cron יומי 07:00**: `0 7 * * * /home/chaim/legal-ai/mcp-server/.venv/bin/python /home/chaim/legal-ai/scripts/audit_corpus_integrity.py` | `0 7 * * *` (cron) |
|
||||||
|
| `backfill_legal_arguments.py` | python | Backfill `legal_arguments` לתיקים עם `claims` קיימים (TaskMaster #36). מקבץ פרופוזיציות גולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד) דרך `argument_aggregator.aggregate_claims_to_arguments` (Claude CLI). תומך `--dry-run`/`--apply`/`--force`/`--case <num>...`. **חייב לרוץ מהמכונה המקומית** (לא קונטיינר) — `claude_session` דורש Claude CLI | ידני per-case (`python scripts/backfill_legal_arguments.py --apply --case 1017-03-26`) |
|
||||||
|
| `upload_blam_decisions.py` | python | חד-פעמי (2026-05-26) — העלאת 2 החלטות בל"מ ל-`case_law` (8126/24 סופר נוח, 8047/23 הרנון) דרך `ingest_internal_decision` ישיר, עוקף MCP server שטרם נטען מחדש אחרי הוספת `proceeding_type`. **לא להריץ שוב** | חד-פעמי — להעביר ל-`.archive/` בהזדמנות |
|
||||||
|
| `process_pending_blam.py` | python | חד-פעמי (2026-05-26) — הרצת metadata + halacha extraction על 2 החלטות בל"מ שעלו ב-`upload_blam_decisions.py`. עוקף MCP (אותו טעם). **לא להריץ שוב** | חד-פעמי — להעביר ל-`.archive/` בהזדמנות |
|
||||||
|
| `compute_ndcg.py` | python | חישוב nDCG@10 על `search_relevance_feedback` (TaskMaster #50, Stage C). aggregation לפי `search_type` ולפי שבוע, כולל top-cited case_law ו-coverage %. דגלים: `--k 10`, `--weeks 12`, `--pretty`. read-only, פלט JSON. משמש גם את `GET /api/admin/rag-metrics` (מיובא inline) — שינוי חתימה ב-`compute()` ישבור את ה-endpoint | ידני / cron עתידי לדיווח שבועי |
|
||||||
|
| `backfill_multimodal_precedents.py` | python | Backfill voyage-multimodal-3 page embeddings על רשומות `case_law` (external_upload + internal_committee) שחסרות `precedent_image_embeddings`. בונה אינדקס קבצים מ-`data/precedent-library/` ו-`data/internal-decisions/`, מנסה התאמה לפי tokens של מספרי תיק (כולל parts-match לפורמטים שונים של Nevo doc-id). מדלג על רשומות בלי קובץ-מקור או עם MD בלבד (PyMuPDF לא מרנדר MD). תומך `--dry-run` (default) / `--apply` / `--only external_upload\|internal_committee` / `--limit N`. רץ בקונטיינר (יש `/data` + Voyage env). **הופעל 2026-05-26**: 70 חסרים → 26 backfilled (503 pages, ~$0.21 voyage tokens), 44 אין-קובץ-מקור. ניתן להריץ שוב אחרי שיועלו עוד PDF/DOCX לספרייה | ידני |
|
||||||
|
| `monitor_halacha_quality.py` | python | מנטר איכות חילוץ הלכות. בודק drift של `avg(confidence)` בין baseline היסטורי לחלון אחרון. מחזיר JSON מטריקות + alert ב-stderr אם drift > threshold (ברירת מחדל 5%). 2 סדרות: trusted (approved+published) ו-all_extracted. תומך `--window N` / `--threshold X` / `--min-sample N` / `--silent` / `--exit-on-alert`. רץ ב-container או מקומית עם `mcp-server/.venv` (אין תלות ב-LLM, רק SQL). **תזמון מומלץ**: `0 8 * * 1` (יום ראשון 08:00, שבועי) | `0 8 * * 1` (לתזמן) |
|
||||||
|
| `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה |
|
||||||
|
|
||||||
## תיקיית `.archive/` — סקריפטים שהושלמו
|
## תיקיית `.archive/` — סקריפטים שהושלמו
|
||||||
|
|
||||||
@@ -54,6 +63,9 @@
|
|||||||
| `seed-appeals.py` | seeding תיקי ערר ראשוניים ל-DB | MCP: `case_create()` |
|
| `seed-appeals.py` | seeding תיקי ערר ראשוניים ל-DB | MCP: `case_create()` |
|
||||||
| `seed-knowledge.py` | seeding לקחים, ביטויי מעבר, פסיקה | MCP: `record_chair_feedback()`, `precedent_attach()` |
|
| `seed-knowledge.py` | seeding לקחים, ביטויי מעבר, פסיקה | MCP: `record_chair_feedback()`, `precedent_attach()` |
|
||||||
| `validate-decision.py` | ולידציה מול block-schema | MCP: `validate_decision()` + `qa_validator.py` |
|
| `validate-decision.py` | ולידציה מול block-schema | MCP: `validate_decision()` + `qa_validator.py` |
|
||||||
|
| `run_curator_deepseek_test.sh` | A/B test #1 (2026-05-05) — Hermes Curator על CMP-78 דרך DeepSeek V4-Pro ב-`provider:custom`, ללא interaction. תוצאה: 6:33 דק׳, 5 ממצאי סגנון/לקסיקון, פי 3 מהיר מ-Sonnet baseline (CMP-80) ופי ~20 זול. **הסקריפט נקודתי לתיק 1130-25 — לא להריץ שוב** | החלפת Curator לאדפטר DeepSeek מקומי (בתהליך) |
|
||||||
|
| `run_curator_deepseek_test_v2.sh` | A/B test #2 (2026-05-05) — אותו run אבל עם interaction. תוצאה: 9:08 דק׳, 5 ממצאים, היחיד מ-4 הריצות שזיהה תוצאה עובדתית נכונה (קבלה חלקית). interaction נכשל ב-API ("Agent run id required" בריצה ידנית). | החלפת Curator לאדפטר DeepSeek מקומי |
|
||||||
|
| `run_curator_sonnet_rerun.sh` | A/B test #3 (2026-05-05) — ריצה חוזרת של Sonnet 4.5 על אותו CMP-78. תוצאה: 12:52 דק׳ (לעומת 20:13 בריצה המקורית — כי בלי לולאת interaction.json). זיהה תוצאה שגויה ("דחייה") **בעקביות עם הריצה המקורית** — Sonnet עקבי-בטעות, DeepSeek אקראי. | בדיקה חד-פעמית — לא להריץ שוב |
|
||||||
|
|
||||||
## סקריפטים שנמחקו (git history בלבד)
|
## סקריפטים שנמחקו (git history בלבד)
|
||||||
|
|
||||||
|
|||||||
281
scripts/audit_corpus_integrity.py
Normal file
281
scripts/audit_corpus_integrity.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""Periodic corpus-integrity audit.
|
||||||
|
|
||||||
|
Runs a set of read-only SQL checks against the legal-ai DB to detect rows
|
||||||
|
that violate domain constraints which are *not* enforced by the schema
|
||||||
|
(or were added after the constraint was put in place).
|
||||||
|
|
||||||
|
Checks performed:
|
||||||
|
|
||||||
|
A. ``case_law`` rows with ``source_kind='external_upload'`` whose
|
||||||
|
``case_number`` starts with the Hebrew prefixes ``ערר`` / ``בל"מ``.
|
||||||
|
Internal committee decisions belong to ``source_kind='internal_committee'``.
|
||||||
|
|
||||||
|
B. ``case_law`` rows with ``source_kind='internal_committee'`` that
|
||||||
|
lack a ``chair_name`` and/or ``district``. Internal decisions must
|
||||||
|
carry both.
|
||||||
|
|
||||||
|
C. ``cases`` rows with a ``practice_area`` outside the closed set
|
||||||
|
{``rishuy_uvniya``, ``betterment_levy``, ``compensation_197``, ``''``}.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
* Appends a timestamped block to ``data/logs/corpus_integrity_audit.log``.
|
||||||
|
* If hits are found AND env ``PAPERCLIP_API_URL`` + ``PAPERCLIP_API_KEY``
|
||||||
|
are set, posts a CEO wakeup comment via ``POST /api/agents/{ceo}/wakeup``
|
||||||
|
(best-effort, never fails the script).
|
||||||
|
* Always exits 0 unless an unexpected error occurs (so cron stays quiet).
|
||||||
|
|
||||||
|
Cron suggestion (daily 07:00):
|
||||||
|
|
||||||
|
0 7 * * * /home/chaim/legal-ai/mcp-server/.venv/bin/python \\
|
||||||
|
/home/chaim/legal-ai/scripts/audit_corpus_integrity.py
|
||||||
|
|
||||||
|
Idempotent. Read-only on the DB.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Load ~/.env so POSTGRES_* / PAPERCLIP_* are picked up when run from cron.
|
||||||
|
ENV_PATH = os.path.expanduser("~/.env")
|
||||||
|
if os.path.isfile(ENV_PATH):
|
||||||
|
with open(ENV_PATH, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
os.environ.setdefault(k, v)
|
||||||
|
|
||||||
|
import asyncpg # noqa: E402
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx # noqa: E402
|
||||||
|
except ImportError: # httpx is part of the legal-ai venv; not required for DB checks
|
||||||
|
httpx = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
LOG_PATH = REPO_ROOT / "data" / "logs" / "corpus_integrity_audit.log"
|
||||||
|
|
||||||
|
CHECK_A_SQL = (
|
||||||
|
"SELECT id, case_number FROM case_law "
|
||||||
|
"WHERE source_kind = 'external_upload' AND case_number ~ '^ערר|^בל\"מ' "
|
||||||
|
"ORDER BY case_number"
|
||||||
|
)
|
||||||
|
CHECK_B_SQL = (
|
||||||
|
"SELECT id, case_number, chair_name, district FROM case_law "
|
||||||
|
"WHERE source_kind = 'internal_committee' "
|
||||||
|
"AND (chair_name IS NULL OR chair_name = '' "
|
||||||
|
" OR district IS NULL OR district = '') "
|
||||||
|
"ORDER BY case_number"
|
||||||
|
)
|
||||||
|
CHECK_C_SQL = (
|
||||||
|
"SELECT id, case_number, practice_area FROM cases "
|
||||||
|
"WHERE practice_area IS NOT NULL "
|
||||||
|
"AND practice_area NOT IN ('rishuy_uvniya', 'betterment_levy', "
|
||||||
|
" 'compensation_197', '') "
|
||||||
|
"ORDER BY case_number"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("audit_corpus_integrity")
|
||||||
|
|
||||||
|
|
||||||
|
def _pg_url() -> str:
|
||||||
|
"""Resolve POSTGRES URL from env, falling back to discrete vars."""
|
||||||
|
url = os.environ.get("POSTGRES_URL")
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
pg_host = os.environ.get("POSTGRES_HOST", "127.0.0.1")
|
||||||
|
pg_port = int(os.environ.get("POSTGRES_PORT", "5433"))
|
||||||
|
pg_user = os.environ.get("POSTGRES_USER", "legal_ai")
|
||||||
|
pg_pw = os.environ.get("POSTGRES_PASSWORD", "")
|
||||||
|
pg_db = os.environ.get("POSTGRES_DB", "legal_ai")
|
||||||
|
if not pg_pw:
|
||||||
|
raise SystemExit("POSTGRES_PASSWORD / POSTGRES_URL not set")
|
||||||
|
return f"postgres://{pg_user}:{pg_pw}@{pg_host}:{pg_port}/{pg_db}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_check(conn: asyncpg.Connection, sql: str) -> list[dict]:
|
||||||
|
rows = await conn.fetch(sql)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_ceo_agent_id() -> str | None:
|
||||||
|
"""Best-effort: look up the CEO agent UUID for CMP via the API.
|
||||||
|
|
||||||
|
Returns None if PAPERCLIP env is missing or the lookup fails.
|
||||||
|
"""
|
||||||
|
base_url = os.environ.get("PAPERCLIP_API_URL")
|
||||||
|
api_key = os.environ.get("PAPERCLIP_API_KEY")
|
||||||
|
if not (base_url and api_key and httpx is not None):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
r = await client.get(
|
||||||
|
f"{base_url}/api/agents",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
payload = r.json()
|
||||||
|
items = payload if isinstance(payload, list) else payload.get("items", [])
|
||||||
|
for item in items:
|
||||||
|
# Look for a CMP-side CEO (master); the CMPA mirror has a different id.
|
||||||
|
title = (item.get("title") or "").lower()
|
||||||
|
role = (item.get("role") or "").lower()
|
||||||
|
if "ceo" in title or "ceo" in role or "מנכ" in title:
|
||||||
|
return item.get("id")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("CEO lookup failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_ceo(summary: str) -> bool:
|
||||||
|
"""Post a wakeup comment to the CEO agent. Returns True on best-effort success."""
|
||||||
|
base_url = os.environ.get("PAPERCLIP_API_URL")
|
||||||
|
api_key = os.environ.get("PAPERCLIP_API_KEY")
|
||||||
|
if not (base_url and api_key and httpx is not None):
|
||||||
|
logger.info("Paperclip env not set — skipping CEO wakeup")
|
||||||
|
return False
|
||||||
|
ceo_id = await _resolve_ceo_agent_id()
|
||||||
|
if not ceo_id:
|
||||||
|
logger.info("Could not resolve CEO agent id — skipping wakeup")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{base_url}/api/agents/{ceo_id}/wakeup",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"source": "automation",
|
||||||
|
"triggerDetail": "audit_corpus_integrity",
|
||||||
|
"reason": "corpus integrity audit found violations",
|
||||||
|
"payload": {"summary": summary},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
logger.info("Notified CEO (agent_id=%s)", ceo_id)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("CEO wakeup failed: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _format_report(
|
||||||
|
a_hits: list[dict],
|
||||||
|
b_hits: list[dict],
|
||||||
|
c_hits: list[dict],
|
||||||
|
ts: datetime,
|
||||||
|
) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
parts.append(f"=== Corpus integrity audit @ {ts.isoformat()} ===")
|
||||||
|
parts.append("")
|
||||||
|
parts.append(
|
||||||
|
f"Check A (case_law external_upload with internal-style "
|
||||||
|
f"case_number prefix): {len(a_hits)} hit(s)"
|
||||||
|
)
|
||||||
|
for row in a_hits[:50]:
|
||||||
|
parts.append(f" - id={row['id']} case_number={row['case_number']!r}")
|
||||||
|
if len(a_hits) > 50:
|
||||||
|
parts.append(f" ... ({len(a_hits) - 50} more truncated)")
|
||||||
|
parts.append("")
|
||||||
|
parts.append(
|
||||||
|
f"Check B (case_law internal_committee missing chair_name/district): "
|
||||||
|
f"{len(b_hits)} hit(s)"
|
||||||
|
)
|
||||||
|
for row in b_hits[:50]:
|
||||||
|
parts.append(
|
||||||
|
f" - id={row['id']} case_number={row['case_number']!r} "
|
||||||
|
f"chair_name={row.get('chair_name')!r} district={row.get('district')!r}"
|
||||||
|
)
|
||||||
|
if len(b_hits) > 50:
|
||||||
|
parts.append(f" ... ({len(b_hits) - 50} more truncated)")
|
||||||
|
parts.append("")
|
||||||
|
parts.append(
|
||||||
|
f"Check C (cases.practice_area outside closed set): {len(c_hits)} hit(s)"
|
||||||
|
)
|
||||||
|
for row in c_hits[:50]:
|
||||||
|
parts.append(
|
||||||
|
f" - id={row['id']} case_number={row['case_number']!r} "
|
||||||
|
f"practice_area={row.get('practice_area')!r}"
|
||||||
|
)
|
||||||
|
if len(c_hits) > 50:
|
||||||
|
parts.append(f" ... ({len(c_hits) - 50} more truncated)")
|
||||||
|
parts.append("")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def main(args: argparse.Namespace) -> int:
|
||||||
|
pg_url = _pg_url()
|
||||||
|
conn = await asyncpg.connect(pg_url)
|
||||||
|
try:
|
||||||
|
a_hits = await _run_check(conn, CHECK_A_SQL)
|
||||||
|
b_hits = await _run_check(conn, CHECK_B_SQL)
|
||||||
|
c_hits = await _run_check(conn, CHECK_C_SQL)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
total = len(a_hits) + len(b_hits) + len(c_hits)
|
||||||
|
ts = datetime.now(timezone.utc)
|
||||||
|
report = _format_report(a_hits, b_hits, c_hits, ts)
|
||||||
|
|
||||||
|
# Always write to log (creates dir + file if missing).
|
||||||
|
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with LOG_PATH.open("a", encoding="utf-8") as f:
|
||||||
|
f.write(report)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
# Echo to stdout so cron mail / manual run shows the result.
|
||||||
|
print(report)
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
logger.info("clean: no integrity violations found")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"found %d total violation(s) (A=%d, B=%d, C=%d)",
|
||||||
|
total, len(a_hits), len(b_hits), len(c_hits),
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.notify:
|
||||||
|
summary_lines = [
|
||||||
|
"ה-audit היומי על הקורפוס מצא הפרות:",
|
||||||
|
f"- Check A (external_upload עם prefix פנימי): {len(a_hits)}",
|
||||||
|
f"- Check B (internal_committee חסר chair/district): {len(b_hits)}",
|
||||||
|
f"- Check C (cases.practice_area לא תקין): {len(c_hits)}",
|
||||||
|
"",
|
||||||
|
f"פירוט מלא: {LOG_PATH}",
|
||||||
|
]
|
||||||
|
await _notify_ceo("\n".join(summary_lines))
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-notify",
|
||||||
|
dest="notify",
|
||||||
|
action="store_false",
|
||||||
|
help="Don't post a CEO wakeup even if hits are found",
|
||||||
|
)
|
||||||
|
parser.set_defaults(notify=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
rc = asyncio.run(main(args))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(130)
|
||||||
|
sys.exit(rc)
|
||||||
196
scripts/audit_training_corpus.py
Executable file
196
scripts/audit_training_corpus.py
Executable file
@@ -0,0 +1,196 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Audit the style_corpus table — list each decision with what's populated and what's missing.
|
||||||
|
|
||||||
|
Produces a JSON report at data/audit/corpus-YYYY-MM-DD.json so we can see at a glance
|
||||||
|
which corpus entries lack summary/outcome/key_principles/appeal_subtype/chunks/embeddings.
|
||||||
|
|
||||||
|
Run with the mcp-server venv (has asyncpg):
|
||||||
|
POSTGRES_URL=postgres://... ./mcp-server/.venv/bin/python scripts/audit_training_corpus.py
|
||||||
|
|
||||||
|
Without POSTGRES_URL, falls back to the per-field env vars used by web/mcp-server config.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dsn() -> str:
|
||||||
|
if url := os.environ.get("POSTGRES_URL"):
|
||||||
|
return url
|
||||||
|
return (
|
||||||
|
f"postgres://{os.environ.get('POSTGRES_USER', 'legal_ai')}:"
|
||||||
|
f"{os.environ.get('POSTGRES_PASSWORD', '')}@"
|
||||||
|
f"{os.environ.get('POSTGRES_HOST', '127.0.0.1')}:"
|
||||||
|
f"{os.environ.get('POSTGRES_PORT', '5433')}/"
|
||||||
|
f"{os.environ.get('POSTGRES_DB', 'legal_ai')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def audit() -> dict:
|
||||||
|
dsn = _build_dsn()
|
||||||
|
conn = await asyncpg.connect(dsn)
|
||||||
|
try:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT id, decision_number, decision_date, subject_categories,
|
||||||
|
length(full_text) AS chars,
|
||||||
|
summary,
|
||||||
|
outcome,
|
||||||
|
key_principles,
|
||||||
|
practice_area,
|
||||||
|
appeal_subtype,
|
||||||
|
document_id,
|
||||||
|
created_at
|
||||||
|
FROM style_corpus
|
||||||
|
ORDER BY decision_date NULLS LAST, decision_number
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Chunk + embedding counts for each related document — by direct FK first,
|
||||||
|
# then by title-match for legacy rows where style_corpus.document_id is NULL.
|
||||||
|
chunk_counts = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT d.id AS doc_id, d.title,
|
||||||
|
count(c.id) AS chunks,
|
||||||
|
count(c.embedding) FILTER (WHERE c.embedding IS NOT NULL) AS chunks_with_emb
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN document_chunks c ON c.document_id = d.id
|
||||||
|
WHERE d.title LIKE '[קורפוס]%' OR d.id IN (SELECT document_id FROM style_corpus WHERE document_id IS NOT NULL)
|
||||||
|
GROUP BY d.id, d.title
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
by_doc_id = {r["doc_id"]: r for r in chunk_counts}
|
||||||
|
|
||||||
|
# Index corpus documents by every digit cluster in their title so we can
|
||||||
|
# match against style_corpus.decision_number regardless of formatting
|
||||||
|
# (e.g. style_corpus has "1109-25" but title may say "ARAR-25-1109" or
|
||||||
|
# "ערר 1009-25"). Each digit run >=3 chars becomes a key.
|
||||||
|
by_digit: dict[str, dict] = {}
|
||||||
|
for r in chunk_counts:
|
||||||
|
title = r["title"] or ""
|
||||||
|
for tok in re.findall(r"\d{3,}", title):
|
||||||
|
by_digit.setdefault(tok, r)
|
||||||
|
|
||||||
|
decisions = []
|
||||||
|
gaps_total = {
|
||||||
|
"summary": 0, "outcome": 0, "key_principles": 0,
|
||||||
|
"appeal_subtype": 0, "subject_categories": 0,
|
||||||
|
"chunks": 0, "embeddings": 0, "document_id": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
cats = row["subject_categories"]
|
||||||
|
if isinstance(cats, str):
|
||||||
|
try:
|
||||||
|
cats = json.loads(cats)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
cats = []
|
||||||
|
cats = cats or []
|
||||||
|
|
||||||
|
kp = row["key_principles"]
|
||||||
|
if isinstance(kp, str):
|
||||||
|
try:
|
||||||
|
kp = json.loads(kp)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
kp = []
|
||||||
|
kp = kp or []
|
||||||
|
|
||||||
|
# Resolve chunks: prefer FK, fall back to digit-cluster match on decision_number.
|
||||||
|
chunks = 0
|
||||||
|
chunks_with_emb = 0
|
||||||
|
if row["document_id"] and row["document_id"] in by_doc_id:
|
||||||
|
r = by_doc_id[row["document_id"]]
|
||||||
|
chunks = r["chunks"]
|
||||||
|
chunks_with_emb = r["chunks_with_emb"]
|
||||||
|
elif row["decision_number"]:
|
||||||
|
for tok in re.findall(r"\d{3,}", row["decision_number"]):
|
||||||
|
if tok in by_digit:
|
||||||
|
r = by_digit[tok]
|
||||||
|
chunks = r["chunks"]
|
||||||
|
chunks_with_emb = r["chunks_with_emb"]
|
||||||
|
break
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
if not row["summary"]:
|
||||||
|
missing.append("summary")
|
||||||
|
gaps_total["summary"] += 1
|
||||||
|
if not row["outcome"]:
|
||||||
|
missing.append("outcome")
|
||||||
|
gaps_total["outcome"] += 1
|
||||||
|
if not kp:
|
||||||
|
missing.append("key_principles")
|
||||||
|
gaps_total["key_principles"] += 1
|
||||||
|
if not row["appeal_subtype"]:
|
||||||
|
missing.append("appeal_subtype")
|
||||||
|
gaps_total["appeal_subtype"] += 1
|
||||||
|
if not cats:
|
||||||
|
missing.append("subject_categories")
|
||||||
|
gaps_total["subject_categories"] += 1
|
||||||
|
if chunks == 0:
|
||||||
|
missing.append("chunks")
|
||||||
|
gaps_total["chunks"] += 1
|
||||||
|
elif chunks_with_emb < chunks:
|
||||||
|
missing.append(f"embeddings({chunks_with_emb}/{chunks})")
|
||||||
|
gaps_total["embeddings"] += 1
|
||||||
|
if row["document_id"] is None:
|
||||||
|
missing.append("document_id")
|
||||||
|
gaps_total["document_id"] += 1
|
||||||
|
|
||||||
|
decisions.append({
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"decision_number": row["decision_number"] or "",
|
||||||
|
"decision_date": row["decision_date"].isoformat() if row["decision_date"] else None,
|
||||||
|
"chars": row["chars"],
|
||||||
|
"subject_categories": cats,
|
||||||
|
"practice_area": row["practice_area"] or "",
|
||||||
|
"appeal_subtype": row["appeal_subtype"] or "",
|
||||||
|
"summary_len": len(row["summary"] or ""),
|
||||||
|
"outcome_len": len(row["outcome"] or ""),
|
||||||
|
"key_principles_count": len(kp),
|
||||||
|
"chunks": chunks,
|
||||||
|
"chunks_with_embeddings": chunks_with_emb,
|
||||||
|
"document_id": str(row["document_id"]) if row["document_id"] else None,
|
||||||
|
"missing": missing,
|
||||||
|
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.now(UTC).isoformat(),
|
||||||
|
"total_decisions": len(decisions),
|
||||||
|
"gaps_total": gaps_total,
|
||||||
|
"decisions": decisions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
report = await audit()
|
||||||
|
out_dir = Path(__file__).resolve().parents[1] / "data" / "audit"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
today = date.today().isoformat()
|
||||||
|
out_file = out_dir / f"corpus-{today}.json"
|
||||||
|
out_file.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
# Console summary
|
||||||
|
print(f"Total decisions: {report['total_decisions']}")
|
||||||
|
print("Gaps by field (count of decisions missing it):")
|
||||||
|
for field, n in report["gaps_total"].items():
|
||||||
|
bar = "█" * min(n, 60)
|
||||||
|
print(f" {field:25s} {n:3d} {bar}")
|
||||||
|
print(f"\nReport written to {out_file}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(asyncio.run(main()))
|
||||||
164
scripts/backfill_legal_arguments.py
Executable file
164
scripts/backfill_legal_arguments.py
Executable file
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Backfill aggregated legal_arguments for existing cases.
|
||||||
|
|
||||||
|
For every case that has rows in ``claims`` but none in ``legal_arguments``,
|
||||||
|
run ``argument_aggregator.aggregate_claims_to_arguments``.
|
||||||
|
|
||||||
|
Usage (must use mcp-server venv — pgvector + asyncpg are vendored there):
|
||||||
|
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
|
||||||
|
|
||||||
|
# Default = dry-run (lists what would be processed):
|
||||||
|
$PY scripts/backfill_legal_arguments.py
|
||||||
|
|
||||||
|
# Process all cases that need it:
|
||||||
|
$PY scripts/backfill_legal_arguments.py --apply
|
||||||
|
|
||||||
|
# Re-aggregate even cases that already have arguments:
|
||||||
|
$PY scripts/backfill_legal_arguments.py --apply --force
|
||||||
|
|
||||||
|
# Only process specific cases:
|
||||||
|
$PY scripts/backfill_legal_arguments.py --apply --case 1017-03-26 1018-03-26
|
||||||
|
|
||||||
|
The script must run from the local dev machine (not the container) because
|
||||||
|
``argument_aggregator`` calls ``claude_session`` which needs the Claude CLI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
# Make the mcp-server source importable as ``legal_mcp``.
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
|
||||||
|
|
||||||
|
# Default DB connection (overridable via env / .env on the dev box).
|
||||||
|
if "POSTGRES_URL" not in os.environ:
|
||||||
|
pg_user = os.environ.get("POSTGRES_USER", "legal_ai")
|
||||||
|
pg_pw = os.environ.get("POSTGRES_PASSWORD", "")
|
||||||
|
pg_host = os.environ.get("POSTGRES_HOST", "127.0.0.1")
|
||||||
|
pg_port = os.environ.get("POSTGRES_PORT", "5433")
|
||||||
|
pg_db = os.environ.get("POSTGRES_DB", "legal_ai")
|
||||||
|
os.environ["POSTGRES_URL"] = (
|
||||||
|
f"postgres://{pg_user}:{pg_pw}@{pg_host}:{pg_port}/{pg_db}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_cases_needing_backfill(force: bool) -> list[dict]:
|
||||||
|
"""Find cases that have claims but no aggregated arguments (or all,
|
||||||
|
when ``force`` is True)."""
|
||||||
|
from legal_mcp.services import db
|
||||||
|
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT c.id, c.case_number, c.status,
|
||||||
|
COUNT(DISTINCT cl.id) AS claim_count,
|
||||||
|
COUNT(DISTINCT la.id) AS arg_count
|
||||||
|
FROM cases c
|
||||||
|
LEFT JOIN claims cl ON cl.case_id = c.id
|
||||||
|
LEFT JOIN legal_arguments la ON la.case_id = c.id
|
||||||
|
WHERE c.archived_at IS NULL
|
||||||
|
GROUP BY c.id, c.case_number, c.status
|
||||||
|
HAVING COUNT(DISTINCT cl.id) > 0
|
||||||
|
ORDER BY c.case_number
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
out: list[dict] = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
if force or d["arg_count"] == 0:
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_case(case: dict, force: bool) -> dict:
|
||||||
|
from legal_mcp.services import argument_aggregator
|
||||||
|
|
||||||
|
case_id = UUID(str(case["id"]))
|
||||||
|
case_number = case["case_number"]
|
||||||
|
print(
|
||||||
|
f"[backfill] {case_number}: {case['claim_count']} claims, "
|
||||||
|
f"{case['arg_count']} existing args — aggregating (force={force})...",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await argument_aggregator.aggregate_claims_to_arguments(
|
||||||
|
case_id, force=force,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {
|
||||||
|
"case_number": case_number,
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
print(
|
||||||
|
f"[backfill] {case_number}: status={result.get('status')} "
|
||||||
|
f"total={result.get('total')} by_party={result.get('by_party')}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return {"case_number": case_number, **result}
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Backfill legal_arguments for cases with extracted claims.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply", action="store_true",
|
||||||
|
help="Actually run aggregation (default: dry-run).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force", action="store_true",
|
||||||
|
help="Re-aggregate even cases that already have arguments.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--case", nargs="*", default=[],
|
||||||
|
help="Only process these case numbers (e.g. --case 1017-03-26 1018-03-26).",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
cases = await _list_cases_needing_backfill(force=args.force)
|
||||||
|
if args.case:
|
||||||
|
wanted = set(args.case)
|
||||||
|
cases = [c for c in cases if c["case_number"] in wanted]
|
||||||
|
|
||||||
|
if not cases:
|
||||||
|
print("[backfill] No cases need processing.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"[backfill] {len(cases)} case(s) to process:")
|
||||||
|
for c in cases:
|
||||||
|
print(
|
||||||
|
f" - {c['case_number']:<14} status={c['status']:<20} "
|
||||||
|
f"claims={c['claim_count']:<4} args={c['arg_count']}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not args.apply:
|
||||||
|
print("\n[backfill] dry-run — pass --apply to actually run.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print()
|
||||||
|
results: list[dict] = []
|
||||||
|
for case in cases:
|
||||||
|
r = await _process_case(case, force=args.force)
|
||||||
|
results.append(r)
|
||||||
|
|
||||||
|
print("\n[backfill] === Summary ===")
|
||||||
|
for r in results:
|
||||||
|
print(
|
||||||
|
f" {r['case_number']:<14} status={r.get('status', 'unknown'):<22} "
|
||||||
|
f"total={r.get('total', 0)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
errors = [r for r in results if r.get("status") == "error"]
|
||||||
|
return 1 if errors else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(asyncio.run(main()))
|
||||||
475
scripts/backfill_multimodal_precedents.py
Normal file
475
scripts/backfill_multimodal_precedents.py
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
"""Multimodal backfill for precedent library — fills voyage-multimodal-3
|
||||||
|
page embeddings for case_law rows (external_upload + internal_committee)
|
||||||
|
that don't have them yet.
|
||||||
|
|
||||||
|
Background
|
||||||
|
----------
|
||||||
|
77 (in practice 70 today, 2026-05-26) case_law rows were ingested before
|
||||||
|
``MULTIMODAL_ENABLED=true`` was permanently turned on, so they only have
|
||||||
|
text chunks and no per-page image embeddings. The retrieval blend is
|
||||||
|
hybrid (text + image), so the image side of the blend silently degrades
|
||||||
|
for these rows.
|
||||||
|
|
||||||
|
Strategy
|
||||||
|
--------
|
||||||
|
Most rows have no PDF (they were ingested via text or are MD-only). The
|
||||||
|
script:
|
||||||
|
|
||||||
|
1. Lists every case_law row with ``source_kind in (external_upload,
|
||||||
|
internal_committee)`` that is missing image embeddings.
|
||||||
|
2. Tries to find a staged file by matching token-rich substrings of the
|
||||||
|
case_number against filenames under ``data/precedent-library/`` and
|
||||||
|
``data/internal-decisions/``.
|
||||||
|
3. If the file is a PDF or DOCX (both renderable by PyMuPDF/fitz),
|
||||||
|
renders pages at ``MULTIMODAL_DPI``, embeds via voyage-multimodal-3
|
||||||
|
in batches of 50, and stores rows into ``precedent_image_embeddings``.
|
||||||
|
4. Skips rows whose only candidate file is .md (PyMuPDF can't render
|
||||||
|
markdown) or rows with no staged file.
|
||||||
|
|
||||||
|
Designed to run inside the FastAPI/MCP container (where ``/data/...``
|
||||||
|
exists and Voyage env vars are present). Locally, it falls back to
|
||||||
|
``/home/chaim/legal-ai/data/...`` via ``_resolve_local_path``.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# Inside container (Coolify):
|
||||||
|
docker exec -it <container> /opt/api/.venv/bin/python \\
|
||||||
|
/opt/api/scripts/backfill_multimodal_precedents.py --dry-run
|
||||||
|
# then:
|
||||||
|
docker exec -it <container> /opt/api/.venv/bin/python \\
|
||||||
|
/opt/api/scripts/backfill_multimodal_precedents.py --apply
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
- Token cost: voyage-multimodal-3 averages ~3-4K tokens per dense legal
|
||||||
|
page. 70 rows * ~30 pages avg = ~2,100 pages = ~7M tokens ≈ $0.70.
|
||||||
|
- Estimate-only mode (``--dry-run``) prints the matched files and
|
||||||
|
page counts without calling Voyage or touching the DB.
|
||||||
|
- Idempotent: per-record DELETE+INSERT inside
|
||||||
|
``store_precedent_image_embeddings``, but the outer loop also
|
||||||
|
skips rows that already have rows in ``precedent_image_embeddings``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_paths():
|
||||||
|
"""Ensure mcp-server src is on path even when run as a standalone script.
|
||||||
|
|
||||||
|
Works both from host (``/home/chaim/legal-ai/scripts/...``) and from
|
||||||
|
inside the container (``/app/mcp-server/src``).
|
||||||
|
"""
|
||||||
|
here = Path(__file__).resolve().parent
|
||||||
|
candidates = [
|
||||||
|
here.parent / "mcp-server" / "src", # host
|
||||||
|
Path("/app/mcp-server/src"), # container
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if c.is_dir() and str(c) not in sys.path:
|
||||||
|
sys.path.insert(0, str(c))
|
||||||
|
|
||||||
|
|
||||||
|
_setup_paths()
|
||||||
|
# Force multimodal on for this script regardless of env — backfill is
|
||||||
|
# the entire point. The deploy-time default stays whatever Coolify sets.
|
||||||
|
os.environ["MULTIMODAL_ENABLED"] = "true"
|
||||||
|
|
||||||
|
from legal_mcp import config # noqa: E402
|
||||||
|
from legal_mcp.services import db, embeddings, extractor # noqa: E402
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("backfill_multimodal_precedents")
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── file matching ─────────────────────────
|
||||||
|
|
||||||
|
# Roots to search for staged precedent files. Both paths are tried; the
|
||||||
|
# first that exists wins. ``/data/`` is the in-container mount;
|
||||||
|
# ``/home/chaim/legal-ai/data/`` is the host path.
|
||||||
|
SEARCH_ROOTS = [
|
||||||
|
Path("/data/precedent-library"),
|
||||||
|
Path("/data/internal-decisions"),
|
||||||
|
Path("/home/chaim/legal-ai/data/precedent-library"),
|
||||||
|
Path("/home/chaim/legal-ai/data/internal-decisions"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Extensions we can render with PyMuPDF (fitz). MD and TXT cannot be
|
||||||
|
# rendered as page images, so we skip them.
|
||||||
|
RENDERABLE_EXTS = {".pdf", ".docx"}
|
||||||
|
|
||||||
|
|
||||||
|
# Token-extraction regex: only tokens that contain a slash or hyphen
|
||||||
|
# (real case-number kernels like "8064/20" or "25226-04-25"). We
|
||||||
|
# deliberately exclude pure numeric runs like "2011" (which is just a
|
||||||
|
# year in "(נבו 5.4.2011)") to avoid false-positive matches against
|
||||||
|
# unrelated filenames that happen to contain the same year.
|
||||||
|
_NUMBER_TOKEN = re.compile(r"\d+[-/]\d+(?:[-/]\d+)*")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_number_tokens(case_number: str) -> list[str]:
|
||||||
|
"""Pull numeric kernels out of a Hebrew case_number string.
|
||||||
|
|
||||||
|
Only returns tokens containing a slash or hyphen (real case-number
|
||||||
|
kernels), so years like "2011" and "2024" don't leak through and
|
||||||
|
falsely match filenames.
|
||||||
|
|
||||||
|
>>> _extract_number_tokens('בר"מ 25226-04-25 הוועדה')
|
||||||
|
['25226-04-25']
|
||||||
|
>>> _extract_number_tokens('ערר 8064/20 חברת')
|
||||||
|
['8064/20']
|
||||||
|
>>> _extract_number_tokens('עע"מ 10089/07 (נבו 5.4.2011)')
|
||||||
|
['10089/07', '5.4.2011'] # date stays; but '5.4.2011' is hyphenless after normalize → no match against random filenames
|
||||||
|
"""
|
||||||
|
# filter out date-shaped tokens (dotted) by additional check — only
|
||||||
|
# keep tokens whose form is N/N or N-N-..., not N.N.N
|
||||||
|
tokens = _NUMBER_TOKEN.findall(case_number)
|
||||||
|
return [t for t in tokens if "." not in t]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_for_match(s: str) -> str:
|
||||||
|
"""Lowercase + strip whitespace/punct for filename matching."""
|
||||||
|
return re.sub(r"[\s/_-]+", "", s.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _build_file_index() -> dict[str, list[Path]]:
|
||||||
|
"""Walk SEARCH_ROOTS and return {normalized_filename: [paths]}.
|
||||||
|
|
||||||
|
Only renderable extensions are included.
|
||||||
|
"""
|
||||||
|
idx: dict[str, list[Path]] = {}
|
||||||
|
for root in SEARCH_ROOTS:
|
||||||
|
if not root.is_dir():
|
||||||
|
continue
|
||||||
|
for p in root.rglob("*"):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
if p.suffix.lower() not in RENDERABLE_EXTS:
|
||||||
|
continue
|
||||||
|
if "thumbnails" in p.parts:
|
||||||
|
continue
|
||||||
|
key = _normalize_for_match(p.name)
|
||||||
|
idx.setdefault(key, []).append(p)
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
def _digit_parts(token: str) -> list[str]:
|
||||||
|
"""Split a token like '14306-09-23' into ['14306','09','23']."""
|
||||||
|
return [p for p in re.split(r"[-/]", token) if p]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_file_for_case_number(case_number: str, file_index: dict[str, list[Path]]) -> Path | None:
|
||||||
|
"""Best-effort match a case_number → staged file path.
|
||||||
|
|
||||||
|
Two strategies:
|
||||||
|
|
||||||
|
1. **Direct contiguous match** — token normalized (e.g. "8064/20"
|
||||||
|
→ "806420") appears as substring of the filename normalized.
|
||||||
|
2. **Parts-match** — every digit part of the token appears
|
||||||
|
somewhere in the filename (handles reordered formats like
|
||||||
|
case_number "14306-09-23" matched to "MM-23-09-14306-967.docx",
|
||||||
|
where Nevo's case_number ordering differs from the legal
|
||||||
|
template's filename ordering). Only accepts when the longest
|
||||||
|
part has at least 4 digits — that filters out matches where
|
||||||
|
only short pieces (year fragments) overlap.
|
||||||
|
|
||||||
|
Returns the first match found, preferring PDFs over DOCX.
|
||||||
|
"""
|
||||||
|
tokens = _extract_number_tokens(case_number)
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidates: list[Path] = []
|
||||||
|
for token in tokens:
|
||||||
|
# Strategy 1: contiguous
|
||||||
|
normalized_token = _normalize_for_match(token)
|
||||||
|
token_hyphenated = token.replace("/", "-")
|
||||||
|
normalized_hyphenated = _normalize_for_match(token_hyphenated)
|
||||||
|
# Strategy 2: parts
|
||||||
|
parts = _digit_parts(token)
|
||||||
|
longest_part = max((len(p) for p in parts), default=0)
|
||||||
|
|
||||||
|
for normalized_name, paths in file_index.items():
|
||||||
|
if normalized_token in normalized_name or normalized_hyphenated in normalized_name:
|
||||||
|
candidates.extend(paths)
|
||||||
|
continue
|
||||||
|
# Parts-match requires longest part >= 4 digits AND all parts present
|
||||||
|
if longest_part >= 4 and parts and all(p in normalized_name for p in parts):
|
||||||
|
candidates.extend(paths)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Dedupe while preserving order
|
||||||
|
seen = set()
|
||||||
|
unique = []
|
||||||
|
for p in candidates:
|
||||||
|
if p not in seen:
|
||||||
|
seen.add(p)
|
||||||
|
unique.append(p)
|
||||||
|
|
||||||
|
# Prefer PDFs over DOCX (PDF rendering is more reliable for embedded fonts/images)
|
||||||
|
pdf = next((p for p in unique if p.suffix.lower() == ".pdf"), None)
|
||||||
|
return pdf or unique[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── backfill core ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
PRECEDENT_LIBRARY_THUMBNAILS = Path(config.DATA_DIR) / "precedent-library" / "thumbnails"
|
||||||
|
|
||||||
|
|
||||||
|
async def _embed_one_precedent(case_law_id: UUID, src_path: Path) -> dict:
|
||||||
|
"""Render + embed + store image embeddings for a single precedent.
|
||||||
|
|
||||||
|
Mirrors ``precedent_library._embed_precedent_pages`` but takes any
|
||||||
|
fitz-renderable file (PDF or DOCX).
|
||||||
|
"""
|
||||||
|
thumb_dir = PRECEDENT_LIBRARY_THUMBNAILS / str(case_law_id)
|
||||||
|
# PyMuPDF reads DOCX natively (uses its own MuPDF backend). We use
|
||||||
|
# the same renderer as the live pipeline for consistency.
|
||||||
|
rendered = await asyncio.to_thread(
|
||||||
|
extractor.render_pages_for_multimodal,
|
||||||
|
src_path,
|
||||||
|
config.MULTIMODAL_DPI,
|
||||||
|
config.MULTIMODAL_THUMB_DPI,
|
||||||
|
thumb_dir,
|
||||||
|
)
|
||||||
|
if not rendered:
|
||||||
|
return {"pages_embedded": 0, "status": "no_pages"}
|
||||||
|
|
||||||
|
images = [pil for pil, _ in rendered]
|
||||||
|
thumbs = [t for _, t in rendered]
|
||||||
|
|
||||||
|
img_embs = await embeddings.embed_images(images)
|
||||||
|
|
||||||
|
page_records = []
|
||||||
|
for i, (emb, thumb) in enumerate(zip(img_embs, thumbs)):
|
||||||
|
rel_thumb = None
|
||||||
|
if thumb is not None:
|
||||||
|
try:
|
||||||
|
rel_thumb = str(thumb.relative_to(config.DATA_DIR))
|
||||||
|
except ValueError:
|
||||||
|
rel_thumb = str(thumb)
|
||||||
|
page_records.append({
|
||||||
|
"page_number": i + 1,
|
||||||
|
"embedding": emb,
|
||||||
|
"image_thumbnail_path": rel_thumb,
|
||||||
|
})
|
||||||
|
|
||||||
|
stored = await db.store_precedent_image_embeddings(
|
||||||
|
case_law_id, page_records, model_name=config.MULTIMODAL_MODEL,
|
||||||
|
)
|
||||||
|
return {"pages_embedded": stored, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _scan_missing_records() -> list[dict]:
|
||||||
|
pool = await db.get_pool()
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"""
|
||||||
|
SELECT id, case_number, source_kind, length(full_text) AS text_len
|
||||||
|
FROM case_law cl
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM precedent_image_embeddings ppi
|
||||||
|
WHERE ppi.case_law_id = cl.id
|
||||||
|
)
|
||||||
|
AND cl.source_kind IN ('external_upload', 'internal_committee')
|
||||||
|
ORDER BY cl.source_kind, cl.case_number
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": UUID(str(r["id"])),
|
||||||
|
"case_number": r["case_number"],
|
||||||
|
"source_kind": r["source_kind"],
|
||||||
|
"text_len": r["text_len"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def backfill_all(
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
limit: int | None = None,
|
||||||
|
only_source_kind: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Main entrypoint — scan, match, render, embed, store."""
|
||||||
|
await db.init_schema()
|
||||||
|
records = await _scan_missing_records()
|
||||||
|
if only_source_kind:
|
||||||
|
records = [r for r in records if r["source_kind"] == only_source_kind]
|
||||||
|
if limit:
|
||||||
|
records = records[:limit]
|
||||||
|
|
||||||
|
file_index = _build_file_index()
|
||||||
|
logger.info("Indexed %d renderable files under %s",
|
||||||
|
sum(len(v) for v in file_index.values()),
|
||||||
|
", ".join(str(r) for r in SEARCH_ROOTS if r.is_dir()))
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"scanned": len(records),
|
||||||
|
"matched": 0,
|
||||||
|
"no_match": 0,
|
||||||
|
"embedded": 0,
|
||||||
|
"skipped_md_only": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"total_pages": 0,
|
||||||
|
"details": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for rec in records:
|
||||||
|
case_law_id = rec["id"]
|
||||||
|
case_number = rec["case_number"]
|
||||||
|
src = _find_file_for_case_number(case_number, file_index)
|
||||||
|
|
||||||
|
if not src:
|
||||||
|
summary["no_match"] += 1
|
||||||
|
summary["details"].append({
|
||||||
|
"case_law_id": str(case_law_id),
|
||||||
|
"case_number": case_number,
|
||||||
|
"source_kind": rec["source_kind"],
|
||||||
|
"status": "no_match",
|
||||||
|
})
|
||||||
|
logger.info(" NO MATCH: %s", case_number[:80])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Probe page count without rendering (cheap)
|
||||||
|
try:
|
||||||
|
doc = fitz.open(str(src))
|
||||||
|
page_count = len(doc)
|
||||||
|
doc.close()
|
||||||
|
except Exception as e:
|
||||||
|
summary["errors"] += 1
|
||||||
|
summary["details"].append({
|
||||||
|
"case_law_id": str(case_law_id),
|
||||||
|
"case_number": case_number,
|
||||||
|
"matched_file": str(src),
|
||||||
|
"status": "open_error",
|
||||||
|
"error": str(e),
|
||||||
|
})
|
||||||
|
logger.warning(" OPEN ERROR for %s: %s", case_number[:60], e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
summary["matched"] += 1
|
||||||
|
summary["total_pages"] += page_count
|
||||||
|
logger.info(" MATCHED: %s -> %s (%d pages)",
|
||||||
|
case_number[:60], src.name, page_count)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
summary["details"].append({
|
||||||
|
"case_law_id": str(case_law_id),
|
||||||
|
"case_number": case_number,
|
||||||
|
"matched_file": str(src),
|
||||||
|
"pages": page_count,
|
||||||
|
"status": "would_embed",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Actually embed + store
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
result = await _embed_one_precedent(case_law_id, src)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
summary["embedded"] += 1
|
||||||
|
summary["details"].append({
|
||||||
|
"case_law_id": str(case_law_id),
|
||||||
|
"case_number": case_number,
|
||||||
|
"matched_file": str(src),
|
||||||
|
"pages": page_count,
|
||||||
|
"elapsed_sec": round(elapsed, 1),
|
||||||
|
"status": "ok",
|
||||||
|
**result,
|
||||||
|
})
|
||||||
|
logger.info(" EMBEDDED %d pages in %.1fs", result["pages_embedded"], elapsed)
|
||||||
|
except Exception as e:
|
||||||
|
summary["errors"] += 1
|
||||||
|
summary["details"].append({
|
||||||
|
"case_law_id": str(case_law_id),
|
||||||
|
"case_number": case_number,
|
||||||
|
"matched_file": str(src),
|
||||||
|
"status": "embed_error",
|
||||||
|
"error": str(e),
|
||||||
|
})
|
||||||
|
logger.exception(" EMBED ERROR for %s", case_number[:60])
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── CLI ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Backfill voyage-multimodal-3 embeddings for case_law records "
|
||||||
|
"(external_upload + internal_committee) missing them.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
help="Only scan + match; do not call Voyage or write to DB.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply", action="store_true",
|
||||||
|
help="Render, embed, and store. Implies not --dry-run.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--limit", type=int, default=None,
|
||||||
|
help="Max number of records to process (debugging).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--only", choices=["external_upload", "internal_committee"], default=None,
|
||||||
|
help="Restrict to a single source_kind.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.apply and not args.dry_run:
|
||||||
|
# Default to dry_run for safety.
|
||||||
|
args.dry_run = True
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Mode=%s MULTIMODAL_MODEL=%s DPI=%d THUMB_DPI=%d",
|
||||||
|
"DRY-RUN" if args.dry_run else "APPLY",
|
||||||
|
config.MULTIMODAL_MODEL, config.MULTIMODAL_DPI, config.MULTIMODAL_THUMB_DPI,
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = asyncio.run(
|
||||||
|
backfill_all(
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
limit=args.limit,
|
||||||
|
only_source_kind=args.only,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("BACKFILL SUMMARY")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f" scanned: {summary['scanned']}")
|
||||||
|
print(f" matched: {summary['matched']}")
|
||||||
|
print(f" no_match: {summary['no_match']}")
|
||||||
|
print(f" total pages: {summary['total_pages']}")
|
||||||
|
if args.dry_run:
|
||||||
|
# Cost estimate: ~3.5K tokens/page * $0.12/1M tokens
|
||||||
|
est_tokens = summary["total_pages"] * 3500
|
||||||
|
est_cost = est_tokens / 1_000_000 * 0.12
|
||||||
|
print(f" est. tokens: ~{est_tokens:,} (~${est_cost:.2f})")
|
||||||
|
else:
|
||||||
|
print(f" embedded: {summary['embedded']}")
|
||||||
|
print(f" errors: {summary['errors']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
313
scripts/compute_ndcg.py
Executable file
313
scripts/compute_ndcg.py
Executable file
@@ -0,0 +1,313 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compute nDCG@10 over the RAG retrieval feedback table (TaskMaster #50).
|
||||||
|
|
||||||
|
Outputs aggregated metrics as JSON:
|
||||||
|
|
||||||
|
{
|
||||||
|
"generated_at": "2026-05-26T12:34:56+00:00",
|
||||||
|
"k": 10,
|
||||||
|
"summary": {
|
||||||
|
"total_searches_with_feedback": int,
|
||||||
|
"total_searches_logged": int,
|
||||||
|
"feedback_coverage_pct": float,
|
||||||
|
"avg_ndcg_at_10": float | null
|
||||||
|
},
|
||||||
|
"by_search_type": [
|
||||||
|
{"search_type": "precedent_library",
|
||||||
|
"searches_with_feedback": int,
|
||||||
|
"avg_ndcg_at_10": float | null},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"by_week": [
|
||||||
|
{"week_start": "2026-05-19",
|
||||||
|
"search_type": "precedent_library",
|
||||||
|
"searches_with_feedback": int,
|
||||||
|
"avg_ndcg_at_10": float | null},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"top_cited_case_law": [
|
||||||
|
{"case_law_id": "...", "case_number": "...",
|
||||||
|
"case_name": "...", "cite_count": int},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Run:
|
||||||
|
python ~/legal-ai/scripts/compute_ndcg.py
|
||||||
|
python ~/legal-ai/scripts/compute_ndcg.py --weeks 12 --k 10
|
||||||
|
python ~/legal-ai/scripts/compute_ndcg.py --pretty
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
# Allow running as a standalone script — no package install required.
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
|
||||||
|
|
||||||
|
|
||||||
|
def _postgres_url() -> str:
|
||||||
|
"""Resolve POSTGRES_URL the same way the MCP server does."""
|
||||||
|
url = os.environ.get("POSTGRES_URL")
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
user = os.environ.get("POSTGRES_USER", "legal_ai")
|
||||||
|
pw = os.environ.get("POSTGRES_PASSWORD", "")
|
||||||
|
host = os.environ.get("POSTGRES_HOST", "127.0.0.1")
|
||||||
|
port = os.environ.get("POSTGRES_PORT", "5433")
|
||||||
|
db = os.environ.get("POSTGRES_DB", "legal_ai")
|
||||||
|
return f"postgres://{user}:{pw}@{host}:{port}/{db}"
|
||||||
|
|
||||||
|
|
||||||
|
def dcg(relevances: list[int]) -> float:
|
||||||
|
"""Discounted Cumulative Gain at the length of ``relevances``.
|
||||||
|
|
||||||
|
Uses the "gain = 2^rel - 1" form so high-relevance hits get
|
||||||
|
significantly more weight than marginal ones — matches the
|
||||||
|
convention used by most IR papers and TREC-EVAL.
|
||||||
|
"""
|
||||||
|
total = 0.0
|
||||||
|
for i, rel in enumerate(relevances, start=1):
|
||||||
|
gain = (2 ** rel) - 1
|
||||||
|
total += gain / math.log2(i + 1)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def ndcg_at_k(rel_at_rank: dict[int, int], k: int) -> float | None:
|
||||||
|
"""Compute nDCG@k.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rel_at_rank: ``{rank (1-based): relevance_score (0..3)}``.
|
||||||
|
Ranks above ``k`` are ignored. Missing ranks count as 0.
|
||||||
|
k: cutoff.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
nDCG in [0,1], or ``None`` if there's nothing to score
|
||||||
|
(no relevant hits in the top-k -> IDCG = 0).
|
||||||
|
"""
|
||||||
|
actual = [rel_at_rank.get(r, 0) for r in range(1, k + 1)]
|
||||||
|
if not any(actual):
|
||||||
|
return None
|
||||||
|
ideal = sorted(actual, reverse=True)
|
||||||
|
idcg = dcg(ideal)
|
||||||
|
if idcg == 0:
|
||||||
|
return None
|
||||||
|
return dcg(actual) / idcg
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_feedback_rows(conn: asyncpg.Connection, weeks: int | None) -> list[dict]:
|
||||||
|
"""Pull all (search_log_id, rank, relevance_score, search_type, created_at)
|
||||||
|
rows where there's at least one feedback row.
|
||||||
|
|
||||||
|
Restricting to recent weeks keeps the scan cheap on a growing log.
|
||||||
|
"""
|
||||||
|
where = ""
|
||||||
|
params: list = []
|
||||||
|
if weeks is not None and weeks > 0:
|
||||||
|
where = "WHERE sl.created_at >= NOW() - ($1::int * INTERVAL '1 week')"
|
||||||
|
params.append(weeks)
|
||||||
|
sql = f"""
|
||||||
|
SELECT sl.id::text AS search_log_id,
|
||||||
|
sl.search_type AS search_type,
|
||||||
|
sl.created_at AS created_at,
|
||||||
|
srf.rank AS rank,
|
||||||
|
srf.relevance_score AS relevance_score
|
||||||
|
FROM search_relevance_feedback srf
|
||||||
|
JOIN search_logs sl ON sl.id = srf.search_log_id
|
||||||
|
{where}
|
||||||
|
"""
|
||||||
|
rows = await conn.fetch(sql, *params)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_corpus_totals(conn: asyncpg.Connection, weeks: int | None) -> dict[str, int]:
|
||||||
|
"""Total search_logs count (overall and by type) — used for coverage %."""
|
||||||
|
where = ""
|
||||||
|
params: list = []
|
||||||
|
if weeks is not None and weeks > 0:
|
||||||
|
where = "WHERE created_at >= NOW() - ($1::int * INTERVAL '1 week')"
|
||||||
|
params.append(weeks)
|
||||||
|
total_row = await conn.fetchrow(
|
||||||
|
f"SELECT COUNT(*) AS n FROM search_logs {where}",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
by_type = await conn.fetch(
|
||||||
|
f"SELECT search_type, COUNT(*) AS n FROM search_logs {where} GROUP BY search_type",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"_total": int(total_row["n"]) if total_row else 0,
|
||||||
|
**{r["search_type"]: int(r["n"]) for r in by_type},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_top_cited(conn: asyncpg.Connection, limit: int = 20) -> list[dict]:
|
||||||
|
"""Most-cited case_law (from auto-inferred feedback)."""
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT cl.id::text AS case_law_id,
|
||||||
|
cl.case_number AS case_number,
|
||||||
|
cl.case_name AS case_name,
|
||||||
|
COUNT(*) AS cite_count
|
||||||
|
FROM search_relevance_feedback srf
|
||||||
|
JOIN case_law cl ON cl.id = srf.case_law_id
|
||||||
|
WHERE srf.feedback_source = 'cited_in_decision'
|
||||||
|
GROUP BY cl.id, cl.case_number, cl.case_name
|
||||||
|
ORDER BY COUNT(*) DESC
|
||||||
|
LIMIT $1
|
||||||
|
""",
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate(
|
||||||
|
feedback_rows: list[dict],
|
||||||
|
k: int,
|
||||||
|
) -> tuple[dict[str, float], dict[tuple[str, str], float], int]:
|
||||||
|
"""Group feedback by search_log, compute per-log nDCG, then aggregate
|
||||||
|
by search_type and by (week, search_type)."""
|
||||||
|
by_log: dict[str, dict] = {}
|
||||||
|
for row in feedback_rows:
|
||||||
|
slid = row["search_log_id"]
|
||||||
|
if slid not in by_log:
|
||||||
|
by_log[slid] = {
|
||||||
|
"search_type": row["search_type"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"rels": {},
|
||||||
|
}
|
||||||
|
rank = int(row["rank"])
|
||||||
|
if 1 <= rank <= k:
|
||||||
|
by_log[slid]["rels"][rank] = int(row["relevance_score"])
|
||||||
|
|
||||||
|
type_ndcg: dict[str, list[float]] = {}
|
||||||
|
week_ndcg: dict[tuple[str, str], list[float]] = {}
|
||||||
|
total_logs_with_feedback = 0
|
||||||
|
for entry in by_log.values():
|
||||||
|
score = ndcg_at_k(entry["rels"], k)
|
||||||
|
if score is None:
|
||||||
|
continue
|
||||||
|
total_logs_with_feedback += 1
|
||||||
|
type_ndcg.setdefault(entry["search_type"], []).append(score)
|
||||||
|
week_start = entry["created_at"].date()
|
||||||
|
# Round down to ISO week Monday.
|
||||||
|
week_start = week_start.fromordinal(
|
||||||
|
week_start.toordinal() - week_start.weekday()
|
||||||
|
)
|
||||||
|
wkey = (week_start.isoformat(), entry["search_type"])
|
||||||
|
week_ndcg.setdefault(wkey, []).append(score)
|
||||||
|
|
||||||
|
type_avg = {t: sum(v) / len(v) for t, v in type_ndcg.items() if v}
|
||||||
|
week_avg = {k_: sum(v) / len(v) for k_, v in week_ndcg.items() if v}
|
||||||
|
return type_avg, week_avg, total_logs_with_feedback
|
||||||
|
|
||||||
|
|
||||||
|
async def compute(weeks: int | None, k: int) -> dict:
|
||||||
|
conn = await asyncpg.connect(_postgres_url())
|
||||||
|
try:
|
||||||
|
fb_rows = await _fetch_feedback_rows(conn, weeks)
|
||||||
|
totals = await _fetch_corpus_totals(conn, weeks)
|
||||||
|
top_cited = await _fetch_top_cited(conn)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
type_avg, week_avg, logs_scored = _aggregate(fb_rows, k)
|
||||||
|
|
||||||
|
total_logs = totals.get("_total", 0)
|
||||||
|
overall_avg = (
|
||||||
|
sum(v * len([s for s in type_avg]) for v in []) or None # placeholder
|
||||||
|
)
|
||||||
|
# Recompute overall_avg cleanly: micro-average over all per-log scores.
|
||||||
|
all_scores: list[float] = []
|
||||||
|
for v in [type_avg[t] for t in type_avg]:
|
||||||
|
# type_avg already collapsed per-type — instead, re-run aggregation
|
||||||
|
# over fb_rows by reusing the per-log calc, micro-averaged.
|
||||||
|
pass
|
||||||
|
# Simpler: redo with per-log granularity for overall mean.
|
||||||
|
by_log_overall: dict[str, dict[int, int]] = {}
|
||||||
|
log_to_type: dict[str, str] = {}
|
||||||
|
for row in fb_rows:
|
||||||
|
slid = row["search_log_id"]
|
||||||
|
by_log_overall.setdefault(slid, {})
|
||||||
|
rank = int(row["rank"])
|
||||||
|
if 1 <= rank <= k:
|
||||||
|
by_log_overall[slid][rank] = int(row["relevance_score"])
|
||||||
|
log_to_type[slid] = row["search_type"]
|
||||||
|
per_log_scores: list[float] = []
|
||||||
|
for slid, rels in by_log_overall.items():
|
||||||
|
s = ndcg_at_k(rels, k)
|
||||||
|
if s is not None:
|
||||||
|
per_log_scores.append(s)
|
||||||
|
overall_avg = (sum(per_log_scores) / len(per_log_scores)) if per_log_scores else None
|
||||||
|
|
||||||
|
by_search_type = []
|
||||||
|
for t, totals_n in sorted(totals.items()):
|
||||||
|
if t == "_total":
|
||||||
|
continue
|
||||||
|
by_search_type.append({
|
||||||
|
"search_type": t,
|
||||||
|
"searches_logged": totals_n,
|
||||||
|
"searches_with_feedback": sum(
|
||||||
|
1 for slid, tp in log_to_type.items() if tp == t
|
||||||
|
),
|
||||||
|
"avg_ndcg_at_k": round(type_avg[t], 4) if t in type_avg else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
by_week = [
|
||||||
|
{
|
||||||
|
"week_start": week,
|
||||||
|
"search_type": stype,
|
||||||
|
"avg_ndcg_at_k": round(score, 4),
|
||||||
|
}
|
||||||
|
for (week, stype), score in sorted(week_avg.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"k": k,
|
||||||
|
"window_weeks": weeks,
|
||||||
|
"summary": {
|
||||||
|
"total_searches_logged": total_logs,
|
||||||
|
"total_searches_with_feedback": logs_scored,
|
||||||
|
"feedback_coverage_pct": (
|
||||||
|
round(100 * logs_scored / total_logs, 2) if total_logs else 0.0
|
||||||
|
),
|
||||||
|
"avg_ndcg_at_k": round(overall_avg, 4) if overall_avg is not None else None,
|
||||||
|
},
|
||||||
|
"by_search_type": by_search_type,
|
||||||
|
"by_week": by_week,
|
||||||
|
"top_cited_case_law": [
|
||||||
|
{**r, "cite_count": int(r["cite_count"])} for r in top_cited
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = argparse.ArgumentParser(description="Compute nDCG@k from search_relevance_feedback")
|
||||||
|
p.add_argument("--k", type=int, default=10, help="cutoff (default: 10)")
|
||||||
|
p.add_argument(
|
||||||
|
"--weeks",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="restrict to the last N weeks (default: all time)",
|
||||||
|
)
|
||||||
|
p.add_argument("--pretty", action="store_true", help="indented JSON output")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
result = asyncio.run(compute(weeks=args.weeks, k=args.k))
|
||||||
|
indent = 2 if args.pretty else None
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=indent, default=str))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
73
scripts/legal-chat-service.config.cjs
Normal file
73
scripts/legal-chat-service.config.cjs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* pm2 ecosystem entry for legal-chat-service — the host-side SSE bridge
|
||||||
|
* to ``claude`` CLI that powers the /training chat tab.
|
||||||
|
*
|
||||||
|
* Security: the service spawns the claude CLI on behalf of any caller
|
||||||
|
* that hits /chat/start. claude tools include Bash, Read, Edit — so an
|
||||||
|
* unauthenticated request to /chat/start is effectively RCE-equivalent.
|
||||||
|
* Two defenses, both required:
|
||||||
|
* 1. Bind to 10.0.1.1 (docker0 bridge gateway) — only host + containers
|
||||||
|
* on docker bridges can reach the socket; nothing outside the host.
|
||||||
|
* 2. Bearer token auth — secret loaded from /home/chaim/.legal-chat-service.env
|
||||||
|
* (chmod 600) and mirrored in Coolify as LEGAL_CHAT_SHARED_SECRET.
|
||||||
|
* The service refuses to start without the secret set.
|
||||||
|
*
|
||||||
|
* Why pm2:
|
||||||
|
* - Auto-restart if the process dies (claude CLI subprocess failures
|
||||||
|
* should never leave the service in a half-dead state).
|
||||||
|
* - Log rotation matches paperclip's behavior so the chair sees
|
||||||
|
* consistent log paths under ~/.pm2/logs/.
|
||||||
|
*
|
||||||
|
* Install (once):
|
||||||
|
* pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs
|
||||||
|
* pm2 save
|
||||||
|
*
|
||||||
|
* Smoke test:
|
||||||
|
* curl http://10.0.1.1:8770/health
|
||||||
|
* # → {"ok":true,"service":"legal-chat-service"}
|
||||||
|
*
|
||||||
|
* Update:
|
||||||
|
* pm2 restart legal-chat-service --update-env
|
||||||
|
*
|
||||||
|
* Stop:
|
||||||
|
* pm2 stop legal-chat-service
|
||||||
|
*/
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
// Load LEGAL_CHAT_SHARED_SECRET from a chmod 600 file off the repo.
|
||||||
|
// The same value is mirrored in Coolify as the LEGAL_CHAT_SHARED_SECRET
|
||||||
|
// env var so the FastAPI proxy sends a matching Authorization header.
|
||||||
|
// Migrate to Infisical (/_GUIDELINES) once the MCP server is back.
|
||||||
|
const ENV_FILE = "/home/chaim/.legal-chat-service.env";
|
||||||
|
const env = {
|
||||||
|
HOME: "/home/chaim",
|
||||||
|
PATH: "/home/chaim/.local/bin:/usr/local/bin:/usr/bin:/bin",
|
||||||
|
PYTHONUNBUFFERED: "1",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(ENV_FILE, "utf8");
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
if (!line || line.trim().startsWith("#")) continue;
|
||||||
|
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||||
|
if (m) env[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`legal-chat-service: failed to load ${ENV_FILE}: ${e.message}`);
|
||||||
|
console.error("Service will refuse to start without LEGAL_CHAT_SHARED_SECRET.");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "legal-chat-service",
|
||||||
|
cwd: "/home/chaim/legal-ai/mcp-server",
|
||||||
|
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
|
||||||
|
args: "-m legal_mcp.chat_service.server --port 8770 --host 10.0.1.1",
|
||||||
|
env,
|
||||||
|
restart_delay: 5000,
|
||||||
|
max_restarts: 10,
|
||||||
|
autorestart: true,
|
||||||
|
max_memory_restart: "500M",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
278
scripts/monitor_halacha_quality.py
Normal file
278
scripts/monitor_halacha_quality.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
"""Halacha extraction quality monitor.
|
||||||
|
|
||||||
|
Tracks ``avg(confidence)`` of halachot extracted by the LLM pipeline
|
||||||
|
over time and emits an alert when the recent-window average drops more
|
||||||
|
than a configurable threshold below the lifetime baseline.
|
||||||
|
|
||||||
|
Intended schedule: weekly cron, e.g. ``0 8 * * 1`` (Monday 08:00).
|
||||||
|
|
||||||
|
Output: a single-line JSON payload to stdout (suitable for piping
|
||||||
|
into ``notify.py`` or a webhook), plus a human-readable alert text
|
||||||
|
on stderr when drift is detected.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
# Default — weekly window, 5% drop threshold (relative)
|
||||||
|
python scripts/monitor_halacha_quality.py
|
||||||
|
|
||||||
|
# Custom window/threshold:
|
||||||
|
python scripts/monitor_halacha_quality.py --window 14 --threshold 0.03
|
||||||
|
|
||||||
|
# Only emit JSON, no stderr alert:
|
||||||
|
python scripts/monitor_halacha_quality.py --silent
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_paths():
|
||||||
|
"""Make ``legal_mcp`` importable when run from anywhere."""
|
||||||
|
here = Path(__file__).resolve().parent
|
||||||
|
candidates = [
|
||||||
|
here.parent / "mcp-server" / "src", # host
|
||||||
|
Path("/app/mcp-server/src"), # container
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if c.is_dir() and str(c) not in sys.path:
|
||||||
|
sys.path.insert(0, str(c))
|
||||||
|
|
||||||
|
|
||||||
|
_setup_paths()
|
||||||
|
|
||||||
|
from legal_mcp.services import db # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# Statuses considered "trusted" — the baseline is computed only over
|
||||||
|
# halachot whose extraction the chair has accepted. ``pending_review``
|
||||||
|
# is the queue waiting for review; their average tends to be lower
|
||||||
|
# because anything obviously bad gets rejected before approval. So we
|
||||||
|
# track BOTH series and alert on either one drifting:
|
||||||
|
# 1. Trusted baseline (approved+published) — drift here means the
|
||||||
|
# extractor's "best output" quality is degrading.
|
||||||
|
# 2. All extracted — drift here means raw extractor accuracy is down.
|
||||||
|
TRUSTED_STATUSES = ("approved", "published")
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_metrics(window_days: int) -> dict:
|
||||||
|
pool = await db.get_pool()
|
||||||
|
|
||||||
|
# Lifetime baselines
|
||||||
|
lifetime_all = await pool.fetchrow(
|
||||||
|
"SELECT count(*) AS n, AVG(confidence) AS avg_conf FROM halachot"
|
||||||
|
)
|
||||||
|
lifetime_trusted = await pool.fetchrow(
|
||||||
|
f"""
|
||||||
|
SELECT count(*) AS n, AVG(confidence) AS avg_conf
|
||||||
|
FROM halachot
|
||||||
|
WHERE review_status = ANY($1::text[])
|
||||||
|
""",
|
||||||
|
list(TRUSTED_STATUSES),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recent window
|
||||||
|
recent_all = await pool.fetchrow(
|
||||||
|
f"""
|
||||||
|
SELECT count(*) AS n, AVG(confidence) AS avg_conf
|
||||||
|
FROM halachot
|
||||||
|
WHERE created_at > NOW() - INTERVAL '{int(window_days)} days'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
recent_trusted = await pool.fetchrow(
|
||||||
|
f"""
|
||||||
|
SELECT count(*) AS n, AVG(confidence) AS avg_conf
|
||||||
|
FROM halachot
|
||||||
|
WHERE created_at > NOW() - INTERVAL '{int(window_days)} days'
|
||||||
|
AND review_status = ANY($1::text[])
|
||||||
|
""",
|
||||||
|
list(TRUSTED_STATUSES),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-precedent recent (extractor outputs that haven't been reviewed
|
||||||
|
# yet) — sometimes the canary that catches drift earliest. We track
|
||||||
|
# the most-recent N extractions regardless of review state.
|
||||||
|
pending_recent = await pool.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS n, AVG(confidence) AS avg_conf
|
||||||
|
FROM halachot
|
||||||
|
WHERE review_status = 'pending_review'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _f(rec, key: str) -> float | None:
|
||||||
|
v = rec[key]
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
return float(v)
|
||||||
|
|
||||||
|
def _i(rec, key: str) -> int:
|
||||||
|
v = rec[key]
|
||||||
|
return int(v) if v is not None else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"window_days": int(window_days),
|
||||||
|
"lifetime_all_count": _i(lifetime_all, "n"),
|
||||||
|
"lifetime_all_avg": _f(lifetime_all, "avg_conf"),
|
||||||
|
"lifetime_trusted_count": _i(lifetime_trusted, "n"),
|
||||||
|
"lifetime_trusted_avg": _f(lifetime_trusted, "avg_conf"),
|
||||||
|
"recent_all_count": _i(recent_all, "n"),
|
||||||
|
"recent_all_avg": _f(recent_all, "avg_conf"),
|
||||||
|
"recent_trusted_count": _i(recent_trusted, "n"),
|
||||||
|
"recent_trusted_avg": _f(recent_trusted, "avg_conf"),
|
||||||
|
"pending_review_count": _i(pending_recent, "n"),
|
||||||
|
"pending_review_avg": _f(pending_recent, "avg_conf"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _drift(baseline: float | None, recent: float | None) -> float | None:
|
||||||
|
"""Return relative drift as a positive number when recent < baseline.
|
||||||
|
|
||||||
|
>>> _drift(0.85, 0.80) # -> 0.0588 (5.88% drop)
|
||||||
|
"""
|
||||||
|
if baseline is None or recent is None or baseline <= 0:
|
||||||
|
return None
|
||||||
|
return (baseline - recent) / baseline
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate(metrics: dict, threshold: float, min_sample: int) -> dict:
|
||||||
|
"""Decide whether any series is drifting below threshold."""
|
||||||
|
alerts: list[dict] = []
|
||||||
|
series = [
|
||||||
|
(
|
||||||
|
"trusted",
|
||||||
|
metrics["lifetime_trusted_avg"],
|
||||||
|
metrics["recent_trusted_avg"],
|
||||||
|
metrics["recent_trusted_count"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"all_extracted",
|
||||||
|
metrics["lifetime_all_avg"],
|
||||||
|
metrics["recent_all_avg"],
|
||||||
|
metrics["recent_all_count"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for name, baseline, recent, recent_n in series:
|
||||||
|
d = _drift(baseline, recent)
|
||||||
|
entry = {
|
||||||
|
"series": name,
|
||||||
|
"baseline": baseline,
|
||||||
|
"recent": recent,
|
||||||
|
"recent_n": recent_n,
|
||||||
|
"drift": d,
|
||||||
|
"alert": False,
|
||||||
|
"reason": None,
|
||||||
|
}
|
||||||
|
if recent_n < min_sample:
|
||||||
|
entry["reason"] = f"recent_n={recent_n} below min_sample={min_sample}"
|
||||||
|
elif d is None:
|
||||||
|
entry["reason"] = "missing baseline or recent average"
|
||||||
|
elif d >= threshold:
|
||||||
|
entry["alert"] = True
|
||||||
|
entry["reason"] = (
|
||||||
|
f"drift {d:.1%} >= threshold {threshold:.1%} "
|
||||||
|
f"(baseline={baseline:.3f}, recent={recent:.3f}, n={recent_n})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
entry["reason"] = (
|
||||||
|
f"drift {d:.1%} < threshold {threshold:.1%} — within tolerance"
|
||||||
|
)
|
||||||
|
alerts.append(entry)
|
||||||
|
|
||||||
|
any_alert = any(a["alert"] for a in alerts)
|
||||||
|
return {"alert": any_alert, "series": alerts}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_alert_text(metrics: dict, decision: dict) -> str:
|
||||||
|
lines = [
|
||||||
|
f"Halacha quality alert — window={metrics['window_days']}d",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for s in decision["series"]:
|
||||||
|
sym = "ALERT" if s["alert"] else "ok"
|
||||||
|
baseline = f"{s['baseline']:.3f}" if s["baseline"] is not None else "—"
|
||||||
|
recent = f"{s['recent']:.3f}" if s["recent"] is not None else "—"
|
||||||
|
drift = f"{s['drift']:.1%}" if s["drift"] is not None else "—"
|
||||||
|
lines.append(
|
||||||
|
f" [{sym}] {s['series']}: baseline={baseline} recent={recent} "
|
||||||
|
f"drift={drift} n={s['recent_n']}"
|
||||||
|
)
|
||||||
|
if s["reason"]:
|
||||||
|
lines.append(f" {s['reason']}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
*,
|
||||||
|
window_days: int,
|
||||||
|
threshold: float,
|
||||||
|
min_sample: int,
|
||||||
|
) -> dict:
|
||||||
|
metrics = await _collect_metrics(window_days)
|
||||||
|
decision = _evaluate(metrics, threshold, min_sample)
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"window_days": window_days,
|
||||||
|
"threshold_rel": threshold,
|
||||||
|
"min_sample": min_sample,
|
||||||
|
"metrics": metrics,
|
||||||
|
"decision": decision,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Monitor halacha extraction quality (confidence drift)."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--window", type=int, default=7,
|
||||||
|
help="Recent window in days (default: 7).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--threshold", type=float, default=0.05,
|
||||||
|
help="Relative drop alert threshold (default: 0.05 = 5%%).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-sample", type=int, default=5,
|
||||||
|
help="Minimum halachot in window to evaluate (default: 5). "
|
||||||
|
"Below this, the series is reported but not alerted on.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--silent", action="store_true",
|
||||||
|
help="Suppress stderr alert text; only print JSON.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--exit-on-alert", action="store_true",
|
||||||
|
help="Exit with status 1 when an alert fires (default: always exit 0).",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
report = asyncio.run(
|
||||||
|
run(
|
||||||
|
window_days=args.window,
|
||||||
|
threshold=args.threshold,
|
||||||
|
min_sample=args.min_sample,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSON to stdout
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
if report["decision"]["alert"] and not args.silent:
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
print(_format_alert_text(report["metrics"], report["decision"]), file=sys.stderr)
|
||||||
|
|
||||||
|
if args.exit_on_alert and report["decision"]["alert"]:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
53
scripts/process_pending_blam.py
Normal file
53
scripts/process_pending_blam.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""One-shot: run pending metadata + halacha extraction on the 2 בל"מ
|
||||||
|
decisions uploaded today (8126/24 + 8047/23). Bypasses MCP because the
|
||||||
|
running MCP server has stale code; calls the services directly with the
|
||||||
|
updated local copy.
|
||||||
|
|
||||||
|
Run from /home/chaim/legal-ai with the venv:
|
||||||
|
POSTGRES_URL=... .venv/bin/python scripts/process_pending_blam.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||||
|
|
||||||
|
from legal_mcp.services import db
|
||||||
|
from legal_mcp.services import precedent_library
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Queue metadata extraction too (ingest_internal_decision only queues
|
||||||
|
# halacha; metadata fills headnote/summary/key_quote and now also
|
||||||
|
# confirms proceeding_type via the new prompt field).
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT id, case_number FROM case_law "
|
||||||
|
"WHERE case_number IN ('8126/24','8047/23') "
|
||||||
|
" AND source_kind = 'internal_committee'"
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE case_law SET metadata_extraction_requested_at = NOW() "
|
||||||
|
"WHERE id = $1",
|
||||||
|
r["id"],
|
||||||
|
)
|
||||||
|
print(f"queued metadata for {r['case_number']} ({r['id']})")
|
||||||
|
|
||||||
|
print("\n→ running metadata extraction…")
|
||||||
|
meta_result = await precedent_library.process_pending_extractions(
|
||||||
|
kind="metadata", limit=10,
|
||||||
|
)
|
||||||
|
print(meta_result)
|
||||||
|
|
||||||
|
print("\n→ running halacha extraction…")
|
||||||
|
halacha_result = await precedent_library.process_pending_extractions(
|
||||||
|
kind="halacha", limit=10,
|
||||||
|
)
|
||||||
|
print(halacha_result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -259,6 +259,14 @@ async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
|
|||||||
if "runtime_config" in diff:
|
if "runtime_config" in diff:
|
||||||
patch_body["runtimeConfig"] = diff["runtime_config"]["to"]
|
patch_body["runtimeConfig"] = diff["runtime_config"]["to"]
|
||||||
|
|
||||||
|
# Stamp claude_md_mtime + last_synced into metadata
|
||||||
|
mtime = diff.get("_claude_md_mtime")
|
||||||
|
if mtime:
|
||||||
|
current_meta = dict(patch_body.get("metadata") or {})
|
||||||
|
current_meta["claude_md_mtime"] = mtime
|
||||||
|
current_meta["claude_md_last_synced"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
patch_body["metadata"] = current_meta
|
||||||
|
|
||||||
if patch_body:
|
if patch_body:
|
||||||
status, data = await call_patch(mirror_id, patch_body)
|
status, data = await call_patch(mirror_id, patch_body)
|
||||||
if status >= 400:
|
if status >= 400:
|
||||||
@@ -278,12 +286,73 @@ async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
|
|||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def get_claude_md_mtime(adapter_config: dict) -> str | None:
|
||||||
|
"""Return Unix mtime of the agent's instructionsFilePath, or None if file missing."""
|
||||||
|
path = adapter_config.get("instructionsFilePath", "")
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return None
|
||||||
|
return str(int(os.path.getmtime(path)))
|
||||||
|
|
||||||
|
|
||||||
|
async def check_instructions(agents: list[dict]) -> bool:
|
||||||
|
"""Print a report of all agents' instruction files. Returns True if all OK."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
all_ok = True
|
||||||
|
print(f"\n{'Agent':<30} {'File':<55} {'Status':<12} {'Size':>7} {'Modified'}")
|
||||||
|
print("-" * 115)
|
||||||
|
|
||||||
|
for agent in agents:
|
||||||
|
name = (agent.get("name") or agent.get("id") or "?")[:29]
|
||||||
|
|
||||||
|
try:
|
||||||
|
adapter_cfg = agent.get("adapter_config") or {}
|
||||||
|
if isinstance(adapter_cfg, str):
|
||||||
|
adapter_cfg = json.loads(adapter_cfg)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
print(f"{name:<30} {'(malformed adapter_config in DB)':<55} {'⚠ ERROR':<12}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = adapter_cfg.get("instructionsFilePath", "")
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
print(f"{name:<30} {'(none)':<55} {'⚠ NOT SET':<12}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
print(f"{name:<30} {file_path[-54:]:<55} {'❌ MISSING':<12}")
|
||||||
|
all_ok = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
stat = os.stat(file_path)
|
||||||
|
size_kb = stat.st_size // 1024
|
||||||
|
mtime = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
# Check for drift vs DB metadata
|
||||||
|
try:
|
||||||
|
metadata = agent.get("metadata") or {}
|
||||||
|
if isinstance(metadata, str):
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
metadata = {}
|
||||||
|
db_mtime = metadata.get("claude_md_mtime", "")
|
||||||
|
actual_mtime = str(int(stat.st_mtime))
|
||||||
|
drift = " ⚠ DRIFT" if db_mtime and db_mtime != actual_mtime else ""
|
||||||
|
|
||||||
|
print(f"{name:<30} {file_path[-54:]:<55} {'✅ OK':<12} {size_kb:>5}KB {mtime}{drift}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
return all_ok
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
g = p.add_mutually_exclusive_group(required=True)
|
g = p.add_mutually_exclusive_group(required=True)
|
||||||
g.add_argument("--verify", action="store_true", help="Show current drift, no changes")
|
g.add_argument("--verify", action="store_true", help="Show current drift, no changes")
|
||||||
g.add_argument("--dry-run", action="store_true", help="Show what would change")
|
g.add_argument("--dry-run", action="store_true", help="Show what would change")
|
||||||
g.add_argument("--apply", action="store_true", help="Backup + apply changes")
|
g.add_argument("--apply", action="store_true", help="Backup + apply changes")
|
||||||
|
g.add_argument("--check-instructions", action="store_true",
|
||||||
|
help="Scan all agents' instructionsFilePath and report missing/outdated files")
|
||||||
p.add_argument("--only", help="Sync only the named agent (e.g., 'עוזר משפטי')")
|
p.add_argument("--only", help="Sync only the named agent (e.g., 'עוזר משפטי')")
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
@@ -295,6 +364,11 @@ async def main() -> None:
|
|||||||
finally:
|
finally:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
if args.check_instructions:
|
||||||
|
all_agents = master_agents + mirror_agents
|
||||||
|
all_ok = await check_instructions(all_agents)
|
||||||
|
sys.exit(0 if all_ok else 1)
|
||||||
|
|
||||||
mirror_by_name = {a["name"]: a for a in mirror_agents}
|
mirror_by_name = {a["name"]: a for a in mirror_agents}
|
||||||
|
|
||||||
print(f"\n=== Master (CMP, 1xxx): {len(master_agents)} agents ===")
|
print(f"\n=== Master (CMP, 1xxx): {len(master_agents)} agents ===")
|
||||||
@@ -332,6 +406,14 @@ async def main() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# APPLY
|
# APPLY
|
||||||
|
# Pre-flight: abort if any master agent is missing its instructions file
|
||||||
|
print("🔍 Pre-flight: checking instruction files...")
|
||||||
|
all_ok = await check_instructions(master_agents)
|
||||||
|
if not all_ok:
|
||||||
|
print("❌ Abort: one or more instruction files are missing. Fix before --apply.")
|
||||||
|
sys.exit(1)
|
||||||
|
print("✅ Pre-flight passed.\n")
|
||||||
|
|
||||||
print(f"\n=== Backup ===")
|
print(f"\n=== Backup ===")
|
||||||
backup_path = backup_agents_table()
|
backup_path = backup_agents_table()
|
||||||
print(f" ✓ {backup_path}")
|
print(f" ✓ {backup_path}")
|
||||||
@@ -340,6 +422,11 @@ async def main() -> None:
|
|||||||
all_errors: list[str] = []
|
all_errors: list[str] = []
|
||||||
for master, mirror, diff in plan:
|
for master, mirror, diff in plan:
|
||||||
print(f"\n → {master['name']} ({mirror['id']})")
|
print(f"\n → {master['name']} ({mirror['id']})")
|
||||||
|
# Inject mtime into diff so apply_diff can stamp metadata
|
||||||
|
master_ac = master.get("adapter_config") or {}
|
||||||
|
mtime = get_claude_md_mtime(master_ac)
|
||||||
|
if mtime:
|
||||||
|
diff["_claude_md_mtime"] = mtime
|
||||||
errors = await apply_diff(mirror["id"], master["name"], diff)
|
errors = await apply_diff(mirror["id"], master["name"], diff)
|
||||||
if errors:
|
if errors:
|
||||||
for e in errors:
|
for e in errors:
|
||||||
|
|||||||
89
scripts/test_retrieval_by_name.py
Normal file
89
scripts/test_retrieval_by_name.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Repro + regression test for retrieval-by-name (RC-A, tasks #52).
|
||||||
|
|
||||||
|
Bug: searching the precedent corpus by a bare case NAME ("אגסי") fails to
|
||||||
|
surface the decision itself, because the lexical tsvector covers only chunk
|
||||||
|
content + halacha text — not case_name / case_number. A name query therefore
|
||||||
|
matches decisions that *cite* the case, not the case.
|
||||||
|
|
||||||
|
Run with the MCP venv:
|
||||||
|
DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data \
|
||||||
|
mcp-server/.venv/bin/python scripts/test_retrieval_by_name.py
|
||||||
|
|
||||||
|
Exit 0 = all assertions pass. Non-zero = failure (prints what was found).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/home/chaim/legal-ai/mcp-server/src")
|
||||||
|
|
||||||
|
from legal_mcp.services import embeddings, hybrid_search # noqa: E402
|
||||||
|
|
||||||
|
AGASI_ID = "1a87efe5-6e13-4ed4-a9ec-3f2f7d61e4ec"
|
||||||
|
# Vinfeld CITES Agasi (its halacha quote names אגסי) but is NOT Agasi.
|
||||||
|
# An exact name match must rank the case itself above any case citing it.
|
||||||
|
VINFELD_ID = "bd5d849c-c15f-43c3-96ab-d44337af9cb5"
|
||||||
|
NAME_QUERY = "אגסי"
|
||||||
|
SUBSTANTIVE_QUERY = 'פטור היטל השבחה לפי סעיף 19(ג)(1) שתי דירות 140 מ"ר אחת מושכרת'
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(rows):
|
||||||
|
return [str(r.get("case_law_id")) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _rank_of(rows, cid):
|
||||||
|
for i, r in enumerate(rows, 1):
|
||||||
|
if str(r.get("case_law_id")) == cid:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _search(query, source_kind, limit=10):
|
||||||
|
query_emb = await embeddings.embed_query(query)
|
||||||
|
return await hybrid_search.search_precedent_library_hybrid(
|
||||||
|
query,
|
||||||
|
query_emb,
|
||||||
|
source_kind=source_kind,
|
||||||
|
limit=limit,
|
||||||
|
include_halachot=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
results = {"pass": [], "fail": []}
|
||||||
|
|
||||||
|
# 1) THE BUG: bare-name query must rank the case ITSELF (Agasi) above any
|
||||||
|
# case that merely CITES it (Vinfeld), and within the top 3.
|
||||||
|
rows = await _search(NAME_QUERY, "internal_committee", limit=10)
|
||||||
|
a_rank = _rank_of(rows, AGASI_ID)
|
||||||
|
v_rank = _rank_of(rows, VINFELD_ID)
|
||||||
|
ok = bool(a_rank) and a_rank <= 3 and (v_rank is None or a_rank < v_rank)
|
||||||
|
msg = (f"[name/internal] query='{NAME_QUERY}' -> Agasi rank={a_rank}, "
|
||||||
|
f"Vinfeld(citer) rank={v_rank} (top ids: {_ids(rows)[:5]})")
|
||||||
|
(results["pass"] if ok else results["fail"]).append(msg)
|
||||||
|
|
||||||
|
# 2) REGRESSION: substantive query must still find Agasi with a real score.
|
||||||
|
rows = await _search(SUBSTANTIVE_QUERY, "internal_committee", limit=10)
|
||||||
|
rank = _rank_of(rows, AGASI_ID)
|
||||||
|
top_score = float(rows[0]["score"]) if rows else 0.0
|
||||||
|
msg = f"[substantive/internal] Agasi rank={rank}, top_score={top_score:.3f}"
|
||||||
|
(results["pass"] if rank and rank <= 8 else results["fail"]).append(msg)
|
||||||
|
|
||||||
|
# 3) REGRESSION: substantive query in the full precedent library still works
|
||||||
|
# (Vinfeld/נווה שלום etc. should surface; just assert non-empty + has betterment content).
|
||||||
|
rows = await _search(SUBSTANTIVE_QUERY, "external_upload", limit=10)
|
||||||
|
msg = f"[substantive/external] returned {len(rows)} rows (top ids: {_ids(rows)[:3]})"
|
||||||
|
(results["pass"] if len(rows) >= 3 else results["fail"]).append(msg)
|
||||||
|
|
||||||
|
print("\n=== PASS ===")
|
||||||
|
for m in results["pass"]:
|
||||||
|
print(" ✓", m)
|
||||||
|
print("=== FAIL ===")
|
||||||
|
for m in results["fail"]:
|
||||||
|
print(" ✗", m)
|
||||||
|
|
||||||
|
return 1 if results["fail"] else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(asyncio.run(main()))
|
||||||
60
scripts/upload_blam_decisions.py
Normal file
60
scripts/upload_blam_decisions.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""One-shot uploader for the 2 new בל"מ decisions Chaim staged in
|
||||||
|
data/precedents/incoming/. Bypasses MCP because the running MCP server
|
||||||
|
was started before SCHEMA_V15 + proceeding_type wiring landed.
|
||||||
|
|
||||||
|
Run from /home/chaim/legal-ai with the venv:
|
||||||
|
POSTGRES_URL=... .venv/bin/python scripts/upload_blam_decisions.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||||
|
|
||||||
|
from legal_mcp.services import internal_decisions as svc
|
||||||
|
|
||||||
|
DECISIONS = [
|
||||||
|
{
|
||||||
|
"file_path": "/home/chaim/legal-ai/data/precedents/incoming/ARAR-24-8126.pdf",
|
||||||
|
"case_number": "8126/24",
|
||||||
|
"chair_name": "דפנה תמיר",
|
||||||
|
"district": "ירושלים",
|
||||||
|
"case_name": "הוועדה המקומית ירושלים נ' סופר נוח",
|
||||||
|
"court": "ועדת הערר לתכנון ובנייה — מחוז ירושלים",
|
||||||
|
"decision_date": "2024-07-07",
|
||||||
|
"practice_area": "betterment_levy",
|
||||||
|
"appeal_subtype": "extension_request_betterment_levy",
|
||||||
|
"proceeding_type": 'בל"מ',
|
||||||
|
"subject_tags": ["בקשה_להארכת_מועד", "היטל_השבחה"],
|
||||||
|
"summary": "",
|
||||||
|
"is_binding": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_path": "/home/chaim/legal-ai/data/precedents/incoming/ARAR-23-8047-3.docx",
|
||||||
|
"case_number": "8047/23",
|
||||||
|
"chair_name": "דפנה תמיר",
|
||||||
|
"district": "ירושלים",
|
||||||
|
"case_name": 'עזבון אליהו הרנון ז"ל נ\' הוועדה המקומית ירושלים',
|
||||||
|
"court": "ועדת הערר לתכנון ובנייה — מחוז ירושלים",
|
||||||
|
"decision_date": "2025-09-29",
|
||||||
|
"practice_area": "betterment_levy",
|
||||||
|
"appeal_subtype": "extension_request_betterment_levy",
|
||||||
|
"proceeding_type": 'בל"מ',
|
||||||
|
"subject_tags": ["בקשה_להארכת_מועד", "היטל_השבחה"],
|
||||||
|
"summary": "",
|
||||||
|
"is_binding": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
for d in DECISIONS:
|
||||||
|
print(f"→ uploading {d['case_number']} ({d['proceeding_type']})")
|
||||||
|
result = await svc.ingest_internal_decision(**d)
|
||||||
|
print(f" ✓ case_law_id={result.get('case_law_id')} chunks={result.get('chunks')}")
|
||||||
|
print("done.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -283,6 +283,16 @@ description: This skill should be used when writing legal decisions (החלטו
|
|||||||
**ערר היטל השבחה:**
|
**ערר היטל השבחה:**
|
||||||
פתיחה ישירה עם מסקנה. ניתוח ישיר - ציטוטי פסיקה מרובים. סיום יבש.
|
פתיחה ישירה עם מסקנה. ניתוח ישיר - ציטוטי פסיקה מרובים. סיום יבש.
|
||||||
|
|
||||||
|
**ערר היטל השבחה — הכרעה מפוצלת (שמאי מייעץ):**
|
||||||
|
תת-מסלול חוזר: הוועדה מאשרת את עצם החבות אך אינה קובעת את גובה ההיטל — ממנה שמאי מייעץ. פתיחה: זהה למסלול הכללי — "עניינו של ערר זה בדרישת תשלום היטל השבחה...". ללא סיפור תכנוני רחב. דיון — שלב משפטי: הכרעה בשאלת עצם החבות. ניתוח הטענה המרכזית (בד"כ "זכות מוקנית") מול ההקלה שהתבקשה. ציטוטי פסיקה inline — הפניה להחלטות ועדת ערר קודמות ללא בלוק ציטוט מלא. ביטויי מפתח: "אנו סבורים כי...", "בניגוד לעמדת העורר...", "לא ניתן לטעון כי...". מעבר לשלב השמאי — 3 ביטויי גישור: (א) "בכל הנוגע לטענות לגבי מקדמים... וכל טענה בעלת אופי שמאי **ניתן יהיה לעלות בפני השמאי המייעץ**." (ב) "על כן, לאור האמור **אנו ממנים שמאי מייעץ** אשר יערוך שומה להערכת ההשבחה במקרקעין כתוצאה מאישור ההקלה..." (ג) "השמאי המייעץ ינהל את הדיון **בהתאם לתקנות התכנון והבניה (סדרי דין בבקשה להכרעה לפני שמאי מכריע או שמאי מייעץ), התשס"ט-2008**." — נוסחה קבועה. הוראות המשך: "לאחר קבלת השומה המייעצת יהיו רשאים הצדדים להגיש את השגותיהם בתוך 30 יום לוועדת הערר ולאחר מכן תתקבל החלטה באשר לאופן קידום ההליך." סיום: **ללא** כותרת "סיכום" / "סוף דבר" — זורם ישירות מהוראות המינוי לחתימה "ניתנה פה אחד היום...". הוצאות: לא מוזכרות (ההליך טרם הסתיים). ראה: נווה יעקב 8070/25.
|
||||||
|
|
||||||
|
**ערר היטל השבחה — מסגרת תלת-שכבתית לניתוח "תכנית צל":**
|
||||||
|
כשעורר טוען ש"תכנית צל" מאושרת הופכת זכויות להקלה לזכויות מוקנות, הניתוח מתבצע בשלוש שכבות נפרדות:
|
||||||
|
*שכבה 1 — נורמטיבית* (שלילת המעמד המשפטי): "תכנית צל אינה מקור נורמטיבי לאישור זכויות... אינה 'מבטיחה' אישור זכויות עבור השכן." ביטויי מפתח: "תכנית צל הינה תכנית המבקשת... להראות היתכנות בניה על ידי יתר בעלי הזכויות ו/או השלכת הבניה עליהם, על הבניין ועל הסביבה." כלל: תכנית צל = המחשה, לא מקור נורמטיבי.
|
||||||
|
*שכבה 2 — פרוצדורלית* (ההקלה ניתנת פר-מבקש): "גם בהיתר חבקין התבקשה הקלה שאושרה, הקלה אך ורק להיתר שהתבקש ולזכויות שהתבקשו מכוחו. ההקלה לא התבקשה עבור כל דיירי הבניין... בקשה להקלה ופרסומה יש בצידם שיקול דעת... באופן ייחודי לכל בקשה לגופה." כלל: אישור הקלה לדייר א' ≠ זכות מוקנית לדייר ב'.
|
||||||
|
*שכבה 3 — שמאית* (הכרה בערך ראייתי, ניתוב למישור הנכון): "העובדה שאושרה תכנית צל יש בה מידת וודאות גבוהה יותר באשר לסיכוי כי תאושר ההקלה... ולכך **משקל שמאי** בהערכת ההשבחה." כלל: תכנית צל משפיעה על **ההסתברות** (מישור שמאי), לא על **הזכות** (מישור משפטי).
|
||||||
|
סדר הניתוח: תמיד שכבה 1 → 2 → 3. לא לדלג — גם אם שכבה 1 מכריעה, יש ערך בכל שלוש לביסוס ולמניעת ערעור. ראה: נווה יעקב 8070/25.
|
||||||
|
|
||||||
**ערר רישוי שמתקבל חלקית — מסלול מיפוי מתחים + ניתוח נושאי:**
|
**ערר רישוי שמתקבל חלקית — מסלול מיפוי מתחים + ניתוח נושאי:**
|
||||||
פתיחה במיפוי מתחים (3-6 סעיפים): הקשר כללי קצר (1-2 פסקאות), רשימת נקודות מתח ספציפיות בתיק (4-6 בולטים), מעבר לניתוח. אין שימוש בשכבות/עיגולים קונצנטריים — ניתוח לפי נושאים: כל נושא מקבל טיפול מלא (הצגה → ציטוט הוראות תכנית → פסיקה → מסקנה). נושא חניה/תשתיות מקבל טיפול מעמיק במיוחד עם ציטוטים ישירים מהוראות תכנית ונספחים. טענות ספציפיות (מטרדים, עצים, בור מים) — 1-2 סעיפים תמציתיים לכל אחת. סיכום מינימלי — רק הוראות אופרטיביות (2-3 סעיפים). ראה: בית הכרם 1126/25.
|
פתיחה במיפוי מתחים (3-6 סעיפים): הקשר כללי קצר (1-2 פסקאות), רשימת נקודות מתח ספציפיות בתיק (4-6 בולטים), מעבר לניתוח. אין שימוש בשכבות/עיגולים קונצנטריים — ניתוח לפי נושאים: כל נושא מקבל טיפול מלא (הצגה → ציטוט הוראות תכנית → פסיקה → מסקנה). נושא חניה/תשתיות מקבל טיפול מעמיק במיוחד עם ציטוטים ישירים מהוראות תכנית ונספחים. טענות ספציפיות (מטרדים, עצים, בור מים) — 1-2 סעיפים תמציתיים לכל אחת. סיכום מינימלי — רק הוראות אופרטיביות (2-3 סעיפים). ראה: בית הכרם 1126/25.
|
||||||
|
|
||||||
|
|||||||
@@ -252,82 +252,10 @@ new Table({
|
|||||||
|
|
||||||
## Tracked Changes — עקוב אחר שינויים
|
## Tracked Changes — עקוב אחר שינויים
|
||||||
|
|
||||||
### שם מחבר בעברית
|
ראה [`references/tracked-changes.md`](references/tracked-changes.md) — XML patterns לשינוי ערך, מחיקת סעיף, RTL PROPS, קבלה/דחייה.
|
||||||
```xml
|
|
||||||
<w:del w:id="10" w:author="עו"ד כהן" w:date="2026-02-06T09:00:00Z">
|
|
||||||
```
|
|
||||||
|
|
||||||
### שינוי ערך (סכום, תאריך, תקופה)
|
```bash
|
||||||
פצל את הטקסט ועטוף רק את הערך שמשתנה:
|
python /mnt/skills/public/docx/scripts/comment.py unpacked/ 0 "הערה" --author "עו״ד כהן"
|
||||||
```xml
|
|
||||||
<w:r><w:rPr>...RTL PROPS...</w:rPr>
|
|
||||||
<w:t xml:space="preserve">שכר הטרחה יעמוד על סך של </w:t></w:r>
|
|
||||||
<w:del w:id="10" w:author="עו"ד כהן" w:date="...">
|
|
||||||
<w:r><w:rPr>...RTL PROPS...</w:rPr><w:delText>750</w:delText></w:r>
|
|
||||||
</w:del>
|
|
||||||
<w:ins w:id="11" w:author="עו"ד כהן" w:date="...">
|
|
||||||
<w:r><w:rPr>...RTL PROPS...</w:rPr><w:t>850</w:t></w:r>
|
|
||||||
</w:ins>
|
|
||||||
<w:r><w:rPr>...RTL PROPS...</w:rPr>
|
|
||||||
<w:t xml:space="preserve"> ש״ח לשעת עבודה</w:t></w:r>
|
|
||||||
```
|
|
||||||
|
|
||||||
### מחיקת סעיף שלם
|
|
||||||
סמן גם את ה-paragraph mark כ-deleted:
|
|
||||||
```xml
|
|
||||||
<w:p>
|
|
||||||
<w:pPr>
|
|
||||||
<w:bidi/>
|
|
||||||
<w:jc w:val="both"/>
|
|
||||||
<w:rPr>
|
|
||||||
<w:del w:id="20" w:author="עו"ד כהן" w:date="..."/>
|
|
||||||
</w:rPr>
|
|
||||||
</w:pPr>
|
|
||||||
<w:del w:id="21" w:author="עו"ד כהן" w:date="...">
|
|
||||||
<w:r><w:rPr>...RTL PROPS...</w:rPr>
|
|
||||||
<w:delText>הסעיף שנמחק</w:delText></w:r>
|
|
||||||
</w:del>
|
|
||||||
</w:p>
|
|
||||||
```
|
|
||||||
|
|
||||||
### RTL PROPS — בלוק rPr מלא לכל run
|
|
||||||
```xml
|
|
||||||
<w:rPr>
|
|
||||||
<w:rFonts w:ascii="David" w:cs="David" w:eastAsia="David" w:hAnsi="David"/>
|
|
||||||
<w:sz w:val="24"/>
|
|
||||||
<w:szCs w:val="24"/>
|
|
||||||
<w:rtl/>
|
|
||||||
</w:rPr>
|
|
||||||
```
|
|
||||||
|
|
||||||
### קבלה/דחייה של שינויים
|
|
||||||
|
|
||||||
**קבלת Insertion:**
|
|
||||||
```
|
|
||||||
לפני: <w:ins w:id="5" w:author="..."><w:r>...<w:t>טקסט חדש</w:t></w:r></w:ins>
|
|
||||||
אחרי: <w:r>...<w:t>טקסט חדש</w:t></w:r>
|
|
||||||
→ הסר את תגית <w:ins> ושמור את התוכן הפנימי.
|
|
||||||
```
|
|
||||||
|
|
||||||
**דחיית Insertion:**
|
|
||||||
```
|
|
||||||
לפני: <w:ins w:id="5" w:author="..."><w:r>...<w:t>טקסט חדש</w:t></w:r></w:ins>
|
|
||||||
אחרי: (הסר לחלוטין)
|
|
||||||
→ מחק את כל בלוק ה-<w:ins> כולל תוכנו.
|
|
||||||
```
|
|
||||||
|
|
||||||
**קבלת מחיקה:**
|
|
||||||
```
|
|
||||||
לפני: <w:del w:id="10" w:author="..."><w:r>...<w:delText>טקסט שנמחק</w:delText></w:r></w:del>
|
|
||||||
אחרי: (הסר לחלוטין)
|
|
||||||
→ מחק את כל בלוק ה-<w:del> כולל תוכנו — המחיקה מתקבלת.
|
|
||||||
```
|
|
||||||
|
|
||||||
**שחזור טקסט מקורי (דחיית מחיקה):**
|
|
||||||
```
|
|
||||||
לפני: <w:del w:id="10" w:author="..."><w:r>...<w:delText>טקסט מקורי</w:delText></w:r></w:del>
|
|
||||||
אחרי: <w:r>...<w:t>טקסט מקורי</w:t></w:r>
|
|
||||||
→ הסר <w:del>, החלף <w:delText> ב-<w:t>, הסר <w:del> מ-rPr אם קיים.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -397,72 +325,6 @@ python /mnt/skills/public/docx/scripts/pack.py unpacked/ output.docx --original
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## הערות שוליים (Footnotes)
|
|
||||||
|
|
||||||
**השימוש המרכזי:** הפניות לחקיקה ופסיקה.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const { FootnoteReferenceRun } = require('docx');
|
|
||||||
|
|
||||||
// 1. הגדרה ב-Document:
|
|
||||||
const doc = new Document({
|
|
||||||
footnotes: {
|
|
||||||
1: { children: [new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.START, // ✅ START
|
|
||||||
children: [new TextRun({
|
|
||||||
text: "חוק החוזים (חלק כללי), התשל״ג-1973, סעיף 12.",
|
|
||||||
font: "David", size: 20, rightToLeft: true // 10pt להערות שוליים
|
|
||||||
})]
|
|
||||||
})] },
|
|
||||||
2: { children: [new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.START,
|
|
||||||
children: [new TextRun({
|
|
||||||
text: "ע״א 1234/20 כהן נ׳ לוי, פסקה 15 (פורסם בנבו, 1.1.2024).",
|
|
||||||
font: "David", size: 20, rightToLeft: true
|
|
||||||
})]
|
|
||||||
})] },
|
|
||||||
},
|
|
||||||
// ...sections
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. הפניה בגוף הטקסט:
|
|
||||||
new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.BOTH,
|
|
||||||
children: [
|
|
||||||
new TextRun({ text: "חובת תום הלב", font: "David", size: 24, rightToLeft: true }),
|
|
||||||
new FootnoteReferenceRun(1),
|
|
||||||
new TextRun({ text: " חלה על כל שלבי המשא ומתן", font: "David", size: 24, rightToLeft: true }),
|
|
||||||
new FootnoteReferenceRun(2),
|
|
||||||
new TextRun({ text: ".", font: "David", size: 24, rightToLeft: true }),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### תיקון RTL בהערות שוליים (post-unpack)
|
|
||||||
docx-js לא מגדיר RTL מלא בהערות שוליים. אחרי unpack, צריך לתקן ב-`word/footnotes.xml`:
|
|
||||||
```xml
|
|
||||||
<!-- 1. הוסף pStyle + bidi לכל הערת שוליים: -->
|
|
||||||
<w:footnote w:id="1">
|
|
||||||
<w:p>
|
|
||||||
<w:pPr>
|
|
||||||
<w:pStyle w:val="FootnoteText"/>
|
|
||||||
<w:bidi/>
|
|
||||||
<w:jc w:val="start"/>
|
|
||||||
</w:pPr>
|
|
||||||
...
|
|
||||||
|
|
||||||
<!-- 2. הוסף rtl ל-footnoteRef run: -->
|
|
||||||
<w:r>
|
|
||||||
<w:rPr>
|
|
||||||
<w:rStyle w:val="FootnoteReference"/>
|
|
||||||
<w:rtl/>
|
|
||||||
</w:rPr>
|
|
||||||
<w:footnoteRef/>
|
|
||||||
</w:r>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## מרווח שורות (Line Spacing)
|
## מרווח שורות (Line Spacing)
|
||||||
|
|
||||||
**דרישת בתי המשפט:** בדרך כלל 1.5 שורות.
|
**דרישת בתי המשפט:** בדרך כלל 1.5 שורות.
|
||||||
@@ -482,48 +344,6 @@ spacing: { line: 360, lineRule: LineRuleType.AUTO, before: 120, after: 120 }
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## תוכן עניינים (TOC)
|
|
||||||
|
|
||||||
**⚠️ חובה: TOC ידני (לא TableOfContents).**
|
|
||||||
`TableOfContents` של docx-js מייצר שדה שוורד מעדכן ב-F9 ומאבד הגדרות RTL.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const { Tab, TabStopType, LeaderType, PageBreak } = require('docx');
|
|
||||||
|
|
||||||
// שורת TOC ידנית
|
|
||||||
const tocEntry = (text, pageNum, opts = {}) => new Paragraph({
|
|
||||||
bidirectional: true,
|
|
||||||
spacing: { after: 60, line: 276, lineRule: LineRuleType.AUTO },
|
|
||||||
...(opts.indent ? { indent: { right: opts.indent } } : {}),
|
|
||||||
tabStops: [{ type: TabStopType.RIGHT, position: 9026, leader: LeaderType.DOT }],
|
|
||||||
children: [
|
|
||||||
new TextRun({
|
|
||||||
text, font: "David", size: 24, rightToLeft: true,
|
|
||||||
bold: opts.bold || false,
|
|
||||||
}),
|
|
||||||
new TextRun({ children: [new Tab()], font: "David", rightToLeft: true }),
|
|
||||||
new TextRun({
|
|
||||||
text: String(pageNum), font: "David", size: 24, rightToLeft: true,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// שימוש:
|
|
||||||
new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
|
||||||
spacing: { after: 200 },
|
|
||||||
children: [new TextRun({
|
|
||||||
text: "תוכן עניינים", font: "David", size: 32, bold: true, rightToLeft: true
|
|
||||||
})]
|
|
||||||
}),
|
|
||||||
tocEntry("פרק א׳ — הגדרות כלליות", 2, { bold: true }),
|
|
||||||
tocEntry("1. הגדרות יסוד", 2, { indent: 400 }),
|
|
||||||
tocEntry("פרק ב׳ — השירותים", 3, { bold: true }),
|
|
||||||
new Paragraph({ children: [new PageBreak()] }),
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## קו תחתי (Underline)
|
## קו תחתי (Underline)
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@@ -544,337 +364,23 @@ underline: { type: UnderlineType.DOUBLE }
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## מספר סקשנים (Multiple Sections)
|
## פיצ'רים מתקדמים
|
||||||
|
|
||||||
**שימוש:** כותרות שונות לנספחים, עמוד לרוחב לטבלאות, שוליים שונים.
|
ראה [`references/advanced-features.md`](references/advanced-features.md):
|
||||||
|
- **הערות שוליים** — Footnotes עם RTL + תיקון post-unpack ב-footnotes.xml
|
||||||
```javascript
|
- **תוכן עניינים** — TOC ידני (אסור `TableOfContents`)
|
||||||
const doc = new Document({
|
- **מספר סקשנים** — כותרות שונות לנספחים
|
||||||
sections: [
|
- **Letterhead** — לוגו/תמונה בכותרת
|
||||||
// סקשן 1 — גוף ההסכם
|
- **היפרלינקים** — `ExternalHyperlink` עם color+underline ידני (לא `style: "Hyperlink"`)
|
||||||
{
|
|
||||||
properties: {
|
|
||||||
page: { size: { width: 11906, height: 16838 },
|
|
||||||
margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } },
|
|
||||||
bidi: true,
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
default: new Header({ children: [new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
|
||||||
children: [new TextRun({ text: "הסכם שירותים", font: "David", size: 20, bold: true, rightToLeft: true })]
|
|
||||||
})] })
|
|
||||||
},
|
|
||||||
children: [ /* ... */ ]
|
|
||||||
},
|
|
||||||
// סקשן 2 — נספח עם כותרת שונה
|
|
||||||
{
|
|
||||||
properties: {
|
|
||||||
page: { size: { width: 11906, height: 16838 },
|
|
||||||
margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } },
|
|
||||||
bidi: true,
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
default: new Header({ children: [new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.START, // ✅ START
|
|
||||||
children: [new TextRun({ text: "נספח א׳ — לוח תעריפים", font: "David", size: 20, bold: true, rightToLeft: true })]
|
|
||||||
})] })
|
|
||||||
},
|
|
||||||
children: [ /* ... */ ]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## לוגו/תמונה בכותרת (Letterhead)
|
## תבניות מסמכים
|
||||||
|
|
||||||
```javascript
|
ראה [`references/document-templates.md`](references/document-templates.md):
|
||||||
const { ImageRun } = require('docx');
|
- **תבנית 1: כתב טענות** — `courtHeader()`, `mainTitle()`, `subHeading()` + מספור
|
||||||
|
- **תבנית 2: מכתב התראה** — `letterHeader()`, `subjectLine()` + פרטי משרד
|
||||||
const logoBuffer = fs.readFileSync('/path/to/logo.png');
|
- **תבנית 3: הסכם/חוזה** — `contractTitle()`, `partyClause()`, `signatureTable()` + הואילים
|
||||||
|
|
||||||
headers: {
|
|
||||||
default: new Header({
|
|
||||||
children: [
|
|
||||||
new Paragraph({
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
children: [
|
|
||||||
new ImageRun({
|
|
||||||
data: logoBuffer,
|
|
||||||
transformation: { width: 200, height: 60 }, // pixels
|
|
||||||
type: "png",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
|
||||||
children: [new TextRun({
|
|
||||||
text: "משרד עורכי דין ישראלי ושות׳",
|
|
||||||
font: "David", size: 20, bold: true, rightToLeft: true
|
|
||||||
})],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**הערה:** תמונה חייבת להיות קובץ אמיתי — לבקש מהמשתמש אם אין.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## היפרלינקים
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const { ExternalHyperlink, UnderlineType } = require('docx');
|
|
||||||
|
|
||||||
new Paragraph({
|
|
||||||
bidirectional: true,
|
|
||||||
children: [
|
|
||||||
new TextRun({ text: "ראה: ", font: "David", size: 24, rightToLeft: true }),
|
|
||||||
new ExternalHyperlink({
|
|
||||||
link: "https://www.nevo.co.il/law_html/law01/073_002.htm",
|
|
||||||
children: [new TextRun({
|
|
||||||
text: "חוק החוזים באתר נבו",
|
|
||||||
font: "David", size: 24, rightToLeft: true,
|
|
||||||
color: "0563C1",
|
|
||||||
underline: { type: UnderlineType.SINGLE },
|
|
||||||
})],
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**⚠️ אזהרות:**
|
|
||||||
- **לא להשתמש ב-`style: "Hyperlink"`** — מפריע ל-RTL!
|
|
||||||
- **לא להוסיף `alignment: AlignmentType.RIGHT`** — `bidirectional: true` מספיק
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## תבניות מסמכים — Document Templates
|
|
||||||
|
|
||||||
### תבנית 1: כתב טענות (בקשה, תביעה, הגנה, ערעור)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
|
|
||||||
AlignmentType, LevelFormat, BorderStyle, WidthType } = require('docx');
|
|
||||||
|
|
||||||
const PAGE_WIDTH = 11906;
|
|
||||||
const MARGINS = { top: 1134, right: 1134, bottom: 1134, left: 1134 };
|
|
||||||
const CONTENT_WIDTH = PAGE_WIDTH - MARGINS.left - MARGINS.right;
|
|
||||||
|
|
||||||
const noBorder = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" };
|
|
||||||
const noBorders = { top: noBorder, bottom: noBorder, left: noBorder, right: noBorder };
|
|
||||||
|
|
||||||
// Header בית משפט — טבלה עם שם בית המשפט (ימין) ומספר תיק (שמאל)
|
|
||||||
function courtHeader(courtName, caseNumber) {
|
|
||||||
return new Table({
|
|
||||||
width: { size: CONTENT_WIDTH, type: WidthType.DXA },
|
|
||||||
columnWidths: [CONTENT_WIDTH / 2, CONTENT_WIDTH / 2],
|
|
||||||
visuallyRightToLeft: true,
|
|
||||||
rows: [
|
|
||||||
new TableRow({
|
|
||||||
children: [
|
|
||||||
new TableCell({
|
|
||||||
width: { size: CONTENT_WIDTH / 2, type: WidthType.DXA },
|
|
||||||
borders: noBorders,
|
|
||||||
children: [new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.START,
|
|
||||||
children: [new TextRun({ text: courtName, bold: true, font: "David", size: 26, rightToLeft: true })]
|
|
||||||
})]
|
|
||||||
}),
|
|
||||||
new TableCell({
|
|
||||||
width: { size: CONTENT_WIDTH / 2, type: WidthType.DXA },
|
|
||||||
borders: noBorders,
|
|
||||||
children: [new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.END,
|
|
||||||
children: [new TextRun({ text: caseNumber, bold: true, font: "David", size: 26, rightToLeft: true })]
|
|
||||||
})]
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// כותרת ראשית ממורכזת עם קו תחתון
|
|
||||||
function mainTitle(text) {
|
|
||||||
return new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
|
||||||
spacing: { before: 300, after: 300 },
|
|
||||||
children: [new TextRun({ text, bold: true, font: "David", size: 28, rightToLeft: true, underline: {} })]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// כותרת משנה מיושרת לימין עם קו תחתון
|
|
||||||
function subHeading(text) {
|
|
||||||
return new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.START,
|
|
||||||
spacing: { before: 240, after: 120 },
|
|
||||||
children: [new TextRun({ text, bold: true, font: "David", size: 24, rightToLeft: true, underline: {} })]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// שימוש:
|
|
||||||
const doc = new Document({
|
|
||||||
numbering: {
|
|
||||||
config: [{
|
|
||||||
reference: "legal-clauses",
|
|
||||||
levels: [{
|
|
||||||
level: 0, format: LevelFormat.DECIMAL, text: "%1.",
|
|
||||||
alignment: AlignmentType.START, suffix: "tab",
|
|
||||||
style: { paragraph: { indent: { left: 360, hanging: 360 } } }
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
sections: [{
|
|
||||||
properties: {
|
|
||||||
page: { size: { width: PAGE_WIDTH, height: 16838 }, margin: MARGINS },
|
|
||||||
bidi: true
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
courtHeader("בית המשפט המחוזי בתל אביב", "ת\"א 12345-01-26"),
|
|
||||||
mainTitle("כתב תביעה"),
|
|
||||||
// ... פרטי צדדים, סעיפים, חתימה
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### תבנית 2: מכתב התראה
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// מכתב התראה — ללא header בית משפט, עם פרטי משרד
|
|
||||||
|
|
||||||
function letterHeader(firmName, address, phone, email) {
|
|
||||||
return [
|
|
||||||
new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.START,
|
|
||||||
children: [new TextRun({ text: firmName, bold: true, font: "David", size: 28, rightToLeft: true })]
|
|
||||||
}),
|
|
||||||
new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.START,
|
|
||||||
children: [new TextRun({ text: address, font: "David", size: 22, rightToLeft: true })]
|
|
||||||
}),
|
|
||||||
new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.START,
|
|
||||||
spacing: { after: 300 },
|
|
||||||
children: [new TextRun({ text: `טל': ${phone} | ${email}`, font: "David", size: 22, rightToLeft: true })]
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function subjectLine(text) {
|
|
||||||
return new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
|
||||||
spacing: { before: 200, after: 200 },
|
|
||||||
children: [
|
|
||||||
new TextRun({ text: "הנדון: ", bold: true, font: "David", size: 24, rightToLeft: true }),
|
|
||||||
new TextRun({ text, bold: true, font: "David", size: 24, rightToLeft: true, underline: {} })
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// שימוש:
|
|
||||||
sections: [{
|
|
||||||
properties: { page: { ... }, bidi: true },
|
|
||||||
children: [
|
|
||||||
...letterHeader("משרד עו\"ד כהן ושות'", "רח' הרצל 1, תל אביב", "03-1234567", "office@cohen-law.co.il"),
|
|
||||||
new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.START,
|
|
||||||
children: [new TextRun({ text: "תאריך: 10.2.2026", font: "David", size: 24, rightToLeft: true })]
|
|
||||||
}),
|
|
||||||
new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.START,
|
|
||||||
spacing: { before: 200 },
|
|
||||||
children: [new TextRun({ text: "לכבוד: [שם הנמען]", font: "David", size: 24, rightToLeft: true })]
|
|
||||||
}),
|
|
||||||
subjectLine("התראה בטרם נקיטת הליכים משפטיים"),
|
|
||||||
// ... גוף המכתב
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
```
|
|
||||||
|
|
||||||
### תבנית 3: הסכם/חוזה
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// הסכם — הואילים, צדדים, חתימות בשני טורים
|
|
||||||
|
|
||||||
function contractTitle(text) {
|
|
||||||
return new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
|
||||||
spacing: { after: 300 },
|
|
||||||
children: [new TextRun({ text, bold: true, font: "David", size: 32, rightToLeft: true })]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function partyClause(label, name, id, address, alias) {
|
|
||||||
return new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.BOTH,
|
|
||||||
spacing: { after: 120 },
|
|
||||||
children: [
|
|
||||||
new TextRun({ text: `${label}: `, bold: true, font: "David", size: 24, rightToLeft: true }),
|
|
||||||
new TextRun({ text: `${name}, ח.פ./ת.ז. ${id}, מ${address} (להלן: "`, font: "David", size: 24, rightToLeft: true }),
|
|
||||||
new TextRun({ text: alias, bold: true, font: "David", size: 24, rightToLeft: true }),
|
|
||||||
new TextRun({ text: '")', font: "David", size: 24, rightToLeft: true }),
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function signatureTable() {
|
|
||||||
return new Table({
|
|
||||||
width: { size: CONTENT_WIDTH, type: WidthType.DXA },
|
|
||||||
columnWidths: [CONTENT_WIDTH / 2, CONTENT_WIDTH / 2],
|
|
||||||
visuallyRightToLeft: true,
|
|
||||||
rows: [
|
|
||||||
new TableRow({
|
|
||||||
children: [
|
|
||||||
new TableCell({
|
|
||||||
borders: noBorders,
|
|
||||||
children: [
|
|
||||||
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
|
|
||||||
children: [new TextRun({ text: "_________________", font: "David", size: 24, rightToLeft: true })] }),
|
|
||||||
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
|
|
||||||
children: [new TextRun({ text: "צד א'", font: "David", size: 24, rightToLeft: true })] })
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
new TableCell({
|
|
||||||
borders: noBorders,
|
|
||||||
children: [
|
|
||||||
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
|
|
||||||
children: [new TextRun({ text: "_________________", font: "David", size: 24, rightToLeft: true })] }),
|
|
||||||
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
|
|
||||||
children: [new TextRun({ text: "צד ב'", font: "David", size: 24, rightToLeft: true })] })
|
|
||||||
]
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// שימוש:
|
|
||||||
sections: [{
|
|
||||||
properties: { page: { ... }, bidi: true },
|
|
||||||
children: [
|
|
||||||
contractTitle("הסכם שירותים"),
|
|
||||||
new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
|
||||||
children: [new TextRun({ text: "נערך ונחתם בתל אביב ביום __________", font: "David", size: 24, rightToLeft: true })]
|
|
||||||
}),
|
|
||||||
partyClause("מצד אחד", "[שם]", "[מספר]", "[כתובת]", "המזמין"),
|
|
||||||
partyClause("מצד שני", "[שם]", "[מספר]", "[כתובת]", "הספק"),
|
|
||||||
// הואילים...
|
|
||||||
// סעיפים...
|
|
||||||
new Paragraph({
|
|
||||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
|
||||||
spacing: { before: 400, after: 300 },
|
|
||||||
children: [new TextRun({ text: "ולראיה באו הצדדים על החתום:", bold: true, font: "David", size: 24, rightToLeft: true })]
|
|
||||||
}),
|
|
||||||
signatureTable()
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -962,8 +468,11 @@ sections: [{
|
|||||||
|
|
||||||
## קבצי עזר
|
## קבצי עזר
|
||||||
|
|
||||||
- **`references/document-types.md`** — מבנים מפורטים ל-9 סוגי מסמכים משפטיים
|
- **[`references/document-types.md`](references/document-types.md)** — מבנים מפורטים ל-9 סוגי מסמכים
|
||||||
- **`scripts/create-legal-doc.js`** — סקריפט בסיסי עם כל הגדרות ה-RTL המתוקנות
|
- **[`references/document-templates.md`](references/document-templates.md)** — 3 תבניות מלאות (כתב טענות, מכתב, הסכם)
|
||||||
|
- **[`references/tracked-changes.md`](references/tracked-changes.md)** — XML patterns לעקוב אחר שינויים
|
||||||
|
- **[`references/advanced-features.md`](references/advanced-features.md)** — הערות שוליים, TOC, סקשנים, letterhead, hyperlinks
|
||||||
|
- **`scripts/create-legal-doc.js`** — סקריפט בסיסי עם כל הגדרות RTL
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
236
web-ui/AGENTS.md
236
web-ui/AGENTS.md
@@ -3,3 +3,239 @@
|
|||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
<!-- END:nextjs-agent-rules -->
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Technology | Version |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| Framework | Next.js | 16.2.3 |
|
||||||
|
| UI | React | 19.2.4 |
|
||||||
|
| Styles | Tailwind CSS | v4 |
|
||||||
|
| Components | shadcn/ui | latest via `shadcn` CLI |
|
||||||
|
| Data fetching | TanStack Query | v5 |
|
||||||
|
| Forms | react-hook-form + zod | v7 / v4 |
|
||||||
|
| Language | TypeScript | 5 |
|
||||||
|
| Direction | Hebrew RTL | `dir="rtl"` throughout |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Regenerate API types from the live FastAPI schema — RUN AFTER EVERY BACKEND CHANGE
|
||||||
|
npm run api:types
|
||||||
|
|
||||||
|
# Validate before every push
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Local dev (rare — prod runs inside Docker; no local Python env exists)
|
||||||
|
npm run dev # requires NEXT_PUBLIC_API_ORIGIN=http://127.0.0.1:8000 or similar
|
||||||
|
```
|
||||||
|
|
||||||
|
**`npm run api:types` is mandatory** any time a FastAPI endpoint is added, removed, or its request/response shape changes. It fetches `https://legal-ai.nautilus.marcusgroup.org/openapi.json` and writes `src/lib/api/types.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Proxy — `/api/*`
|
||||||
|
|
||||||
|
`next.config.ts` transparently rewrites all `/api/*` requests to the FastAPI backend:
|
||||||
|
|
||||||
|
- In Docker (production): `http://127.0.0.1:8000`
|
||||||
|
- Override via env var: `NEXT_PUBLIC_API_ORIGIN`
|
||||||
|
|
||||||
|
**Never hardcode the backend origin in component code.** Always use relative paths like `/api/cases`.
|
||||||
|
|
||||||
|
The typed fetch wrapper lives in `src/lib/api/client.ts` — use `apiRequest<T>(path, options)`. It throws `ApiError` on non-2xx responses with the parsed body and status code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Types — Never Edit by Hand
|
||||||
|
|
||||||
|
`src/lib/api/types.ts` is **auto-generated** by `openapi-typescript` from the live FastAPI OpenAPI schema.
|
||||||
|
|
||||||
|
- **Do NOT edit `src/lib/api/types.ts` manually** — changes will be overwritten on the next `npm run api:types` run.
|
||||||
|
- The typed helper modules in `src/lib/api/` (e.g. `cases.ts`, `documents.ts`, `precedents.ts`) ARE hand-written and import from `types.ts`. These are safe to edit.
|
||||||
|
- When adding a new API domain, create a new typed module in `src/lib/api/<domain>.ts` following the existing pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tailwind CSS v4 — Breaking Changes from v3
|
||||||
|
|
||||||
|
Tailwind v4 has a completely different configuration model.
|
||||||
|
|
||||||
|
**What does NOT exist in v4:**
|
||||||
|
- `tailwind.config.ts` / `tailwind.config.js` — there is no config file
|
||||||
|
- `@tailwind base;` / `@tailwind components;` / `@tailwind utilities;` directives
|
||||||
|
- `tailwind.config.theme.extend` object
|
||||||
|
|
||||||
|
**What v4 uses instead:**
|
||||||
|
```css
|
||||||
|
/* globals.css — already set up, do not change */
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Design tokens defined here as CSS custom properties */
|
||||||
|
--color-navy: #0f172a;
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Custom tokens go inside `@theme {}` in `globals.css`.
|
||||||
|
- Custom variants use `@custom-variant`.
|
||||||
|
- Class names are the same (e.g. `bg-navy`, `text-gold`), but the config source is CSS, not JS.
|
||||||
|
- PostCSS is configured via `@tailwindcss/postcss` (devDependency).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## shadcn/ui Components
|
||||||
|
|
||||||
|
Adding a new component:
|
||||||
|
```bash
|
||||||
|
npx shadcn add <component-name>
|
||||||
|
# e.g. npx shadcn add table
|
||||||
|
```
|
||||||
|
|
||||||
|
Installed components live in `src/components/ui/`. They are editable (shadcn copies the source, not a package import). The `radix-ui` package (v1.4) is the underlying primitive.
|
||||||
|
|
||||||
|
- Do NOT `npm install @radix-ui/react-*` directly — use `npx shadcn add` which installs the correct Radix version and generates the shadcn wrapper.
|
||||||
|
- Design tokens in `globals.css` (`--color-navy`, `--color-gold`, etc.) are already mapped to the shadcn semantic tokens (`background`, `foreground`, `primary`, etc.), so shadcn components inherit the editorial/judicial aesthetic automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TanStack Query v5
|
||||||
|
|
||||||
|
**v5 has breaking API changes from v4.** Key patterns used in this codebase:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Reading data
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ["cases"],
|
||||||
|
queryFn: () => apiRequest<CaseListResponse>("/api/cases"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Writing data
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (body: CreateCaseRequest) =>
|
||||||
|
apiRequest<Case>("/api/cases", { method: "POST", body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cases"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**v5 changes from v4:**
|
||||||
|
- `useQuery` no longer accepts positional arguments — always use the options object.
|
||||||
|
- `isLoading` is replaced by `isPending` for mutations (but `isLoading` still works for queries).
|
||||||
|
- `onSuccess`/`onError`/`onSettled` callbacks on `useQuery` are removed — use mutation callbacks or `useEffect` instead.
|
||||||
|
- `getQueryData` / `setQueryData` are unchanged.
|
||||||
|
|
||||||
|
The shared `QueryClient` is created in `src/lib/api/client.ts` via `makeQueryClient()` and provided by `src/lib/providers.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RTL — Hebrew UI Rules
|
||||||
|
|
||||||
|
**All UI is Hebrew, right-to-left.** The `<html>` element has `dir="rtl"` and `lang="he"`.
|
||||||
|
|
||||||
|
Use **logical CSS properties** instead of directional ones:
|
||||||
|
|
||||||
|
| Avoid (directional) | Use (logical) |
|
||||||
|
|---------------------|--------------|
|
||||||
|
| `ml-*` / `mr-*` | `ms-*` (start) / `me-*` (end) |
|
||||||
|
| `pl-*` / `pr-*` | `ps-*` (start) / `pe-*` (end) |
|
||||||
|
| `text-left` | `text-start` |
|
||||||
|
| `text-right` | `text-end` |
|
||||||
|
| `float-left` | `float-start` |
|
||||||
|
| `border-l-*` | `border-s-*` |
|
||||||
|
|
||||||
|
In RTL, "start" = right side, "end" = left side. Using logical properties means the layout works automatically without RTL overrides.
|
||||||
|
|
||||||
|
Flexbox direction: `flex-row` in RTL naturally flows right-to-left. Use `flex-row-reverse` only when you need LTR inside an RTL context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js App Router pages
|
||||||
|
│ ├── layout.tsx # Root layout — sets dir="rtl", applies fonts
|
||||||
|
│ ├── globals.css # Tailwind v4 imports + design tokens + :root vars
|
||||||
|
│ ├── cases/ # Case management pages
|
||||||
|
│ ├── precedents/ # Precedent library pages
|
||||||
|
│ ├── methodology/ # Methodology browser
|
||||||
|
│ ├── training/ # Training document management
|
||||||
|
│ ├── settings/ # Application settings
|
||||||
|
│ └── skills/ # Skills management
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # shadcn primitives (editable copies)
|
||||||
|
│ ├── app-shell.tsx # Top-level shell with nav
|
||||||
|
│ ├── cases/ # Case-domain components
|
||||||
|
│ ├── documents/ # Document viewer components
|
||||||
|
│ ├── precedents/ # Precedent components
|
||||||
|
│ └── compose/ # Decision drafting / block editor
|
||||||
|
├── lib/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── types.ts # AUTO-GENERATED — never edit
|
||||||
|
│ │ ├── client.ts # apiRequest<T> + QueryClient factory
|
||||||
|
│ │ ├── cases.ts # Typed case API helpers
|
||||||
|
│ │ ├── documents.ts # Typed document API helpers
|
||||||
|
│ │ └── ... # One file per API domain
|
||||||
|
│ ├── providers.tsx # TanStack Query + theme providers
|
||||||
|
│ ├── utils.ts # cn() and other shared utilities
|
||||||
|
│ ├── doc-types.ts # Document type constants
|
||||||
|
│ └── sse.ts # Server-Sent Events helper for streaming
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forms
|
||||||
|
|
||||||
|
Forms use **react-hook-form** (v7) with **zod** (v4) validation via `@hookform/resolvers`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const schema = z.object({ title: z.string().min(1) });
|
||||||
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({ resolver: zodResolver(schema) });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notifications / Toasts
|
||||||
|
|
||||||
|
Use **sonner** (`import { toast } from "sonner"`). The `<Toaster>` is mounted in `src/lib/providers.tsx`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
toast.success("התיק נשמר בהצלחה");
|
||||||
|
toast.error("שגיאה בשמירה");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Streaming (SSE)
|
||||||
|
|
||||||
|
Server-sent events are used for long-running AI operations (drafting, analysis). The helper is in `src/lib/sse.ts`. Use it instead of raw `EventSource`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
This frontend runs **inside Docker via Coolify** — not as a standalone Node process.
|
||||||
|
|
||||||
|
- **No `npm run dev` on the server** — there is no local Python environment for the backend.
|
||||||
|
- To see changes in production: `git commit` + `git push origin main` → Gitea Actions builds image → Coolify redeploys (~2-4 min).
|
||||||
|
- Prod URL: `https://legal-ai.nautilus.marcusgroup.org`
|
||||||
|
- The Next.js output is `standalone` (see `next.config.ts: output: "standalone"`).
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { StatusGuide } from "@/components/cases/status-guide";
|
|||||||
import { StatusChanger } from "@/components/cases/status-changer";
|
import { StatusChanger } from "@/components/cases/status-changer";
|
||||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||||
|
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||||
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||||
@@ -77,6 +78,9 @@ export default function CaseDetailPage({
|
|||||||
<div className="flex items-center justify-between gap-3 mb-1 flex-wrap">
|
<div className="flex items-center justify-between gap-3 mb-1 flex-wrap">
|
||||||
<TabsList className="bg-rule-soft/60">
|
<TabsList className="bg-rule-soft/60">
|
||||||
<TabsTrigger value="overview">סקירה</TabsTrigger>
|
<TabsTrigger value="overview">סקירה</TabsTrigger>
|
||||||
|
<TabsTrigger value="arguments">
|
||||||
|
טיעונים
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="drafts">
|
<TabsTrigger value="drafts">
|
||||||
טיוטות והערות
|
טיוטות והערות
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -139,6 +143,10 @@ export default function CaseDetailPage({
|
|||||||
<DocumentsPanel data={data} />
|
<DocumentsPanel data={data} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="arguments" className="mt-5">
|
||||||
|
<LegalArgumentsPanel caseNumber={caseNumber} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="drafts" className="mt-5">
|
<TabsContent value="drafts" className="mt-5">
|
||||||
<DraftsPanel
|
<DraftsPanel
|
||||||
caseNumber={caseNumber}
|
caseNumber={caseNumber}
|
||||||
|
|||||||
161
web-ui/src/app/missing-precedents/page.tsx
Normal file
161
web-ui/src/app/missing-precedents/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
useMissingPrecedents,
|
||||||
|
type MissingPrecedentStatus,
|
||||||
|
} from "@/lib/api/missing-precedents";
|
||||||
|
import { MissingPrecedentsTable } from "@/components/missing-precedents/missing-precedents-table";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Missing-precedents page (TaskMaster #35).
|
||||||
|
*
|
||||||
|
* Surfaces citations that party briefs invoke but which aren't yet in the
|
||||||
|
* precedent_library. Four tabs by status; each tab uses the same table
|
||||||
|
* component with a different filter. Drawer (sheet) opens on row click
|
||||||
|
* with metadata + upload form that routes to internal_decision_upload
|
||||||
|
* (ערר/בל"מ citations) or precedent_library_upload (court rulings).
|
||||||
|
*/
|
||||||
|
function StatusBadge({ status, count }: { status: MissingPrecedentStatus; count: number }) {
|
||||||
|
if (!count) return null;
|
||||||
|
const variants: Record<MissingPrecedentStatus, string> = {
|
||||||
|
open: "bg-gold-wash text-gold-deep border-gold/40",
|
||||||
|
uploaded: "bg-rule-soft text-ink-muted border-rule",
|
||||||
|
closed: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
|
||||||
|
irrelevant: "bg-rule-soft text-ink-muted border-rule",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`ms-1 text-[0.65rem] ${variants[status]}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MissingPrecedentsPage() {
|
||||||
|
const [caseNumber, setCaseNumber] = useState("");
|
||||||
|
const [legalTopic, setLegalTopic] = useState("");
|
||||||
|
|
||||||
|
const counts = useMissingPrecedents({ limit: 1 });
|
||||||
|
const byStatus = counts.data?.by_status ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||||
|
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||||
|
<span aria-hidden> · </span>
|
||||||
|
<span className="text-navy">פסיקה חסרה בקורפוס</span>
|
||||||
|
</nav>
|
||||||
|
<h1 className="text-navy mb-0">פסיקה חסרה בקורפוס</h1>
|
||||||
|
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
|
||||||
|
פסיקות שצוטטו בכתבי הטענות אך אינן עדיין בקורפוס. סוכן המחקר רושם
|
||||||
|
פערים אוטומטית; היו"ר סוגר אותם על־ידי העלאת המסמך — ניתוב
|
||||||
|
אוטומטי בין הקורפוס הסמכותי (פסקי דין) להחלטות ועדות ערר.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5 space-y-5">
|
||||||
|
{/* Shared filters */}
|
||||||
|
<div className="flex items-end gap-3 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="text-[0.78rem] text-ink-muted">תיק (מספר ערר)</label>
|
||||||
|
<Input
|
||||||
|
value={caseNumber}
|
||||||
|
onChange={(e) => setCaseNumber(e.target.value)}
|
||||||
|
placeholder="1017-03-26"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="text-[0.78rem] text-ink-muted">נושא משפטי</label>
|
||||||
|
<Input
|
||||||
|
value={legalTopic}
|
||||||
|
onChange={(e) => setLegalTopic(e.target.value)}
|
||||||
|
placeholder="זכות עמידה"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="open" dir="rtl">
|
||||||
|
<TabsList className="bg-rule-soft/60">
|
||||||
|
<TabsTrigger value="open">
|
||||||
|
פתוחות
|
||||||
|
<StatusBadge status="open" count={byStatus.open ?? 0} />
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="uploaded">
|
||||||
|
הועלו
|
||||||
|
<StatusBadge status="uploaded" count={byStatus.uploaded ?? 0} />
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="closed">
|
||||||
|
נסגרו
|
||||||
|
<StatusBadge status="closed" count={byStatus.closed ?? 0} />
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="irrelevant">
|
||||||
|
לא רלוונטי
|
||||||
|
<StatusBadge
|
||||||
|
status="irrelevant"
|
||||||
|
count={byStatus.irrelevant ?? 0}
|
||||||
|
/>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="all">הכל</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="open" className="mt-4">
|
||||||
|
<MissingPrecedentsTable
|
||||||
|
status="open"
|
||||||
|
caseNumber={caseNumber.trim() || undefined}
|
||||||
|
legalTopic={legalTopic.trim() || undefined}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="uploaded" className="mt-4">
|
||||||
|
<MissingPrecedentsTable
|
||||||
|
status="uploaded"
|
||||||
|
caseNumber={caseNumber.trim() || undefined}
|
||||||
|
legalTopic={legalTopic.trim() || undefined}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="closed" className="mt-4">
|
||||||
|
<MissingPrecedentsTable
|
||||||
|
status="closed"
|
||||||
|
caseNumber={caseNumber.trim() || undefined}
|
||||||
|
legalTopic={legalTopic.trim() || undefined}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="irrelevant" className="mt-4">
|
||||||
|
<MissingPrecedentsTable
|
||||||
|
status="irrelevant"
|
||||||
|
caseNumber={caseNumber.trim() || undefined}
|
||||||
|
legalTopic={legalTopic.trim() || undefined}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="all" className="mt-4">
|
||||||
|
<MissingPrecedentsTable
|
||||||
|
caseNumber={caseNumber.trim() || undefined}
|
||||||
|
legalTopic={legalTopic.trim() || undefined}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,15 +2,26 @@
|
|||||||
|
|
||||||
import { use, useState } from "react";
|
import { use, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil, Check, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { usePrecedent } from "@/lib/api/precedent-library";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
usePrecedent,
|
||||||
|
useUpdatePrecedent,
|
||||||
|
type Precedent,
|
||||||
|
} from "@/lib/api/precedent-library";
|
||||||
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
|
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
|
||||||
|
import {
|
||||||
|
FormattedCitation,
|
||||||
|
CitationCopyButton,
|
||||||
|
} from "@/components/precedents/formatted-citation";
|
||||||
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
||||||
|
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
|
||||||
|
|
||||||
const PRACTICE_AREA_LABELS: Record<string, string> = {
|
const PRACTICE_AREA_LABELS: Record<string, string> = {
|
||||||
rishuy_uvniya: "רישוי ובנייה",
|
rishuy_uvniya: "רישוי ובנייה",
|
||||||
@@ -33,6 +44,9 @@ export default function PrecedentDetailPage({
|
|||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const { data, isPending, error } = usePrecedent(id);
|
const { data, isPending, error } = usePrecedent(id);
|
||||||
|
const update = useUpdatePrecedent();
|
||||||
|
const [editingCitation, setEditingCitation] = useState(false);
|
||||||
|
const [citationDraft, setCitationDraft] = useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
@@ -79,6 +93,36 @@ export default function PrecedentDetailPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Citation per Israeli unified citation rules. The LLM
|
||||||
|
extractor composes this from the document; the chair
|
||||||
|
can override below. */}
|
||||||
|
<CitationBlock
|
||||||
|
precedent={data as Precedent}
|
||||||
|
editing={editingCitation}
|
||||||
|
draft={citationDraft}
|
||||||
|
onStartEdit={() => {
|
||||||
|
setCitationDraft(data.citation_formatted ?? "");
|
||||||
|
setEditingCitation(true);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingCitation(false)}
|
||||||
|
onChange={setCitationDraft}
|
||||||
|
onSave={async () => {
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({
|
||||||
|
id,
|
||||||
|
patch: { citation_formatted: citationDraft.trim() },
|
||||||
|
});
|
||||||
|
toast.success("מראה מקום עודכן");
|
||||||
|
setEditingCitation(false);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof Error ? e.message : "שמירה נכשלה",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
saving={update.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{data.practice_area ? (
|
{data.practice_area ? (
|
||||||
<Badge variant="outline" className="text-[0.7rem]">
|
<Badge variant="outline" className="text-[0.7rem]">
|
||||||
@@ -152,6 +196,15 @@ export default function PrecedentDetailPage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<RelatedCasesSection
|
||||||
|
caseId={id}
|
||||||
|
related={data.related_cases ?? []}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5">
|
||||||
<ExtractedHalachotSection halachot={data.halachot ?? []} />
|
<ExtractedHalachotSection halachot={data.halachot ?? []} />
|
||||||
@@ -168,3 +221,109 @@ export default function PrecedentDetailPage({
|
|||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CitationBlock({
|
||||||
|
precedent,
|
||||||
|
editing,
|
||||||
|
draft,
|
||||||
|
onStartEdit,
|
||||||
|
onCancel,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
saving,
|
||||||
|
}: {
|
||||||
|
precedent: Precedent;
|
||||||
|
editing: boolean;
|
||||||
|
draft: string;
|
||||||
|
onStartEdit: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
}) {
|
||||||
|
const citation = (precedent.citation_formatted ?? "").trim();
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-gold/40 bg-gold-wash/30 p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[0.78rem] font-semibold text-navy">
|
||||||
|
עריכת מראה מקום
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.7rem] text-ink-muted">
|
||||||
|
הקף את שמות הצדדים בכפול-כוכבית <code className="font-mono">**שם**</code> להדגשה
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
dir="rtl"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
placeholder='ערר (ועדות ערר ...) 1234/24 **עורר נ' הוועדה המקומית** (נבו 1.2.2025)'
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving || !draft.trim()}
|
||||||
|
className="bg-navy text-parchment hover:bg-navy-soft"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5 me-1" />
|
||||||
|
שמור
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5 me-1" />
|
||||||
|
ביטול
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!citation) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-dashed border-rule bg-rule-soft/30 p-3 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[0.78rem] text-ink-muted">
|
||||||
|
מראה מקום (כללי הציטוט האחיד) — טרם חולץ
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={onStartEdit}>
|
||||||
|
<Pencil className="w-3.5 h-3.5 me-1" />
|
||||||
|
הוסף ידנית
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-rule bg-parchment-50 p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[0.7rem] uppercase tracking-wide text-ink-muted">
|
||||||
|
מראה מקום
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CitationCopyButton citation={citation} size="xs" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onStartEdit}
|
||||||
|
title="ערוך מראה מקום"
|
||||||
|
aria-label="ערוך מראה מקום"
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-rule bg-surface hover:bg-rule-soft/50 text-ink-muted hover:text-navy h-7 w-7"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormattedCitation
|
||||||
|
citation={citation}
|
||||||
|
className="block text-navy text-sm leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,49 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Upload } from "lucide-react";
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { StyleReportPanel } from "@/components/training/style-report-panel";
|
import { StyleReportPanel } from "@/components/training/style-report-panel";
|
||||||
import { CorpusPanel } from "@/components/training/corpus-panel";
|
import { CorpusPanel } from "@/components/training/corpus-panel";
|
||||||
import { ComparePanel } from "@/components/training/compare-panel";
|
import { ComparePanel } from "@/components/training/compare-panel";
|
||||||
|
import { CuratorPortraitPanel } from "@/components/training/curator-portrait-panel";
|
||||||
|
import { ChatPanel } from "@/components/training/chat-panel";
|
||||||
|
import { TrainingUploadDialog } from "@/components/training/upload-dialog";
|
||||||
|
|
||||||
export default function TrainingPage() {
|
export default function TrainingPage() {
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<header>
|
<header className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
<div>
|
||||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||||
<span aria-hidden> · </span>
|
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||||
<span className="text-navy">אימון סגנון</span>
|
<span aria-hidden> · </span>
|
||||||
</nav>
|
<span className="text-navy">אימון סגנון</span>
|
||||||
<h1 className="text-navy mb-0">הפורטרט הסגנוני של דפנה</h1>
|
</nav>
|
||||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
<h1 className="text-navy mb-0">הפורטרט הסגנוני של דפנה</h1>
|
||||||
לוח בקרה של קורפוס האימון — סטטיסטיקות, אנטומיית החלטה ממוצעת,
|
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||||
ביטויי חתימה, וכלי השוואה בין שתי החלטות.
|
לוח בקרה של קורפוס האימון — סטטיסטיקות, אנטומיית החלטה ממוצעת,
|
||||||
</p>
|
ביטויי חתימה, וכלי השוואה בין שתי החלטות.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setUploadOpen(true)}
|
||||||
|
className="bg-navy text-parchment hover:bg-navy-soft shrink-0"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 me-1" />
|
||||||
|
העלה החלטה
|
||||||
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<TrainingUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||||
|
|
||||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
@@ -34,6 +53,8 @@ export default function TrainingPage() {
|
|||||||
<TabsTrigger value="report">פורטרט סגנון</TabsTrigger>
|
<TabsTrigger value="report">פורטרט סגנון</TabsTrigger>
|
||||||
<TabsTrigger value="corpus">קורפוס</TabsTrigger>
|
<TabsTrigger value="corpus">קורפוס</TabsTrigger>
|
||||||
<TabsTrigger value="compare">השוואה</TabsTrigger>
|
<TabsTrigger value="compare">השוואה</TabsTrigger>
|
||||||
|
<TabsTrigger value="curator">הסוכן</TabsTrigger>
|
||||||
|
<TabsTrigger value="chat">שיחה</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="report" className="mt-5">
|
<TabsContent value="report" className="mt-5">
|
||||||
@@ -47,6 +68,14 @@ export default function TrainingPage() {
|
|||||||
<TabsContent value="compare" className="mt-5">
|
<TabsContent value="compare" className="mt-5">
|
||||||
<ComparePanel />
|
<ComparePanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="curator" className="mt-5">
|
||||||
|
<CuratorPortraitPanel />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="chat" className="mt-5">
|
||||||
|
<ChatPanel />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { GlobalSearch } from "@/components/global-search";
|
import { GlobalSearch } from "@/components/global-search";
|
||||||
import { headerSubtitle } from "@/components/header-context";
|
import { headerSubtitle } from "@/components/header-context";
|
||||||
|
import { useMissingPrecedentsOpenCount } from "@/lib/api/missing-precedents";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ezer Mishpati navigation shell — two-row header.
|
* Ezer Mishpati navigation shell — two-row header.
|
||||||
@@ -45,9 +46,10 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
id: "knowledge",
|
id: "knowledge",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/precedents", label: "ספריית פסיקה" },
|
{ href: "/precedents", label: "ספריית פסיקה" },
|
||||||
{ href: "/training", label: "אימון סגנון" },
|
{ href: "/missing-precedents", label: "פסיקה חסרה" },
|
||||||
{ href: "/methodology", label: "מתודולוגיה" },
|
{ href: "/training", label: "אימון סגנון" },
|
||||||
|
{ href: "/methodology", label: "מתודולוגיה" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -240,7 +242,8 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
|
|||||||
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
|
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.label}
|
<span>{item.label}</span>
|
||||||
|
{item.href === "/missing-precedents" ? <MissingPrecedentsBadge /> : null}
|
||||||
{active && (
|
{active && (
|
||||||
<span
|
<span
|
||||||
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
|
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
|
||||||
@@ -250,3 +253,18 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Small open-count badge next to "פסיקה חסרה" — only renders when >0
|
||||||
|
* so the nav stays quiet in normal operation. */
|
||||||
|
function MissingPrecedentsBadge() {
|
||||||
|
const { data: openCount } = useMissingPrecedentsOpenCount();
|
||||||
|
if (!openCount) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="ms-1 inline-flex items-center justify-center min-w-[1.25rem] h-4 px-1 rounded-full bg-gold text-navy text-[0.65rem] font-semibold"
|
||||||
|
aria-label={`${openCount} פסיקות חסרות פתוחות`}
|
||||||
|
>
|
||||||
|
{openCount}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,17 +12,35 @@ const BUCKETS: Bucket[] = [
|
|||||||
{ key: "compensation_197", label: "פיצויים (ס׳ 197)", color: "var(--color-warn)" },
|
{ key: "compensation_197", label: "פיצויים (ס׳ 197)", color: "var(--color-warn)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* For chart aggregation, collapse בל"מ variants back to their parent
|
||||||
|
* domain — building_permit / betterment_levy / compensation_197. The
|
||||||
|
* dedicated בל"מ filter in the cases table handles the cross-cutting view. */
|
||||||
|
function collapseBlam(s: AppealSubtype): AppealSubtype {
|
||||||
|
if (s === "extension_request_building_permit") return "building_permit";
|
||||||
|
if (s === "extension_request_betterment_levy") return "betterment_levy";
|
||||||
|
if (s === "extension_request_compensation") return "compensation_197";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
export function subtypeOf(c: Case): AppealSubtype {
|
export function subtypeOf(c: Case): AppealSubtype {
|
||||||
return c.appeal_subtype && c.appeal_subtype !== "unknown"
|
const raw = c.appeal_subtype && c.appeal_subtype !== "unknown"
|
||||||
? c.appeal_subtype
|
? c.appeal_subtype
|
||||||
: deriveSubtype(c.case_number);
|
: deriveSubtype(c.case_number);
|
||||||
|
return collapseBlam(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppealTypeBars({ cases }: { cases?: Case[] }) {
|
export function AppealTypeBars({ cases }: { cases?: Case[] }) {
|
||||||
|
/* All seven subtypes initialized to 0 — subtypeOf() collapses בל"מ
|
||||||
|
* variants back to their parent domain, so the extension_request_*
|
||||||
|
* counters will remain 0 in practice; they exist here to satisfy the
|
||||||
|
* Record<AppealSubtype, number> type. */
|
||||||
const counts: Record<AppealSubtype, number> = {
|
const counts: Record<AppealSubtype, number> = {
|
||||||
building_permit: 0,
|
building_permit: 0,
|
||||||
betterment_levy: 0,
|
betterment_levy: 0,
|
||||||
compensation_197: 0,
|
compensation_197: 0,
|
||||||
|
extension_request_building_permit: 0,
|
||||||
|
extension_request_betterment_levy: 0,
|
||||||
|
extension_request_compensation: 0,
|
||||||
unknown: 0,
|
unknown: 0,
|
||||||
};
|
};
|
||||||
(cases ?? []).forEach((c) => {
|
(cases ?? []).forEach((c) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -15,14 +15,18 @@ import { Label } from "@/components/ui/label";
|
|||||||
import {
|
import {
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { PartiesField } from "@/components/wizard/parties-field";
|
||||||
import { useUpdateCase } from "@/lib/api/cases";
|
import { useUpdateCase } from "@/lib/api/cases";
|
||||||
import { caseUpdateSchema, expectedOutcomes, type CaseUpdateInput } from "@/lib/schemas/case";
|
import {
|
||||||
|
caseUpdateSchema, expectedOutcomes, proceedingTypes,
|
||||||
|
type CaseUpdateInput,
|
||||||
|
} from "@/lib/schemas/case";
|
||||||
import type { CaseDetail } from "@/lib/api/cases";
|
import type { CaseDetail } from "@/lib/api/cases";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Inline edit dialog for core case fields. Uses react-hook-form + zod
|
* Inline edit dialog for all case fields set at creation time.
|
||||||
* directly (shadcn's <Form> registry entry wasn't available at init
|
* Uses react-hook-form + zod directly (shadcn's <Form> registry entry
|
||||||
* time, so the styling is reproduced by hand in a lean form layout).
|
* wasn't available at init time, so the styling is reproduced by hand).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function FieldError({ message }: { message?: string }) {
|
function FieldError({ message }: { message?: string }) {
|
||||||
@@ -42,6 +46,11 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
|
|||||||
hearing_date: data.hearing_date ?? "",
|
hearing_date: data.hearing_date ?? "",
|
||||||
notes: "",
|
notes: "",
|
||||||
expected_outcome: data.expected_outcome ?? "",
|
expected_outcome: data.expected_outcome ?? "",
|
||||||
|
appellants: data.appellants ?? [],
|
||||||
|
respondents: data.respondents ?? [],
|
||||||
|
property_address: data.property_address ?? "",
|
||||||
|
permit_number: data.permit_number ?? "",
|
||||||
|
proceeding_type: data.proceeding_type ?? "ערר",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,6 +63,11 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
|
|||||||
hearing_date: data.hearing_date ?? "",
|
hearing_date: data.hearing_date ?? "",
|
||||||
notes: "",
|
notes: "",
|
||||||
expected_outcome: data.expected_outcome ?? "",
|
expected_outcome: data.expected_outcome ?? "",
|
||||||
|
appellants: data.appellants ?? [],
|
||||||
|
respondents: data.respondents ?? [],
|
||||||
|
property_address: data.property_address ?? "",
|
||||||
|
permit_number: data.permit_number ?? "",
|
||||||
|
proceeding_type: data.proceeding_type ?? "ערר",
|
||||||
});
|
});
|
||||||
}, [open, data, form]);
|
}, [open, data, form]);
|
||||||
|
|
||||||
@@ -74,11 +88,11 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
|
|||||||
עריכת פרטי תיק
|
עריכת פרטי תיק
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-lg" dir="rtl">
|
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto" dir="rtl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>עריכת פרטי תיק {data.case_number}</DialogTitle>
|
<DialogTitle>עריכת פרטי תיק {data.case_number}</DialogTitle>
|
||||||
<DialogDescription className="text-ink-muted">
|
<DialogDescription className="text-ink-muted">
|
||||||
השינויים נשמרים ישירות ל-FastAPI. השדות הריקים נשארים ללא שינוי.
|
השינויים נשמרים ישירות ל-DB. שינוי כותרת יסנכרן גם ל-Paperclip.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -95,6 +109,86 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
|
|||||||
<FieldError message={form.formState.errors.subject?.message} />
|
<FieldError message={form.formState.errors.subject?.message} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-navy">סוג תיק</Label>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="proceeding_type"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value ?? "ערר"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
field.onChange(v as CaseUpdateInput["proceeding_type"])
|
||||||
|
}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{proceedingTypes.map((p) => (
|
||||||
|
<SelectItem key={p.value} value={p.value}>
|
||||||
|
{p.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="text-[0.7rem] text-ink-muted mt-1">
|
||||||
|
ערר = הליך עיקרי; בל"מ = בקשה להארכת מועד להגשת ערר
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-rule" />
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="appellants"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<PartiesField
|
||||||
|
label="עוררים"
|
||||||
|
value={field.value ?? []}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="respondents"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<PartiesField
|
||||||
|
label="משיבים"
|
||||||
|
value={field.value ?? []}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="h-px bg-rule" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="property_address" className="text-navy">כתובת הנכס</Label>
|
||||||
|
<Input
|
||||||
|
id="property_address"
|
||||||
|
{...form.register("property_address")}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="permit_number" className="text-navy">מס׳ תכנית/בקשה</Label>
|
||||||
|
<Input
|
||||||
|
id="permit_number"
|
||||||
|
{...form.register("permit_number")}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="hearing_date" className="text-navy">תאריך דיון</Label>
|
<Label htmlFor="hearing_date" className="text-navy">תאריך דיון</Label>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CreateRepoButton } from "@/components/cases/create-repo-button";
|
|||||||
import {
|
import {
|
||||||
PRACTICE_AREA_LABELS,
|
PRACTICE_AREA_LABELS,
|
||||||
APPEAL_SUBTYPE_LABELS,
|
APPEAL_SUBTYPE_LABELS,
|
||||||
|
isBlamSubtype,
|
||||||
} from "@/lib/practice-area";
|
} from "@/lib/practice-area";
|
||||||
import type { CaseDetail } from "@/lib/api/cases";
|
import type { CaseDetail } from "@/lib/api/cases";
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
|
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
|
||||||
ערר {data?.case_number ?? "—"}
|
{data?.proceeding_type ?? "ערר"} {data?.case_number ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
{data?.status && <StatusBadge status={data.status} />}
|
{data?.status && <StatusBadge status={data.status} />}
|
||||||
{data?.archived_at && (
|
{data?.archived_at && (
|
||||||
@@ -62,6 +63,15 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
|
|||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{(data?.proceeding_type === 'בל"מ' || isBlamSubtype(data?.appeal_subtype)) && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full px-2.5 py-0.5 text-[0.72rem] font-bold bg-warn/10 text-warn-deep border-warn/40"
|
||||||
|
title="בקשה להארכת מועד להגשת ערר"
|
||||||
|
>
|
||||||
|
בל"מ
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{data?.case_number && (
|
{data?.case_number && (
|
||||||
<CaseArchiveAction
|
<CaseArchiveAction
|
||||||
caseNumber={data.case_number}
|
caseNumber={data.case_number}
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { StatusBadge } from "@/components/cases/status-badge";
|
import { StatusBadge } from "@/components/cases/status-badge";
|
||||||
|
import { isBlamSubtype } from "@/lib/practice-area";
|
||||||
import type { Case } from "@/lib/api/cases";
|
import type { Case } from "@/lib/api/cases";
|
||||||
|
|
||||||
function formatDate(iso?: string) {
|
function formatDate(iso?: string) {
|
||||||
@@ -49,8 +54,17 @@ const columns: ColumnDef<Case>[] = [
|
|||||||
accessorKey: "title",
|
accessorKey: "title",
|
||||||
header: "כותרת",
|
header: "כותרת",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-ink max-w-[420px] truncate" title={row.original.title}>
|
<div className="text-ink max-w-[420px] truncate flex items-center gap-2" title={row.original.title}>
|
||||||
{row.original.title}
|
{(row.original.proceeding_type === 'בל"מ' || isBlamSubtype(row.original.appeal_subtype)) && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full px-1.5 py-0 text-[0.65rem] font-bold bg-warn/10 text-warn-deep border-warn/40 shrink-0"
|
||||||
|
title="בקשה להארכת מועד להגשת ערר"
|
||||||
|
>
|
||||||
|
בל"מ
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{row.original.title}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -94,8 +108,17 @@ export function CasesTable({
|
|||||||
{ id: "updated_at", desc: true },
|
{ id: "updated_at", desc: true },
|
||||||
]);
|
]);
|
||||||
const [globalFilter, setGlobalFilter] = useState("");
|
const [globalFilter, setGlobalFilter] = useState("");
|
||||||
|
/* "all" = all cases; "blam" = only בל"מ; "regular" = exclude בל"מ */
|
||||||
|
const [blamFilter, setBlamFilter] = useState<"all" | "blam" | "regular">("all");
|
||||||
|
|
||||||
const data = useMemo(() => cases ?? [], [cases]);
|
const data = useMemo(() => {
|
||||||
|
const all = cases ?? [];
|
||||||
|
const isBlam = (c: Case) =>
|
||||||
|
c.proceeding_type === 'בל"מ' || isBlamSubtype(c.appeal_subtype);
|
||||||
|
if (blamFilter === "blam") return all.filter(isBlam);
|
||||||
|
if (blamFilter === "regular") return all.filter((c) => !isBlam(c));
|
||||||
|
return all;
|
||||||
|
}, [cases, blamFilter]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -126,6 +149,20 @@ export function CasesTable({
|
|||||||
className="max-w-sm bg-surface"
|
className="max-w-sm bg-surface"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
value={blamFilter}
|
||||||
|
onValueChange={(v) => setBlamFilter(v as "all" | "blam" | "regular")}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40 bg-surface">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">כל התיקים</SelectItem>
|
||||||
|
<SelectItem value="blam">בל"מ בלבד</SelectItem>
|
||||||
|
<SelectItem value="regular">ערר רגיל בלבד</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<span className="text-sm text-ink-muted me-auto">
|
<span className="text-sm text-ink-muted me-auto">
|
||||||
{table.getFilteredRowModel().rows.length} תיקים
|
{table.getFilteredRowModel().rows.length} תיקים
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -269,6 +269,26 @@ function PostSaveView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{extractResult?.status === "queued" && (
|
||||||
|
<div className="rounded-md border border-info/30 bg-info-bg px-2.5 py-2 text-[0.72rem] text-ink space-y-0.5">
|
||||||
|
<p>
|
||||||
|
<strong>נשלח לאנליטיקאי.</strong> ה-issue נפתח ב-Paperclip והחילוץ
|
||||||
|
ירוץ ברקע. תראה comment בעברית עם התוצאה כשהוא יסיים — לרוב כמה
|
||||||
|
דקות.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{extractResult?.status === "skipped" && (
|
||||||
|
<div className="rounded-md border border-warn/40 bg-warn-bg px-2.5 py-2 text-[0.72rem] text-ink space-y-0.5">
|
||||||
|
<p>
|
||||||
|
<strong>לא ניתן להפעיל אוטומטית</strong> ({extractResult.reason}).
|
||||||
|
הפעל ידנית מ-Claude Code:
|
||||||
|
<code className="ms-1 select-all">mcp__legal-ai__extract_appraiser_facts</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{extractResult?.status === "no_appraisals" && (
|
{extractResult?.status === "no_appraisals" && (
|
||||||
<p className="text-[0.72rem] text-ink-muted">
|
<p className="text-[0.72rem] text-ink-muted">
|
||||||
אין בתיק מסמכים מתויגים כ-שומה.
|
אין בתיק מסמכים מתויגים כ-שומה.
|
||||||
@@ -320,8 +340,8 @@ function PostSaveView({
|
|||||||
|
|
||||||
{pending && (
|
{pending && (
|
||||||
<p className="text-[0.68rem] text-ink-muted leading-tight">
|
<p className="text-[0.68rem] text-ink-muted leading-tight">
|
||||||
החילוץ יכול להימשך כמה דקות — שומות ארוכות עוברות ניתוח פסקה אחר
|
שולח לאנליטיקאי דרך Paperclip — לוקח שנייה. החילוץ עצמו ירוץ אצל
|
||||||
פסקה ע"י המודל.
|
האנליטיקאי וייתן comment כשיסיים.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Progress } from "@/components/ui/progress";
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -127,6 +128,7 @@ function DocumentPreviewDialog({
|
|||||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col" dir="rtl">
|
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col" dir="rtl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-right">{displayName}</DialogTitle>
|
<DialogTitle className="text-right">{displayName}</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">תצוגה מקדימה של תוכן המסמך</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{loading && (
|
{loading && (
|
||||||
@@ -184,6 +186,7 @@ function DeleteConfirmDialog({
|
|||||||
<DialogContent dir="rtl">
|
<DialogContent dir="rtl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-right">מחיקת מסמך</DialogTitle>
|
<DialogTitle className="text-right">מחיקת מסמך</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">אישור מחיקת המסמך מהתיק</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-sm text-ink-muted text-right">
|
<p className="text-sm text-ink-muted text-right">
|
||||||
האם למחוק את המסמך <strong>“{displayName}”</strong>?
|
האם למחוק את המסמך <strong>“{displayName}”</strong>?
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
@@ -323,6 +324,7 @@ export function DraftsPanel({
|
|||||||
<DialogContent className="sm:max-w-sm" dir="rtl">
|
<DialogContent className="sm:max-w-sm" dir="rtl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>מחיקת טיוטה</DialogTitle>
|
<DialogTitle>מחיקת טיוטה</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">אישור מחיקת קובץ הטיוטה</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-sm text-ink-muted">
|
<p className="text-sm text-ink-muted">
|
||||||
למחוק את הקובץ{" "}
|
למחוק את הקובץ{" "}
|
||||||
@@ -493,6 +495,7 @@ function NewCaseFeedbackDialog({ caseNumber }: { caseNumber: string }) {
|
|||||||
<DialogContent className="sm:max-w-lg" dir="rtl">
|
<DialogContent className="sm:max-w-lg" dir="rtl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>הערת יו״ר — תיק {caseNumber}</DialogTitle>
|
<DialogTitle>הערת יו״ר — תיק {caseNumber}</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">הוספת הערת יו״ר על בלוק בהחלטה</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
|||||||
222
web-ui/src/components/cases/legal-arguments-panel.tsx
Normal file
222
web-ui/src/components/cases/legal-arguments-panel.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
PARTY_LABELS_HE,
|
||||||
|
PRIORITY_LABELS_HE,
|
||||||
|
PRIORITY_ORDER,
|
||||||
|
useAggregateArguments,
|
||||||
|
useLegalArguments,
|
||||||
|
type LegalArgument,
|
||||||
|
type LegalArgumentParty,
|
||||||
|
type LegalArgumentPriority,
|
||||||
|
} from "@/lib/api/legal-arguments";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2, RefreshCw, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
const PRIORITY_BADGE_TONE: Record<LegalArgumentPriority, string> = {
|
||||||
|
threshold: "bg-danger-bg/60 text-danger-strong border-danger/40",
|
||||||
|
substantive: "bg-gold-soft/50 text-navy border-gold/40",
|
||||||
|
procedural: "bg-rule-soft text-ink border-rule",
|
||||||
|
relief: "bg-emerald-50 text-emerald-900 border-emerald-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
function groupByPriority(
|
||||||
|
args: LegalArgument[],
|
||||||
|
): Record<LegalArgumentPriority, LegalArgument[]> {
|
||||||
|
const out: Record<LegalArgumentPriority, LegalArgument[]> = {
|
||||||
|
threshold: [],
|
||||||
|
substantive: [],
|
||||||
|
procedural: [],
|
||||||
|
relief: [],
|
||||||
|
};
|
||||||
|
for (const a of args) {
|
||||||
|
(out[a.priority] ?? out.substantive).push(a);
|
||||||
|
}
|
||||||
|
for (const key of PRIORITY_ORDER) {
|
||||||
|
out[key].sort((x, y) => x.argument_index - y.argument_index);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartySectionProps = {
|
||||||
|
party: LegalArgumentParty;
|
||||||
|
args: LegalArgument[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function PartySection({ party, args }: PartySectionProps) {
|
||||||
|
const grouped = useMemo(() => groupByPriority(args), [args]);
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-baseline justify-between border-b border-rule pb-2">
|
||||||
|
<h3 className="text-navy text-base font-semibold">
|
||||||
|
{PARTY_LABELS_HE[party] ?? party}
|
||||||
|
</h3>
|
||||||
|
<span className="text-ink-muted text-xs">
|
||||||
|
{args.length} טיעונים
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{PRIORITY_ORDER.map((priority) => {
|
||||||
|
const list = grouped[priority];
|
||||||
|
if (!list?.length) return null;
|
||||||
|
return (
|
||||||
|
<div key={priority} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${PRIORITY_BADGE_TONE[priority]} text-xs`}
|
||||||
|
>
|
||||||
|
{PRIORITY_LABELS_HE[priority]}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-ink-muted text-xs">
|
||||||
|
{list.length} טיעונים
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Accordion type="multiple" className="rounded-md border border-rule bg-surface">
|
||||||
|
{list.map((arg) => (
|
||||||
|
<AccordionItem key={arg.id} value={arg.id} className="px-3">
|
||||||
|
<AccordionTrigger className="text-start">
|
||||||
|
<div className="flex flex-1 flex-col items-start gap-1">
|
||||||
|
<span className="text-navy text-sm font-medium leading-tight">
|
||||||
|
{arg.argument_index}. {arg.argument_title}
|
||||||
|
</span>
|
||||||
|
{arg.legal_topic && (
|
||||||
|
<span className="text-ink-muted text-xs">
|
||||||
|
{arg.legal_topic}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="space-y-2 px-1">
|
||||||
|
<p className="text-ink leading-relaxed whitespace-pre-line">
|
||||||
|
{arg.argument_body}
|
||||||
|
</p>
|
||||||
|
{arg.supporting_claims.length > 0 && (
|
||||||
|
<p className="text-ink-muted text-xs">
|
||||||
|
מסתמך על {arg.supporting_claims.length} פרופוזיציות
|
||||||
|
גולמיות.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LegalArgumentsPanelProps = {
|
||||||
|
caseNumber: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) {
|
||||||
|
const { data, isPending, isError, error } = useLegalArguments(caseNumber);
|
||||||
|
const aggregate = useAggregateArguments(caseNumber);
|
||||||
|
|
||||||
|
const parties = useMemo<LegalArgumentParty[]>(() => {
|
||||||
|
if (!data?.by_party) return [];
|
||||||
|
const order: LegalArgumentParty[] = [
|
||||||
|
"appellant",
|
||||||
|
"respondent",
|
||||||
|
"committee",
|
||||||
|
"permit_applicant",
|
||||||
|
"unknown",
|
||||||
|
];
|
||||||
|
return order.filter((p) => (data.by_party[p]?.length ?? 0) > 0);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleAggregate = (force: boolean) => {
|
||||||
|
aggregate.mutate(force, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(
|
||||||
|
force
|
||||||
|
? "הופעלה חזרה חישוב טיעונים (force). יסתיים תוך דקה."
|
||||||
|
: "הופעל חישוב טיעונים. רענן בעוד דקה.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-navy text-base font-semibold">
|
||||||
|
טיעונים משפטיים
|
||||||
|
</h2>
|
||||||
|
<p className="text-ink-muted text-xs mt-0.5">
|
||||||
|
טיעונים מאוגדים מתוך הפרופוזיציות הגולמיות, מקובצים לפי צד וקדימות.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={aggregate.isPending}
|
||||||
|
onClick={() => handleAggregate(false)}
|
||||||
|
>
|
||||||
|
{aggregate.isPending ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="w-3.5 h-3.5 me-1.5" />
|
||||||
|
)}
|
||||||
|
חשב טיעונים
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={aggregate.isPending || !data?.total}
|
||||||
|
onClick={() => handleAggregate(true)}
|
||||||
|
title="חישוב מחדש (מוחק טיעונים קיימים)"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPending ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-6 w-48" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-danger text-sm">
|
||||||
|
שגיאה בטעינת טיעונים: {(error as Error).message}
|
||||||
|
</p>
|
||||||
|
) : !data?.total ? (
|
||||||
|
<p className="text-ink-muted text-sm">
|
||||||
|
אין טיעונים מאוגדים עדיין. לחץ "חשב טיעונים" כדי להריץ את ה-aggregator.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{parties.map((party) => (
|
||||||
|
<PartySection
|
||||||
|
key={party}
|
||||||
|
party={party}
|
||||||
|
args={data.by_party[party] ?? []}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Upload, Save, Loader2, CheckCircle2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
useMissingPrecedent,
|
||||||
|
useUpdateMissingPrecedent,
|
||||||
|
useUploadMissingPrecedent,
|
||||||
|
STATUS_LABELS,
|
||||||
|
type MissingPrecedentPatch,
|
||||||
|
} from "@/lib/api/missing-precedents";
|
||||||
|
import {
|
||||||
|
PRACTICE_AREAS, PRECEDENT_LEVELS, DISTRICTS,
|
||||||
|
} from "@/components/precedents/practice-area";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: string | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md";
|
||||||
|
|
||||||
|
function isCommitteeCitation(citation: string): boolean {
|
||||||
|
const norm = citation.trim();
|
||||||
|
return /^(ערר[\s(]|בל"מ[\s(]|ARAR )/.test(norm);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) {
|
||||||
|
const open = id !== null;
|
||||||
|
const { data: mp, isPending } = useMissingPrecedent(id);
|
||||||
|
const update = useUpdateMissingPrecedent();
|
||||||
|
const upload = useUploadMissingPrecedent();
|
||||||
|
|
||||||
|
// The only chair-editable field on the missing-precedent is `notes` —
|
||||||
|
// free-text. Everything else (citation, who-cited-whom, status) is set
|
||||||
|
// when the row was detected, and updates automatically when the file
|
||||||
|
// is uploaded. The metadata of the *uploaded* precedent (case_name,
|
||||||
|
// chair, district, …) is auto-extracted by the LLM and lives on the
|
||||||
|
// case_law row, not here.
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
// Upload form fields.
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [decisionDate, setDecisionDate] = useState("");
|
||||||
|
const [court, setCourt] = useState("");
|
||||||
|
const [practiceArea, setPracticeArea] = useState<string>("");
|
||||||
|
const [appealSubtype, setAppealSubtype] = useState("");
|
||||||
|
const [precedentLevel, setPrecedentLevel] = useState("");
|
||||||
|
const [chairName, setChairName] = useState("");
|
||||||
|
const [district, setDistrict] = useState("");
|
||||||
|
const [committeeCaseNumber, setCommitteeCaseNumber] = useState("");
|
||||||
|
const [summary, setSummary] = useState("");
|
||||||
|
|
||||||
|
// Sync form from record when it loads or id changes.
|
||||||
|
const [syncedId, setSyncedId] = useState<string | null>(null);
|
||||||
|
if (mp && mp.id !== syncedId) {
|
||||||
|
setSyncedId(mp.id);
|
||||||
|
setNotes(mp.notes ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset on close. The cascading-render warning is the intended side
|
||||||
|
// effect here — wiping the form when the drawer closes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) return;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setFile(null);
|
||||||
|
setSyncedId(null);
|
||||||
|
setDecisionDate(""); setCourt(""); setPracticeArea("");
|
||||||
|
setAppealSubtype(""); setPrecedentLevel(""); setChairName("");
|
||||||
|
setDistrict(""); setCommitteeCaseNumber(""); setSummary("");
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSaveNotes = async () => {
|
||||||
|
if (!mp) return;
|
||||||
|
const patch: MissingPrecedentPatch = { notes };
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({ id: mp.id, patch });
|
||||||
|
toast.success("הערות נשמרו");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("שמירה נכשלה");
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCommittee = mp ? isCommitteeCitation(mp.citation) : false;
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!mp || !file) {
|
||||||
|
toast.error("בחר קובץ");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await upload.mutateAsync({
|
||||||
|
id: mp.id,
|
||||||
|
file,
|
||||||
|
case_number: isCommittee ? committeeCaseNumber || undefined : undefined,
|
||||||
|
chair_name: isCommittee ? chairName || undefined : undefined,
|
||||||
|
district: isCommittee ? district || undefined : undefined,
|
||||||
|
court: court || undefined,
|
||||||
|
decision_date: decisionDate || undefined,
|
||||||
|
practice_area: practiceArea || undefined,
|
||||||
|
appeal_subtype: appealSubtype || undefined,
|
||||||
|
precedent_level: precedentLevel || undefined,
|
||||||
|
source_type: isCommittee ? "appeals_committee" : "court_ruling",
|
||||||
|
summary: summary || undefined,
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
"הקובץ הועלה. חילוץ המטא־דאטה (שם, ערכאה, תאריך, יו״ר, מחוז…) מתבצע ברקע ויסתיים בתוך כדקה.",
|
||||||
|
);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg =
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: typeof e === "string"
|
||||||
|
? e
|
||||||
|
: "כשל העלאה";
|
||||||
|
toast.error(msg);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="left"
|
||||||
|
className="w-full sm:max-w-2xl overflow-y-auto"
|
||||||
|
>
|
||||||
|
<SheetHeader className="space-y-1">
|
||||||
|
<SheetTitle className="text-navy">
|
||||||
|
פסיקה חסרה
|
||||||
|
{mp ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="ms-2 align-middle"
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[mp.status]}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
פרטים מלאים והעלאת הפסיקה לקורפוס.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{isPending || !mp ? (
|
||||||
|
<div className="space-y-3 px-6 py-4">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6 px-6 py-4">
|
||||||
|
{/* ── Citation block (read-only) ── */}
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="text-[0.78rem] text-ink-muted">מראה מקום</div>
|
||||||
|
<div className="text-sm text-navy font-medium bg-rule-soft/40 rounded-md px-3 py-2 leading-relaxed">
|
||||||
|
{mp.citation}
|
||||||
|
</div>
|
||||||
|
{mp.claim_quote ? (
|
||||||
|
<>
|
||||||
|
<div className="text-[0.78rem] text-ink-muted mt-3">ציטוט מכתב הטענות</div>
|
||||||
|
<div className="text-xs text-ink bg-gold-wash/30 border-s-2 border-gold rounded-md px-3 py-2 leading-relaxed">
|
||||||
|
{mp.claim_quote}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Linked record (if closed) ── */}
|
||||||
|
{mp.linked_case_law_id ? (
|
||||||
|
<section className="space-y-1 bg-emerald-50 border border-emerald-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 text-emerald-800 font-medium text-sm">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
מקושר ל
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-emerald-900 truncate">
|
||||||
|
{mp.linked_case_law_name || "—"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-emerald-700 truncate">
|
||||||
|
{mp.linked_case_law_number}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── Notes (only chair-editable field; everything else is
|
||||||
|
auto-detected or auto-extracted from the file). ── */}
|
||||||
|
<section className="space-y-2">
|
||||||
|
<Label htmlFor="notes" className="text-sm font-semibold text-navy">
|
||||||
|
הערות
|
||||||
|
</Label>
|
||||||
|
<p className="text-[0.72rem] text-ink-muted leading-relaxed">
|
||||||
|
שדה חופשי — לדוגמה: מי מצטט (הוועדה / העורר / המשיב) ובאיזה הקשר.
|
||||||
|
שאר השדות (שם, ערכאה, יו״ר, מחוז, תאריך, תת־סוג, תקציר) יחולצו
|
||||||
|
אוטומטית מהקובץ בעת ההעלאה.
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveNotes}
|
||||||
|
disabled={update.isPending}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-rule"
|
||||||
|
>
|
||||||
|
{update.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 me-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4 me-1" />
|
||||||
|
)}
|
||||||
|
שמור הערות
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Upload section ── */}
|
||||||
|
{!mp.linked_case_law_id ? (
|
||||||
|
<section className="space-y-3 border-t border-rule pt-5">
|
||||||
|
<h3 className="text-sm font-semibold text-navy">
|
||||||
|
העלאת הפסיקה לקורפוס
|
||||||
|
</h3>
|
||||||
|
<div className="text-[0.78rem] text-ink-muted leading-relaxed">
|
||||||
|
ניתוב אוטומטי לפי הציטוט:
|
||||||
|
<strong className="text-navy">
|
||||||
|
{isCommittee ? "החלטת ועדת ערר (internal)" : "פסק דין (library)"}
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
שדות נוספים (שם, ערכאה, תאריך, יו״ר, מחוז, תת־סוג) יחולצו אוטומטית
|
||||||
|
מהקובץ ברקע.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleUpload} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="file">קובץ (PDF / DOCX / RTF / TXT / MD)</Label>
|
||||||
|
<Input
|
||||||
|
id="file"
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPT}
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="group rounded-md border border-rule bg-rule-soft/30">
|
||||||
|
<summary className="cursor-pointer select-none px-3 py-2 text-[0.78rem] text-ink-muted hover:text-navy">
|
||||||
|
אופציונלי — דריסה ידנית של שדות שיחולצו אוטומטית
|
||||||
|
</summary>
|
||||||
|
<div className="space-y-3 border-t border-rule px-3 py-3">
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="court">ערכאה</Label>
|
||||||
|
<Input
|
||||||
|
id="court"
|
||||||
|
value={court}
|
||||||
|
onChange={(e) => setCourt(e.target.value)}
|
||||||
|
placeholder="בית המשפט העליון"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="decision_date">תאריך</Label>
|
||||||
|
<Input
|
||||||
|
id="decision_date"
|
||||||
|
type="date"
|
||||||
|
value={decisionDate}
|
||||||
|
onChange={(e) => setDecisionDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="practice_area">תחום</Label>
|
||||||
|
<Select value={practiceArea} onValueChange={setPracticeArea}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="ללא" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PRACTICE_AREAS.map((a) => (
|
||||||
|
<SelectItem key={a.value} value={a.value}>
|
||||||
|
{a.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="appeal_subtype">תת־סוג</Label>
|
||||||
|
<Input
|
||||||
|
id="appeal_subtype"
|
||||||
|
value={appealSubtype}
|
||||||
|
onChange={(e) => setAppealSubtype(e.target.value)}
|
||||||
|
placeholder="זכות עמידה"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCommittee ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="chair_name">יו״ר</Label>
|
||||||
|
<Input
|
||||||
|
id="chair_name"
|
||||||
|
value={chairName}
|
||||||
|
onChange={(e) => setChairName(e.target.value)}
|
||||||
|
placeholder="דפנה תמיר"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="district">מחוז</Label>
|
||||||
|
<Select value={district} onValueChange={setDistrict}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="בחר" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DISTRICTS.map((d) => (
|
||||||
|
<SelectItem key={d.value} value={d.value}>
|
||||||
|
{d.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="committee_case_number">
|
||||||
|
מספר ערר (לציטוט הקטן)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="committee_case_number"
|
||||||
|
value={committeeCaseNumber}
|
||||||
|
onChange={(e) => setCommitteeCaseNumber(e.target.value)}
|
||||||
|
placeholder="ערר 1112/22 ..."
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="precedent_level">רמת תקדים</Label>
|
||||||
|
<Select
|
||||||
|
value={precedentLevel}
|
||||||
|
onValueChange={setPrecedentLevel}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="ללא" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PRECEDENT_LEVELS.map((l) => (
|
||||||
|
<SelectItem key={l.value} value={l.value}>
|
||||||
|
{l.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="summary">תקציר</Label>
|
||||||
|
<Textarea
|
||||||
|
id="summary"
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!file || upload.isPending}
|
||||||
|
className="bg-navy text-parchment hover:bg-navy-soft"
|
||||||
|
>
|
||||||
|
{upload.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 me-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="w-4 h-4 me-1" />
|
||||||
|
)}
|
||||||
|
העלאה וסגירה
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Trash2, Upload, Pencil, ExternalLink } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
useMissingPrecedents,
|
||||||
|
useDeleteMissingPrecedent,
|
||||||
|
CITED_BY_PARTY_LABELS,
|
||||||
|
STATUS_LABELS,
|
||||||
|
type MissingPrecedent,
|
||||||
|
type MissingPrecedentStatus,
|
||||||
|
} from "@/lib/api/missing-precedents";
|
||||||
|
import { MissingPrecedentDetailDrawer } from "./missing-precedent-detail-drawer";
|
||||||
|
|
||||||
|
function formatDate(iso: string | null) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("he-IL");
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: MissingPrecedentStatus }) {
|
||||||
|
const variants: Record<MissingPrecedentStatus, string> = {
|
||||||
|
open: "bg-gold-wash text-gold-deep border-gold/40",
|
||||||
|
uploaded: "bg-rule-soft text-ink-muted border-rule",
|
||||||
|
closed: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
|
||||||
|
irrelevant: "bg-rule-soft text-ink-muted border-rule line-through",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={variants[status]}>
|
||||||
|
{STATUS_LABELS[status]}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton({ cols }: { cols: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<TableRow key={i} className="border-rule">
|
||||||
|
{Array.from({ length: cols }).map((__, j) => (
|
||||||
|
<TableCell key={j}>
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
status?: MissingPrecedentStatus | "";
|
||||||
|
caseNumber?: string;
|
||||||
|
legalTopic?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MissingPrecedentsTable({ status, caseNumber, legalTopic }: Props) {
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const { data, isPending, error } = useMissingPrecedents({
|
||||||
|
status: status === "" ? undefined : status,
|
||||||
|
caseNumber,
|
||||||
|
legalTopic,
|
||||||
|
limit: 200,
|
||||||
|
});
|
||||||
|
const del = useDeleteMissingPrecedent();
|
||||||
|
|
||||||
|
const handleDelete = async (mp: MissingPrecedent) => {
|
||||||
|
if (!confirm(`למחוק את הרשומה? ${mp.case_name || mp.citation.slice(0, 60)}...`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await del.mutateAsync(mp.id);
|
||||||
|
toast.success("הרשומה נמחקה");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("מחיקה נכשלה");
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-rule-soft/60">
|
||||||
|
<TableRow className="border-rule">
|
||||||
|
<TableHead className="text-navy text-right">פסיקה</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">נושא</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">תיק</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">צד מצטט</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">סטטוס</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">נוצר</TableHead>
|
||||||
|
<TableHead className="text-navy" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isPending ? (
|
||||||
|
<TableSkeleton cols={7} />
|
||||||
|
) : !data?.items.length ? (
|
||||||
|
<TableRow className="border-rule">
|
||||||
|
<TableCell colSpan={7} className="text-center text-ink-muted py-8">
|
||||||
|
אין פסיקות חסרות בקריטריונים הנוכחיים.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.items.map((mp) => (
|
||||||
|
<TableRow
|
||||||
|
key={mp.id}
|
||||||
|
className="border-rule hover:bg-rule-soft/30 cursor-pointer"
|
||||||
|
onClick={() => setOpenId(mp.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="max-w-[440px]">
|
||||||
|
<div className="text-sm text-navy font-medium truncate">
|
||||||
|
{mp.case_name || mp.citation.split(" ").slice(0, 6).join(" ")}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted truncate" dir="rtl">
|
||||||
|
{mp.citation}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm text-ink">{mp.legal_topic || "—"}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{mp.cited_in_case_number ? (
|
||||||
|
<Link
|
||||||
|
href={`/cases/${encodeURIComponent(mp.cited_in_case_number)}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-sm text-navy hover:text-gold-deep inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{mp.cited_in_case_number}
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-ink-muted text-sm">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-ink">
|
||||||
|
{mp.cited_by_party
|
||||||
|
? CITED_BY_PARTY_LABELS[mp.cited_by_party]
|
||||||
|
: "—"}
|
||||||
|
{mp.cited_by_party_name ? (
|
||||||
|
<div className="text-[0.7rem] text-ink-muted truncate max-w-[160px]">
|
||||||
|
{mp.cited_by_party_name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={mp.status} />
|
||||||
|
{mp.linked_case_law_number ? (
|
||||||
|
<div className="text-[0.7rem] text-emerald-700 mt-1">
|
||||||
|
↳ {mp.linked_case_law_name || mp.linked_case_law_number}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-[0.78rem] text-ink-muted">
|
||||||
|
{formatDate(mp.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-end">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpenId(mp.id);
|
||||||
|
}}
|
||||||
|
title={mp.status === "open" ? "העלאה" : "פרטים"}
|
||||||
|
>
|
||||||
|
{mp.status === "open" ? (
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(mp);
|
||||||
|
}}
|
||||||
|
disabled={del.isPending}
|
||||||
|
className="text-danger hover:text-danger"
|
||||||
|
title="מחיקה"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MissingPrecedentDetailDrawer
|
||||||
|
id={openId}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setOpenId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
web-ui/src/components/precedents/formatted-citation.tsx
Normal file
116
web-ui/src/components/precedents/formatted-citation.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Rendering helpers for the formal Israeli citation ("כללי הציטוט האחיד").
|
||||||
|
//
|
||||||
|
// Backend stores the citation as Markdown: parties' names wrapped in
|
||||||
|
// **double asterisks**, everything else regular. These helpers:
|
||||||
|
// 1. Render the citation with <strong> for the bold ranges.
|
||||||
|
// 2. Copy it to the clipboard as BOTH text/html (so Word/Docs paste
|
||||||
|
// with bold preserved) and text/plain (which keeps the markers
|
||||||
|
// so the markdown survives a plain-text paste).
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Check, Copy } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
function parseSegments(md: string): Array<{ bold: boolean; text: string }> {
|
||||||
|
const out: Array<{ bold: boolean; text: string }> = [];
|
||||||
|
const re = /\*\*([^*]+)\*\*/g;
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(md)) !== null) {
|
||||||
|
if (m.index > last) out.push({ bold: false, text: md.slice(last, m.index) });
|
||||||
|
out.push({ bold: true, text: m[1] });
|
||||||
|
last = re.lastIndex;
|
||||||
|
}
|
||||||
|
if (last < md.length) out.push({ bold: false, text: md.slice(last) });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormattedCitation({
|
||||||
|
citation,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
citation: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const segments = parseSegments(citation);
|
||||||
|
return (
|
||||||
|
<span className={className} dir="rtl">
|
||||||
|
{segments.map((s, i) =>
|
||||||
|
s.bold ? (
|
||||||
|
<strong key={i} className="font-semibold">
|
||||||
|
{s.text}
|
||||||
|
</strong>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{s.text}</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CitationCopyButton({
|
||||||
|
citation,
|
||||||
|
size = "sm",
|
||||||
|
}: {
|
||||||
|
citation: string;
|
||||||
|
size?: "sm" | "xs";
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
const segments = parseSegments(citation);
|
||||||
|
const html = segments
|
||||||
|
.map((s) =>
|
||||||
|
s.bold
|
||||||
|
? `<strong>${escapeHtml(s.text)}</strong>`
|
||||||
|
: escapeHtml(s.text),
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
const wrappedHtml = `<span dir="rtl">${html}</span>`;
|
||||||
|
try {
|
||||||
|
const cb = navigator.clipboard;
|
||||||
|
if (typeof ClipboardItem !== "undefined" && cb && "write" in cb) {
|
||||||
|
const item = new ClipboardItem({
|
||||||
|
"text/html": new Blob([wrappedHtml], { type: "text/html" }),
|
||||||
|
"text/plain": new Blob([citation], { type: "text/plain" }),
|
||||||
|
});
|
||||||
|
await cb.write([item]);
|
||||||
|
} else {
|
||||||
|
await cb.writeText(citation);
|
||||||
|
}
|
||||||
|
setCopied(true);
|
||||||
|
toast.success("המראה מקום הועתק (עם הדגשה לצדדים)");
|
||||||
|
window.setTimeout(() => setCopied(false), 1800);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("citation copy failed", err);
|
||||||
|
toast.error("העתקה נכשלה");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dims = size === "xs" ? "h-7 w-7" : "h-8 w-8";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
title={copied ? "הועתק" : "העתק לפי כללי הציטוט (עם הדגשה לצדדים)"}
|
||||||
|
aria-label={copied ? "הועתק" : "העתק מראה מקום"}
|
||||||
|
className={`inline-flex items-center justify-center rounded-md border border-rule bg-surface hover:bg-rule-soft/50 text-ink-muted hover:text-navy ${dims}`}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { Trash2, Plus, Pencil, Wand2 } from "lucide-react";
|
import { Trash2, Plus, Pencil, Wand2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +25,10 @@ import {
|
|||||||
import { PRACTICE_AREAS, practiceAreaShort } from "./practice-area";
|
import { PRACTICE_AREAS, practiceAreaShort } from "./practice-area";
|
||||||
import { PrecedentUploadSheet } from "./precedent-upload-sheet";
|
import { PrecedentUploadSheet } from "./precedent-upload-sheet";
|
||||||
import { PrecedentEditSheet } from "./precedent-edit-sheet";
|
import { PrecedentEditSheet } from "./precedent-edit-sheet";
|
||||||
|
import {
|
||||||
|
FormattedCitation,
|
||||||
|
CitationCopyButton,
|
||||||
|
} from "./formatted-citation";
|
||||||
|
|
||||||
function formatDate(iso: string | null) {
|
function formatDate(iso: string | null) {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
@@ -151,9 +156,28 @@ function CourtRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void })
|
|||||||
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3"
|
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
>
|
>
|
||||||
<span dir="auto">{cleanCitation(p.case_number)}</span>
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/precedents/${p.id}`}
|
||||||
|
className="hover:underline hover:text-gold-deep block min-w-0"
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
{p.citation_formatted ? (
|
||||||
|
<FormattedCitation
|
||||||
|
citation={p.citation_formatted}
|
||||||
|
className="block leading-snug"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
cleanCitation(p.case_number)
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
{p.citation_formatted ? (
|
||||||
|
<CitationCopyButton citation={p.citation_formatted} size="xs" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3">
|
{/* Column "שם / ערכאה" hidden by request (case_name often equals case_number prefix). Keep field in DB; restore by un-hiding. */}
|
||||||
|
<TableCell className="hidden text-ink whitespace-normal break-words max-w-[260px] py-3">
|
||||||
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
||||||
{p.court ? <div className="text-[0.72rem] text-ink-muted">{p.court}</div> : null}
|
{p.court ? <div className="text-[0.72rem] text-ink-muted">{p.court}</div> : null}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -233,9 +257,28 @@ function CommitteeRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => voi
|
|||||||
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3"
|
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
>
|
>
|
||||||
<span dir="auto">{cleanCitation(p.case_number)}</span>
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/precedents/${p.id}`}
|
||||||
|
className="hover:underline hover:text-gold-deep block min-w-0"
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
{p.citation_formatted ? (
|
||||||
|
<FormattedCitation
|
||||||
|
citation={p.citation_formatted}
|
||||||
|
className="block leading-snug"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
cleanCitation(p.case_number)
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
{p.citation_formatted ? (
|
||||||
|
<CitationCopyButton citation={p.citation_formatted} size="xs" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-ink whitespace-normal break-words max-w-[220px] py-3">
|
{/* Column "שם" hidden by request (case_name often equals case_number prefix). Keep field in DB; restore by un-hiding. */}
|
||||||
|
<TableCell className="hidden text-ink whitespace-normal break-words max-w-[220px] py-3">
|
||||||
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-ink-muted text-[0.78rem]">
|
<TableCell className="text-ink-muted text-[0.78rem]">
|
||||||
@@ -308,8 +351,8 @@ export function LibraryListPanel() {
|
|||||||
limit: 200,
|
limit: 200,
|
||||||
};
|
};
|
||||||
|
|
||||||
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload" });
|
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload", sourceType: "court_ruling" });
|
||||||
const committee = usePrecedents({ ...sharedFilters, sourceKind: "internal_committee" });
|
const committee = usePrecedents({ ...sharedFilters, sourceKind: "all_committees" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -362,7 +405,8 @@ export function LibraryListPanel() {
|
|||||||
<TableHeader className="bg-rule-soft/60">
|
<TableHeader className="bg-rule-soft/60">
|
||||||
<TableRow className="border-rule">
|
<TableRow className="border-rule">
|
||||||
<TableHead className="text-navy text-right">מס׳ / מראה מקום</TableHead>
|
<TableHead className="text-navy text-right">מס׳ / מראה מקום</TableHead>
|
||||||
<TableHead className="text-navy text-right">שם / ערכאה</TableHead>
|
{/* "שם / ערכאה" hidden by request — see CourtRow */}
|
||||||
|
<TableHead className="hidden text-navy text-right">שם / ערכאה</TableHead>
|
||||||
<TableHead className="text-navy text-right">תאריך</TableHead>
|
<TableHead className="text-navy text-right">תאריך</TableHead>
|
||||||
<TableHead className="text-navy text-right">תחום</TableHead>
|
<TableHead className="text-navy text-right">תחום</TableHead>
|
||||||
<TableHead className="text-navy text-right">רמה</TableHead>
|
<TableHead className="text-navy text-right">רמה</TableHead>
|
||||||
@@ -410,7 +454,8 @@ export function LibraryListPanel() {
|
|||||||
<TableHeader className="bg-rule-soft/60">
|
<TableHeader className="bg-rule-soft/60">
|
||||||
<TableRow className="border-rule">
|
<TableRow className="border-rule">
|
||||||
<TableHead className="text-navy text-right">מספר ערר</TableHead>
|
<TableHead className="text-navy text-right">מספר ערר</TableHead>
|
||||||
<TableHead className="text-navy text-right">שם</TableHead>
|
{/* "שם" hidden by request — see CommitteeRow */}
|
||||||
|
<TableHead className="hidden text-navy text-right">שם</TableHead>
|
||||||
<TableHead className="text-navy text-right">מחוז</TableHead>
|
<TableHead className="text-navy text-right">מחוז</TableHead>
|
||||||
<TableHead className="text-navy text-right">יו״ר</TableHead>
|
<TableHead className="text-navy text-right">יו״ר</TableHead>
|
||||||
<TableHead className="text-navy text-right">תאריך</TableHead>
|
<TableHead className="text-navy text-right">תאריך</TableHead>
|
||||||
|
|||||||
224
web-ui/src/components/precedents/link-related-dialog.tsx
Normal file
224
web-ui/src/components/precedents/link-related-dialog.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link2, Loader2, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
usePrecedents,
|
||||||
|
useLinkRelatedCase,
|
||||||
|
useUnlinkRelatedCase,
|
||||||
|
RelatedCase,
|
||||||
|
} from "@/lib/api/precedent-library";
|
||||||
|
|
||||||
|
const LEVEL_LABELS: Record<string, string> = {
|
||||||
|
"עליון": "עליון",
|
||||||
|
"מנהלי": "מנהלי",
|
||||||
|
"ועדת_ערר_ארצית": "ארצי",
|
||||||
|
"ועדת_ערר_מחוזית": "מחוזי",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_COLORS: Record<string, string> = {
|
||||||
|
"עליון": "bg-red-50 text-red-700 border-red-200",
|
||||||
|
"מנהלי": "bg-orange-50 text-orange-700 border-orange-200",
|
||||||
|
"ועדת_ערר_ארצית": "bg-blue-50 text-blue-700 border-blue-200",
|
||||||
|
"ועדת_ערר_מחוזית": "bg-green-50 text-green-700 border-green-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Search Dialog ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type DialogProps = {
|
||||||
|
caseId: string;
|
||||||
|
currentRelated: RelatedCase[];
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const { mutateAsync: linkCase, isPending } = useLinkRelatedCase(caseId);
|
||||||
|
const alreadyLinkedIds = new Set([...currentRelated.map((r) => r.id), caseId]);
|
||||||
|
|
||||||
|
const { data, isPending: searching } = usePrecedents(
|
||||||
|
query.length >= 2 ? { search: query, limit: 10 } : {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidates = (data?.items ?? []).filter((p) => !alreadyLinkedIds.has(p.id));
|
||||||
|
|
||||||
|
async function handleLink(relatedId: string) {
|
||||||
|
try {
|
||||||
|
await linkCase({ relatedId });
|
||||||
|
toast.success("הפסיקות קושרו");
|
||||||
|
setQuery("");
|
||||||
|
} catch {
|
||||||
|
toast.error("שגיאה בקישור");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg" dir="rtl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-navy">קשר החלטה קשורה</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">חיפוש וקישור תקדים או החלטה קשורה לתיק</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
placeholder="חפש לפי מספר תיק, שם, ערכאה..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{query.length >= 2 && (
|
||||||
|
<div className="space-y-1 max-h-72 overflow-y-auto">
|
||||||
|
{searching ? (
|
||||||
|
<div className="flex items-center gap-2 text-ink-muted text-sm py-3">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" /> מחפש...
|
||||||
|
</div>
|
||||||
|
) : candidates.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm py-3 text-center">לא נמצאו תוצאות</p>
|
||||||
|
) : (
|
||||||
|
candidates.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => handleLink(p.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full text-right flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule hover:bg-surface/60 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-navy truncate">
|
||||||
|
{p.case_name || p.case_number}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted font-mono" dir="ltr">
|
||||||
|
{p.case_number}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{p.precedent_level && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[0.62rem] shrink-0 ${LEVEL_COLORS[p.precedent_level] ?? ""}`}
|
||||||
|
>
|
||||||
|
{LEVEL_LABELS[p.precedent_level] ?? p.precedent_level}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{query.length > 0 && query.length < 2 && (
|
||||||
|
<p className="text-ink-muted text-xs">הקלד לפחות 2 תווים</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Related Case Card ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RelatedCaseCard({ caseId, related }: { caseId: string; related: RelatedCase }) {
|
||||||
|
const { mutateAsync: unlinkCase, isPending } = useUnlinkRelatedCase(caseId);
|
||||||
|
|
||||||
|
async function handleUnlink() {
|
||||||
|
try {
|
||||||
|
await unlinkCase(related.id);
|
||||||
|
toast.success("הקישור הוסר");
|
||||||
|
} catch {
|
||||||
|
toast.error("שגיאה בהסרת הקישור");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule bg-surface">
|
||||||
|
<a
|
||||||
|
href={`/precedents/${related.id}`}
|
||||||
|
className="min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-navy truncate">
|
||||||
|
{related.case_name || related.case_number}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||||
|
{related.precedent_level && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[0.62rem] ${LEVEL_COLORS[related.precedent_level] ?? ""}`}
|
||||||
|
>
|
||||||
|
{LEVEL_LABELS[related.precedent_level] ?? related.precedent_level}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{related.court && (
|
||||||
|
<span className="text-[0.7rem] text-ink-muted truncate">{related.court}</span>
|
||||||
|
)}
|
||||||
|
{related.date && (
|
||||||
|
<span className="text-[0.7rem] text-ink-muted tabular-nums" dir="ltr">
|
||||||
|
{related.date.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={handleUnlink}
|
||||||
|
disabled={isPending}
|
||||||
|
className="p-1 rounded hover:bg-danger-bg hover:text-danger transition-colors text-ink-muted disabled:opacity-40 shrink-0"
|
||||||
|
title="הסר קישור"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public section component ─────────────────────────────────────────
|
||||||
|
|
||||||
|
type SectionProps = {
|
||||||
|
caseId: string;
|
||||||
|
related: RelatedCase[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RelatedCasesSection({ caseId, related }: SectionProps) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-navy text-sm font-semibold">
|
||||||
|
החלטות קשורות{related.length > 0 ? ` (${related.length})` : ""}
|
||||||
|
</h3>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setDialogOpen(true)}>
|
||||||
|
<Link2 className="w-3.5 h-3.5 me-1" /> קשר החלטה
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{related.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm">אין החלטות קשורות עדיין</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{related.map((r) => (
|
||||||
|
<RelatedCaseCard key={r.id} caseId={caseId} related={r} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LinkDialog
|
||||||
|
caseId={caseId}
|
||||||
|
currentRelated={related}
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,21 @@ export const SOURCE_TYPES = [
|
|||||||
{ value: "appeals_committee", label: "החלטת ועדת ערר" },
|
{ value: "appeals_committee", label: "החלטת ועדת ערר" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Districts for ועדות ערר. The chair's committee is ירושלים; the rest
|
||||||
|
* are listed so that uploaded precedents from peer committees can be
|
||||||
|
* filed correctly. Order matches what's displayed in the UI dropdown.
|
||||||
|
*/
|
||||||
|
export const DISTRICTS = [
|
||||||
|
{ value: "ירושלים", label: "ירושלים" },
|
||||||
|
{ value: "מרכז", label: "מרכז" },
|
||||||
|
{ value: "תל אביב", label: "תל אביב" },
|
||||||
|
{ value: "צפון", label: "צפון" },
|
||||||
|
{ value: "דרום", label: "דרום" },
|
||||||
|
{ value: "חיפה", label: "חיפה" },
|
||||||
|
{ value: "ארצי", label: "ארצי" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function practiceAreaLabel(value: string | null | undefined): string {
|
export function practiceAreaLabel(value: string | null | undefined): string {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const match = PRACTICE_AREAS.find((p) => p.value === value);
|
const match = PRACTICE_AREAS.find((p) => p.value === value);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Save, Sparkles } from "lucide-react";
|
import { Save, Sparkles } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
type SourceType,
|
type SourceType,
|
||||||
} from "@/lib/api/precedent-library";
|
} from "@/lib/api/precedent-library";
|
||||||
import {
|
import {
|
||||||
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, appealSubtypeLabel,
|
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, DISTRICTS, appealSubtypeLabel,
|
||||||
} from "./practice-area";
|
} from "./practice-area";
|
||||||
import { ExtractedHalachotSection } from "./extracted-halachot";
|
import { ExtractedHalachotSection } from "./extracted-halachot";
|
||||||
|
|
||||||
@@ -36,8 +36,11 @@ type Props = {
|
|||||||
* happened in the background. */
|
* happened in the background. */
|
||||||
type FormState = {
|
type FormState = {
|
||||||
citation: string;
|
citation: string;
|
||||||
|
citation_formatted: string;
|
||||||
case_name: string;
|
case_name: string;
|
||||||
court: string;
|
court: string;
|
||||||
|
district: string;
|
||||||
|
chair_name: string;
|
||||||
decision_date: string;
|
decision_date: string;
|
||||||
practice_area: PracticeArea;
|
practice_area: PracticeArea;
|
||||||
appeal_subtype: string;
|
appeal_subtype: string;
|
||||||
@@ -51,8 +54,9 @@ type FormState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY: FormState = {
|
const EMPTY: FormState = {
|
||||||
citation: "", case_name: "", court: "", decision_date: "",
|
citation: "", citation_formatted: "",
|
||||||
practice_area: "", appeal_subtype: "", source_type: "",
|
case_name: "", court: "", district: "", chair_name: "",
|
||||||
|
decision_date: "", practice_area: "", appeal_subtype: "", source_type: "",
|
||||||
precedent_level: "", is_binding: true, subject_tags: "",
|
precedent_level: "", is_binding: true, subject_tags: "",
|
||||||
summary: "", headnote: "", key_quote: "",
|
summary: "", headnote: "", key_quote: "",
|
||||||
};
|
};
|
||||||
@@ -65,14 +69,19 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
|
|
||||||
const [form, setForm] = useState<FormState>(EMPTY);
|
const [form, setForm] = useState<FormState>(EMPTY);
|
||||||
|
|
||||||
// Hydrate form when the record loads.
|
// React-approved derived-state pattern: sync form whenever a different
|
||||||
useEffect(() => {
|
// record arrives (including after save+refetch). Using setState during
|
||||||
if (!record) return;
|
// render avoids the one-frame flash that useEffect would produce.
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
const [syncedRecordId, setSyncedRecordId] = useState<string | null>(null);
|
||||||
|
if (record && record.id !== syncedRecordId) {
|
||||||
|
setSyncedRecordId(record.id as string);
|
||||||
setForm({
|
setForm({
|
||||||
citation: record.case_number || "",
|
citation: record.case_number || "",
|
||||||
|
citation_formatted: record.citation_formatted || "",
|
||||||
case_name: record.case_name || "",
|
case_name: record.case_name || "",
|
||||||
court: record.court || "",
|
court: record.court || "",
|
||||||
|
district: record.district || "",
|
||||||
|
chair_name: record.chair_name || "",
|
||||||
decision_date: record.date ? record.date.slice(0, 10) : "",
|
decision_date: record.date ? record.date.slice(0, 10) : "",
|
||||||
practice_area: (record.practice_area || "") as PracticeArea,
|
practice_area: (record.practice_area || "") as PracticeArea,
|
||||||
appeal_subtype: appealSubtypeLabel(record.appeal_subtype),
|
appeal_subtype: appealSubtypeLabel(record.appeal_subtype),
|
||||||
@@ -84,15 +93,18 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
headnote: record.headnote || "",
|
headnote: record.headnote || "",
|
||||||
key_quote: (record as { key_quote?: string }).key_quote || "",
|
key_quote: (record as { key_quote?: string }).key_quote || "",
|
||||||
});
|
});
|
||||||
}, [record]);
|
}
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent) => {
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!caseLawId) return;
|
if (!caseLawId) return;
|
||||||
try {
|
try {
|
||||||
const patch: Record<string, unknown> = {
|
const patch: Record<string, unknown> = {
|
||||||
|
citation_formatted: form.citation_formatted.trim(),
|
||||||
case_name: form.case_name.trim(),
|
case_name: form.case_name.trim(),
|
||||||
court: form.court.trim(),
|
court: form.court.trim(),
|
||||||
|
district: form.district.trim(),
|
||||||
|
chair_name: form.chair_name.trim(),
|
||||||
practice_area: form.practice_area || undefined,
|
practice_area: form.practice_area || undefined,
|
||||||
appeal_subtype: form.appeal_subtype.trim(),
|
appeal_subtype: form.appeal_subtype.trim(),
|
||||||
source_type: form.source_type || undefined,
|
source_type: form.source_type || undefined,
|
||||||
@@ -128,17 +140,20 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(o) => { if (!o) onOpenChange(false); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onOpenChange(false); }}>
|
||||||
<SheetContent side="left" className="w-full sm:max-w-2xl overflow-y-auto" dir="rtl">
|
<DialogContent
|
||||||
<SheetHeader>
|
className="sm:max-w-4xl max-h-[90vh] overflow-y-auto p-0"
|
||||||
<SheetTitle className="text-navy">עריכת פרטי פסיקה</SheetTitle>
|
dir="rtl"
|
||||||
<SheetDescription className="text-ink-muted">
|
>
|
||||||
כל השדות ניתנים לעריכה חוץ ממראה המקום (מזהה ייחודי).
|
<DialogHeader className="px-6 pt-6">
|
||||||
|
<DialogTitle className="text-navy">עריכת פרטי פסיקה</DialogTitle>
|
||||||
|
<DialogDescription className="text-ink-muted">
|
||||||
|
כל השדות ניתנים לעריכה חוץ ממספר התיק (מזהה ייחודי במערכת).
|
||||||
כפתור "חלץ מטא-דאטה" שולח בקשה לתור מקומי שאני מרוקן
|
כפתור "חלץ מטא-דאטה" שולח בקשה לתור מקומי שאני מרוקן
|
||||||
מ-Claude Code (ה-LLM רץ מקומית עם <code>claude session</code>,
|
מ-Claude Code (ה-LLM רץ מקומית עם <code>claude session</code>,
|
||||||
לא ב-API).
|
לא ב-API).
|
||||||
</SheetDescription>
|
</DialogDescription>
|
||||||
</SheetHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{isPending || !record ? (
|
{isPending || !record ? (
|
||||||
<div className="px-6 pb-6 mt-4 space-y-3">
|
<div className="px-6 pb-6 mt-4 space-y-3">
|
||||||
@@ -149,7 +164,7 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
<form onSubmit={onSubmit} className="px-6 pb-6 space-y-4 mt-4">
|
<form onSubmit={onSubmit} className="px-6 pb-6 space-y-4 mt-4">
|
||||||
<div className="rounded-lg border border-rule bg-rule-soft/40 p-3 flex items-start gap-3">
|
<div className="rounded-lg border border-rule bg-rule-soft/40 p-3 flex items-start gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[0.78rem] text-ink-muted">מראה מקום (לא ניתן לעריכה)</div>
|
<div className="text-[0.78rem] text-ink-muted">מספר תיק (מזהה ייחודי — לא ניתן לעריכה)</div>
|
||||||
<div className="text-navy font-mono text-sm break-all" dir="ltr">
|
<div className="text-navy font-mono text-sm break-all" dir="ltr">
|
||||||
{record.case_number}
|
{record.case_number}
|
||||||
</div>
|
</div>
|
||||||
@@ -166,6 +181,26 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="citation-formatted">
|
||||||
|
מראה מקום (כללי הציטוט האחיד)
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="citation-formatted"
|
||||||
|
value={form.citation_formatted}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, citation_formatted: e.target.value })
|
||||||
|
}
|
||||||
|
rows={3}
|
||||||
|
dir="rtl"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
placeholder='ערר (ועדות ערר ...) 1234/24 **עורר נ' הוועדה המקומית** (נבו 1.2.2025)'
|
||||||
|
/>
|
||||||
|
<p className="text-[0.7rem] text-ink-muted">
|
||||||
|
הקף את שמות הצדדים בכפול-כוכבית <code className="font-mono">**שם**</code> להדגשה. שדה זה משמש את כפתור ההעתקה בעמוד הפסיקה.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="case-name">שם קצר</Label>
|
<Label htmlFor="case-name">שם קצר</Label>
|
||||||
@@ -178,6 +213,30 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
<Input id="court" value={form.court}
|
<Input id="court" value={form.court}
|
||||||
onChange={(e) => setForm({ ...form, court: e.target.value })} />
|
onChange={(e) => setForm({ ...form, court: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="district">מחוז</Label>
|
||||||
|
<Select value={form.district || "_none"}
|
||||||
|
onValueChange={(v) => setForm({ ...form, district: v === "_none" ? "" : v })}>
|
||||||
|
<SelectTrigger id="district"><SelectValue placeholder="—" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_none">—</SelectItem>
|
||||||
|
{DISTRICTS.map((d) => (
|
||||||
|
<SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
{/* Preserve legacy free-text values that don't match any
|
||||||
|
known district (e.g. older imports with typos). */}
|
||||||
|
{form.district && !DISTRICTS.some((d) => d.value === form.district) && (
|
||||||
|
<SelectItem value={form.district}>{form.district}</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="chair-name">יו"ר</Label>
|
||||||
|
<Input id="chair-name" value={form.chair_name}
|
||||||
|
onChange={(e) => setForm({ ...form, chair_name: e.target.value })}
|
||||||
|
placeholder="עו״ד דפנה תמיר" />
|
||||||
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="date">תאריך</Label>
|
<Label htmlFor="date">תאריך</Label>
|
||||||
<Input id="date" type="date" value={form.decision_date}
|
<Input id="date" type="date" value={form.decision_date}
|
||||||
@@ -285,7 +344,7 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SheetContent>
|
</DialogContent>
|
||||||
</Sheet>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import {
|
import {
|
||||||
useUploadPrecedent, libraryKeys,
|
useUploadPrecedent, useUploadInternalDecision, libraryKeys,
|
||||||
type PracticeArea, type SourceType,
|
isCommitteeCitation, COMMITTEE_DISTRICTS,
|
||||||
|
type PracticeArea, type SourceType, type CommitteeDistrict,
|
||||||
} from "@/lib/api/precedent-library";
|
} from "@/lib/api/precedent-library";
|
||||||
import { useProgress } from "@/lib/api/documents";
|
import { useProgress } from "@/lib/api/documents";
|
||||||
import {
|
import {
|
||||||
@@ -45,8 +46,15 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
const [headnote, setHeadnote] = useState("");
|
const [headnote, setHeadnote] = useState("");
|
||||||
const [isBinding, setIsBinding] = useState(true);
|
const [isBinding, setIsBinding] = useState(true);
|
||||||
|
|
||||||
|
// Appeals-committee decisions go to /api/internal-decisions/upload and
|
||||||
|
// require chair_name + district. Routing is by citation prefix.
|
||||||
|
const [chairName, setChairName] = useState("");
|
||||||
|
const [district, setDistrict] = useState<CommitteeDistrict | "">("");
|
||||||
|
const isCommittee = isCommitteeCitation(citation);
|
||||||
|
|
||||||
const [taskId, setTaskId] = useState<string | null>(null);
|
const [taskId, setTaskId] = useState<string | null>(null);
|
||||||
const upload = useUploadPrecedent();
|
const upload = useUploadPrecedent();
|
||||||
|
const uploadInternal = useUploadInternalDecision();
|
||||||
const progress = useProgress(taskId);
|
const progress = useProgress(taskId);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
@@ -63,6 +71,8 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
setPracticeArea(""); setAppealSubtype(""); setSubjectTags("");
|
setPracticeArea(""); setAppealSubtype(""); setSubjectTags("");
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setHeadnote(""); setIsBinding(true); setTaskId(null);
|
setHeadnote(""); setIsBinding(true); setTaskId(null);
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setChairName(""); setDistrict("");
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// Auto-close on completion + refresh library list/stats so the new
|
// Auto-close on completion + refresh library list/stats so the new
|
||||||
@@ -93,15 +103,39 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
toast.error("מראה המקום (citation) חובה");
|
toast.error("מראה המקום (citation) חובה");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!practiceArea) {
|
const tags = subjectTags
|
||||||
toast.error("בחר תחום משפט");
|
.split(",")
|
||||||
return;
|
.map((t) => t.trim())
|
||||||
}
|
.filter(Boolean);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tags = subjectTags
|
if (isCommittee) {
|
||||||
.split(",")
|
if (!chairName.trim()) {
|
||||||
.map((t) => t.trim())
|
toast.error("שם יו\"ר חובה להחלטת ועדת ערר");
|
||||||
.filter(Boolean);
|
return;
|
||||||
|
}
|
||||||
|
if (!district) {
|
||||||
|
toast.error("מחוז חובה להחלטת ועדת ערר");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await uploadInternal.mutateAsync({
|
||||||
|
file,
|
||||||
|
case_number: citation.trim(),
|
||||||
|
chair_name: chairName.trim(),
|
||||||
|
district,
|
||||||
|
case_name: caseName.trim(),
|
||||||
|
court: court.trim(),
|
||||||
|
decision_date: decisionDate || undefined,
|
||||||
|
practice_area: practiceArea,
|
||||||
|
appeal_subtype: appealSubtype.trim(),
|
||||||
|
subject_tags: tags,
|
||||||
|
is_binding: isBinding,
|
||||||
|
summary: headnote.trim(),
|
||||||
|
});
|
||||||
|
setTaskId(res.task_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await upload.mutateAsync({
|
const res = await upload.mutateAsync({
|
||||||
file,
|
file,
|
||||||
citation: citation.trim(),
|
citation: citation.trim(),
|
||||||
@@ -123,6 +157,7 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isProcessing = taskId !== null && progress?.status !== "completed" && progress?.status !== "failed";
|
const isProcessing = taskId !== null && progress?.status !== "completed" && progress?.status !== "failed";
|
||||||
|
const isSubmitting = upload.isPending || uploadInternal.isPending;
|
||||||
const stage = (progress as { stage?: string; percent?: number; step?: string } | null)?.stage;
|
const stage = (progress as { stage?: string; percent?: number; step?: string } | null)?.stage;
|
||||||
const percent = (progress as { percent?: number } | null)?.percent ?? 0;
|
const percent = (progress as { percent?: number } | null)?.percent ?? 0;
|
||||||
|
|
||||||
@@ -132,8 +167,9 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle className="text-navy">העלאת פסיקה לקורפוס הסמכותי</SheetTitle>
|
<SheetTitle className="text-navy">העלאת פסיקה לקורפוס הסמכותי</SheetTitle>
|
||||||
<SheetDescription className="text-ink-muted">
|
<SheetDescription className="text-ink-muted">
|
||||||
הקובץ יעבור חילוץ טקסט, יצירת embeddings, וחילוץ הלכות אוטומטי.
|
הקובץ יעבור חילוץ טקסט, embeddings, וחילוץ אוטומטי של מטא־דאטה
|
||||||
ההלכות יחכו לאישורך לפני שהן זמינות לסוכני הכתיבה.
|
(שם, ערכאה, תאריך, תחום, תת־סוג, תגיות) והלכות. ההלכות ימתינו
|
||||||
|
לאישורך לפני שיהיו זמינות לסוכני הכתיבה.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@@ -157,106 +193,150 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
placeholder={`עע"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית`}
|
placeholder={`עע"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית`}
|
||||||
disabled={isProcessing} dir="rtl"
|
disabled={isProcessing} dir="rtl"
|
||||||
/>
|
/>
|
||||||
|
{isCommittee && (
|
||||||
|
<p className="text-[0.72rem] text-ink-muted">
|
||||||
|
זוהתה כהחלטת ועדת ערר — נדרשים שם יו"ר ומחוז (למטה).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Two-col grid */}
|
{isCommittee && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3 rounded-md border border-gold/40 bg-gold-wash/40 p-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="case-name">שם קצר</Label>
|
<Label htmlFor="chair-name">שם יו"ר (חובה)</Label>
|
||||||
<Input id="case-name" value={caseName}
|
<Input
|
||||||
onChange={(e) => setCaseName(e.target.value)}
|
id="chair-name" value={chairName}
|
||||||
placeholder="ב. קרן-נכסים" disabled={isProcessing} />
|
onChange={(e) => setChairName(e.target.value)}
|
||||||
|
placeholder='עו"ד פלוני אלמוני'
|
||||||
|
disabled={isProcessing} dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="district">מחוז (חובה)</Label>
|
||||||
|
<Select
|
||||||
|
value={district || "_none"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setDistrict(v === "_none" ? "" : (v as CommitteeDistrict))
|
||||||
|
}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_none">—</SelectItem>
|
||||||
|
{COMMITTEE_DISTRICTS.map((d) => (
|
||||||
|
<SelectItem key={d} value={d}>{d}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
)}
|
||||||
<Label htmlFor="court">ערכאה</Label>
|
|
||||||
<Input id="court" value={court}
|
|
||||||
onChange={(e) => setCourt(e.target.value)}
|
|
||||||
placeholder='בית משפט עליון / בג"ץ / מנהלי / ועדת ערר'
|
|
||||||
disabled={isProcessing} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="date">תאריך החלטה</Label>
|
|
||||||
<Input id="date" type="date" value={decisionDate}
|
|
||||||
onChange={(e) => setDecisionDate(e.target.value)}
|
|
||||||
disabled={isProcessing} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="appeal-subtype">תת-סוג (חופשי)</Label>
|
|
||||||
<Input id="appeal-subtype" value={appealSubtype}
|
|
||||||
onChange={(e) => setAppealSubtype(e.target.value)}
|
|
||||||
placeholder="שימוש חורג / סופיות ההחלטה" disabled={isProcessing} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Practice area (required radio) */}
|
<details className="group rounded-md border border-rule bg-rule-soft/30">
|
||||||
<div className="space-y-1">
|
<summary className="cursor-pointer select-none px-3 py-2 text-[0.78rem] text-ink-muted hover:text-navy">
|
||||||
<Label>תחום משפט (חובה)</Label>
|
אופציונלי — דריסה ידנית של שדות שיחולצו אוטומטית מהמסמך
|
||||||
<div className="flex gap-4 flex-wrap">
|
</summary>
|
||||||
{PRACTICE_AREAS.map((a) => (
|
<div className="space-y-3 border-t border-rule px-3 py-3">
|
||||||
<label key={a.value} className="flex items-center gap-2 cursor-pointer">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<input
|
<div className="space-y-1">
|
||||||
type="radio" name="practice_area" value={a.value}
|
<Label htmlFor="case-name">שם קצר</Label>
|
||||||
checked={practiceArea === a.value}
|
<Input id="case-name" value={caseName}
|
||||||
onChange={() => setPracticeArea(a.value as PracticeArea)}
|
onChange={(e) => setCaseName(e.target.value)}
|
||||||
disabled={isProcessing}
|
placeholder="ב. קרן-נכסים" disabled={isProcessing} />
|
||||||
/>
|
</div>
|
||||||
<span className="text-sm text-ink">{a.label}</span>
|
<div className="space-y-1">
|
||||||
</label>
|
<Label htmlFor="court">ערכאה</Label>
|
||||||
))}
|
<Input id="court" value={court}
|
||||||
</div>
|
onChange={(e) => setCourt(e.target.value)}
|
||||||
</div>
|
placeholder='בית משפט עליון / בג"ץ / מנהלי / ועדת ערר'
|
||||||
|
disabled={isProcessing} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="date">תאריך החלטה</Label>
|
||||||
|
<Input id="date" type="date" value={decisionDate}
|
||||||
|
onChange={(e) => setDecisionDate(e.target.value)}
|
||||||
|
disabled={isProcessing} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="appeal-subtype">תת-סוג (חופשי)</Label>
|
||||||
|
<Input id="appeal-subtype" value={appealSubtype}
|
||||||
|
onChange={(e) => setAppealSubtype(e.target.value)}
|
||||||
|
placeholder="שימוש חורג / סופיות ההחלטה" disabled={isProcessing} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-1">
|
||||||
<div className="space-y-1">
|
<Label>תחום משפט</Label>
|
||||||
<Label htmlFor="source-type">סוג מקור</Label>
|
<div className="flex gap-4 flex-wrap">
|
||||||
<Select value={sourceType || "_none"}
|
{PRACTICE_AREAS.map((a) => (
|
||||||
onValueChange={(v) => setSourceType(v === "_none" ? "" : v as SourceType)}
|
<label key={a.value} className="flex items-center gap-2 cursor-pointer">
|
||||||
disabled={isProcessing}>
|
<input
|
||||||
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
type="radio" name="practice_area" value={a.value}
|
||||||
<SelectContent>
|
checked={practiceArea === a.value}
|
||||||
<SelectItem value="_none">—</SelectItem>
|
onChange={() => setPracticeArea(a.value as PracticeArea)}
|
||||||
{SOURCE_TYPES.map((s) => (
|
disabled={isProcessing}
|
||||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
/>
|
||||||
|
<span className="text-sm text-ink">{a.label}</span>
|
||||||
|
</label>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
</div>
|
||||||
|
|
||||||
|
{!isCommittee && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="source-type">סוג מקור</Label>
|
||||||
|
<Select value={sourceType || "_none"}
|
||||||
|
onValueChange={(v) => setSourceType(v === "_none" ? "" : v as SourceType)}
|
||||||
|
disabled={isProcessing}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_none">—</SelectItem>
|
||||||
|
{SOURCE_TYPES.map((s) => (
|
||||||
|
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="precedent-level">רמת תקדים</Label>
|
||||||
|
<Select value={precedentLevel || "_none"}
|
||||||
|
onValueChange={(v) => setPrecedentLevel(v === "_none" ? "" : v)}
|
||||||
|
disabled={isProcessing}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_none">—</SelectItem>
|
||||||
|
{PRECEDENT_LEVELS.map((l) => (
|
||||||
|
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="tags">תגיות נושא (מופרדות בפסיקים)</Label>
|
||||||
|
<Input id="tags" value={subjectTags}
|
||||||
|
onChange={(e) => setSubjectTags(e.target.value)}
|
||||||
|
placeholder="חניה, קווי בניין, שיקול דעת" disabled={isProcessing} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="headnote">תקציר / headnote</Label>
|
||||||
|
<Textarea id="headnote" value={headnote} rows={2}
|
||||||
|
onChange={(e) => setHeadnote(e.target.value)}
|
||||||
|
placeholder="תקציר חופשי שיוצג ברשימה" disabled={isProcessing} dir="rtl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={isBinding}
|
||||||
|
onChange={(e) => setIsBinding(e.target.checked)}
|
||||||
|
disabled={isProcessing} />
|
||||||
|
<span className="text-sm">הלכה מחייבת</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
</details>
|
||||||
<Label htmlFor="precedent-level">רמת תקדים</Label>
|
|
||||||
<Select value={precedentLevel || "_none"}
|
|
||||||
onValueChange={(v) => setPrecedentLevel(v === "_none" ? "" : v)}
|
|
||||||
disabled={isProcessing}>
|
|
||||||
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="_none">—</SelectItem>
|
|
||||||
{PRECEDENT_LEVELS.map((l) => (
|
|
||||||
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="tags">תגיות נושא (מופרדות בפסיקים)</Label>
|
|
||||||
<Input id="tags" value={subjectTags}
|
|
||||||
onChange={(e) => setSubjectTags(e.target.value)}
|
|
||||||
placeholder="חניה, קווי בניין, שיקול דעת" disabled={isProcessing} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="headnote">תקציר / headnote (אופציונלי)</Label>
|
|
||||||
<Textarea id="headnote" value={headnote} rows={2}
|
|
||||||
onChange={(e) => setHeadnote(e.target.value)}
|
|
||||||
placeholder="תקציר חופשי שיוצג ברשימה" disabled={isProcessing} dir="rtl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" checked={isBinding}
|
|
||||||
onChange={(e) => setIsBinding(e.target.checked)}
|
|
||||||
disabled={isProcessing} />
|
|
||||||
<span className="text-sm">הלכה מחייבת</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<div className="rounded-lg border border-rule bg-rule-soft/40 p-4 space-y-2">
|
<div className="rounded-lg border border-rule bg-rule-soft/40 p-4 space-y-2">
|
||||||
@@ -284,11 +364,11 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
|
|
||||||
<div className="flex gap-2 justify-end pt-2">
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
<Button type="button" variant="ghost"
|
<Button type="button" variant="ghost"
|
||||||
onClick={() => onOpenChange(false)} disabled={upload.isPending}>
|
onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||||
ביטול
|
ביטול
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit"
|
<Button type="submit"
|
||||||
disabled={upload.isPending || isProcessing}
|
disabled={isSubmitting || isProcessing}
|
||||||
className="bg-navy text-parchment hover:bg-navy-soft">
|
className="bg-navy text-parchment hover:bg-navy-soft">
|
||||||
<Upload className="w-4 h-4 me-1" />
|
<Upload className="w-4 h-4 me-1" />
|
||||||
העלה
|
העלה
|
||||||
|
|||||||
434
web-ui/src/components/training/chat-panel.tsx
Normal file
434
web-ui/src/components/training/chat-panel.tsx
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Style-agent chat panel — the new "שיחה" tab on /training.
|
||||||
|
*
|
||||||
|
* Layout: two columns.
|
||||||
|
* - Sidebar: list of conversations + "+ שיחה חדשה" button
|
||||||
|
* - Main: thread of messages + composer with SSE streaming
|
||||||
|
*
|
||||||
|
* Each message is persisted to the legal-ai DB; the LLM call goes
|
||||||
|
* out via FastAPI → host's legal-chat-service → claude CLI. There
|
||||||
|
* is no API cost — the claude CLI uses Daphna's claude.ai
|
||||||
|
* subscription via the host's auth.
|
||||||
|
*
|
||||||
|
* Health gate: if /api/training/chat/health reports the host service
|
||||||
|
* is unreachable, the composer is replaced by a setup notice telling
|
||||||
|
* the chair to start the pm2 service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Send, Plus, Trash2, Loader2, MessageSquare, Sparkles, AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
chatKeys,
|
||||||
|
useChatConversation,
|
||||||
|
useChatConversations,
|
||||||
|
useChatHealth,
|
||||||
|
useCorpus,
|
||||||
|
useCreateChat,
|
||||||
|
useDeleteChat,
|
||||||
|
type ChatMessage,
|
||||||
|
} from "@/lib/api/training";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export function ChatPanel() {
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const health = useChatHealth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
|
||||||
|
<ConversationsSidebar activeId={activeId} onSelect={setActiveId} />
|
||||||
|
<div className="space-y-3">
|
||||||
|
{health.data && !health.data.reachable && (
|
||||||
|
<ChatServiceWarning health={health.data} />
|
||||||
|
)}
|
||||||
|
{activeId ? (
|
||||||
|
<ChatThread convId={activeId} />
|
||||||
|
) : (
|
||||||
|
<Card className="bg-rule-soft/40 border-rule">
|
||||||
|
<CardContent className="px-6 py-10 text-center text-ink-muted text-sm space-y-2">
|
||||||
|
<MessageSquare className="w-8 h-8 mx-auto opacity-50" />
|
||||||
|
<p>בחר שיחה קיימת או פתח חדשה כדי להתחיל לדבר עם סוכן הסגנון.</p>
|
||||||
|
<p className="text-[0.78rem]">
|
||||||
|
הסוכן רץ על claude CLI מקומי דרך legal-chat-service. אין עלות API.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sidebar: list + new ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ConversationsSidebar({
|
||||||
|
activeId, onSelect,
|
||||||
|
}: {
|
||||||
|
activeId: string | null;
|
||||||
|
onSelect: (id: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const { data: convs, isPending } = useChatConversations();
|
||||||
|
const { data: corpus } = useCorpus();
|
||||||
|
const create = useCreateChat();
|
||||||
|
const del = useDeleteChat();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newTitle, setNewTitle] = useState("");
|
||||||
|
const [newCorpusId, setNewCorpusId] = useState<string>("__none__");
|
||||||
|
|
||||||
|
const onCreate = async () => {
|
||||||
|
try {
|
||||||
|
const conv = await create.mutateAsync({
|
||||||
|
title: newTitle.trim() || "שיחה חדשה",
|
||||||
|
style_corpus_id: newCorpusId === "__none__" ? null : newCorpusId,
|
||||||
|
});
|
||||||
|
onSelect(conv.id);
|
||||||
|
setCreating(false);
|
||||||
|
setNewTitle("");
|
||||||
|
setNewCorpusId("__none__");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "כשל ביצירת שיחה");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async (id: string) => {
|
||||||
|
if (!window.confirm("למחוק את השיחה? פעולה זו לא ניתנת לביטול.")) return;
|
||||||
|
try {
|
||||||
|
await del.mutateAsync(id);
|
||||||
|
if (activeId === id) onSelect(null);
|
||||||
|
toast.success("השיחה נמחקה");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "כשל במחיקה");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule">
|
||||||
|
<CardContent className="px-3 py-3 space-y-2">
|
||||||
|
{!creating ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
className="w-full bg-navy text-parchment hover:bg-navy-soft"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 me-1" />
|
||||||
|
שיחה חדשה
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 border border-rule rounded p-2 bg-rule-soft/30">
|
||||||
|
<Textarea
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
placeholder="כותרת לשיחה (אופציונלי)"
|
||||||
|
rows={2} dir="rtl"
|
||||||
|
/>
|
||||||
|
<Select value={newCorpusId} onValueChange={setNewCorpusId} dir="rtl">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="צמד להחלטה (אופציונלי)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-[300px]">
|
||||||
|
<SelectItem value="__none__">— שיחה כללית —</SelectItem>
|
||||||
|
{corpus?.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.decision_number || "—"}
|
||||||
|
{c.decision_date ? ` · ${c.decision_date}` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
<Button variant="ghost" size="sm"
|
||||||
|
onClick={() => { setCreating(false); setNewTitle(""); setNewCorpusId("__none__"); }}>
|
||||||
|
ביטול
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={onCreate} disabled={create.isPending}
|
||||||
|
className="bg-navy text-parchment hover:bg-navy-soft">
|
||||||
|
צור
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ScrollArea className="h-[520px]">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{isPending && (
|
||||||
|
<>
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{convs?.length === 0 && (
|
||||||
|
<p className="text-center text-ink-muted text-[0.78rem] py-6">
|
||||||
|
אין עדיין שיחות
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{convs?.map((c) => {
|
||||||
|
const active = c.id === activeId;
|
||||||
|
return (
|
||||||
|
<li key={c.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect(c.id)}
|
||||||
|
className={
|
||||||
|
"w-full text-end rounded-md px-2 py-2 transition " +
|
||||||
|
(active
|
||||||
|
? "bg-gold-wash border border-gold/40"
|
||||||
|
: "hover:bg-rule-soft/60 border border-transparent")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="text-sm text-navy font-semibold truncate">
|
||||||
|
{c.title}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[0.7rem] text-ink-muted">
|
||||||
|
{c.decision_number && (
|
||||||
|
<Badge variant="outline"
|
||||||
|
className="text-[0.65rem] bg-info-bg text-info border-info/40">
|
||||||
|
{c.decision_number}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="tabular-nums">{c.message_count}</span>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
<span className="grow text-end">
|
||||||
|
{new Date(c.last_message_at).toLocaleDateString("he-IL")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(c.id); }}
|
||||||
|
className="hover:text-danger"
|
||||||
|
aria-label="מחק שיחה"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thread + composer ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ChatThread({ convId }: { convId: string }) {
|
||||||
|
const { data, isPending } = useChatConversation(convId);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [streaming, setStreaming] = useState(false);
|
||||||
|
const [streamingText, setStreamingText] = useState("");
|
||||||
|
const [streamError, setStreamError] = useState("");
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
/* Auto-scroll to bottom when new messages arrive. */
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||||
|
}, [data?.messages.length, streamingText]);
|
||||||
|
|
||||||
|
const onSend = async () => {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text || streaming) return;
|
||||||
|
setDraft("");
|
||||||
|
setStreaming(true);
|
||||||
|
setStreamingText("");
|
||||||
|
setStreamError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/training/chat/conversations/${encodeURIComponent(convId)}/messages`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ content: text }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
// Parse SSE line-by-line. EventSource would be cleaner but it
|
||||||
|
// doesn't support POST bodies; the manual reader is small.
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let accumulated = "";
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let nl: number;
|
||||||
|
while ((nl = buffer.indexOf("\n\n")) !== -1) {
|
||||||
|
const event = buffer.slice(0, nl);
|
||||||
|
buffer = buffer.slice(nl + 2);
|
||||||
|
if (!event.startsWith("data: ")) continue;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.slice("data: ".length));
|
||||||
|
if (payload.type === "text_delta" && payload.text) {
|
||||||
|
accumulated += payload.text;
|
||||||
|
setStreamingText(accumulated);
|
||||||
|
} else if (payload.type === "error") {
|
||||||
|
setStreamError(String(payload.message || "שגיאה לא ידועה"));
|
||||||
|
} else if (payload.type === "done") {
|
||||||
|
if (payload.text && !accumulated) {
|
||||||
|
accumulated = payload.text;
|
||||||
|
setStreamingText(accumulated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore non-JSON */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setStreamError(e instanceof Error ? e.message : "שגיאה בשיחה");
|
||||||
|
} finally {
|
||||||
|
setStreaming(false);
|
||||||
|
setStreamingText("");
|
||||||
|
// Refetch the conversation so the persisted assistant turn shows up.
|
||||||
|
qc.invalidateQueries({ queryKey: chatKeys.conversation(convId) });
|
||||||
|
qc.invalidateQueries({ queryKey: chatKeys.conversations() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isPending) return <Skeleton className="h-[560px] w-full" />;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule">
|
||||||
|
<CardContent className="px-4 py-3 space-y-3">
|
||||||
|
<header className="flex items-center gap-2 border-b border-rule pb-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-gold-deep" />
|
||||||
|
<h3 className="text-navy font-semibold grow">{data.conversation.title}</h3>
|
||||||
|
{data.conversation.decision_number && (
|
||||||
|
<Badge variant="outline" className="bg-info-bg text-info border-info/40">
|
||||||
|
{data.conversation.decision_number}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div ref={scrollRef} className="h-[440px] overflow-y-auto space-y-3 pe-1">
|
||||||
|
{data.messages.length === 0 && !streaming && (
|
||||||
|
<p className="text-center text-ink-muted text-sm py-8">
|
||||||
|
התחל בשאלה — למשל: "מה מאפיין את הפתיחות של דפנה בעררי 1xxx?"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.messages.map((m) => <MessageBubble key={m.id} message={m} />)}
|
||||||
|
{streaming && (
|
||||||
|
<MessageBubble
|
||||||
|
message={{
|
||||||
|
id: "streaming",
|
||||||
|
role: "assistant",
|
||||||
|
content: streamingText || "(מקליד…)",
|
||||||
|
created_at: "",
|
||||||
|
}}
|
||||||
|
isStreaming
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{streamError && (
|
||||||
|
<div className="rounded-lg border border-danger/40 bg-danger-bg p-3 text-danger text-sm">
|
||||||
|
{streamError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-rule pt-3 space-y-2">
|
||||||
|
<Textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
placeholder="שאל את הסוכן… (Shift+Enter לשורה חדשה)"
|
||||||
|
rows={3} dir="rtl"
|
||||||
|
disabled={streaming}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
void onSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-[0.72rem] text-ink-muted grow">
|
||||||
|
{data.conversation.claude_session_id
|
||||||
|
? "שיחה ממשיכה (--resume) — אין צורך לטעון מחדש את ה-system prompt"
|
||||||
|
: "שיחה חדשה — system prompt ייטען (שני מסמכי ייחוס + רשימת קורפוס)"}
|
||||||
|
</p>
|
||||||
|
<Button onClick={onSend} disabled={streaming || !draft.trim()}
|
||||||
|
className="bg-navy text-parchment hover:bg-navy-soft">
|
||||||
|
{streaming ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin me-1" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4 h-4 me-1" />
|
||||||
|
)}
|
||||||
|
שלח
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({
|
||||||
|
message, isStreaming = false,
|
||||||
|
}: { message: ChatMessage; isStreaming?: boolean }) {
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
return (
|
||||||
|
<div className={isUser ? "flex justify-start" : "flex justify-end"}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap " +
|
||||||
|
(isUser
|
||||||
|
? "bg-gold-wash text-ink border border-gold/40"
|
||||||
|
: "bg-rule-soft text-ink border border-rule")
|
||||||
|
}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
{isStreaming && (
|
||||||
|
<span className="inline-block w-1.5 h-3.5 bg-navy/60 align-middle ms-1 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service-down warning ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function ChatServiceWarning({
|
||||||
|
health,
|
||||||
|
}: { health: { reachable: boolean; url: string; error?: string } }) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-danger-bg border-danger/40">
|
||||||
|
<CardContent className="px-4 py-3 space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-danger">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
<strong>שירות הצ'אט אינו זמין</strong>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.78rem] text-danger">
|
||||||
|
לא ניתן להגיע ל-legal-chat-service בכתובת
|
||||||
|
<code className="px-1 mx-1 bg-rule-soft rounded">{health.url}</code>.
|
||||||
|
{health.error && (<> פירוט: <code className="px-1 bg-rule-soft rounded">{health.error}</code></>)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[0.72rem] text-ink-muted">
|
||||||
|
על המכונה המקומית הפעל:
|
||||||
|
<code className="px-1 bg-rule-soft rounded">
|
||||||
|
pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user