Compare commits
166 Commits
03e7d88aee
...
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 | |||
| d81c3c37ab | |||
| fff2d1c859 | |||
| 36b78ea404 | |||
| c7132ba0d2 | |||
| 171da84680 | |||
| afcc4818a4 | |||
| bd4b0ca766 | |||
| 7c9582ed04 | |||
| ea29778197 | |||
| 3be676e062 | |||
| 799b950961 | |||
| 77e5996497 | |||
| 69d4827f33 | |||
| c0f67ab841 | |||
| 92a2763b86 | |||
| 1b14e04373 | |||
| 69e153b3db | |||
| 702c01d678 | |||
| bd6a66e80d | |||
| af2dc0df2a | |||
| eab0ca906c | |||
| cf5f6fe274 | |||
| 6f713042b5 | |||
| d0994704cf | |||
| 82b29510f2 | |||
| e90faa9ba4 | |||
| ae35934383 | |||
| d1e12619d4 | |||
| 1cb832473c | |||
| 89ce6c79d7 | |||
| 7e3c912899 | |||
| f418686724 | |||
| 8289b4d643 | |||
| 6c129a1350 | |||
| 320b9d3529 | |||
| 394b971856 | |||
| 1da3587334 | |||
| 272e49b6b0 | |||
| 69bdf7b30a | |||
| 2fe73fcce1 | |||
| c30c987ec2 | |||
| 562eae010a | |||
| a3ca32355a | |||
| 55a0eca070 | |||
| 796f9d5f9c | |||
| 70052b0133 | |||
| 2f05cdea2e | |||
| bd1fb61655 | |||
| f6bb46dc4a | |||
| 36f21c815e | |||
| d4496b96f1 | |||
| d12cdb1fad | |||
| 8a815ecff5 | |||
| 81ccf3a888 | |||
| 5724ed8e5b | |||
| c31fe0866b | |||
| 242f668319 | |||
| b9cdcf980d | |||
| 36e464f668 | |||
| 4d1924c7e6 | |||
| 26c3fddf41 | |||
| 688ba37d9c | |||
| b2985f88de | |||
| 01ea902156 | |||
| cca17689de | |||
| deb1a1eaf4 | |||
| f722fa45bd | |||
| cbdbc522a0 | |||
| 6c727cb5d0 | |||
| 923903217c | |||
| da0a385d9c | |||
| cb0b4b6a8b | |||
| 72c4593e74 | |||
| 789cc273ee | |||
| 1f17419ee9 | |||
| 4a9a6b7970 | |||
| 8e1384b897 | |||
| 6420fe4b0b | |||
| fc3b6b6cae | |||
| 2cfdf35191 | |||
| 5d836ca414 | |||
| 73a79ea7e8 | |||
| b51163b67c | |||
| 7ee90dce31 | |||
| a6edb75bbf | |||
| e849285806 | |||
| f7249b7807 | |||
| 5deb38f5cf | |||
| 817d6e6d8d | |||
| f256eddbb1 | |||
| 6a38789379 | |||
| fa70944ed4 | |||
| 7600810639 | |||
| 47127f1e85 | |||
| a1969dd90d | |||
| 1fbcdd0d16 | |||
| cd4eed0045 | |||
| 903fb4d140 | |||
| 28f49defff | |||
| 9bdfb05350 |
@@ -1,160 +1,165 @@
|
||||
# HEARTBEAT.md — רשימת ביצוע לכל ריצה
|
||||
# HEARTBEAT.md — רשימת ביצוע לכל ריצה (Project-Specific)
|
||||
|
||||
## שפה — כלל עליון
|
||||
|
||||
**כל הפלט שלך חייב להיות בעברית בלבד.** זה כולל:
|
||||
- Comments ב-Paperclip
|
||||
- הודעות סטטוס
|
||||
- תיאורי שגיאות
|
||||
- סיכומים ודיווחים
|
||||
- חשיבה פנימית (thinking)
|
||||
|
||||
אין יוצאים מן הכלל. גם שמות tools, פקודות, ונתיבי קבצים — ההסבר סביבם בעברית.
|
||||
> **🎯 קובץ זה — Project-specific only.** ה-skill הרשמי `paperclipai/paperclip/paperclip` (טעון אוטומטית בכל heartbeat דרך `paperclipSkillSync`) מכיל את כל ה-API patterns הגנריים: identity (`/api/agents/me`), `PAPERCLIP_WAKE_PAYLOAD_JSON`, `APPROVAL_ID`, inbox, comments, checkout, status updates, וכו'. **קובץ זה מתעד רק התאמות שלנו** — סינון חברה, helpers, workarounds, ו-quirks.
|
||||
>
|
||||
> **בקונפליקט:** קובץ זה גובר על ה-skill (project-specific מנצח default).
|
||||
|
||||
---
|
||||
|
||||
הרץ את הרשימה הזו בכל heartbeat.
|
||||
## שפה — כלל עליון
|
||||
|
||||
## 1. זיהוי וסינון חברה
|
||||
**כל הפלט שלך חייב להיות בעברית בלבד.** כולל: comments, סטטוס, שגיאות, סיכומים, ו-thinking פנימי. אין יוצאים מן הכלל. גם שמות tools, פקודות, ונתיבי קבצים — ההסבר סביבם בעברית. ה-skill הרשמי באנגלית — תרגם אם נדרש.
|
||||
|
||||
- וודא שאתה יודע מי אתה: `$PAPERCLIP_AGENT_ID`
|
||||
- בדוק הקשר: `$PAPERCLIP_TASK_ID`, `$PAPERCLIP_WAKE_REASON`
|
||||
- **זהה את החברה שלך**: `$PAPERCLIP_COMPANY_ID`
|
||||
---
|
||||
|
||||
### ⚠️ סינון תיקים לפי חברה — כלל ברזל
|
||||
## §0. כל קריאה ל-Paperclip API — דרך `pc.sh` בלבד
|
||||
|
||||
**אתה אחראי רק על תיקים ששייכים לחברה שלך.** הספרה הראשונה של מספר התיק קובעת:
|
||||
|
||||
| חברה | COMPANY_ID | סוגי תיקים | טווח מספרים |
|
||||
|------|------------|-------------|-------------|
|
||||
| ועדת ערר רישוי ובניה | `42a7acd0-30c5-4cbd-ac97-7424f65df294` | רישוי ובניה | **1xxx** |
|
||||
| ועדת ערר היטלי השבחה | `8639e837-4c9d-47fa-a76b-95788d651896` | היטל השבחה + פיצויים ס' 197 | **8xxx, 9xxx** |
|
||||
|
||||
- אם `$PAPERCLIP_COMPANY_ID` = `42a7acd0...` → עבוד רק על תיקים שמתחילים ב-**1**
|
||||
- אם `$PAPERCLIP_COMPANY_ID` = `8639e837...` → עבוד רק על תיקים שמתחילים ב-**8** או **9**
|
||||
- **לעולם אל תיצור פרויקט, issue, או תוכן לתיק שלא בטווח שלך**
|
||||
- אם issue שהוקצה לך מכוון לתיק שלא בטווח שלך — סרב בנימוס ודווח ב-comment
|
||||
|
||||
## 2. בדוק תיבת דואר
|
||||
**ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "$PAPERCLIP_API_URL/api/agents/me/inbox-lite"
|
||||
~/legal-ai/scripts/pc.sh <METHOD> <PATH> [BODY_JSON] [extra curl args...]
|
||||
```
|
||||
|
||||
- תעדוף: `in_progress` קודם, אחר כך `todo`
|
||||
- אם `PAPERCLIP_TASK_ID` מוגדר — תעדף אותו
|
||||
|
||||
## 2b. קרא תגובות אחרונות על ה-issue
|
||||
|
||||
לפני שאתה מתחיל לעבוד, בדוק אם יש comments חדשים מחיים:
|
||||
מוסיף אוטומטית: `Authorization`, `X-Paperclip-Run-Id` (audit), `Content-Type`, base URL.
|
||||
|
||||
**דוגמאות:**
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]'
|
||||
~/legal-ai/scripts/pc.sh GET "/api/agents/me/inbox-lite"
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/$ISSUE_ID/checkout"
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$ISSUE_ID" '{"status":"done"}'
|
||||
```
|
||||
|
||||
- אם יש comment מחיים (authorUserId, לא authorAgentId) שנכתב **אחרי** ה-comment האחרון שלך — **קרא אותו בתשומת לב**
|
||||
- אם ה-comment מכיל הוראות עבודה — **עקוב אחריהן**
|
||||
- אם ה-comment מזכיר קובץ שהועלה — בדוק attachments (ראה 2c)
|
||||
- אם ה-comment מבקש להעביר לסוכן אחר — **עצור**, פרסם comment שמאשר, והעֵר את ה-CEO
|
||||
**ל-body גדול עם backticks** — `Write` ל-temp file, אז `pc.sh ... "" -H "Content-Type: application/json" -d @/tmp/comment.json`. ראה §דיווח למה.
|
||||
|
||||
## 2c. בדוק קבצים מצורפים
|
||||
---
|
||||
|
||||
אם comment מחיים מזכיר קובץ או טיוטה:
|
||||
## §1. זיהוי וסינון חברה — כלל ברזל ⚠️
|
||||
|
||||
| חברה | COMPANY_ID | סוגי תיקים | טווח מספרים | CEO Agent ID |
|
||||
|------|------------|-------------|---------------|---------------|
|
||||
| ועדת ערר רישוי ובניה (CMP) | `42a7acd0-30c5-4cbd-ac97-7424f65df294` | רישוי ובניה | **1xxx** | `752cebdd-6748-4a04-aacd-c7ab0294ef33` |
|
||||
| ועדת ערר היטלי השבחה (CMPA) | `8639e837-4c9d-47fa-a76b-95788d651896` | היטל השבחה + פיצויים ס' 197 | **8xxx, 9xxx** | `cdbfa8bc-3d61-41a4-a2e7-677ec7d34562` |
|
||||
|
||||
- אם `$PAPERCLIP_COMPANY_ID` = `42a7acd0...` → רק תיקים ש-**1xxx**
|
||||
- אם `$PAPERCLIP_COMPANY_ID` = `8639e837...` → רק תיקים ש-**8xxx/9xxx**
|
||||
- **אסור** ליצור פרויקט/issue/תוכן לתיק שלא בטווח שלך
|
||||
- אם issue שהוקצה לך מכוון לתיק שלא בטווח — סרב בנימוס ב-comment, והעֵר את ה-CEO של החברה הנכונה
|
||||
|
||||
---
|
||||
|
||||
## §1.5. טיפול ב-wake (skill הרשמי + תוספות שלנו)
|
||||
|
||||
ה-skill מסביר `PAPERCLIP_WAKE_PAYLOAD_JSON`, `APPROVAL_ID`, ו-`heartbeat-context` (Step 6). הוסף עליו:
|
||||
|
||||
**1.5א. אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל comment חדש מחיים** — התייחס אליו ב-comment הראשון שלך ("ראיתי שביקשת X — מבצע Y") **לפני** עבודה רחבה. זה מבטיח שחיים יודע שקלטת.
|
||||
|
||||
**1.5ב. תמיד לקרוא `heartbeat-context`** — לא רק מה ש-skill ממליץ ("Prefer"). אצלנו ה-`attachments` המוחזרים חיוניים (חיים מעלה DOCX/PDF דרך comments). ראה §2.
|
||||
|
||||
```bash
|
||||
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
|
||||
SELECT a.original_filename, a.content_type, a.object_key, a.byte_size
|
||||
FROM issue_attachments ia
|
||||
JOIN assets a ON a.id = ia.asset_id
|
||||
WHERE ia.issue_id = '{issue-id}'
|
||||
ORDER BY ia.created_at DESC LIMIT 5;"
|
||||
CONTEXT=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/heartbeat-context?wakeCommentId=$LATEST_COMMENT_ID")
|
||||
ATTACHMENTS=$(echo "$CONTEXT" | jq '.attachments')
|
||||
```
|
||||
|
||||
- נתיב מלא לקובץ: `/home/chaim/.paperclip/instances/default/data/storage/{object_key}`
|
||||
- קבצי DOCX — קרא אותם עם `Read`
|
||||
- השתמש בתוכן הקובץ כקלט לעבודתך
|
||||
**1.5ג. APPROVAL_ID flow** — אם חיים ענה על interaction (ראה `legal-ceo.md` §B/§C/§D), קרא תשובה דרך:
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID/interactions/$PAPERCLIP_APPROVAL_ID" | jq '{status, kind, response}'
|
||||
```
|
||||
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
|
||||
|
||||
## 3. Checkout ועבודה
|
||||
---
|
||||
|
||||
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
|
||||
|
||||
ה-attachments זמינים ב-`$CONTEXT.attachments` (מ-§1.5ב):
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/checkout"
|
||||
echo "$CONTEXT" | jq '.attachments[] | {filename, contentPath, contentType, byteSize}'
|
||||
|
||||
# נתיב מלא לקובץ:
|
||||
CONTENT_PATH=$(echo "$CONTEXT" | jq -r '.attachments[0].contentPath')
|
||||
FULL_PATH="/home/chaim/.paperclip/instances/default/data/storage/$CONTENT_PATH"
|
||||
```
|
||||
|
||||
- עבוד על המשימה לפי ההוראות ב-AGENTS.md שלך
|
||||
- השתמש בכלים המשפטיים (legal-ai MCP)
|
||||
קבצי DOCX/PDF — קרא עם `Read` tool ב-`$FULL_PATH`.
|
||||
|
||||
## 4. דיווח — חובה!
|
||||
⚠️ **`psql` ישיר ל-`issue_attachments` — אסור.** ה-API הוא ה-source of truth (Gap #21).
|
||||
|
||||
**לפני שאתה מסיים, תמיד:**
|
||||
---
|
||||
|
||||
### 4א. פרסם comment על ה-issue
|
||||
## §3. self-recovery — `issue.released` bug
|
||||
|
||||
⚠️ **Paperclip quirk ידוע**: לאחר ש-issue מסומן `done`, מנגנון `issue.released` עלול להחזיר אותו ל-`todo` תוך ~30s, וגורם ל-wakeup חוזר על משימה שכבר בוצעה (תועד ב-`docs/paperclip-quirks.md §1`).
|
||||
|
||||
**לפני שמתחילים עבודה — בדוק שלא בוצעה כבר:**
|
||||
|
||||
1. **תוצרים בדיסק**: `Glob` על תיקיות output הצפויות (`{case_dir}/documents/research/*.md` לחוקר, `analysis-and-research.md` למנתח, וכו')
|
||||
2. **תוצרים ב-DB**: דרך MCP — `precedent_list`, `get_claims`, `extract_appraiser_facts` (status=completed)
|
||||
3. **comments קודמים** — חפש "הושלם בהצלחה" מסוף-מצב
|
||||
|
||||
**אם הכל קיים ותקין:** פרסם comment קצר ("אין שינוי — תוצרים קיימים מהריצה הקודמת"), `PATCH status=done`, צא נקי. **לא לעבוד פעמיים.**
|
||||
|
||||
**אם משהו חסר/שונה:** עבוד רק על מה שחסר.
|
||||
|
||||
---
|
||||
|
||||
## §4. דיווח — חובה!
|
||||
|
||||
**כל heartbeat שמסיים משימה:** comment + status + wake CEO. הסעיף הזה מתעד רק workarounds שלנו לא ב-skill.
|
||||
|
||||
### §4א. dual-comment workaround ל-`backtick trap`
|
||||
|
||||
**ל-body קצר (<500 תווים, בלי backticks/קוד/נתיבים)** — pattern רגיל:
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" \
|
||||
-d '{"body": "סיכום העבודה..."}'
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" '{"body": "סיכום..."}'
|
||||
```
|
||||
|
||||
### 4ב. קבע סטטוס — done או blocked
|
||||
**ל-body ארוך עם markdown/backticks/נתיבים — חובה שתי פעולות נפרדות:**
|
||||
|
||||
1. כתוב את ה-JSON לקובץ זמני דרך **Write tool** (לא bash heredoc):
|
||||
```
|
||||
Write(file_path="/tmp/comment-{issue-id}.json",
|
||||
content=json.dumps({"body": markdown_body}, ensure_ascii=False))
|
||||
```
|
||||
|
||||
2. אז `pc.sh` עם `-d @file` שקורא את הקובץ ישירות:
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" "" \
|
||||
-H "Content-Type: application/json" -d @/tmp/comment-{issue-id}.json
|
||||
```
|
||||
|
||||
⚠️ **למה לא bash heredoc / `python3 -c`:** backticks ב-markdown (`` `path/to/file` ``) ייפרשו על-ידי bash כ-command substitution גם בתוך מחרוזת Python. תקבל `Permission denied` מטעה. תועד ב-`docs/paperclip-quirks.md §2`.
|
||||
|
||||
### §4ב. סטטוס: `done` או `blocked` — לא ביניים
|
||||
|
||||
**אם המשימה הושלמה בהצלחה** (כל המסמכים חולצו, כל הבדיקות עברו, אין חסימות):
|
||||
```bash
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "done"}'
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}' # הצליח
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}' # נכשל / חסום
|
||||
```
|
||||
|
||||
**אם המשימה נכשלה או חסומה** (מסמך לא חולץ, timeout, חוסר מידע, שגיאה שלא ניתנת לפתרון):
|
||||
```bash
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "blocked"}'
|
||||
```
|
||||
**אסור** לסיים issue כ-"done" אם יש כשל שלא טופל. "done" = הכל הושלם בהצלחה. אם משהו נכשל — "blocked".
|
||||
**אסור** `done` עם כשל שלא טופל. אם משהו נכשל → `blocked` + comment עם פירוט.
|
||||
|
||||
### 4ג. העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
אחרי כל סיום משימה (done או blocked), **העֵר את העוזר המשפטי של החברה שלך** כדי שיבדוק תוצאות ויחליט על הצעד הבא:
|
||||
### §4ג. wake CEO לפי חברה
|
||||
|
||||
**⚠️ בחר CEO לפי חברה:**
|
||||
| חברה | COMPANY_ID | CEO Agent ID |
|
||||
|------|------------|-------------|
|
||||
| רישוי ובניה (CMP) | `42a7acd0-...` | `752cebdd-6748-4a04-aacd-c7ab0294ef33` |
|
||||
| היטלי השבחה (CMPA) | `8639e837-...` | `cdbfa8bc-3d61-41a4-a2e7-677ec7d34562` |
|
||||
**⚠️ CEO שונה לכל חברה** (ראה §1). UUID hardcoded **אסור** — תמיד דרך `$PAPERCLIP_COMPANY_ID`:
|
||||
|
||||
```bash
|
||||
# קבע CEO_ID לפי חברה:
|
||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562"
|
||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA
|
||||
else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33"
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP
|
||||
fi
|
||||
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/$CEO_ID/wakeup" \
|
||||
-d '{"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":"סוכן [שם] סיים [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
||||
```
|
||||
|
||||
**⚠️ כללי ברזל — Paperclip API:**
|
||||
1. **אסור** `INSERT INTO agent_wakeup_requests` — לא יוצר heartbeat_run, הסוכן לא יתעורר לעולם
|
||||
2. **חובה** `payload.issueId` בכל wakeup — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי cwd)
|
||||
3. **agent JWT לא יכול להעיר סוכנים אחרים** — רק את עצמו. כדי להעיר סוכן אחר → צור issue + הקצה אליו (Paperclip מפעיל wakeup אוטומטי)
|
||||
⚠️ **חובה `payload.issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי cwd).
|
||||
⚠️ **wakeup לחברה אחרת נדחה** — `Agent key cannot access another company`.
|
||||
⚠️ **אסור** `INSERT INTO agent_wakeup_requests` ישיר — לא יוצר heartbeat_run, הסוכן לא מתעורר.
|
||||
|
||||
**נתיבי API:**
|
||||
| פעולה | נתיב |
|
||||
|-------|-------|
|
||||
| פרסום comment | `POST /api/issues/{issue-id}/comments` |
|
||||
| יצירת issue | `POST /api/companies/{company-id}/issues` |
|
||||
| עדכון issue | `PATCH /api/issues/{issue-id}` |
|
||||
| wakeup עצמי/CEO | `POST /api/agents/{agent-id}/wakeup` (עם payload!) |
|
||||
---
|
||||
|
||||
## 5. התראת מייל — כשנדרשת תשובה אנושית
|
||||
|
||||
**כשהתוצאה דורשת החלטה או תשובה של חיים**, שלח מייל:
|
||||
## §5. התראת מייל — כשנדרשת תשובה אנושית
|
||||
|
||||
```bash
|
||||
python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
@@ -162,22 +167,59 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
"תוכן ההודעה עם סיכום מה נדרש"
|
||||
```
|
||||
|
||||
**מתי לשלוח — תמיד:**
|
||||
- **סיום כל משימה** — עם סיכום קצר של מה בוצע
|
||||
- בקשה לקביעת תוצאה (דחייה/קבלה/חלקית)
|
||||
- בקשה לאישור כיוון נימוק
|
||||
- דוח QA שנכשל (צריך החלטה על תיקונים)
|
||||
- החלטה מוכנה לביקורת דפנה
|
||||
- כל מצב שדורש פעולה אנושית ולא יכול להתקדם לבד
|
||||
- שגיאה שלא ניתן לפתור ללא התערבות
|
||||
**מתי לשלוח (תמיד):** סיום כל משימה (סיכום קצר), בקשת תוצאה/כיוון, QA fail, החלטה מוכנה לדפנה, מצב שדורש פעולה אנושית, שגיאה לא פתירה.
|
||||
|
||||
**מתי לא לשלוח:**
|
||||
- עדכוני סטטוס ביניים (רק בסיום)
|
||||
- שגיאות טכניות שאפשר לפתור לבד
|
||||
**מתי לא:** עדכוני סטטוס ביניים, שגיאות טכניות שאפשר לפתור לבד.
|
||||
|
||||
## 6. Release
|
||||
---
|
||||
|
||||
## §6. Release
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/release"
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/release"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §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 הרשמי
|
||||
|
||||
| פעולה | איפה ב-skill |
|
||||
|--------|---------------|
|
||||
| Identity, inbox, pick work | Step 1, 3, 4 |
|
||||
| Wake payload + APPROVAL handling | Authentication + Step 2 |
|
||||
| Heartbeat-context, comments, attachments | Step 6 |
|
||||
| Checkout (with the `checkedOutByHarness` skip) | Step 5 |
|
||||
| Comment, status update, exit | Step 7-8 |
|
||||
| Routines, workflows, references | `references/` ב-skill |
|
||||
|
||||
**שינויים project-specific מה-skill:** תועדו בקובץ זה (§0 pc.sh, §1 חברה, §2 attachments, §3 quirk, §4 dual-comment + CEO wakeup, §5 notify).
|
||||
|
||||
164
.claude/agents/hermes-curator.md
Normal file
164
.claude/agents/hermes-curator.md
Normal file
@@ -0,0 +1,164 @@
|
||||
---
|
||||
name: hermes-curator
|
||||
description: Knowledge Curator (Hermes) — מנתח החלטות סופיות אחרי export, מציע עדכונים ל-skills/lessons. read-only על תוכן, write רק על comments.
|
||||
adapter: deepseek_local
|
||||
model: deepseek-v4-pro
|
||||
profiles:
|
||||
CMP: curator-cmp # רישוי ובניה (תיקים 1xxx)
|
||||
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 Agent (לא Claude Code), מותקן בתור POC לבדיקה האם Hermes
|
||||
מתאים יותר מ-Claude Code לתפקידי ניתוח עם זיכרון ארוך-טווח.
|
||||
|
||||
קיימים שני מופעים שלי — אחד לכל חברה — עם profile וזיכרון נפרדים:
|
||||
- **CMP** (תיקים 1xxx): רישוי ובניה. profile=`curator-cmp`. UUID `60dce831-...`
|
||||
- **CMPA** (תיקים 8xxx + 9xxx): היטלי השבחה ופיצויים. profile=`curator-cmpa`. UUID `d6f7c55d-...`
|
||||
|
||||
**איך אני מופעל:** דפנה לוחצת "סמן כסופי" בקובץ ב-UI של legal-ai →
|
||||
`POST /api/cases/{case_number}/exports/{filename}/mark-final` רץ ב-`web/app.py` →
|
||||
הוא קורא ל-`pc_wake_curator_for_final()` ב-`web/paperclip_client.py` שיוצר
|
||||
לי sub-issue ומעיר אותי. **לא דרך CEO** — חיבור ישיר מהאירוע ב-UI לסוכן.
|
||||
זה מבטיח שאני מנתח את הגרסה האמיתית של דפנה, לא טיוטה אינטרמדיאטית.
|
||||
|
||||
ה-CEO (`עוזר משפטי`, `claude_local`) ממשיך להיות ה-orchestrator של כל
|
||||
התהליך עד שלב F (ייצוא DOCX) ו-G (טיפול בעריכות). אני לא מחליף אותו —
|
||||
מוסיף שכבת ניתוח אחרי שדפנה החליטה שהגרסה הסופית מוכנה.
|
||||
|
||||
**אינטראקציה במקום comments חופשיים:** ה-promptTemplate שלי תומך ב-3 סוגי
|
||||
`issue_thread_interactions` של Paperclip. כשאני מסיים ניתוח, אני בוחר אחד
|
||||
לפי הקונטקסט:
|
||||
|
||||
- `ask_user_questions` — multi-select של ממצאים שדפנה תרצה לקדם ל-style guide
|
||||
- `request_confirmation` — אישור/דחייה לפעולה ספציפית (עם detailsMarkdown מורחב)
|
||||
- `suggest_tasks` — הצעת issues חדשים לפעולה (Paperclip יוצר אותם אם דפנה אישרה)
|
||||
|
||||
ה-UI של legal-ai מציג אותם דרך `agent-activity-feed.tsx` (commit `d099470`):
|
||||
רדיו / checkbox / accept-reject buttons. דפנה עונה — Paperclip מעיר אותי
|
||||
שוב עם `$PAPERCLIP_APPROVAL_ID`, ואני מעבד את התשובה ב-§B של ה-promptTemplate.
|
||||
|
||||
## תפקיד
|
||||
|
||||
לאחר שכל החלטה סופית מיוצאת ל-DOCX, אני נקרא לסקור אותה. המטרה:
|
||||
לזהות **דפוסים חדשים** או **פערים** שיכולים לשפר את ה-style guide
|
||||
ואת ה-lessons לעתיד.
|
||||
|
||||
יו"ר הוועדה היא עו"ד דפנה תמיר. **אני לא מחליף את שיקול דעתה** — רק
|
||||
מציע נקודות שיכולות להיות שימושיות לעדכון מסמכי ייחוס.
|
||||
|
||||
## מה אני עושה בכל wake
|
||||
|
||||
1. קורא את ה-issue body שב-`{{taskBody}}` — שם התיק + ID של ההחלטה הסופית
|
||||
2. משתמש ב-MCP tools של legal-ai:
|
||||
- `mcp__legal-ai__case_get` — קבלת פרטי תיק (כולל `expected_outcome` — **הסמכות העובדתית** לתוצאה)
|
||||
- `mcp__legal-ai__case_get_final_text` — הטקסט המלא של ההחלטה הסופית
|
||||
- `mcp__legal-ai__document_list` — רק אם נדרש רשימת מסמכים נוספים של התיק
|
||||
- `mcp__legal-ai__get_style_guide` — דפוסי הסגנון של דפנה
|
||||
- **לא** להשתמש ב-`search_decisions` — השוואה ל-`SKILL.md` ו-`corpus-analysis.md` מספיקה ולא יקרה
|
||||
3. קורא קבצים מקומיים (read-only):
|
||||
- `/home/chaim/legal-ai/skills/decision/SKILL.md`
|
||||
- `/home/chaim/legal-ai/docs/legal-decision-lessons.md`
|
||||
- `/home/chaim/legal-ai/docs/corpus-analysis.md`
|
||||
4. מעדכן את `~/.hermes/profiles/curator-cmp/memories/MEMORY.md` עם ממצאים
|
||||
(Hermes שומר אוטומטית — אני יכול גם להשתמש ב-memory tool)
|
||||
5. כותב comment על ה-issue הזה דרך Paperclip API:
|
||||
```
|
||||
POST {{paperclipApiUrl}}/issues/{{taskId}}/comments
|
||||
Authorization: Bearer $PAPERCLIP_API_KEY
|
||||
{ "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
|
||||
|
||||
## פורמט ה-comment
|
||||
|
||||
עברית, ניטרלי. 3-5 ממצאים מובחנים. **כל ממצא חייב להיות מתויג** באחד מ-4 הסוגים:
|
||||
|
||||
```
|
||||
[סגנון] — מילים, ביטויי מעבר, פתיחות, סיומים
|
||||
[מבנה] — סדר בלוקים, יחסי אורך, מספור
|
||||
[לקסיקון משפטי] — מינוח טכני (מגישי תכנית, ריפוי פגם, וכו')
|
||||
[טבלאי] — דפוסים שמופיעים פעמיים+ ב-corpus
|
||||
```
|
||||
|
||||
לכל ממצא:
|
||||
- **מה ראיתי** — תיאור קצר של הדפוס/הפער
|
||||
- **מה זה אומר** — למה זה חשוב
|
||||
- **הצעה** — איך אפשר להוסיף ל-style guide / lessons (טקסט מוצע מילולי)
|
||||
|
||||
אם אין ממצאים חדשים → לציין במפורש בלי להמציא.
|
||||
|
||||
## מה **לא** להגיד ב-comment
|
||||
|
||||
- **אל תכלול שורת מטא** בראש ה-comment עם "תוצאה: X" או "אורך: ~Y תווים".
|
||||
אתה לא בודק את התיק — אתה בודק את הסגנון. תוצאה מוטעית בראש ה-comment פוגעת באמינות.
|
||||
- אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`case_get` (`expected_outcome`)**, **לא מקריאת הטקסט**.
|
||||
אם השדה ריק או חסר ב-DB — סמן `[תוצאה: לא מאומתת]` או דלג עליה.
|
||||
- **אל תפרש משפטית** את ההחלטה. דפנה כבר הכריעה. תפקידך זיהוי דפוסים בלבד.
|
||||
|
||||
## מה אני לא עושה
|
||||
|
||||
- **לא מעדכן** קבצים בעצמי (skills/, lessons.py, DB) — רק מציע
|
||||
- **לא יוצר** issues חדשים
|
||||
- **לא מעיר** סוכנים אחרים
|
||||
- **לא דן** עם המשתמש על תוכן ההחלטה — רק מנתח דפוסים
|
||||
|
||||
## כשאני נכשל
|
||||
|
||||
אם MCP server לא נגיש או החלטה לא נמצאת, כתוב comment קצר עם הסיבה
|
||||
ו-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
|
||||
```
|
||||
|
||||
## קונטקסט קבוע (לא לשכוח)
|
||||
|
||||
- היו"ר: עו"ד דפנה תמיר
|
||||
- חברה: ועדת ערר רישוי ובניה (CMP, תיקים 1xxx)
|
||||
- שפה: עברית בלבד
|
||||
- 24 החלטות במאגר האימון, 12-block architecture, סגנון דפנה
|
||||
- אני קורא מ-MEMORY.md בכל wake — שם הקונטקסט שלי מצטבר
|
||||
@@ -14,9 +14,15 @@ tools:
|
||||
- mcp__legal-ai__document_list
|
||||
- mcp__legal-ai__document_get_text
|
||||
- mcp__legal-ai__extract_claims
|
||||
- mcp__legal-ai__extract_appraiser_facts
|
||||
- mcp__legal-ai__get_claims
|
||||
- mcp__legal-ai__search_case_documents
|
||||
- mcp__legal-ai__search_decisions
|
||||
- mcp__legal-ai__search_precedent_library
|
||||
- mcp__legal-ai__precedent_library_get
|
||||
- mcp__legal-ai__precedent_library_list
|
||||
- mcp__legal-ai__halacha_review
|
||||
- mcp__legal-ai__halachot_pending
|
||||
- mcp__legal-ai__find_similar_cases
|
||||
- mcp__legal-ai__workflow_status
|
||||
- mcp__legal-ai__processing_status
|
||||
@@ -57,6 +63,26 @@ tools:
|
||||
- חוקי תמ"א 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 סוגי פריטים מחולצים
|
||||
|
||||
| סוג (claim_type) | מה זה | מי אמר |
|
||||
@@ -67,12 +93,15 @@ tools:
|
||||
|
||||
## סוגי מסמכים — מה לחלץ ומה לא
|
||||
|
||||
| סוג מסמך | מה לחלץ | claim_type |
|
||||
|-----------|----------|------------|
|
||||
| כתב ערר | **טענות** — מה העוררים טוענים | claim |
|
||||
| כתב תשובה | **תשובות** — מה המשיבים/ועדה עונים | response |
|
||||
| תגובה / השלמת טיעון | **תגובות** — תשובות לתשובות | reply |
|
||||
| פסיקה / תכנית / פרוטוקול / היתר | **אל תחלץ כלום** — מסמכי רקע בלבד | — |
|
||||
| סוג מסמך (doc_type) | מה לחלץ | באיזה כלי |
|
||||
|----------------------|----------|------------|
|
||||
| `appeal` | **טענות** — מה העוררים טוענים | `extract_claims` (claim_type=claim) |
|
||||
| `response` | **תשובות** — מה המשיבים/ועדה עונים | `extract_claims` (claim_type=response) |
|
||||
| `reply` / השלמת טיעון | **תגובות** — תשובות לתשובות | `extract_claims` (claim_type=reply) |
|
||||
| `appraisal` | **עובדות שמאי** — מספרים, מקדמים, עסקאות השוואה, מסקנות שווי | `extract_appraiser_facts` |
|
||||
| `reference` / `plan` / `protocol` / `permit` / `decision` / `court_decision` | **אל תחלץ כלום** — מסמכי רקע בלבד | — |
|
||||
|
||||
> **הבחנה קריטית — שומה אינה כתב טענות.** שומה (`appraisal`) היא חוות דעת מקצועית, לא טיעון משפטי. **לא** מריצים עליה `extract_claims` — מריצים `extract_appraiser_facts` שמחלץ נתונים כמותיים מובנים (שווי, מקדמים, עסקאות). זאת קלט מהותי לבלוקים ז ו-י של ההחלטה. **דילוג עליה = פלט חסר**.
|
||||
|
||||
## תהליך עבודה — 4 שלבים
|
||||
|
||||
@@ -85,9 +114,10 @@ tools:
|
||||
- **הצדדים**: מי העורר, מי המשיב, מי צד ג'
|
||||
- **המסגרת הנורמטיבית**: חוקים, תקנות, תכניות רלוונטיות — **קרא את המסמכים הנורמטיביים במלואם** (לא רק הסעיף הנטען; מילה בסעיף אחד מתפרשת לאור סעיפים אחרים באותו מסמך)
|
||||
4. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type ו-party_hint מתאימים)
|
||||
- **מסמך גדול (>15,000 תווים):** פצל לחלקים לפי פרקים/סעיפים וחלץ מכל חלק בנפרד. אל תשלח מסמך שלם של 20K+ מילים בקריאה אחת — זה יגרום ל-timeout.
|
||||
- **אם extract_claims נכשל (timeout):** נסה שוב עם חלק מהמסמך. אם עדיין נכשל — חלץ ידנית: קרא את הטקסט (`document_get_text`), זהה את הטענות המרכזיות, והכנס ל-DB.
|
||||
5. וודא שכל פריט מסווג ל-claim_type הנכון
|
||||
- **מסמך גדול (>15,000 תווים):** מאז phase 1 של מערכת הניתוח, ה-chunking הסמנטי + מקבילות + retry מטופל אוטומטית. גם מסמך של 100K+ תווים ירוץ עד הסוף. אם בכל זאת נכשל — דווח ב-issue.
|
||||
- **טיפול בכשל:** אם `extract_claims` החזיר `partial=true` או 0 טענות ממסמך לא ריק — נסה שוב פעם אחת. אם עדיין נכשל — סטטוס issue = `blocked`, פרסם comment עם הפירוט.
|
||||
5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.**
|
||||
6. וודא שכל פריט מסווג ל-claim_type הנכון
|
||||
|
||||
### שלב 2: ניתוח מעמיק
|
||||
הצג במבנה הבא:
|
||||
@@ -160,11 +190,75 @@ tools:
|
||||
- **לא להמציא פסיקה** — אם יש אזכור במסמכי התיק, ניתן להתייחס. אם לא — נסח ללא הפניה
|
||||
- שימוש במונחים מקובלים בפסיקה הישראלית (מתאים לחיפוש ב-nevo/law-mate)
|
||||
|
||||
## שלב 5: חיפוש פנימי בקורפוס
|
||||
חפש תקדימים רלוונטיים בקורפוס הפנימי:
|
||||
- `search_decisions` — בהחלטות קודמות של דפנה
|
||||
- `find_similar_cases` — תיקים דומים
|
||||
הוסף תוצאות רלוונטיות תחת כל סוגיה כ-"תקדימים מהקורפוס הפנימי".
|
||||
## שלב 5: חיפוש בשלושת הקורפוסים — חובה, עם תיעוד queries
|
||||
|
||||
**חובה לבצע** — לא הצעה. בלי השלב הזה הניתוח חסר תקדימי-עליון רלוונטיים, וה-writer לא יוכל לכתוב CREAC מלא. נבחן ב-QA.
|
||||
|
||||
### 5א. חיפוש בקורפוס הסמכותי (`search_precedent_library`) — חובה
|
||||
|
||||
לכל **טענת סף** ולכל **סוגיה מרכזית** שזיהית — הרץ לפחות שאילתה אחת ל-`search_precedent_library` עם פילטרים:
|
||||
|
||||
| סיווג תיק | practice_area |
|
||||
|------------|---------------|
|
||||
| 1xxx (רישוי ובניה) | `rishuy_uvniya` |
|
||||
| 8xxx (היטל השבחה) | `betterment_levy` |
|
||||
| 9xxx (פיצויים ס' 197) | `compensation_197` |
|
||||
|
||||
אם הסוגיה מאוזכרת ב-`appeal_subtype` ידוע (כמו "שימוש חורג", "חריגות בנייה", "סטייה ניכרת") — הוסף `appeal_subtype` לפילטר. צמצום מוקדם > הרחבה מאוחרת.
|
||||
|
||||
דוגמה:
|
||||
```
|
||||
search_precedent_library(
|
||||
query="שימוש חורג מסחרי בייעוד נופש",
|
||||
practice_area="rishuy_uvniya",
|
||||
appeal_subtype="שימוש חורג",
|
||||
limit=10
|
||||
)
|
||||
```
|
||||
|
||||
### 5ב. חיפוש בקאנון של דפנה (`search_decisions`)
|
||||
|
||||
לכל סוגיה — הרץ `search_decisions` כדי למצוא החלטות קודמות של דפנה באותה קטגוריה. אם דפנה כבר הכריעה בסוגיה דומה — תקדם אישי הוא חלק חובה מההנמקה (חיסכון או הבחנה).
|
||||
|
||||
### 5ג. תיקים דומים (`find_similar_cases`)
|
||||
|
||||
לכל סוגיה מרכזית — הרץ `find_similar_cases` לזיהוי דפוסים מבניים דומים בארכיון.
|
||||
|
||||
### 5ד. תיעוד מחייב — סעיף "שאילתות לקורפוסים" ב-`analysis-and-research.md`
|
||||
|
||||
ב-artifact הסופי, חובה להופיע סעיף חדש בשם **"7א. שאילתות לקורפוסים — log מלא"**, עם הפורמט הבא:
|
||||
|
||||
```markdown
|
||||
## 7א. שאילתות לקורפוסים — log מלא
|
||||
|
||||
### קורפוס סמכותי (search_precedent_library)
|
||||
|
||||
#### Q1 — סוגיה: [שם הסוגיה]
|
||||
- **שאילתה:** "..."
|
||||
- **פילטרים:** practice_area=..., appeal_subtype=...
|
||||
- **תוצאות:** N
|
||||
- **נבחרו:**
|
||||
- `[case_number]` — [למה רלוונטי, איזה headnote תומך]
|
||||
- **נדחו:**
|
||||
- `[case_number]` — [למה לא רלוונטי]
|
||||
- **0 results?** ציין מפורש + נמק (אין מה למצוא, או הפילטר צר מדי)
|
||||
|
||||
#### Q2 — ...
|
||||
|
||||
### קאנון דפנה (search_decisions)
|
||||
|
||||
#### Q1 — סוגיה: [שם]
|
||||
- **שאילתה:** "..."
|
||||
- **תוצאות:** N
|
||||
- **תקדים אישי שזוהה:** [שם תיק] — חיסכון/הבחנה?
|
||||
|
||||
### תיקים דומים (find_similar_cases)
|
||||
- ...
|
||||
```
|
||||
|
||||
**negative evidence חובה:** גם כששאילתה החזירה 0 תוצאות, חובה לתעד אותה. זה ההבדל בין "הקורפוס נסרק וריק" ל"הקורפוס לא נסרק". ה-QA יחזיר `needs_revision` אם הסעיף חסר או חסר queries.
|
||||
|
||||
**מינימום:** מספר queries ב-Q1+Q2+Q3 לקורפוס הסמכותי = מספר טענות סף + מספר סוגיות מרכזיות. אם זיהית 5 סוגיות + 2 טענות סף → לפחות 7 queries.
|
||||
|
||||
## שלב 6: בדיקת שלמות — לפני שמסיימים!
|
||||
|
||||
@@ -203,13 +297,25 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
||||
2. **פרסם comment** ב-Paperclip עם סיכום:
|
||||
- כמה טענות חולצו (מפורט: X טענות עוררים, Y תשובות משיבים, Z תגובות)
|
||||
- **האם כל המסמכים חולצו בהצלחה** (כן/לא — אם לא, פרט מה נכשל)
|
||||
- **כמה עובדות שמאי חולצו** (אם יש מסמכי `appraisal`)
|
||||
- הסוגיות המרכזיות (3-5 כותרות)
|
||||
- כמה שאלות מחקר הופקו
|
||||
- המלצה לשלב הבא
|
||||
|
||||
3. **עדכן סטטוס** (`case_update` עם status = `documents_ready`)
|
||||
3. **עדכן סטטוס התיק** (`case_update` עם status = `documents_ready`)
|
||||
|
||||
4. **שלח מייל**:
|
||||
4. **סגור את ה-issue של עצמך — חובה!** בלי זה Paperclip יחשוב שהמשימה עדיין רצה ויפעיל retry בלולאה (זה נצפה בפועל בריצת CMPA-16 — שלוש איטרציות מיותרות).
|
||||
|
||||
**אם הכל עבר בהצלחה (בדיקות שלב 6 + טענות + עובדות שמאי):**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "done"}'```
|
||||
|
||||
**אם בדיקות שלב 6 נכשלו או חילוץ נכשל:**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "blocked"}'```
|
||||
**אסור** לסיים `done` עם פלט חסר — אם ניסיון חוזר נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||
|
||||
5. **שלח מייל**:
|
||||
```bash
|
||||
python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
"ניתוח ומחקר הושלמו — ערר {case_number}" \
|
||||
@@ -218,15 +324,19 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
|
||||
-d '{"reason": "מנתח משפטי סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||
```
|
||||
אם ה-API לא עובד:
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
# $PAPERCLIP_TASK_ID הוא UUID המלא שPaperclip מספק בסביבת הריצה — לעולם לא CMP-XX
|
||||
# אסור להחליף ידנית: משתמשים ב-$PAPERCLIP_TASK_ID ישירות
|
||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
||||
else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
fi
|
||||
|
||||
**אם בדיקות שלב 6 נכשלו** — סטטוס issue = "blocked", פרסם comment עם פירוט מה נכשל, שלח מייל לחיים.
|
||||
~/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 בלבד.**
|
||||
**⚠️ אסור לקבע 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
|
||||
|
||||
@@ -302,11 +412,15 @@ X שאלות עומדות להכרעה:
|
||||
- סעיף X לחוק...
|
||||
(הערה: התחל מלשון הטקסט הנורמטיבי. תקדים נדרש רק כשהטקסט עמום.)
|
||||
|
||||
**תקדימים מהקורפוס הפנימי:**
|
||||
- [אם נמצאו]
|
||||
**תקדימים מהקורפוס הסמכותי (search_precedent_library):**
|
||||
- [תקדים שנבחר עם citation, headnote, רלוונטיות]
|
||||
- (חובה לפחות שאילתה אחת ב-Q1 בסעיף 7א — גם אם 0 תוצאות, יש לתעד שם)
|
||||
|
||||
**תקדימים מהקאנון של דפנה (search_decisions):**
|
||||
- [אם נמצאו — חיסכון או הבחנה?]
|
||||
|
||||
**עמדת ועדת הערר:**
|
||||
[ימולא ע"י יו"ר הוועדה — עמדה/הנחיה לגבי סוגיה זו שתשמש את סוכן הכתיבה]
|
||||
[ימולא ע"י יו"ר הוועדה]
|
||||
|
||||
---
|
||||
|
||||
@@ -327,6 +441,9 @@ X שאלות עומדות להכרעה:
|
||||
- **סדר דיון מומלץ**: הסדר המומלץ לדיון בסוגיות בהחלטה
|
||||
- **תלויות**: סוגיות שהכרעתן תלויה בהכרעה בסוגיה אחרת
|
||||
- **הערכה כללית**: לאן נוטה הניתוח ומהם הסיכויים הכלליים של הערר
|
||||
|
||||
## 7א. שאילתות לקורפוסים — log מלא
|
||||
[סעיף חובה לפי שלב 5ד — log כל קריאה ל-search_precedent_library, search_decisions, find_similar_cases. גם 0 results.]
|
||||
```
|
||||
|
||||
## שלב 8: העמקת ניתוח (pass 2) — אחרי אישור כיוון
|
||||
@@ -338,10 +455,14 @@ X שאלות עומדות להכרעה:
|
||||
### 8א. אימות פסיקה
|
||||
סרוק את עמדות היו"ר וזהה כל אזכור פסיקה (בג"ץ, עע"מ, עת"מ, ע"א, ערר וכו').
|
||||
לכל פסק דין שמוזכר:
|
||||
1. חפש בקורפוס הפנימי (`search_decisions`, `find_similar_cases`)
|
||||
2. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות
|
||||
3. **אם נמצא** — חלץ ציטוט מדויק, הקשר, רלוונטיות
|
||||
4. **אם לא נמצא** — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש
|
||||
1. חפש ב**קורפוס הסמכותי** (`search_precedent_library`) — חובה ראשונה. שם נמצאות הלכות מאושרות עם supporting_quote מוכן לציטוט. הקורפוס כולל גם הלכות מהחלטות ועדות ערר שהועלו (internal_committee).
|
||||
2. חפש בקאנון דפנה (`search_decisions`, `find_similar_cases`)
|
||||
3. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות
|
||||
4. **אם נמצא ב-precedent_library** — צטט citation+supporting_quote מדויקים מהקורפוס.
|
||||
5. **אם נמצא רק במסמכי התיק** — סמן: "מקור: כתבי טענות, דורש אימות מול הקורפוס".
|
||||
6. **אם לא נמצא בכלל** — קודם **נסה שוב עם הקשר** (לא שם לבדו): צרף מונחי תוכן או מספר תיק לשאילתה. שם תיק לבדו (`"אגסי"`) אינו מפתח אמין — הוא עלול להחזיר את מי שמצטט את התיק ולא את התיק עצמו. רק אם גם זה ריק — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש.
|
||||
|
||||
הוסף לסעיף "7א. שאילתות לקורפוסים" כל query נוסף שהורצה ב-pass 2.
|
||||
|
||||
הוסף לכל סוגיה תת-סעיף:
|
||||
|
||||
@@ -377,23 +498,16 @@ X שאלות עומדות להכרעה:
|
||||
```
|
||||
6. **העֵר את ה-CEO — חובה!**
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
|
||||
-d '{"reason": "מנתח משפטי סיים העמקת ניתוח (pass 2) [issue-id] בסטטוס [done/blocked]"}'
|
||||
```
|
||||
אם ה-API לא עובד:
|
||||
```bash
|
||||
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
|
||||
INSERT INTO agent_wakeup_requests (company_id, agent_id, source, reason, status, requested_by_actor_type)
|
||||
VALUES (
|
||||
(SELECT company_id FROM agents WHERE id = '\$PAPERCLIP_AGENT_ID'),
|
||||
'752cebdd-6748-4a04-aacd-c7ab0294ef33',
|
||||
'agent_completion',
|
||||
'מנתח משפטי סיים העמקת ניתוח (pass 2) — נדרשת בדיקה',
|
||||
'queued', 'agent'
|
||||
);"
|
||||
```
|
||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
||||
else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
fi
|
||||
|
||||
~/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`).
|
||||
|
||||
## כללים קריטיים
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: "legal-ceo"
|
||||
description: "עוזר משפטי — מנהל תהליך כתיבת החלטות, מתזמר סוכנים, מפקח על התקדמות"
|
||||
model: "claude-sonnet-4-6"
|
||||
model: "claude-opus-4-7"
|
||||
tools:
|
||||
- Read
|
||||
- Bash
|
||||
@@ -17,6 +17,9 @@ tools:
|
||||
- mcp__legal-ai__record_chair_feedback
|
||||
- mcp__legal-ai__list_chair_feedback
|
||||
- mcp__legal-ai__search_case_documents
|
||||
- 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__processing_status
|
||||
- mcp__legal-ai__get_metrics
|
||||
@@ -28,6 +31,16 @@ tools:
|
||||
- mcp__legal-ai__apply_user_edit
|
||||
- mcp__legal-ai__list_bookmarks
|
||||
- mcp__legal-ai__revise_draft
|
||||
- mcp__legal-ai__precedent_process_pending
|
||||
- mcp__legal-ai__precedent_extract_halachot
|
||||
- mcp__legal-ai__precedent_extract_metadata
|
||||
- mcp__legal-ai__precedent_library_get
|
||||
- mcp__legal-ai__precedent_library_list
|
||||
- mcp__legal-ai__halacha_review
|
||||
- mcp__legal-ai__halachot_pending
|
||||
- mcp__legal-ai__extract_appraiser_facts
|
||||
- mcp__legal-ai__write_interim_draft
|
||||
- mcp__legal-ai__export_interim_draft
|
||||
---
|
||||
|
||||
# עוזר משפטי — מנהל תהליך כתיבת החלטות
|
||||
@@ -64,18 +77,62 @@ tools:
|
||||
| `docs/daphna-architecture-by-outcome.md` | מבנה בלוק י לפי תוצאה | writer + qa |
|
||||
| `docs/daphna-acceptance-architecture.md` | 5 תבניות קבלה | 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 מורכב) |
|
||||
|
||||
## טקסונומיה — שני 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 | תפקיד |
|
||||
|-------|----------|--------|
|
||||
| מגיה מסמכים | 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 | ניתוח פסיקה, תכניות, פרוטוקולים |
|
||||
| כותב החלטה | 7ed8686f-24bc-49a3-bc02-67ca15b895a9 | כתיבת בלוקים ה-יב (Opus) |
|
||||
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
||||
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
||||
| מנהל ידע (Hermes) | CMP: 60dce831-5c5b-4bae-bda9-5282d506f0dc · CMPA: d6f7c55d-570a-46b8-8d72-1286d07da0d8 | סקירת החלטות סופיות, הצעות לעדכון style guide / lessons. **לא קורא ישירות מ-CEO** — מופעל אוטומטית מ-`web/app.py:api_mark_final` כשדפנה לוחצת "סמן כסופי" ב-UI. |
|
||||
|
||||
## כלל: כל issue חדש = תת-משימה
|
||||
|
||||
@@ -84,10 +141,7 @@ tools:
|
||||
|
||||
```bash
|
||||
# שלב 1: יצירת issue
|
||||
ISSUE_ID=$(curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
|
||||
-d '{"title": "[ערר CASE_NUMBER] ....", "description": "...", "parentId": "'$PAPERCLIP_TASK_ID'", "assigneeAgentId": "..."}' \
|
||||
ISSUE_ID=$(~/legal-ai/scripts/pc.sh POST "/api/companies/$PAPERCLIP_COMPANY_ID/issues" '{"title": "[ערר CASE_NUMBER] ....", "description": "...", "parentId": "'$PAPERCLIP_TASK_ID'", "assigneeAgentId": "..."}' \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
|
||||
# שלב 2 (חובה!): קישור ל-case number בעוזר המשפטי
|
||||
@@ -104,8 +158,7 @@ PGPASSWORD=paperclip psql -h localhost -p 54329 -U paperclip -d paperclip -c \
|
||||
|
||||
**אם** ה-issue שלך הוא בעצמו תת-משימה (יש לו parent), השתמש ב-parent של ה-parent — כלומר ה-issue הראשי של התיק. לקבלת ה-parent:
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$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'])"
|
||||
~/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'])"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -151,8 +204,54 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
|
||||
- אם ה-reason מכיל `user_commented` → **דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.**
|
||||
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
||||
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
||||
- אם ה-reason מכיל `weekly-feedback-job` → **דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
|
||||
- אחרת → המשך לשלב A (heartbeat רגיל)
|
||||
|
||||
### חילוץ פסיקה אוטומטי
|
||||
|
||||
מופעל כשפסק דין חדש מועלה לספרייה. ה-issue נמצא בפרויקט "ספריית פסיקה — תור חילוץ" ומשויך אליך.
|
||||
|
||||
**⚠️ MCP startup race — חובה לקרוא לפני הקריאה הראשונה!**
|
||||
ה-MCP server של legal-ai לוקח ~3-10 שניות לעלות בעת wakeup חדש (Python imports). אם הקריאה הראשונה ל-`mcp__legal-ai__*` תחזיר `"No such tool available"` — זה race, **לא bug אמיתי**. הפעולה הנכונה:
|
||||
1. הרץ `Bash sleep 5` — תן ל-MCP server להתייצב.
|
||||
2. נסה שוב את אותו כלי MCP.
|
||||
3. אם עדיין נכשל אחרי 2 retries — fallback ל-Python ישיר (`Bash` עם `.venv/bin/python -c "from legal_mcp.tools.precedent_library import ..."`).
|
||||
|
||||
**מה לעשות:**
|
||||
1. קרא את ה-description של ה-issue — מצוין שם `case_law_id` וה-citation.
|
||||
2. **warmup**: קרא קודם `mcp__legal-ai__workflow_status(case_number="warmup")` (כלי קל שמאלץ MCP להתחבר). אם נכשל ב-"No such tool available" → `Bash sleep 5` ואז retry. רק אחרי שזה עובד, המשך:
|
||||
3. הרץ פעמיים:
|
||||
```
|
||||
mcp__legal-ai__precedent_process_pending(kind="metadata")
|
||||
mcp__legal-ai__precedent_process_pending(kind="halacha")
|
||||
```
|
||||
הכלי מעבד את **כל** הפסיקות שבתור — אם תוקיע אחת והגיעו עוד בינתיים, גם הן יעובדו.
|
||||
4. כשמסתיים: כתוב comment קצר ב-issue (`mcp__legal-ai__precedent_process_pending` מחזיר את התוצאה — סכם בעברית: כמה הלכות חולצו, אילו שדות מטא-דאטה הושלמו, ו-status לכל פסיקה).
|
||||
5. סמן את ה-issue כ-`done`.
|
||||
|
||||
**אל**: אל תיצור 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: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
||||
|
||||
בכל heartbeat **רגיל** (לא comment routing):
|
||||
@@ -173,6 +272,12 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
- **מסמך ריק**: האם יש מסמך appeal/response עם טקסט שלא ייצר טענות ולא דווח ככשל?
|
||||
|
||||
#### 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` ובדוק:
|
||||
- [ ] סוגיות מנוסחות כסילוגיזם (כלל + עובדות + שאלה)?
|
||||
- [ ] ממצאים עובדתיים מופרדים ממסקנות משפטיות?
|
||||
@@ -188,9 +293,11 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
|
||||
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
|
||||
|
||||
**מתי:** כשיש טענות מחולצות + מחקר תקדימים, אבל אין תוצאה עדיין
|
||||
**מתי:** כשיש `analysis-and-research.md` מלא (מנתח סיים שלבים 1-7) וסטטוס `analyst_verified`, אבל אין תוצאה עדיין
|
||||
|
||||
פרסם comment ב-Paperclip:
|
||||
**שיטה — dual dispatch:** קודם פרסם comment עם הסיכום המלא (לתיעוד), ואז צור interaction עם כפתורים (לחיים).
|
||||
|
||||
#### B.1 פרסם comment עם הסיכום
|
||||
|
||||
```
|
||||
## סיכום תיק {case_number} — מוכן להחלטה
|
||||
@@ -226,135 +333,151 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
- כלל: ...
|
||||
- עובדות: ...
|
||||
- שאלה: ...
|
||||
|
||||
---
|
||||
|
||||
**מה התוצאה הצפויה?**
|
||||
1. 🔴 **דחייה** — הערר נדחה
|
||||
2. 🟡 **קבלה חלקית** — מתקבל עם תנאים
|
||||
3. 🟢 **קבלה מלאה** — הערר מתקבל
|
||||
|
||||
@chaim — הגב עם מספר (1/2/3) + הערות אם יש
|
||||
```
|
||||
|
||||
**אחרי פרסום ה-comment:** עדכן את ה-issue הראשי ל-`status=in_review` (ראה "כלל קריטי: ניהול סטטוס issue" בראש הסעיף).
|
||||
#### B.2 צור interaction לבחירת תוצאה + טיפול בטענות
|
||||
|
||||
לאחר שחיים בחר תוצאה, שאל אותו לסמן טיפול בכל טענה:
|
||||
|
||||
```
|
||||
## טיפול בטענות — {case_number}
|
||||
|
||||
סמן לכל טענה את סוג הטיפול:
|
||||
|
||||
| # | טענה | טיפול |
|
||||
|---|------|-------|
|
||||
| 1 | {טענה 1} | דיון מלא / קיבוץ / דילוג |
|
||||
| 2 | {טענה 2} | דיון מלא / קיבוץ / דילוג |
|
||||
| 3 | {טענה 3} | דיון מלא / קיבוץ / דילוג |
|
||||
| ... | ... | ... |
|
||||
|
||||
**הסבר:**
|
||||
- **דיון מלא** — ניתוח סילוגיסטי מלא (כלל → עובדות → מסקנה)
|
||||
- **קיבוץ** — טענות שמכוונות לאותה נקודה ייאגדו יחד
|
||||
- **דילוג** — "לא מצאנו ממש" או "אין צורך להכריע נוכח מסקנתנו"
|
||||
|
||||
@chaim — סמן בטבלה והחזר
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/$PAPERCLIP_TASK_ID/interactions" '{
|
||||
"kind": "ask_user_questions",
|
||||
"idempotencyKey": "outcome:'"$PAPERCLIP_TASK_ID"':v1",
|
||||
"title": "תוצאה וטיפול בטענות — {case_number}",
|
||||
"summary": "ראה את הסיכום ב-comment לעיל. שתי שאלות מובנות.",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
"version": 1,
|
||||
"submitLabel": "המשך לכיוונים",
|
||||
"questions": [
|
||||
{
|
||||
"id": "outcome",
|
||||
"prompt": "מה התוצאה?",
|
||||
"selectionMode": "single",
|
||||
"required": true,
|
||||
"options": [
|
||||
{"id":"reject", "label":"דחייה", "description":"הערר נדחה"},
|
||||
{"id":"partial","label":"קבלה חלקית","description":"מתקבל עם תנאים"},
|
||||
{"id":"accept", "label":"קבלה מלאה","description":"הערר מתקבל"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "claims_treatment",
|
||||
"prompt": "אילו טענות לדון בנפרד? (multi)",
|
||||
"selectionMode": "multi",
|
||||
"helpText": "סמן רק טענות שצריכות דיון מלא. השאר → קיבוץ או דילוג.",
|
||||
"options": [
|
||||
{"id":"claim_1","label":"{טענה 1 מקוצר}"},
|
||||
{"id":"claim_2","label":"{טענה 2 מקוצר}"},
|
||||
{"id":"claim_3","label":"{טענה 3 מקוצר}"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**אחרי פרסום ה-comment:** עדכן את ה-issue הראשי ל-`status=in_review`.
|
||||
**אחרי יצירת ה-interaction:** עדכן את ה-issue הראשי ל-`status=in_review` (ראה "כלל קריטי: ניהול סטטוס issue" בראש הסעיף). חיים יקבל UI עם dropdowns וכפתורי radio במקום להקליד מספרים.
|
||||
|
||||
⚠️ **`idempotencyKey`** — חובה. אם תתעורר פעמיים, Paperclip לא יוצר 2 interactions זהים.
|
||||
|
||||
**מתי לחזור אחורה:** אם הסיכום לא מצליח לנסח שאלות כסילוגיזמים מכווצים — ייתכן שחסר מידע עובדתי או נורמטיבי. חזור למנתח/חוקר להשלמה.
|
||||
|
||||
### שלב C: קליטת תוצאה וכיוונים סילוגיסטיים
|
||||
|
||||
**מתי:** חיים הגיב עם מספר תוצאה + טיפול בטענות
|
||||
**מתי:** התעוררת עם `$PAPERCLIP_APPROVAL_ID` שמצביע על interaction מ-§B (תשובת תוצאה+טענות).
|
||||
|
||||
0. **החזר את ה-issue הראשי ל-`status=in_progress`** (קיבלת קלט והמשכת לעבוד).
|
||||
1. קרא את ה-comment של חיים
|
||||
2. זהה את הבחירה (1=rejected, 2=partial, 3=accepted)
|
||||
3. הרץ `set_outcome(case_number, outcome, reasoning)`
|
||||
4. **חשוב סילוגיסטית** על 2-3 כיוונים לנימוק — אתה כבר Claude, אתה יודע את הטענות והתקדימים. בנה כל כיוון כסילוגיזם מלא.
|
||||
1. **קרא את תשובת חיים מה-API** (לא מ-comment חופשי):
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID/interactions/$PAPERCLIP_APPROVAL_ID" \
|
||||
| jq '{status, payload: .response}'
|
||||
```
|
||||
- תשובת `outcome`: `reject` / `partial` / `accept` (זהה ל-1/2/3 הישן)
|
||||
- תשובת `claims_treatment`: array של claim IDs לדיון מלא
|
||||
2. הרץ `set_outcome(case_number, outcome, reasoning)`
|
||||
3. **חשוב סילוגיסטית** על 2-3 כיוונים לנימוק — אתה כבר Claude, אתה יודע את הטענות והתקדימים. בנה כל כיוון כסילוגיזם מלא.
|
||||
|
||||
> **הערה טכנית:** אל תקרא ל-`brainstorm_directions` — זה מפעיל Claude בתוך Claude ולוקח יותר מדי זמן.
|
||||
|
||||
5. פרסם comment עם **סדר סוגיות מוצע**:
|
||||
4. פרסם comment קצר עם **סדר סוגיות מוצע** (לתיעוד thread):
|
||||
|
||||
```
|
||||
## כיוונים אפשריים לנימוק — {outcome_hebrew}
|
||||
## כיוונים לנימוק — {outcome_hebrew}
|
||||
|
||||
### סדר הסוגיות המוצע
|
||||
1. {שאלת סף — אם רלוונטית}
|
||||
2. {הסוגיה המכריעה}
|
||||
3. {סוגיות נוספות לפי חוזק}
|
||||
|
||||
---
|
||||
|
||||
### כיוון 1: {title}
|
||||
|
||||
**כלל (הנחה עליונה):**
|
||||
{הוראת תכנית / סעיף חוק / הלכה פסוקה}
|
||||
|
||||
**עובדות (הנחה תחתונה):**
|
||||
{העובדות הספציפיות של הערר שנבחנות לאור הכלל}
|
||||
|
||||
**מסקנה:**
|
||||
{התוצאה שנובעת מהחלת הכלל על העובדות}
|
||||
|
||||
**תקדימים תומכים:** {precedents}
|
||||
|
||||
---
|
||||
|
||||
### כיוון 2: {title}
|
||||
|
||||
**כלל (הנחה עליונה):**
|
||||
{...}
|
||||
|
||||
**עובדות (הנחה תחתונה):**
|
||||
{...}
|
||||
|
||||
**מסקנה:**
|
||||
{...}
|
||||
|
||||
**תקדימים תומכים:** {precedents}
|
||||
|
||||
---
|
||||
|
||||
### כיוון 3: {title}
|
||||
|
||||
**כלל (הנחה עליונה):**
|
||||
{...}
|
||||
|
||||
**עובדות (הנחה תחתונה):**
|
||||
{...}
|
||||
|
||||
**מסקנה:**
|
||||
{...}
|
||||
|
||||
**תקדימים תומכים:** {precedents}
|
||||
|
||||
---
|
||||
|
||||
@chaim — איזה כיוון מועדף? (1/2/3)
|
||||
אפשר גם לשלב כיוונים או להוסיף הערות.
|
||||
(הכיוונים המלאים — בinteraction למטה)
|
||||
```
|
||||
|
||||
**אחרי פרסום ה-comment:** עדכן את ה-issue הראשי ל-`status=in_review`.
|
||||
5. צור **interaction לבחירת כיוון** עם detailsMarkdown מלא:
|
||||
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/$PAPERCLIP_TASK_ID/interactions" '{
|
||||
"kind": "ask_user_questions",
|
||||
"idempotencyKey": "direction:'"$PAPERCLIP_TASK_ID"':v1",
|
||||
"title": "בחירת כיוון לנימוק — {case_number}",
|
||||
"summary": "3 כיוונים סילוגיסטיים. בחר אחד או שלב.",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
"version": 1,
|
||||
"submitLabel": "אישור כיוון — להעברה לכותב",
|
||||
"questions": [
|
||||
{
|
||||
"id": "direction",
|
||||
"prompt": "איזה כיוון מועדף?",
|
||||
"selectionMode": "single",
|
||||
"required": true,
|
||||
"helpText": "ניתן לשלב כיוונים בהערות ב-comment נפרד אחרי הבחירה.",
|
||||
"options": [
|
||||
{
|
||||
"id": "direction_1",
|
||||
"label": "כיוון 1: {title}",
|
||||
"description": "כלל: {הוראת תכנית/סעיף חוק/הלכה}\nעובדות: {ספציפיות הערר}\nמסקנה: {התוצאה}\nתקדימים: {precedents}"
|
||||
},
|
||||
{
|
||||
"id": "direction_2",
|
||||
"label": "כיוון 2: {title}",
|
||||
"description": "כלל: {...}\nעובדות: {...}\nמסקנה: {...}\nתקדימים: {precedents}"
|
||||
},
|
||||
{
|
||||
"id": "direction_3",
|
||||
"label": "כיוון 3: {title}",
|
||||
"description": "כלל: {...}\nעובדות: {...}\nמסקנה: {...}\nתקדימים: {precedents}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
⚠️ ה-`description` של כל option בעברית. ה-`label` קצר (3-4 מילים), ה-`description` הוא הסילוגיזם המלא — חיים רואה הכל בלי להקליד.
|
||||
|
||||
**אחרי יצירת ה-interaction:** עדכן את ה-issue הראשי ל-`status=in_review`.
|
||||
|
||||
**מתי לחזור אחורה:** אם לא ניתן לבנות סילוגיזם מלא (חסר כלל, חסרות עובדות, או המסקנה לא נובעת) — חזור לחוקר תקדימים או למנתח להשלמת החסר.
|
||||
|
||||
### שלב D: אישור כיוון והפעלת כתיבה
|
||||
|
||||
**מתי:** חיים הגיב עם בחירת כיוון
|
||||
**מתי:** התעוררת עם `$PAPERCLIP_APPROVAL_ID` שמצביע על interaction מ-§C (תשובת כיוון).
|
||||
|
||||
0. **החזר את ה-issue הראשי ל-`status=in_progress`** (קיבלת קלט והמשכת לעבוד).
|
||||
1. קרא את ה-comment של חיים
|
||||
2. זהה כיוון (1/2/3) + הערות נוספות
|
||||
1. **קרא את תשובת חיים מה-API:**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID/interactions/$PAPERCLIP_APPROVAL_ID" \
|
||||
| jq '{status, response: .response}'
|
||||
```
|
||||
- `response.direction` יחזיר `direction_1` / `direction_2` / `direction_3`
|
||||
- אם יש הערות נוספות — חיים יוסיף ב-comment נפרד; קרא את ה-comments האחרונים
|
||||
2. זהה את הכיוון מהתשובה (1/2/3 → לפי המספר ב-id)
|
||||
3. **אימות שלמות chair_directions** — לפני שליחה לכותב, ודא:
|
||||
- [ ] טיפול בטענות (דיון מלא / קיבוץ / דילוג) מוגדר לכל טענה
|
||||
- [ ] כיוון סילוגיסטי נבחר ומאושר
|
||||
- [ ] טיפול בטענות (דיון מלא / קיבוץ / דילוג) מוגדר לכל טענה (מ-§B)
|
||||
- [ ] כיוון סילוגיסטי נבחר ומאושר (מ-§C — interaction status=`answered`)
|
||||
- [ ] סדר סוגיות מוגדר
|
||||
- [ ] תקן ביקורת מצוין
|
||||
- אם חסר פריט כלשהו — **שאל את חיים** לפני שממשיכים
|
||||
- אם חסר פריט כלשהו — צור interaction חדש (`request_confirmation` או `ask_user_questions`) **לפני** שממשיכים. אסור לקרוא לחיים בcomment חופשי.
|
||||
4. הרץ `approve_direction(case_number, direction_index, additional_notes)`
|
||||
5. עדכן סטטוס: `case_update(status=direction_approved)`
|
||||
6. צור issue חדש ב-Paperclip:
|
||||
@@ -363,7 +486,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
- תיאור: "כיוון אושר. בצע pass 2: אמת פסיקה מעמדות היו"ר, העמק עובדות לאור הכיוון שנבחר."
|
||||
7. פרסם comment: "כיוון אושר. הועבר למנתח להעמקת ניתוח לפני כתיבה."
|
||||
|
||||
**מתי לחזור אחורה:** אם חיים שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב B או C בהתאם.
|
||||
**מתי לחזור אחורה:** אם חיים דחה את ה-interaction (`status=rejected`) או שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב B או C בהתאם וצור interaction חדש עם `idempotencyKey` מעודכן (לדוגמה `:v2`).
|
||||
|
||||
### שלב D2: אחרי העמקת ניתוח (pass 2)
|
||||
|
||||
@@ -441,17 +564,84 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
- השתמש ב-`revise_draft` בלבד במצב ג'.
|
||||
- אם המשתמש ביקש שינוי מאסיבי (שכתוב מלא של בלוק) — עדיף להציע לו לעבוד על זה בעריכה נוספת מצדו ולא לייצר revisions ארוכים.
|
||||
|
||||
### שלב H: טיוטת ביניים (לבקשת חיים, לפני דיון והכרעה)
|
||||
|
||||
**מתי:** חיים מבקש בקומנט "טיוטת ביניים" / "interim draft" / "טיוטה לפני דיון" / "תכין לי את הטיוטה עם טענות הצדדים". בכל שלב לפני שיש תוצאה (בד"כ כשהתיק ב-`research_complete` או `analyst_verified`).
|
||||
|
||||
**מטרה:** ייצור מסמך עבודה לחיים עם פתיחה ניטרלית, רקע, תכניות+היתרים, טענות הצדדים, והליכים — **בלי דיון והכרעה**. חיים יכתוב את בלוק י בעצמו ואז נמשיך לזרימה הרגילה (QA + ייצוא סופי).
|
||||
|
||||
**זה side-quest, לא חלק מהזרימה B-F.** אל תשנה `cases.status`. אל תייצר issues לסוכני משנה. הכלים `write_interim_draft` ו-`export_interim_draft` עושים הכל בעצמם.
|
||||
|
||||
**זרימה (~5-10 דקות):**
|
||||
|
||||
1. פרסם comment קצר: "מתחיל יצירת טיוטת ביניים — אעדכן בסיום." עדכן את ה-issue הראשי ל-`status=in_progress`.
|
||||
|
||||
2. **חילוץ עובדות שמאיות** (אם תיק 8xxx/9xxx ויש מסמכי שומה):
|
||||
```
|
||||
mcp__legal-ai__extract_appraiser_facts(case_number="...")
|
||||
```
|
||||
⚠️ אם מחזיר `status="sides_missing"` → דווח לחיים שאין תיוג `appraiser_side` במסמכי השומה (`document_update` עם `appraiser_side` בערכים `committee`/`appellant`/`deciding`). עצור עד שיתוקן.
|
||||
|
||||
אם הטבלה כבר מלאה — `write_interim_draft` ידלג על ההרצה אוטומטית, אז גם בלי הצעד הזה זה יעבוד.
|
||||
|
||||
3. **כתיבת 5 הבלוקים:**
|
||||
```
|
||||
mcp__legal-ai__write_interim_draft(
|
||||
case_number="...",
|
||||
instructions="לבלוק ה (פתיחה): נוסח ניטרלי לחלוטין — 'לפנינו ערר על שומה מכרעת...' + הגדרות 'להלן' בלבד. אין לרמוז על תוצאת הדיון, אין מילות שיפוט, אין אזכור 'דין הערר להידחות/להתקבל'. רק זיהוי הצדדים, השומה המכרעת, המקרקעין והגורם המחליט."
|
||||
)
|
||||
```
|
||||
הכלי כותב ל-DB את בלוקים ה (פתיחה), ו (רקע), ט (תכניות+היתרים מורחב), ז (טענות), ח (הליכים). מחזיר `word_count` לכל בלוק.
|
||||
|
||||
4. **ייצוא DOCX:**
|
||||
```
|
||||
mcp__legal-ai__export_interim_draft(case_number="...")
|
||||
```
|
||||
מייצר `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`, מעדכן `active_draft_path`.
|
||||
|
||||
5. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
|
||||
```
|
||||
## טיוטת ביניים מוכנה — ערר {case_number}
|
||||
|
||||
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`
|
||||
|
||||
### מה כלול
|
||||
| בלוק | כותרת | מילים |
|
||||
|------|-------|-------|
|
||||
| ה | פתיחה (ניטרלית) | {N} |
|
||||
| ו | רקע עובדתי | {N} |
|
||||
| ט | תכניות + היתרים | {N} |
|
||||
| ז | טענות הצדדים | {N} |
|
||||
| ח | הליכים | {N} |
|
||||
| **סה"כ** | | **{N}** |
|
||||
|
||||
### סתירות שמאיות שזוהו
|
||||
{אם יש — רשימה קצרה: "תכנית X — שמאי A קבע ..., שמאי B קבע ...". אם אין — "לא זוהו סתירות בין שמאים."}
|
||||
|
||||
### מה הלאה
|
||||
הטיוטה מוכנה לעבודה. כשתסיים לכתוב את בלוק י, חזור ב-comment ונמשיך
|
||||
לשלב F (QA + ייצוא סופי).
|
||||
```
|
||||
|
||||
6. **סטטוס issue הראשי:** עדכן ל-`in_review` (ממתין לחיים שיכתוב את בלוק י).
|
||||
|
||||
**אזהרות:**
|
||||
- אל תייצא DOCX סופי (`export_docx`) — זה לא תחליף לטיוטת ביניים.
|
||||
- אל תפעיל את שלב B (סיכום + שאלת תוצאה) במקביל — חיים מחליט מתי לעבור לזרימה הראשית.
|
||||
- אם בלוק ח חסר (אין פרוטוקול דיון/סיור) — ציין זאת בדוח. הכלי כותב מה שיש, אבל המשתמש צריך לדעת אם חסר.
|
||||
|
||||
## מפת סטטוסים
|
||||
|
||||
**סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
|
||||
|
||||
| סטטוס | מי שינה לזה | פעולה הבאה |
|
||||
|--------|-------------|------------|
|
||||
| `processing` | start-workflow (ממשק) | → בדוק אם כבר קיים issue פעיל לסוכן משנה. אם לא → המשך ל-§A כרגיל (בדוק documents + claims) |
|
||||
| `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח |
|
||||
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
||||
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified` |
|
||||
| `analyst_verified` | CEO (אחרי שלב A) | → האם יש מחקר תקדימים? אם לא → צור issue לחוקר (35022af0). אם כן → שלב B |
|
||||
| `research_complete` | חוקר | → שלב B (סיכום + סיווג + שאלת תוצאה לחיים) |
|
||||
| `analyst_verified` | CEO (אחרי שלב A) | → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
||||
| `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 |
|
||||
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
||||
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
||||
@@ -508,11 +698,51 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
---
|
||||
|
||||
**תבנית issue למנתח — חובה בכל תיק:**
|
||||
1. **טבלת מיפוי מסמכים** — לכל מסמך: שם, claim_type, party_role. בנה מ-`document_list`.
|
||||
2. **רשימת מסמכים שלא לחלץ מהם** (reference, plan, decision, court_decision)
|
||||
3. **הנחיה לפיצול מסמכים גדולים** — מעל 15,000 תווים → חלץ בחלקים
|
||||
4. **הנחיה לשלוח wakeup ל-CEO בסיום**
|
||||
5. **הנחיה לסיים כ-blocked אם מסמך נכשל**
|
||||
|
||||
**כותרת:** `[ערר CASE_NUMBER] ניתוח משפטי ומחקר — CASE_NAME`
|
||||
|
||||
**תיאור חובה — כלול את כל הסעיפים הבאים:**
|
||||
|
||||
```
|
||||
בצע ניתוח משפטי מלא לפי legal-analyst.md שלבים 1-7:
|
||||
|
||||
שלב 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.
|
||||
|
||||
## סינון תיקים לפי חברה — חובה!
|
||||
|
||||
@@ -555,22 +785,18 @@ case_prefix="${case_number:0:1}"
|
||||
|
||||
0. **החזר את ה-issue הראשי ל-`status=in_progress`** — אם ה-issue ב-`in_review` (כי המתנת לחיים) או ב-`blocked` (כי Paperclip חסם אוטומטית), הראשון דבר: עדכן ל-`in_progress` כדי לסמן שאתה עובד עליו.
|
||||
|
||||
1. **קרא את ה-comments האחרונים** על ה-issue שצוין ב-prompt:
|
||||
1. **קרא את ההקשר המלא** — issue + ancestors + project + goal + comments + attachments בקריאה אחת (ראה `HEARTBEAT.md §1.7`):
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]'
|
||||
CONTEXT=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/heartbeat-context")
|
||||
```
|
||||
|
||||
2. **בדוק attachments** — אם חיים ציין קובץ שהועלה:
|
||||
2. **בדוק attachments** — אם חיים ציין קובץ שהועלה, הוא כבר ב-`$CONTEXT.attachments`:
|
||||
```bash
|
||||
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
|
||||
SELECT a.original_filename, a.content_type, a.object_key
|
||||
FROM issue_attachments ia
|
||||
JOIN assets a ON a.id = ia.asset_id
|
||||
WHERE ia.issue_id = '{issue-id}'
|
||||
ORDER BY ia.created_at DESC LIMIT 5;"
|
||||
echo "$CONTEXT" | jq '.attachments[] | {filename, contentPath, contentType, byteSize}'
|
||||
```
|
||||
נתיב מלא לקובץ: `/home/chaim/.paperclip/instances/default/data/storage/{object_key}`
|
||||
נתיב מלא לקובץ: `/home/chaim/.paperclip/instances/default/data/storage/$(echo $CONTEXT | jq -r '.attachments[0].contentPath')`
|
||||
|
||||
⚠️ **אסור** psql ישיר ל-`issue_attachments` — ה-API הוא ה-source of truth.
|
||||
|
||||
3. **אם יש טיוטה/קובץ — קרא אותו מילה במילה.** חפש בתוכו:
|
||||
- הוראות עריכה (טקסט כמו "צריך לערוך", "להוסיף", "חסר", "הוראות כתיבה")
|
||||
@@ -621,34 +847,37 @@ case_prefix="${case_number:0:1}"
|
||||
## נתיבי API — חובה!
|
||||
|
||||
```bash
|
||||
# קרא comments על issue
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '.[-1].body'
|
||||
# קרא comments על issue (אבל בד"כ עדיף heartbeat-context — ראה HEARTBEAT.md §1.7)
|
||||
~/legal-ai/scripts/pc.sh GET "/api/issues/{issue-id}/comments" | jq '.[-1].body'
|
||||
|
||||
# פרסם comment
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" \
|
||||
-d '{"body": "..."}'
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" '{"body": "..."}'
|
||||
|
||||
# צור issue חדש (עם הקצאה לסוכן → מפעיל wakeup אוטומטי!)
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/companies/42a7acd0-30c5-4cbd-ac97-7424f65df294/issues" \
|
||||
-d '{"title":"...","projectId":"25c1b4a1-2c0e-4a2d-9938-8ae56ccda6f1","assigneeAgentId":"{agent-id}","description":"...","status":"todo"}'
|
||||
# ⚠️ שלוף projectId מה-issue ההורה — אל תקבע UUID ידנית:
|
||||
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
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "done"}'
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'
|
||||
|
||||
# צור interaction מובנה לחיים (ראה §B/§C למעלה למבנה payload)
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/interactions" '{"kind":"...","payload":{...}}'
|
||||
|
||||
# קרא תשובת interaction (כשהתעוררת עם $PAPERCLIP_APPROVAL_ID)
|
||||
~/legal-ai/scripts/pc.sh GET "/api/issues/{issue-id}/interactions/$PAPERCLIP_APPROVAL_ID" | jq '.'
|
||||
```
|
||||
|
||||
**⚠️ agent JWT לא יכול להעיר סוכנים אחרים ישירות.** כדי להעיר סוכן → **צור issue חדש + הקצה אליו** (Paperclip מפעיל wakeup אוטומטי על assignment).
|
||||
|
||||
חפש ב-comment של חיים:
|
||||
- מספר (1/2/3) → בחירה
|
||||
- "כיוון" + מספר → אישור כיוון
|
||||
- טבלת טיפול בטענות → סימון claim_handling
|
||||
- שאלה → ענה
|
||||
- הערה → שלב בתהליך
|
||||
## מתי להשתמש בinteraction לעומת comment
|
||||
|
||||
| מצב | פתרון |
|
||||
|------|--------|
|
||||
| נדרשת בחירה מובנית מחיים (תוצאה, כיוון, אישור) | **interaction** (`ask_user_questions` / `request_confirmation`) — UI עם כפתורים |
|
||||
| הצעת עץ משימות לאישור | **interaction** (`suggest_tasks`) |
|
||||
| עדכון סטטוס/תיעוד מסע (לא דורש פעולה) | **comment** רגיל |
|
||||
| הסבר ארוך + שאלת בחירה | **dual** — comment עם הסבר + interaction עם options (ראה §B) |
|
||||
|
||||
**אסור:** "@chaim — ענה 1/2/3 בcomment". זה anti-pattern. תמיד interaction עם options.
|
||||
|
||||
@@ -19,6 +19,7 @@ tools:
|
||||
- mcp__legal-ai__revise_draft
|
||||
- mcp__legal-ai__get_style_guide
|
||||
- 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/42a7acd0-30c5-4cbd-ac97-7424f65df294/legal-docx/references/document-types.md`
|
||||
- `/home/chaim/.paperclip/instances/default/skills/$PAPERCLIP_COMPANY_ID/legal-docx/SKILL.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: שמירה מגורסת
|
||||
1. צור תיקייה `~/legal-ai/data/cases/{מספר-ערר}/exports/` (אם לא קיימת)
|
||||
2. בדוק כמה טיוטות כבר קיימות בתיקייה (קבצים שמתחילים ב-`טיוטה-V`)
|
||||
3. שמור כ-`טיוטה-V{N}.docx` כאשר N = המספר הבא בתור
|
||||
- אם אין טיוטות: `טיוטה-V1.docx`
|
||||
- אם יש V1: `טיוטה-V2.docx`
|
||||
2. בדוק כמה טיוטות כבר קיימות בתיקייה (קבצים שמתחילים ב-`טיוטה-v`)
|
||||
3. שמור כ-`טיוטה-v{N}.docx` כאשר N = המספר הבא בתור
|
||||
- אם אין טיוטות: `טיוטה-v1.docx`
|
||||
- אם יש v1: `טיוטה-v2.docx`
|
||||
- וכן הלאה
|
||||
4. ודא שהקובץ נוצר ושגודלו סביר
|
||||
5. עדכן סטטוס תיק ל-`exported` דרך `case_update(case_number, {"status": "exported"})`
|
||||
|
||||
### שלב 5: דיווח
|
||||
דווח למשתמש:
|
||||
@@ -116,19 +118,35 @@ tools:
|
||||
- ממצאי הבדיקה הסופית (אם היו הערות)
|
||||
- גודל הקובץ
|
||||
|
||||
### סגור את ה-issue של עצמך — חובה!
|
||||
|
||||
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||
|
||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
|
||||
-d '{"reason": "מייצא טיוטה סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||
```
|
||||
אם ה-API לא עובד:
|
||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
||||
else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
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"}}'```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||
|
||||
## כללים קריטיים
|
||||
|
||||
1. **לעולם אל תייצא בלי בדיקה** — תמיד הרץ validate_decision קודם
|
||||
2. **לא לדרוס טיוטות קודמות** — תמיד גרסה חדשה (V1, V2, V3...)
|
||||
3. **שמות קבצים בעברית** — `טיוטה-V1.docx`, לא `draft-V1.docx`
|
||||
2. **לא לדרוס טיוטות קודמות** — תמיד גרסה חדשה (v1, v2, v3...)
|
||||
3. **שמות קבצים בעברית** — `טיוטה-v1.docx`, לא `draft-v1.docx`
|
||||
4. **קרא את הסקייל** — לפני כל ייצוא, קרא את legal-docx SKILL.md
|
||||
|
||||
@@ -69,5 +69,46 @@ tools:
|
||||
### שלב 4: שמירה
|
||||
1. **גיבוי**: העתק את הקובץ המקורי מ-`extracted/` לתיקיית `documents/backup/` עם סיומת `.pre-proofread.txt`
|
||||
2. **כתוב** את הגרסה המתוקנת לתיקיית `documents/proofread/` (עם אותו שם קובץ כמו ב-`extracted/`)
|
||||
3. עדכן את מסד הנתונים — שנה `extraction_status` ל-`proofread`:
|
||||
3. עדכן את מסד הנתונים — שנה `extraction_status` ל-`proofread`
|
||||
|
||||
### שלב 5: דיווח — חובה!
|
||||
|
||||
1. **פרסם comment ב-issue** עם סיכום:
|
||||
- כמה מסמכים הוגהו
|
||||
- כמה החלפות אוטומטיות בוצעו (לפי מילון ראשי תיבות)
|
||||
- כמה תיקונים ידניים בוצעו
|
||||
- אם נמצאו בעיות שלא ניתן היה לתקן — פרט (`[?]` markers)
|
||||
|
||||
2. **שלח מייל**:
|
||||
```bash
|
||||
python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
"הגהה הושלמה — ערר {case_number}" \
|
||||
"סיכום: X מסמכים הוגהו, Y החלפות, Z תיקונים. נדרשת ביקורתך."
|
||||
```
|
||||
|
||||
### סגור את ה-issue של עצמך — חובה!
|
||||
|
||||
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**אם הכל עבר בהצלחה:**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "done"}'```
|
||||
|
||||
**אם נכשלו תיקונים קריטיים או יש markers `[?]` רבים:**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "blocked"}'```
|
||||
**אסור** לסיים `done` עם פלט חסר — אם נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
|
||||
```bash
|
||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
||||
else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
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"}}'```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||
|
||||
@@ -14,6 +14,11 @@ tools:
|
||||
- mcp__legal-ai__get_metrics
|
||||
- mcp__legal-ai__workflow_status
|
||||
- mcp__legal-ai__search_case_documents
|
||||
- mcp__legal-ai__search_precedent_library
|
||||
- mcp__legal-ai__search_internal_decisions
|
||||
- mcp__legal-ai__precedent_library_get
|
||||
- mcp__legal-ai__precedent_list
|
||||
- mcp__legal-ai__halacha_review
|
||||
---
|
||||
|
||||
# בודק איכות — סוכן QA להחלטות ועדת ערר
|
||||
@@ -76,6 +81,31 @@ tools:
|
||||
- סעיפים 1, 2, 3... ללא איפוס בין בלוקים
|
||||
- ללא כפילויות במספור
|
||||
|
||||
### 7א. שלמות חיפוש בקורפוסים (corpus_queries_logged) — critical
|
||||
|
||||
ה-analyst וה-researcher חייבים לתעד queries לקורפוסים שלהם. בלי תיעוד — אין דרך לוודא שתקדימי עליון רלוונטיים לא הוחמצו.
|
||||
|
||||
**שיטת בדיקה:** grep ידני — קרא את קבצי המחקר וחפש בהם את הסעיפים הנ"ל. `validate_decision` **לא** בודק זאת אוטומטית. הצלבה עם MCP (סעיף 4 למטה) היא אופציונלית ומשלימה.
|
||||
|
||||
בדוק:
|
||||
1. **קיום סעיף "שאילתות לקורפוסים"**:
|
||||
- ב-`{case_dir}/documents/research/analysis-and-research.md` — סעיף **7א** (לפי שלב 5ד של ה-analyst)
|
||||
- ב-`{case_dir}/documents/research/precedent-research.md` — סעיף **ז** (לפי שלב 2ב.4 של ה-researcher)
|
||||
- אם חסר באחד מהם — `corpus_queries_logged = fail` (critical, חוסם המשך).
|
||||
|
||||
2. **מספר queries מינימלי לקורפוס הסמכותי (`search_precedent_library`):**
|
||||
- `analyst >= (מספר טענות סף + מספר סוגיות מרכזיות)`
|
||||
- `researcher >= מספר סוגיות מרכזיות`
|
||||
- חישוב: ספור את הסוגיות בסעיף 6 של `analysis-and-research.md`. מתחת לסף → `fail`.
|
||||
|
||||
3. **negative evidence מתועד:** גם 0-result query חייבת להופיע. אם מצאת queries שכולן 0-result — לא fail; פשוט תיעוד שהקורפוס דליל בנושא.
|
||||
|
||||
4. **אצליבה הצלבה (cross-check):**
|
||||
- הרץ `mcp__legal-ai__precedent_library_list(practice_area=X, search="<keyword מרכזי מהתיק>")` עם practice_area של התיק.
|
||||
- אם החזיר תוצאות שלא מופיעות בסעיף "נבחרו" או "נדחו" של ה-analyst/researcher → `corpus_queries_logged = warning` (לא חוסם, אבל דווח לחיים).
|
||||
|
||||
חומרה: **critical** — בלי queries מתועדות אין דרך לאמת שלא הוחמצה הלכה מחייבת.
|
||||
|
||||
### 7. עמידה במתודולוגיה (methodology_compliance)
|
||||
ראה `docs/decision-methodology.md` לעקרונות המלאים. בדוק:
|
||||
- לכל סוגיה בבלוק י — ניתן לזהות מבנה סילוגיסטי: כלל + עובדות + מסקנה?
|
||||
@@ -115,6 +145,40 @@ tools:
|
||||
#### תקדמים (מ-`daphna-precedent-network.md`)
|
||||
- לכל סוגיה משפטית — האם נבחר התקדים המועדף של דפנה?
|
||||
- האם יש תקדים אישי שלה רלוונטי? אם כן — האם הופנה אליו (חיסכון / דחייה / הבחנה)?
|
||||
- **ציטוטי פסיקה חיצונית בבלוק י** — לכל ציטוט (`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` — אם תוצאה = קבלה)
|
||||
- האם הסיבה לקבלה ברורה: פגם פנימי / החזרה / תיקונים / 8xxx מהותית / שומה?
|
||||
@@ -133,8 +197,12 @@ tools:
|
||||
| משקלות | warning | מדווח, לא חוסם |
|
||||
| כפילות | warning | מדווח, לא חוסם |
|
||||
| מספור | warning | מדווח, לא חוסם |
|
||||
| **שאילתות לקורפוסים** | **critical** | **חוסם ייצוא** |
|
||||
| מתודולוגיה | critical | חוסם ייצוא |
|
||||
| **קול דפנה** | **critical** | **חוסם ייצוא** |
|
||||
| **צירוף פסיקה ל-DB** | **critical** | **חוסם ייצוא** |
|
||||
| מראה מקום מלא | warning | מדווח, לא חוסם |
|
||||
| תקפות סטטוס | sanity | דיווח בלבד |
|
||||
|
||||
## תהליך עבודה
|
||||
|
||||
@@ -163,12 +231,28 @@ tools:
|
||||
- האם מותר לייצא (כל הקריטיים pass?)
|
||||
- עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר)
|
||||
|
||||
### סגור את ה-issue של עצמך — חובה!
|
||||
|
||||
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||
|
||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
|
||||
-d '{"reason": "בודק איכות סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||
```
|
||||
אם ה-API לא עובד:
|
||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
||||
else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
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"}}'```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||
|
||||
@@ -14,11 +14,30 @@ tools:
|
||||
- mcp__legal-ai__document_get_text
|
||||
- mcp__legal-ai__search_case_documents
|
||||
- mcp__legal-ai__search_decisions
|
||||
- mcp__legal-ai__search_internal_decisions
|
||||
- mcp__legal-ai__find_similar_cases
|
||||
- mcp__legal-ai__extract_references
|
||||
- mcp__legal-ai__precedent_attach
|
||||
- mcp__legal-ai__precedent_list
|
||||
- mcp__legal-ai__precedent_search_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_list
|
||||
- mcp__legal-ai__precedent_extract_halachot
|
||||
- mcp__legal-ai__precedent_extract_metadata
|
||||
- mcp__legal-ai__precedent_process_pending
|
||||
- mcp__legal-ai__halacha_review
|
||||
- mcp__legal-ai__halachot_pending
|
||||
- mcp__legal-ai__missing_precedent_create
|
||||
- mcp__legal-ai__missing_precedent_list
|
||||
- mcp__legal-ai__missing_precedent_close
|
||||
- mcp__legal-ai__workflow_status
|
||||
---
|
||||
|
||||
> ראה גם: [HEARTBEAT.md](HEARTBEAT.md) לכללי הפעלה כלליים — routing, company filtering, wakeup API
|
||||
|
||||
# חוקר תקדימים — סוכן מחקר משפטי
|
||||
|
||||
אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים.
|
||||
@@ -56,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: התמצאות
|
||||
@@ -74,15 +179,133 @@ tools:
|
||||
- **האם זה תקדם מהקאנון של דפנה?** (בדוק `docs/daphna-precedent-network.md` — אם כן, ציין שזה התקדם המועדף שלה לסוגיה)
|
||||
4. הפק הפניות (`extract_references`)
|
||||
|
||||
### שלב 2ב: בדיקה מצטלבת מול הקאנון של דפנה
|
||||
אחרי שאספת את הפסיקה הרלוונטית בתיק:
|
||||
1. **לכל סוגיה משפטית** בתיק — בדוק ב-`daphna-precedent-network.md`:
|
||||
- האם יש תקדם מועדף של דפנה לסוגיה?
|
||||
- האם הוא הוצג בכתבי הטענות? אם לא — סמן כתקדם שיש להוסיף
|
||||
2. **תקדמים אישיים**: `search_decisions` בקטגוריה זהה לתיק. אם דפנה כבר הכריעה בסוגיה דומה:
|
||||
- אם תוצאה דומה: תקדם לחיסכון דוקטרינרי ("כפי שקבענו ב-X")
|
||||
- אם תוצאה הפוכה: ציין כי **חובה** הבחנה (distinguishing)
|
||||
3. **דווח** איזה תקדמים מהקאנון רלוונטיים, ואיזה תקדמים אישיים נמצאו
|
||||
### שלב 2ב: חיפוש מובנה בשלושת הקורפוסים — חובה, עם תיעוד queries
|
||||
|
||||
**חובה לבצע** — לא הצעה. הניתוח קודם הראה (ערר 1200-25) שאם הקורפוס לא נסרק במפורש, מפספסים תקדימי עליון רלוונטיים שיושבים בו. ה-QA יחזיר `needs_revision` אם סעיף ה-queries חסר.
|
||||
|
||||
**שלושת הקורפוסים — אל תבלבל:**
|
||||
- `search_precedent_library` = פסיקה חיצונית סמכותית עם הלכות מאושרות (עליון/מנהלי/ועדות ערר אחרות) + supporting_quote מוכן.
|
||||
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
||||
- `precedent_search_library` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
||||
|
||||
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
||||
|
||||
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
|
||||
|
||||
| סיווג תיק | practice_area |
|
||||
|------------|---------------|
|
||||
| 1xxx (רישוי ובניה) | `rishuy_uvniya` |
|
||||
| 8xxx (היטל השבחה) | `betterment_levy` |
|
||||
| 9xxx (פיצויים ס' 197) | `compensation_197` |
|
||||
|
||||
אם הסוגיה ב-`appeal_subtype` ידוע (כמו "שימוש חורג", "סטייה ניכרת") — הוסף `appeal_subtype` לפילטר.
|
||||
|
||||
```
|
||||
search_precedent_library(
|
||||
query="...",
|
||||
practice_area="rishuy_uvniya",
|
||||
appeal_subtype="שימוש חורג",
|
||||
limit=10
|
||||
)
|
||||
```
|
||||
|
||||
#### 2ב.2 — קאנון דפנה (`search_decisions`)
|
||||
|
||||
לכל סוגיה — בדוק אם דפנה כבר הכריעה:
|
||||
- אם תוצאה דומה: תקדם לחיסכון דוקטרינרי ("כפי שקבענו ב-X")
|
||||
- אם תוצאה הפוכה: ציין כי **חובה** הבחנה (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ב.4 — תיעוד מחייב — סעיף "שאילתות לקורפוסים" ב-`precedent-research.md`
|
||||
|
||||
חובה להופיע סעיף בשם **"ז. שאילתות לקורפוסים — log מלא"** עם:
|
||||
|
||||
```markdown
|
||||
## ז. שאילתות לקורפוסים — log מלא
|
||||
|
||||
### קורפוס סמכותי (search_precedent_library)
|
||||
|
||||
#### Q1 — סוגיה: [שם]
|
||||
- **שאילתה:** "..."
|
||||
- **פילטרים:** practice_area=..., appeal_subtype=...
|
||||
- **תוצאות:** N
|
||||
- **נבחרו:** [case_number] — headnote/למה רלוונטי
|
||||
- **נדחו:** [case_number] — למה לא
|
||||
- **0 results?** ציין מפורש + נמק
|
||||
|
||||
#### Q2 — ...
|
||||
|
||||
### קאנון דפנה (search_decisions)
|
||||
#### Q1 — ...
|
||||
```
|
||||
|
||||
**negative evidence חובה:** גם 0 results נרשם. זה ההבדל בין "נסרק וריק" ל"לא נסרק".
|
||||
|
||||
**מינימום:** 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. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||
|
||||
### שלב 3: מיפוי תכנית
|
||||
1. קרא הוראות התכנית **במלואן** — לא רק את הסעיף הנטען
|
||||
@@ -97,33 +320,69 @@ tools:
|
||||
|
||||
### שלב 5: דיווח — חובה!
|
||||
|
||||
1. **עדכן סטטוס**: `case_update(case_number, status='research_complete')`
|
||||
1. **שמור את הדוח לדיסק** (חובה — ה-writer וה-QA קוראים מהקובץ הזה ישירות):
|
||||
```
|
||||
{case_dir}/documents/research/precedent-research.md
|
||||
```
|
||||
המבנה המומלץ: רקע דיוני → מפת שומות (אם רלוונטי) → סוגיות + תקדימים מאומתים לכל אחת → המלצה לכיוון. כל תקדים עם citation מלא + ציטוט מדויק + הקשר.
|
||||
|
||||
2. **שלח מייל**:
|
||||
2. **רשום ב-DB את התקדימים שאומתו** — חובה, אחרת ה-writer יקבל רשימה ריקה כשהוא קורא `precedent_list`.
|
||||
|
||||
לכל פסק דין שעבר את שלב 2 (ניתוח פסיקה) **ויש לו ציטוט מדויק מהמקור** — קרא `precedent_attach`:
|
||||
```
|
||||
mcp__legal-ai__precedent_attach(
|
||||
case_number = "8174-24",
|
||||
citation = "בר\"מ 3644/13 הוועדה המקומית גבעתיים נ' גלר (פורסם בנבו, 24.05.2017)",
|
||||
quote = "ציטוט מדויק מפסק הדין — הקטע הספציפי שרלוונטי לסוגיה",
|
||||
section_id = "issue_2" # או "threshold_1" לטענת סף; ריק אם כללי
|
||||
)
|
||||
```
|
||||
תקדימים שלא הצלחת לאמת (ציטוט לא נמצא, רק "טוענים שמופיע בפסק") **אל תכתוב ל-DB** — סמן ב-comment כ"דורש אימות חיצוני" בלבד.
|
||||
|
||||
3. **עדכן סטטוס**: `case_update(case_number, status='research_complete')`
|
||||
|
||||
4. **שלח מייל**:
|
||||
```bash
|
||||
python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
"מחקר תקדימים הושלם — ערר {case_number}" \
|
||||
"סיכום: X פסקי דין נותחו, Y תכניות מופו. נדרשת ביקורתך לפני המשך."
|
||||
"סיכום: X פסקי דין נותחו ונרשמו ל-DB, Y תכניות מופו. נדרשת ביקורתך לפני המשך."
|
||||
```
|
||||
|
||||
3. פרסם comment ב-Paperclip עם:
|
||||
- סיכום כל פסק דין (2-3 שורות לכל אחד)
|
||||
5. **פרסם comment ב-Paperclip** עם:
|
||||
- סיכום כל פסק דין (2-3 שורות לכל אחד) — **ציין במפורש כמה תקדימים נרשמו ב-DB דרך `precedent_attach`**
|
||||
- מיפוי הוראות תכנית רלוונטיות
|
||||
- ציר זמן ההליך
|
||||
- **המלצה מובנית לפי מקורות הנמקה:**
|
||||
- **טקסט**: אילו סעיפי תכנית/חוק מרכזיים (ציטוט הנוסח)
|
||||
- **תקדים**: אילו פסקי דין הכי חזקים (עם ציון היררכיה ומעמד — הלכה/אגב)
|
||||
- **מדיניות**: אילו שיקולים תכנוניים עולים מהחומר
|
||||
- קישור למיקום הקובץ: `{case_dir}/documents/research/precedent-research.md`
|
||||
|
||||
### סגור את ה-issue של עצמך — חובה!
|
||||
|
||||
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||
|
||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
|
||||
-d '{"reason": "חוקר תקדימים סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||
```
|
||||
אם ה-API לא עובד:
|
||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
||||
else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
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"}}'```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||
|
||||
## כללים
|
||||
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים
|
||||
|
||||
@@ -19,6 +19,11 @@ tools:
|
||||
- mcp__legal-ai__save_block_content
|
||||
- mcp__legal-ai__write_block
|
||||
- mcp__legal-ai__search_decisions
|
||||
- mcp__legal-ai__search_precedent_library
|
||||
- mcp__legal-ai__search_internal_decisions
|
||||
- mcp__legal-ai__precedent_library_get
|
||||
- mcp__legal-ai__precedent_library_list
|
||||
- mcp__legal-ai__halacha_review
|
||||
- mcp__legal-ai__search_case_documents
|
||||
- mcp__legal-ai__get_style_guide
|
||||
- mcp__legal-ai__workflow_status
|
||||
@@ -55,6 +60,9 @@ tools:
|
||||
### חובה לפני בלוק ז (טענות הצדדים):
|
||||
- **בלוק ז: `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`** — איך לחשוב על החלטה
|
||||
6. מדריך סגנון: `skills/decision/SKILL.md` — איך דפנה כותבת
|
||||
@@ -200,15 +208,31 @@ case_update(case_number, status="drafted")
|
||||
- ספירת מילים לכל בלוק
|
||||
- יחסי משקל (% מהמסמך)
|
||||
|
||||
### סגור את ה-issue של עצמך — חובה!
|
||||
|
||||
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||
|
||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
|
||||
-d '{"reason": "כותב החלטה סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||
```
|
||||
אם ה-API לא עובד:
|
||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
||||
else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
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"}}'```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||
|
||||
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
|
||||
|
||||
@@ -313,6 +337,42 @@ curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
|
||||
זה לא קישוט. דפנה בונה ג'וריספרודנציה אישית מתמשכת. ראה דוגמה ב-1194-25 פס' 61, 64, 97, 98, 99 — חמש הפניות ל-1130-25.
|
||||
|
||||
### חיפוש פסיקה סמכותית חיצונית (חובה)
|
||||
|
||||
אחרי `search_decisions`, חפש גם ב-**`search_precedent_library`** — הקורפוס של פסיקת ערכאות עליונות וועדות ערר אחרות, עם הלכות שדפנה אישרה. זה המקור היחיד לציטוטי פסיקה בבלוק י לפי CREAC:
|
||||
|
||||
- **rule (כלל)** — נסח את הכלל המחייב מתוך `rule_statement`. אל תמציא ניסוח חדש; השתמש בניסוח שאושר.
|
||||
- **explanation (הרחבה)** — צטט את `supporting_quote` במלואו, מילה במילה. כל ציטוט חייב לכלול `case_number` + `court` + מראה מקום (`page_reference` כשיש).
|
||||
|
||||
**הבחנה בין כלים:**
|
||||
- `search_decisions` = החלטות דפנה עצמה (סגנון, אסטרטגיה, ג'וריספרודנציה אישית).
|
||||
- `search_precedent_library` = פסיקה חיצונית סמכותית (מחייבת או משכנעת — בית המשפט העליון, מנהלי, ועדות ערר אחרות).
|
||||
- `precedent_search_library` (שונה!) = ציטוטים שדפנה צירפה ידנית לתיקים בעבר. לא לבלבל.
|
||||
|
||||
חפש לפי `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,5 +1,7 @@
|
||||
data/
|
||||
.claude/
|
||||
!.claude/agents/
|
||||
!.claude/agents/hermes-curator.md
|
||||
mcp-server/.venv/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
@@ -11,7 +13,11 @@ scripts/
|
||||
skills/
|
||||
!skills/docx/
|
||||
!skills/docx/decision_template.docx
|
||||
!skills/decision/
|
||||
!skills/decision/SKILL.md
|
||||
docs/
|
||||
!docs/legal-decision-lessons.md
|
||||
!docs/corpus-analysis.md
|
||||
legacy/
|
||||
node_modules/
|
||||
.next/
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,7 +3,10 @@ data/cases/
|
||||
data/training/
|
||||
data/exports/
|
||||
data/backups/
|
||||
data/precedent-library/
|
||||
data/.auto-sync.log
|
||||
data/*.db
|
||||
*.bak-pre-*
|
||||
mcp-server/.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"migrationNoticeShown": true
|
||||
"migrationNoticeShown": true,
|
||||
"currentTag": "legal-ai",
|
||||
"lastSwitched": "2026-05-03T20:31:48.957Z",
|
||||
"branchTagMapping": {}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
101
CLAUDE.md
101
CLAUDE.md
@@ -48,11 +48,16 @@
|
||||
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
|
||||
| [`docs/product-specification.md`](docs/product-specification.md) | איפיון מוצר מלא — personas, תהליכים עסקיים, דרישות | להתמצאות עסקית/מוצרית |
|
||||
| [`docs/new-company-setup-guide.md`](docs/new-company-setup-guide.md) | מדריך הקמת חברה חדשה (CMPA) — skills, corpus, style analysis | לפני הוספת חברה/סוג ערר חדש |
|
||||
| [`skills/new-company-setup/SKILL.md`](skills/new-company-setup/SKILL.md) | **Blueprint טכני מלא להוספת חברה** — 11 שלבים מסודרים (companies, agents, runtime/adapter, skills, instructions, code, mappings) + checklist 10 מלכודות מ-Gap analysis #16-#28 | **חובה לפני הוספת חברה** (יותר actionable מ-doc) |
|
||||
| [`docs/audit-report.md`](docs/audit-report.md) | דוח audit של המערכת | רקע כללי |
|
||||
| [`docs/case-migration-tracker.md`](docs/case-migration-tracker.md) | מעקב מיגרציה של תיקים קיימים | לצורך מעקב |
|
||||
| [`docs/case-deletion-runbook.md`](docs/case-deletion-runbook.md) | runbook מלא למחיקת תיק — legal-ai DB + disk + Paperclip + Gitea, FK ordering, fallback ל-SQL ישיר | לפני reset שלם של תיק (מבחן, מחיקה בטעות) |
|
||||
| [`docs/paperclip-quirks.md`](docs/paperclip-quirks.md) | מלכודות ידועות ב-Paperclip — `issue.released` ש-flips done→todo, bash backtick trap, CEO auto-block, wakeup דרך DB | לפני שמייחסים באג בסוכן ל-skill — לבדוק קודם אם זה Paperclip-side |
|
||||
| [`docs/decision-block-mapping.md`](docs/decision-block-mapping.md) | מיפוי בלוקים להחלטות — איך 12 הבלוקים משתקפים ב-DOCX | להתמצאות במבנה |
|
||||
| [`docs/memory.md`](docs/memory.md) | הקשר כללי — skills, פרויקטים שהושלמו, מבנה vault | להתמצאות כללית |
|
||||
| [`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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +91,16 @@
|
||||
- שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip`
|
||||
- **אין צורך ב-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).
|
||||
|
||||
---
|
||||
|
||||
## מבנה תיקיות
|
||||
@@ -103,18 +118,34 @@
|
||||
├── skills/ ← כלי עבודה ומדריכים
|
||||
│ ├── decision/ מדריך סגנון + references + 12 בלוקים
|
||||
│ ├── 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/
|
||||
│ ├── training/ ← 4 החלטות לאימון (DOCX)
|
||||
│ ├── exports/ ← טיוטות DOCX מיוצאות
|
||||
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
|
||||
├── web/ ← FastAPI backend (Python): 75 API endpoints
|
||||
├── web/ ← FastAPI backend (Python): 75+ API endpoints
|
||||
│ ├── 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
|
||||
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
|
||||
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
|
||||
├── mcp-server/ ← MCP server + services + tools
|
||||
├── adapters/ ← Paperclip external adapters (ראה למטה)
|
||||
│ └── deepseek-paperclip-adapter/ ← `deepseek_local` (Hermes-pinned ל-DeepSeek profile)
|
||||
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
|
||||
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
|
||||
```
|
||||
@@ -132,12 +163,14 @@
|
||||
|
||||
הפרויקט משתמש ב-**TaskMaster AI** (MCP server) לניהול משימות מובנה:
|
||||
- **תמיד** להשתמש ב-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`
|
||||
- לפני התחלת עבודה → `next_task` כדי לדעת מה הבא לפי תלויות
|
||||
- אחרי סיום משימה → `update_task` עם status=done
|
||||
- משימה מורכבת → `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 — כללי אינטגרציה קריטיים
|
||||
@@ -158,6 +191,66 @@
|
||||
- ה-CEO קורא את ה-comment, מחליט על ניתוב, ויוצר issue לסוכן המתאים
|
||||
- כל הסוכנים חייבים לקרוא comments אחרונים לפני שהם מתחילים לעבוד (HEARTBEAT שלבים 2b-2c)
|
||||
|
||||
### קריאות API — תמיד דרך helper, לעולם לא `curl` ישיר
|
||||
- **bash (סוכנים):** `~/legal-ai/scripts/pc.sh <METHOD> <PATH> [BODY_JSON]` — מוסיף Authorization, X-Paperclip-Run-Id, Content-Type, base URL. ראה `HEARTBEAT.md §0`.
|
||||
- **Python (FastAPI):** `from web.paperclip_api import pc_request; await pc_request("POST", "/api/...", json={...})` — שימוש ב-board API key.
|
||||
- **אסור** `curl ... $PAPERCLIP_API_URL` ישיר ב-bash; **אסור** `httpx.AsyncClient` ישיר ל-Paperclip ב-Python.
|
||||
- **למה:** ה-skill הרשמי דורש `X-Paperclip-Run-Id` בכל קריאה משנה issue. אצלנו ה-audit trail עבד ממילא דרך JWT claims (`runId: runIdHeader || claims.run_id`), אבל ה-helper מבטיח עקביות + תאימות ל-board API keys (long-lived) שלא נושאות JWT claims.
|
||||
|
||||
### Cross-company agent sync — אחרי כל שינוי הגדרות
|
||||
- יש 14 סוכנים = 7 × 2 חברות (CMP=1xxx, CMPA=8xxx). Paperclip מחייב `agents.company_id NOT NULL` — אין shared agents.
|
||||
- **Master = CMP (1xxx)**, **Mirror = CMPA (8xxx)**.
|
||||
- אחרי כל שינוי ב-`adapter_config`, `runtime_config`, `budget_monthly_cents`, או skills של סוכן ב-master (UI, SQL, או API), **חובה להריץ:**
|
||||
```bash
|
||||
PAPERCLIP_BOARD_API_KEY=$(...infisical...) \
|
||||
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify # לבדיקה
|
||||
PAPERCLIP_BOARD_API_KEY=$(...) \
|
||||
python ~/legal-ai/scripts/sync_agents_across_companies.py --apply # לסנכרן
|
||||
```
|
||||
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
|
||||
- שאלות ה-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")
|
||||
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)
|
||||
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;
|
||||
"
|
||||
```
|
||||
@@ -40,7 +40,7 @@ Local (developer machine, pm2):
|
||||
|
||||
External:
|
||||
← Claude API (Opus 4.7 for agents)
|
||||
← Voyage AI (voyage-3-large, 1024-dim embeddings)
|
||||
← Voyage AI (voyage-3, 1024-dim embeddings)
|
||||
← Infisical (secret management)
|
||||
← Gmail SMTP (agent notifications)
|
||||
```
|
||||
@@ -59,7 +59,7 @@ External:
|
||||
- מפעיל OCR (Google Vision) אם PDF ללא טקסט
|
||||
- מריץ proofreader להסרת artifacts מ-Nevo
|
||||
- מחלץ טקסט ל-`documents.extracted_text`
|
||||
- מפצל ל-chunks של ~500 מילים, מחשב embeddings (voyage-3-large, 1024D), שומר ב-`document_chunks`
|
||||
- מפצל ל-chunks של ~500 מילים, מחשב embeddings (voyage-3, 1024D), שומר ב-`document_chunks`
|
||||
4. סטטוס תיק: `new` → `proofread`
|
||||
|
||||
### שלב 2 — ניתוח משפטי (legal-researcher + analyst)
|
||||
@@ -223,7 +223,7 @@ legal-qa מריץ 6 בדיקות איכות:
|
||||
`case_law`, `statutory_provisions`, `transition_phrases`, `lessons_learned`, `style_corpus`, `style_patterns`
|
||||
|
||||
### Layer 4: Semantic Search (RAG)
|
||||
`document_embeddings`, `paragraph_embeddings`, `case_law_embeddings` (pgvector 1024-dim, voyage-3-large)
|
||||
`document_embeddings`, `paragraph_embeddings`, `case_law_embeddings` (pgvector 1024-dim, voyage-3)
|
||||
|
||||
### Layer 5 — Multi-tenancy
|
||||
`companies`, `tag_company_mappings` (appeal_subtype → company_id)
|
||||
@@ -283,7 +283,9 @@ legal-qa מריץ 6 בדיקות איכות:
|
||||
## טכנולוגיות עיקריות
|
||||
|
||||
- **Database**: PostgreSQL 15 + pgvector 0.8.1
|
||||
- **Embeddings**: Voyage AI (`voyage-3-large`, 1024-dim)
|
||||
- **Embeddings**: Voyage AI (`voyage-3`, 1024-dim) + cross-encoder rerank (`rerank-2`)
|
||||
- bi-encoder: voyage-3 לכל chunk (חד-פעמי בעת ingestion)
|
||||
- cross-encoder: rerank-2 לכל query (top-50 → top-K), feature flag `VOYAGE_RERANK_ENABLED`
|
||||
- **Agents**: Claude Opus 4.7 (via Paperclip pm2)
|
||||
- **DOCX manipulation**: `python-docx` 1.2+ ו-`lxml` 5.2+ (XML surgery)
|
||||
- **Frontend**: Next.js + TanStack Query + Tailwind
|
||||
|
||||
179
docs/case-deletion-runbook.md
Normal file
179
docs/case-deletion-runbook.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# מחיקת תיק — runbook
|
||||
|
||||
> **מתי להשתמש:** reset שלם של תיק (לבדיקות end-to-end), מחיקת תיק שנפתח בטעות, או ניקיון לפני העלאה חוזרת של מסמכים.
|
||||
>
|
||||
> **חשוב:** ה-API `DELETE /api/cases` בלבד **לא מספיק** — הוא מטפל רק בצד legal-ai (DB + on-disk dir). תיק חי במקביל ב-4 מערכות והכול חייב להתנקות יחד.
|
||||
|
||||
---
|
||||
|
||||
## איפה ה-state של תיק חי
|
||||
|
||||
| מערכת | מה נשמר | איך מנקים |
|
||||
|---|---|---|
|
||||
| **legal-ai DB** (port 5433) | `cases` + `documents` + `document_chunks` + `claims` + `appraiser_facts` + `decisions` + `qa_results` + `case_precedents` | API DELETE (cascade על FK) |
|
||||
| **legal-ai disk** | `/data/cases/{N}/` בתוך ה-container — מכיל drafts/, documents/, .git/ | API עם `remove_files=true` (`shutil.rmtree` בתוך ה-container) |
|
||||
| **Paperclip DB** (port 54329) | `projects` + `issues` + `issue_comments` + `agent_wakeup_requests` + `heartbeat_runs` (audit) + עוד 6+ טבלאות | SQL ידני (אין API) |
|
||||
| **Gitea** | repo `cases/{N}` אם נוצר ב-case-create | Gitea API |
|
||||
|
||||
ה-API לא מטפל ב-Paperclip ו-Gitea כי אלה מערכות חיצוניות שלגמרי מחוץ ל-DB של legal-ai. תועד מפורשות ב-docstring של [`services/db.py:delete_case`](../mcp-server/src/legal_mcp/services/db.py).
|
||||
|
||||
---
|
||||
|
||||
## תהליך מחיקה מלא — שלב אחרי שלב
|
||||
|
||||
הצב את מספר התיק במשתנה לפני שמתחילים:
|
||||
|
||||
```bash
|
||||
CASE_NUMBER=8174-24
|
||||
```
|
||||
|
||||
### שלב 1 — legal-ai (DB + disk)
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE \
|
||||
"https://legal-ai.nautilus.marcusgroup.org/api/cases?case_number=${CASE_NUMBER}&remove_files=true" \
|
||||
-w "\nhttp=%{http_code}\n"
|
||||
```
|
||||
|
||||
תוצאה צפויה: `200` עם `{"deleted": true, "removed_files": true, ...}`.
|
||||
|
||||
מה זה עושה מאחורי הקלעים:
|
||||
1. `DELETE FROM cases` — מפעיל **CASCADE** ל-7 טבלאות, **SET NULL** ל-`audit_log` ו-`chair_feedback`.
|
||||
2. `shutil.rmtree(/data/cases/{N})` — מסיר את כל הספרייה כולל `.git`.
|
||||
|
||||
> **הערה:** עד לפני [commit `903fb4d`](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/commit/903fb4d) ה-endpoint הזה החזיר 500 כי `db.delete_case` לא היה מוגדר. אם נתקלת ב-500 בגרסה ישנה, השתמש ב-SQL הישיר (ראה Fallback בסוף).
|
||||
|
||||
### שלב 2 — Paperclip
|
||||
|
||||
אין API. SQL ישיר:
|
||||
|
||||
```bash
|
||||
PGPASSWORD=paperclip psql -h localhost -p 54329 -U paperclip -d paperclip <<SQL
|
||||
BEGIN;
|
||||
|
||||
-- 1. מצא את כל ה-issues של הפרויקט (לפי שם)
|
||||
CREATE TEMP TABLE _issue_ids AS
|
||||
SELECT i.id, i.identifier
|
||||
FROM issues i
|
||||
JOIN projects p ON i.project_id = p.id
|
||||
WHERE p.name LIKE '%${CASE_NUMBER}%';
|
||||
|
||||
SELECT identifier FROM _issue_ids ORDER BY identifier; -- וידוא לפני המחיקה
|
||||
|
||||
-- 2. מחק blockers ל-FK עם NO ACTION (אסור למחוק issue אם יש להם reference)
|
||||
DELETE FROM issue_comments WHERE issue_id IN (SELECT id FROM _issue_ids);
|
||||
DELETE FROM cost_events WHERE issue_id IN (SELECT id FROM _issue_ids);
|
||||
DELETE FROM finance_events WHERE issue_id IN (SELECT id FROM _issue_ids);
|
||||
DELETE FROM feedback_votes WHERE issue_id IN (SELECT id FROM _issue_ids);
|
||||
DELETE FROM issue_inbox_archives WHERE issue_id IN (SELECT id FROM _issue_ids);
|
||||
DELETE FROM issue_read_states WHERE issue_id IN (SELECT id FROM _issue_ids);
|
||||
|
||||
-- 3. מחק את ה-issues. CASCADE מטפל ב-7 טבלאות נוספות:
|
||||
-- issue_approvals, issue_attachments, issue_documents,
|
||||
-- issue_execution_decisions, issue_labels, issue_relations,
|
||||
-- issue_work_products
|
||||
DELETE FROM issues WHERE id IN (SELECT id FROM _issue_ids);
|
||||
|
||||
-- 4. שבור FK מ-heartbeat_runs כדי שאפשר יהיה למחוק wakeup_requests.
|
||||
-- heartbeat_runs נשמרים כ-audit log לא משויך.
|
||||
UPDATE heartbeat_runs
|
||||
SET wakeup_request_id = NULL
|
||||
WHERE wakeup_request_id IN (
|
||||
SELECT id FROM agent_wakeup_requests
|
||||
WHERE payload->>'issueId' IN (SELECT id::text FROM _issue_ids)
|
||||
);
|
||||
|
||||
DELETE FROM agent_wakeup_requests
|
||||
WHERE payload->>'issueId' IN (SELECT id::text FROM _issue_ids);
|
||||
|
||||
-- 5. מחק blockers ברמת ה-project (NO ACTION FK ל-projects)
|
||||
DELETE FROM cost_events WHERE project_id IN (SELECT id FROM projects WHERE name LIKE '%${CASE_NUMBER}%');
|
||||
DELETE FROM finance_events WHERE project_id IN (SELECT id FROM projects WHERE name LIKE '%${CASE_NUMBER}%');
|
||||
|
||||
-- 6. מחק את הפרויקט. CASCADE מטפל ב:
|
||||
-- execution_workspaces, project_goals, project_workspaces, routines
|
||||
DELETE FROM projects WHERE name LIKE '%${CASE_NUMBER}%' RETURNING id, name;
|
||||
|
||||
COMMIT;
|
||||
SQL
|
||||
```
|
||||
|
||||
> **למה Paperclip לא הוסיף API למחיקה?** כי זאת מערכת רב-משתמשית ומחיקה היא הרסנית מטבעה — Paperclip מעדיף `archive` (`projects.archived_at`). אנחנו אכן רוצים מחיקה אמיתית רק לסביבת בדיקות.
|
||||
|
||||
### שלב 3 — Gitea (אם repo נוצר)
|
||||
|
||||
```bash
|
||||
GITEA_TOKEN=$(infisical secrets get GITEA__API_TOKEN --silent || \
|
||||
echo "$GITEA_TOKEN") # סגדור מ-Infisical או ENV
|
||||
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"https://gitea.nautilus.marcusgroup.org/api/v1/repos/cases/${CASE_NUMBER}" \
|
||||
-w "http=%{http_code}\n"
|
||||
```
|
||||
|
||||
תוצאה צפויה: `204` (deleted) או `404` (לא נוצר מעולם).
|
||||
|
||||
### שלב 4 — וידוא ניקיון
|
||||
|
||||
```bash
|
||||
echo "=== legal-ai ==="
|
||||
PGPASSWORD=$LEGAL_AI_PG psql -h localhost -p 5433 -U legal_ai -d legal_ai -t -c "
|
||||
SELECT count(*) FROM cases WHERE case_number = '${CASE_NUMBER}';
|
||||
" # → 0
|
||||
|
||||
ls /home/chaim/legal-ai/data/cases/${CASE_NUMBER} 2>&1 | head -1
|
||||
# → "No such file or directory"
|
||||
|
||||
echo "=== Paperclip ==="
|
||||
PGPASSWORD=paperclip psql -h localhost -p 54329 -U paperclip -d paperclip -t -c "
|
||||
SELECT 'projects:'||count(*) FROM projects WHERE name LIKE '%${CASE_NUMBER}%'
|
||||
UNION ALL SELECT 'issues:'||count(*) FROM issues WHERE title LIKE '%${CASE_NUMBER}%'
|
||||
UNION ALL SELECT 'comments:'||count(*) FROM issue_comments WHERE body LIKE '%${CASE_NUMBER}%'
|
||||
UNION ALL SELECT 'wakeups:'||count(*) FROM agent_wakeup_requests WHERE payload::text LIKE '%${CASE_NUMBER}%';
|
||||
" # → all 0
|
||||
|
||||
echo "=== Gitea ==="
|
||||
curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"https://gitea.nautilus.marcusgroup.org/api/v1/repos/cases/${CASE_NUMBER}" \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('full_name','NOT FOUND'))"
|
||||
# → NOT FOUND
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fallback — אם ה-API נשבר
|
||||
|
||||
אם משום מה ה-API DELETE לא עובד (ראינו את זה בעבר עם `delete_case` החסר), עשה DELETE ישיר ב-DB. ה-FK constraints יבצעו את העבודה:
|
||||
|
||||
```sql
|
||||
PGPASSWORD=$LEGAL_AI_PG psql -h localhost -p 5433 -U legal_ai -d legal_ai -c "
|
||||
DELETE FROM cases WHERE case_number = '${CASE_NUMBER}' RETURNING case_number, title;
|
||||
"
|
||||
```
|
||||
|
||||
לאחר מכן הסר את הספרייה מהדיסק. הספרייה בבעלות `root` כי ה-container רץ כ-root, אז תצטרך `sudo`:
|
||||
|
||||
```bash
|
||||
sudo rm -rf /home/chaim/legal-ai/data/cases/${CASE_NUMBER}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## הערות שנלמדו תוך כדי
|
||||
|
||||
1. **`heartbeat_runs.wakeup_request_id`** הוא ה-trap היחיד. הוא NO ACTION FK, ולכן חוסם מחיקה של `agent_wakeup_requests`. הפתרון: `UPDATE ... SET wakeup_request_id = NULL` לפני המחיקה. ה-runs עצמם נשמרים כ-audit log (לא הפסד).
|
||||
|
||||
2. **פרויקט "name" ב-Paperclip** — לפי הקונבנציה הוא מתחיל ב-"ערר {N}" — לכן `LIKE '%{N}%'` מספיק. אם יש מספר תיקים שמכילים את אותו מספר, להחמיר עם match מלא או לפי `id`.
|
||||
|
||||
3. **Container ↔ host file ownership** — קבצים שיוצר ה-container (כולל ספריית התיק) שייכים ל-`root`. מחיקה מהמארח דורשת `sudo`, או דרך docker exec, או דרך ה-API (שמבצעת `rmtree` בתוך ה-container).
|
||||
|
||||
4. **`audit_log` ו-`chair_feedback` נשארים** — FK שלהם הוא SET NULL כדי לשמור היסטוריה גם אחרי שהתיק נמחק. אם אתה צריך מחיקה היסטרית מוחלטת, מחק שורות אלה ידנית.
|
||||
|
||||
---
|
||||
|
||||
## TODO — אוטומציה
|
||||
|
||||
ה-runbook הזה ניתן להמרה לסקריפט `scripts/delete-case.sh` שמקבל `CASE_NUMBER` ומבצע את 4 השלבים עם prompt confirmation. עדיין לא הוטמע — נכון להיום העבודה ידנית.
|
||||
|
||||
מי שמטמיע: שמור את הסקריפט כ-`destructive` ב-SCRIPTS.md ודרוש `--confirm` או prompt אינטראקטיבי. אסור שיעבוד בלי אישור מפורש.
|
||||
@@ -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. עץ החלטה ראשי — בחירת סוג ארכיטקטורה
|
||||
|
||||
```
|
||||
@@ -517,5 +549,6 @@
|
||||
| `daphna-architecture-by-outcome.md` | §1 (עץ ראשי), §2 (משני), §4 (מודי פתיחה) |
|
||||
| `daphna-acceptance-architecture.md` | §1 (עץ ראשי — קבלה), §3.7 (פורמטי סיום) |
|
||||
| `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)
|
||||
- **~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. מה עדיין לא ראינו
|
||||
|
||||
@@ -385,3 +385,64 @@ The draft's biggest structural error was adding the "נבאר" doctrinal paragra
|
||||
- [ ] Update voice-fingerprint: add new transition phrases
|
||||
- [ ] Update architecture-by-outcome: add "clean acceptance" archetype
|
||||
- [ ] 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` — מדריך סגנון של דפנה
|
||||
157
docs/paperclip-quirks.md
Normal file
157
docs/paperclip-quirks.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Paperclip Quirks — מלכודות ידועות
|
||||
|
||||
> **הקשר:** מה ש-Paperclip עושה בעצמו, מתחת לרגליהם של הסוכנים שלנו, ושאנחנו צריכים לעקוף אותו או לחיות איתו.
|
||||
>
|
||||
> כל מלכודת מתועדת עם:
|
||||
> 1. מה קורה בפועל
|
||||
> 2. ראיה אמפירית מתוך לוגים
|
||||
> 3. ההשפעה על הצינור שלנו
|
||||
> 4. עקיפה / תיקון / קבלה
|
||||
|
||||
---
|
||||
|
||||
## 1. `issue.released` הופך `done` ל-`todo`
|
||||
|
||||
### מה קורה
|
||||
|
||||
לאחר שסוכן מבצע `PATCH /api/issues/{id}` עם `status: done`, **Paperclip מבצע פעולה נוספת בשם `issue.released`** מספר שניות מאוחר יותר. ל-`issue.released` יש side-effect לא-מתועד שמחזיר את ה-status ל-`todo`.
|
||||
|
||||
### ראיה אמפירית — תיק 8174-24, CMPA-18 (30/04/26)
|
||||
|
||||
מתוך `activity_log`:
|
||||
|
||||
```
|
||||
ts | action | actor_type | details
|
||||
----------+---------------------+------------+----------------------------------------
|
||||
18:14:49 | issue.comment_added | agent | comment by researcher
|
||||
18:14:57 | issue.updated | agent | {"status": "done", "_previous": {"status": "in_progress"}}
|
||||
18:15:35 | issue.released | agent | ← here
|
||||
```
|
||||
|
||||
מצב מ-`issues` table 38 שניות לאחר ה-`released`:
|
||||
```
|
||||
identifier | status | updated_at
|
||||
CMPA-18 | todo | 18:15:35
|
||||
```
|
||||
|
||||
ה-status חזר מ-`done` ל-`todo` למרות שאף סוכן או משתמש לא ביקש זאת.
|
||||
|
||||
### ההשפעה על הצינור שלנו
|
||||
|
||||
Paperclip מזהה issue ב-`todo` כ"יש עבודה לעשות" → מיד מפעיל wakeup לסוכן הרלוונטי → הסוכן רץ שוב עם prompt cache מלא (~$0.10-0.50 פר-ריצה) → מסתכל סביב ומבין שהעבודה כבר נעשתה → סוגר את ה-issue שוב → `issue.released` חוזר על עצמו ⇒ פוטנציאל ללולאה.
|
||||
|
||||
### עקיפה — בצד שלנו (ללא תיקון Paperclip)
|
||||
|
||||
הסוכן שלנו **עושה זאת כבר היום בהצלחה** במקרה שהוא רואה issue ב-`todo` עם תוצרים קיימים:
|
||||
|
||||
1. בודק שהקבצים הצפויים קיימים (`Glob /documents/research/*.md`)
|
||||
2. בודק שה-DB מאוכלס (`mcp__legal-ai__precedent_list`, `get_claims`, וכו')
|
||||
3. אם הכל קיים → לא מבצע עבודה כפולה → כותב comment "אין שינוי" → `PATCH issue → done`
|
||||
|
||||
**הראיה:** בריצה החוזרת (PID 309786 ב-30/04/26 18:15:54), המנתח של החוקר זיהה תוך 90 שניות שכל 9 התקדימים והקובץ קיימים, וסגר את ה-issue ב-`PATCH → done` שוב. הריצה הזאת עלתה כ-$0.20 — לא חינם, אבל לא לולאה.
|
||||
|
||||
### אם תרצה לחקור פנימה
|
||||
|
||||
ה-`issue.released` נרשם ב-`activity_log` עם `actor_type=agent` אבל בלי `agent_id` שמסביר מי. הוא לא נכתב על ידי הסקריפטים שלנו (אנחנו לא קוראים endpoint כזה). מקור אפשרי:
|
||||
- מנגנון `executionLockedAt` / `executionWorkspaceId` של Paperclip שמשחרר משאבים אחרי שריצה מסתיימת ובמקביל מאפס status
|
||||
|
||||
האפשרות הנכונה לסגור את הבאג היא **ב-Paperclip עצמו** — לתקן את `issue.released` שלא ידרוס status מסוף-מצב כמו `done`. עד שזה נסגר אצלם, אנחנו חיים עם self-recovery.
|
||||
|
||||
### סטטוס
|
||||
|
||||
- **לא נסגר ב-Paperclip** (ידוע לפי 30/04/26)
|
||||
- **טופל בצד שלנו** דרך self-recovery בסקייל של הסוכן (HEARTBEAT.md §4-recovery)
|
||||
- **לתעד עלות**: כל ריצת self-recovery מוסיפה ~$0.20 לתיק
|
||||
|
||||
---
|
||||
|
||||
## 2. Bash backtick trap בעת בניית comment body דרך curl
|
||||
|
||||
### מה קורה
|
||||
|
||||
הסוכן בונה pipeline מורכב כדי לפרסם comment עם markdown ארוך:
|
||||
|
||||
```bash
|
||||
curl ... -d "$(python3 -c "
|
||||
body = '''## כותרת
|
||||
📁 קובץ: \`/path/to/file.md\`
|
||||
'''
|
||||
print(json.dumps({'body': body}))")"
|
||||
```
|
||||
|
||||
ה-`bash` שמריץ את ה-`$(...)` הראשון רואה את ה-backticks (` ` ` ) בתוך המחרוזת של Python ומפרש אותם **כ-command substitution של bash**. הוא מנסה להריץ את `/path/to/file.md` כפקודה, ומכיוון שהקובץ לא executable — מחזיר:
|
||||
|
||||
```
|
||||
/bin/bash: line 56: /path/to/file.md: Permission denied
|
||||
```
|
||||
|
||||
### ההטעיה
|
||||
|
||||
ההודעה `Permission denied` היא **לא** באמת בעיית הרשאות:
|
||||
- `ls -la` מראה שהקובץ הוא `chaim:chaim` עם `-rw-r--r--`
|
||||
- `touch` ידני באותו נתיב מצליח
|
||||
- ה-Write tool כבר כתב את הקובץ הזה בהצלחה דקה קודם
|
||||
|
||||
### למה זה קורה דווקא בנתיבי מסמכים
|
||||
|
||||
Backticks הם תחביר markdown נפוץ לציטוט נתיבים: `` `/home/chaim/...` ``. בפלט markdown זה נכון, אבל כשהסוכן מטמיע את ה-markdown בתוך bash heredoc / command substitution, ה-backticks מפעילים את עצמם.
|
||||
|
||||
### תיקון — דפוס "כתוב לקובץ זמני אז curl -d @file"
|
||||
|
||||
במקום:
|
||||
```bash
|
||||
curl ... -d "$(python3 -c "...long body with backticks...")"
|
||||
```
|
||||
|
||||
עשה:
|
||||
```python
|
||||
# 1. כתוב את ה-body לקובץ זמני דרך Write tool (בלי שום bash quoting)
|
||||
Write("/tmp/comment.json", json.dumps({"body": markdown_body}))
|
||||
```
|
||||
```bash
|
||||
# 2. אז curl קורא מהקובץ — אין shell expansion על התוכן
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" \
|
||||
-d @/tmp/comment.json
|
||||
```
|
||||
|
||||
הנתיב `-d @file` קורא את התוכן של הקובץ **בלי שום ניתוח** — אין shell, אין quoting, אין backticks-as-commands. זה גם מאפשר body של 10K+ תווים ללא הגבלת ARG_MAX.
|
||||
|
||||
### סטטוס
|
||||
|
||||
- **תיעוד ב-HEARTBEAT.md** עם הוראה מפורשת להשתמש ב-Write+`-d @file` ל-bodies מעל 500 תווים
|
||||
- **השפעה היסטורית**: לפני התיקון, הריצה ב-CMPA-18 (30/04/26) הצליחה (curl באמת רץ) — אבל ה-`Permission denied` בלוג היה מבלבל וגרם לחקירה. עתה שהסיבה ידועה, אפשר להתעלם.
|
||||
|
||||
---
|
||||
|
||||
## 3. CEO main issue auto-block ב-`in_progress`
|
||||
|
||||
### מה קורה
|
||||
|
||||
CEO שמסיים turn (פרסם comment "ממתין לסיום של סוכן Y") ומשאיר את ה-issue ב-`in_progress` יקבל auto-block תוך דקה אחת מ-Paperclip ("live execution disappeared"). הסטטוס יקפוץ ל-`blocked` ויידרש wakeup ידני להמשיך.
|
||||
|
||||
### עקיפה
|
||||
|
||||
CEO צריך להעביר את ה-issue ל-`in_review` (לא `in_progress`) כשהוא ממתין למשאב חיצוני (סוכן אחר, יו"ר). זה מתועד ב-CLAUDE.md זיכרון: `feedback_paperclip_enums.md`.
|
||||
|
||||
### סטטוס
|
||||
|
||||
- **תיקון ב-`legal-ceo.md`** (commit a1969dd)
|
||||
- נצפה עובד ב-CMPA-15 ב-30/04/26 — ה-CEO עבר ל-`in_review` נכון
|
||||
|
||||
---
|
||||
|
||||
## 4. Wakeup דרך DB ישיר ≠ wakeup דרך API
|
||||
|
||||
### מה קורה
|
||||
|
||||
`INSERT INTO agent_wakeup_requests` ידני בלי לעבור דרך `POST /api/agents/{id}/wakeup` יוצר רשומת wakeup אבל **לא יוצר `heartbeat_run`**. בלי `heartbeat_run`, ה-runtime של Paperclip לא מזהה שיש משהו להריץ → הסוכן לעולם לא מתעורר.
|
||||
|
||||
### עקיפה
|
||||
|
||||
תמיד להשתמש ב-API. כל הסקייל שלנו תועדו עם האזהרה הזאת.
|
||||
|
||||
### סטטוס
|
||||
|
||||
- **תיקון בכל הסקייל** (CLAUDE.md זיכרון: `reference_paperclip_wakeup.md`)
|
||||
38
docs/runbooks/coolify-mcp-settings-volumes.md
Normal file
38
docs/runbooks/coolify-mcp-settings-volumes.md
Normal file
@@ -0,0 +1,38 @@
|
||||
<!-- docs/runbooks/coolify-mcp-settings-volumes.md -->
|
||||
# Coolify Volume Mounts ל-MCP Settings Page
|
||||
|
||||
## רקע
|
||||
|
||||
טאב **Registrations** בדף `/settings` קורא רישומי MCP מתוך:
|
||||
- `~/.claude.json` (host)
|
||||
- `~/.paperclip/instances/*/mcp.json` (host)
|
||||
|
||||
הקונטיינר של legal-ai חייב גישת קריאה לקבצים אלה דרך volume mounts.
|
||||
בלי המאונט, ה-endpoint יחזיר `error: "host_path_unavailable"` והטאב יציג הודעת אי-זמינות.
|
||||
|
||||
## הוראות
|
||||
|
||||
1. פתח Coolify UI: `http://158.178.131.193:8000`.
|
||||
2. נווט לאפליקציה: legal-ai (UUID `gyjo0mtw2c42ej3xxvbz8zio`).
|
||||
3. לשונית **Storages** → **Add Storage**.
|
||||
4. הוסף שני mounts:
|
||||
|
||||
| Source path (host) | Destination path (container) | Mode |
|
||||
|---|---|---|
|
||||
| `/home/chaim/.claude.json` | `/host/.claude.json` | `ro` |
|
||||
| `/home/chaim/.paperclip` | `/host/.paperclip` | `ro` |
|
||||
|
||||
5. שמור ולחץ **Redeploy**.
|
||||
|
||||
## אימות
|
||||
|
||||
אחרי ה-redeploy:
|
||||
```bash
|
||||
curl -s https://legal-ai.nautilus.marcusgroup.org/api/settings/mcp/registrations | jq
|
||||
```
|
||||
צריך להחזיר `"error": null` ורשימת רישומים.
|
||||
|
||||
## הערה אבטחה
|
||||
|
||||
המאונטים הם read-only. ה-endpoint לא מחזיר ערכי env (רק שמות keys),
|
||||
ולא מאפשר לעדכן את הקבצים.
|
||||
2158
docs/superpowers/plans/2026-05-04-mcp-settings-page.md
Normal file
2158
docs/superpowers/plans/2026-05-04-mcp-settings-page.md
Normal file
File diff suppressed because it is too large
Load Diff
336
docs/superpowers/specs/2026-05-04-mcp-settings-page-design.md
Normal file
336
docs/superpowers/specs/2026-05-04-mcp-settings-page-design.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# דף הגדרות MCP — איפיון
|
||||
|
||||
**תאריך:** 2026-05-04
|
||||
**מצב:** Draft → ממתין לאישור משתמש
|
||||
**הקשר:** הרחבת `/settings` ב-web-ui עם מידע על MCP server של legal-ai (env vars, tools, registrations).
|
||||
|
||||
---
|
||||
|
||||
## 1. מטרה
|
||||
|
||||
לתת ליו"ר/מנהל המערכת מקום מרכזי לראות (ולערוך כשבטוח) את כל מצב התצורה של ה-MCP server, בלי לעבור בין Infisical UI, Coolify UI, וקבצי קונפיגורציה מקומיים.
|
||||
|
||||
## 2. גבולות (Scope)
|
||||
|
||||
**בתוך הסקופ:**
|
||||
- תצוגה + עריכה של env vars לא-סודיים, שמירה ל-Infisical, redeploy ידני של Coolify.
|
||||
- תצוגה (read-only) של env vars סודיים, עם indicator של drift בין Infisical לקונטיינר.
|
||||
- תצוגה (read-only) של רשימת tools שה-MCP server חושף (introspection דינמי).
|
||||
- תצוגה (read-only) של רישומי MCP בקבצי הקונפיגורציה של Claude Code ו-Paperclip.
|
||||
|
||||
**מחוץ לסקופ (אולי בעתיד):**
|
||||
- Enable/disable של tools בודדים.
|
||||
- עריכת `~/.claude.json` או `~/.paperclip/...` מ-UI.
|
||||
- Auth/RBAC חדש (משתמש ב-auth קיים של הדף — אין כרגע).
|
||||
- ניהול secrets — נשאר ב-Infisical UI.
|
||||
- Auto-redeploy אחרי שמירה (משתמש לוחץ Redeploy ידנית).
|
||||
|
||||
## 3. ארכיטקטורה
|
||||
|
||||
### 3.1 מבנה דף (Frontend)
|
||||
|
||||
`/settings` הופך לדף מבוסס-טאבים (`shadcn/Tabs`):
|
||||
|
||||
| Tab | תוכן | מצב |
|
||||
|---|---|---|
|
||||
| Paperclip | התוכן הקיים: Tag mappings + Companies | קיים, ללא שינוי לוגי |
|
||||
| Environment | env vars של MCP server, Infisical / Container | חדש, עריכה |
|
||||
| Tools | רשימת tools של ה-MCP server | חדש, read-only |
|
||||
| Registrations | רישומי MCP ב-Claude Code ו-Paperclip | חדש, read-only |
|
||||
|
||||
טאב ברירת מחדל: `Paperclip`.
|
||||
|
||||
### 3.2 שכבת Backend (FastAPI ב-`web/app.py`)
|
||||
|
||||
#### Endpoints חדשים
|
||||
|
||||
| Path | Method | תיאור |
|
||||
|---|---|---|
|
||||
| `/api/settings/mcp/env` | GET | מחזיר רשימת env vars מאוחדת |
|
||||
| `/api/settings/mcp/env/{key}` | PATCH | מעדכן ערך ב-Infisical (רק לא-סודיים) |
|
||||
| `/api/settings/mcp/env/redeploy` | POST | מפעיל Coolify redeploy |
|
||||
| `/api/settings/mcp/tools` | GET | מחזיר רשימת tools של MCP server |
|
||||
| `/api/settings/mcp/registrations` | GET | מחזיר רישומי MCP מ-`/host/.claude.json` ומ-`/host/.paperclip/instances/*/mcp.json` |
|
||||
|
||||
#### Catalog של env vars
|
||||
|
||||
קובץ חדש: `web/mcp_env_catalog.py`
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Any
|
||||
|
||||
EnvType = Literal["bool", "int", "float", "string", "enum"]
|
||||
EnvCategory = Literal["multimodal", "rerank", "halacha", "credentials", "connection", "general"]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnvSpec:
|
||||
key: str
|
||||
category: EnvCategory
|
||||
type: EnvType
|
||||
description: str
|
||||
is_secret: bool
|
||||
is_editable: bool
|
||||
default: Any = None
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
enum_values: list[str] | None = None
|
||||
|
||||
ENV_CATALOG: dict[str, EnvSpec] = {
|
||||
# multimodal
|
||||
"MULTIMODAL_ENABLED": EnvSpec("MULTIMODAL_ENABLED", "multimodal", "bool",
|
||||
"הפעלת page-image embeddings", False, True, default=False),
|
||||
"MULTIMODAL_MODEL": EnvSpec("MULTIMODAL_MODEL", "multimodal", "string",
|
||||
"מודל multimodal של Voyage", False, True, default="voyage-multimodal-3"),
|
||||
"MULTIMODAL_DPI": EnvSpec("MULTIMODAL_DPI", "multimodal", "int",
|
||||
"DPI ל-rendering של עמוד למודל", False, True, default=144, min=72, max=300),
|
||||
"MULTIMODAL_THUMB_DPI": EnvSpec("MULTIMODAL_THUMB_DPI", "multimodal", "int",
|
||||
"DPI ל-thumbnail בתצוגה", False, True, default=96, min=72, max=200),
|
||||
"MULTIMODAL_TEXT_WEIGHT": EnvSpec("MULTIMODAL_TEXT_WEIGHT", "multimodal", "float",
|
||||
"משקל text vs image ב-RRF", False, True, default=0.5, min=0.0, max=1.0),
|
||||
"MULTIMODAL_RRF_K": EnvSpec("MULTIMODAL_RRF_K", "multimodal", "int",
|
||||
"RRF damping constant", False, True, default=60, min=1, max=200),
|
||||
# rerank
|
||||
"VOYAGE_RERANK_ENABLED": EnvSpec("VOYAGE_RERANK_ENABLED", "rerank", "bool",
|
||||
"הפעלת cross-encoder rerank", False, True, default=False),
|
||||
"VOYAGE_RERANK_MODEL": EnvSpec("VOYAGE_RERANK_MODEL", "rerank", "string",
|
||||
"מודל rerank", False, True, default="rerank-2"),
|
||||
"VOYAGE_RERANK_FETCH_K": EnvSpec("VOYAGE_RERANK_FETCH_K", "rerank", "int",
|
||||
"מספר candidates לפני rerank", False, True, default=50, min=10, max=200),
|
||||
# halacha
|
||||
"HALACHA_AUTO_APPROVE_THRESHOLD": EnvSpec("HALACHA_AUTO_APPROVE_THRESHOLD",
|
||||
"halacha", "float", "סף confidence ל-auto-approve",
|
||||
False, True, default=0.80, min=0.0, max=1.0),
|
||||
# general
|
||||
"VOYAGE_MODEL": EnvSpec("VOYAGE_MODEL", "general", "string",
|
||||
"מודל embedding ראשי", False, True, default="voyage-law-2"),
|
||||
"AUDIT_ENABLED": EnvSpec("AUDIT_ENABLED", "general", "bool",
|
||||
"הפעלת audit log", False, True, default=True),
|
||||
# credentials (read-only, masked)
|
||||
"VOYAGE_API_KEY": EnvSpec("VOYAGE_API_KEY", "credentials", "string",
|
||||
"Voyage AI API key", True, False),
|
||||
"GOOGLE_CLOUD_VISION_API_KEY": EnvSpec("GOOGLE_CLOUD_VISION_API_KEY",
|
||||
"credentials", "string", "Google Cloud Vision API key", True, False),
|
||||
"INFISICAL_TOKEN": EnvSpec("INFISICAL_TOKEN", "credentials", "string",
|
||||
"Infisical SDK token", True, False),
|
||||
# connection (read-only — מסוכן לשנות runtime)
|
||||
"POSTGRES_URL": EnvSpec("POSTGRES_URL", "connection", "string",
|
||||
"PostgreSQL connection URL", True, False),
|
||||
"REDIS_URL": EnvSpec("REDIS_URL", "connection", "string",
|
||||
"Redis connection URL", False, False),
|
||||
"DATA_DIR": EnvSpec("DATA_DIR", "connection", "string",
|
||||
"Data directory path", False, False),
|
||||
}
|
||||
```
|
||||
|
||||
המקור: `mcp-server/src/legal_mcp/config.py`. כל מפתח שלא ב-catalog לא מוצג (whitelist policy).
|
||||
|
||||
#### Response shape של `GET /api/settings/mcp/env`
|
||||
|
||||
```json
|
||||
{
|
||||
"vars": [
|
||||
{
|
||||
"key": "MULTIMODAL_ENABLED",
|
||||
"category": "multimodal",
|
||||
"type": "bool",
|
||||
"description": "הפעלת page-image embeddings",
|
||||
"is_secret": false,
|
||||
"is_editable": true,
|
||||
"default": false,
|
||||
"infisical_value": "true",
|
||||
"container_value": "true",
|
||||
"drift": false,
|
||||
"min": null, "max": null, "enum_values": null
|
||||
},
|
||||
{
|
||||
"key": "VOYAGE_API_KEY",
|
||||
"category": "credentials",
|
||||
"type": "string",
|
||||
"description": "Voyage AI API key",
|
||||
"is_secret": true,
|
||||
"is_editable": false,
|
||||
"infisical_value": "****",
|
||||
"container_value": "****",
|
||||
"drift": false
|
||||
}
|
||||
],
|
||||
"infisical_environment": "dev",
|
||||
"coolify_app_uuid": "gyjo0mtw2c42ej3xxvbz8zio",
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
- `infisical_value`: דרך `InfisicalSDKClient.get_secret(...)`. אם יש שגיאה → `null` ועדכון `errors`.
|
||||
- `container_value`: `os.environ.get(key)`. אם לא מוגדר → `null`.
|
||||
- `drift`: `infisical_value != container_value` (אחרי normalization של bool/int/float; secrets לא משווים ערכים גולמיים — רק hash).
|
||||
- ל-secret: שני הערכים מוחזרים מטושטשים (`"****" + last_4`); השוואת drift על ה-hash בלבד.
|
||||
|
||||
#### Save flow ב-`PATCH /api/settings/mcp/env/{key}`
|
||||
|
||||
1. ולידציה: הקיי קיים ב-catalog ו-`is_editable=true`. אם לא → 400.
|
||||
2. ולידציה לפי type: int/float ב-טווח, bool מוסב מ-string, enum בערכים מותרים.
|
||||
3. כתיבה ל-Infisical:
|
||||
```python
|
||||
client.update_secret(
|
||||
project_id=INFISICAL_PROJECT_ID,
|
||||
environment_slug=INFISICAL_ENV, # "dev" כברירת מחדל
|
||||
secret_path="/legal-ai",
|
||||
secret_name=key,
|
||||
secret_value=str(value),
|
||||
)
|
||||
```
|
||||
4. Audit log: `logger.info("mcp_env_update", extra={"key": key, "value": value if not is_secret else "[masked]"})`.
|
||||
5. Response: `{"ok": true, "requires_redeploy": true, "message": "נשמר ב-Infisical. נדרש redeploy."}`.
|
||||
|
||||
#### Redeploy flow ב-`POST /api/settings/mcp/env/redeploy`
|
||||
|
||||
1. קריאה ל-Coolify API: `POST /api/v1/deploy?uuid=gyjo0mtw2c42ej3xxvbz8zio&force=false`.
|
||||
2. אסימון: `COOLIFY_API_TOKEN` (מ-Infisical).
|
||||
3. Polling: קריאה ל-`/api/v1/deployments/{deployment_uuid}` כל 5 שניות, עד `status="finished"` או `status="failed"` (max 10 דקות).
|
||||
4. UI מציג סטטוס מתעדכן (פשוט: spinner + הודעת סטטוס; לא נדרש streaming).
|
||||
|
||||
#### Tools introspection ב-`GET /api/settings/mcp/tools`
|
||||
|
||||
```python
|
||||
from legal_mcp.server import mcp # FastMCP instance
|
||||
|
||||
async def api_mcp_tools():
|
||||
tools = await mcp.list_tools() # FastMCP API
|
||||
return {
|
||||
"tools": [
|
||||
{
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"module": _module_for_tool(t.name), # מ-tools/__init__.py
|
||||
"params_schema": t.inputSchema,
|
||||
"source_location": _source_location(t), # f"{file}:{line}"
|
||||
}
|
||||
for t in tools
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`_module_for_tool` ו-`_source_location` נכתבים ב-`web/mcp_introspection.py` עם קריאת `inspect.getfile()` ו-`inspect.getsourcelines()`.
|
||||
|
||||
#### Registrations ב-`GET /api/settings/mcp/registrations`
|
||||
|
||||
קורא:
|
||||
1. `/host/.claude.json` — תחת `mcpServers` או `projects.<path>.mcpServers`.
|
||||
2. `/host/.paperclip/instances/*/mcp.json` — לכל instance בנפרד.
|
||||
|
||||
לכל רישום: `{client, instance_name?, server_name, command, args, cwd, env_keys}`.
|
||||
- `env_keys`: רק שמות, לא ערכים.
|
||||
- אם command/args מכילים paths רגישים — מוצגים as-is (לא secrets).
|
||||
|
||||
#### Coolify config — volume mounts נדרשים
|
||||
|
||||
לפני שהפיצ'ר עולה לפרודקשן, יש לוודא ב-Coolify (UUID `gyjo0mtw2c42ej3xxvbz8zio`):
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /home/chaim/.claude.json:/host/.claude.json:ro
|
||||
- /home/chaim/.paperclip:/host/.paperclip:ro
|
||||
```
|
||||
|
||||
המימוש כולל סקריפט/הוראה אופרטיבית להוסיף את ה-mounts (לא חלק מקוד הפרויקט — שינוי תצורה).
|
||||
|
||||
### 3.3 שכבת Frontend
|
||||
|
||||
#### קובץ קיים: `web-ui/src/lib/api/settings.ts`
|
||||
|
||||
מורחב עם hooks חדשים:
|
||||
|
||||
```ts
|
||||
// קריאות חדשות
|
||||
export function useMcpEnv() { /* GET /api/settings/mcp/env */ }
|
||||
export function useUpdateMcpEnv() { /* PATCH /api/settings/mcp/env/{key} */ }
|
||||
export function useMcpRedeploy() { /* POST /api/settings/mcp/env/redeploy */ }
|
||||
export function useMcpTools() { /* GET /api/settings/mcp/tools */ }
|
||||
export function useMcpRegistrations() { /* GET /api/settings/mcp/registrations */ }
|
||||
```
|
||||
|
||||
#### קבצי components חדשים תחת `web-ui/src/app/settings/_components/`
|
||||
|
||||
```
|
||||
_components/
|
||||
├── paperclip-tab.tsx ← העברת התוכן הקיים מ-page.tsx
|
||||
├── environment-tab.tsx ← רשימת קבוצות + EnvVarRow
|
||||
├── env-var-row.tsx ← שורה אחת של env var
|
||||
├── env-var-editor.tsx ← input controls לפי type
|
||||
├── tools-tab.tsx ← טבלה + drawer
|
||||
├── tool-detail-drawer.tsx ← פרטי tool
|
||||
├── registrations-tab.tsx ← כרטיסים לפי client
|
||||
└── drift-badge.tsx ← badge ויזואלי
|
||||
```
|
||||
|
||||
`page.tsx` הופך לאחראי רק על ה-Tabs ולעטיפה.
|
||||
|
||||
#### חוויית עריכת env var
|
||||
|
||||
לחיצה על שורה → התרחבות (accordion) → הצגת editor + שני ערכים (Infisical / Container) + כפתור "שמור".
|
||||
|
||||
לחיצה על "שמור":
|
||||
1. PATCH → toast הצלחה: "נשמר ב-Infisical. לחץ Redeploy כדי להחיל בקונטיינר."
|
||||
2. השורה מסומנת כ-"pending redeploy" עד ה-redeploy הבא.
|
||||
3. כפתור "Redeploy now" קבוע בתחתית הטאב, מודגש כשיש שינויים pending.
|
||||
|
||||
#### חוויית Tools
|
||||
|
||||
טבלה לפי module. שורה → drawer מימין עם schema + תיאור + מיקום בקוד.
|
||||
|
||||
#### חוויית Registrations
|
||||
|
||||
כרטיס לכל client (Claude Code, Paperclip) → פירוט הרישום: command/args/cwd/env_keys.
|
||||
|
||||
## 4. טיפול בשגיאות
|
||||
|
||||
| תרחיש | התנהגות |
|
||||
|---|---|
|
||||
| Infisical לא זמין | `errors: ["infisical_unreachable"]` ב-GET. ערך infisical = null. UI מציג `?` במקום הערך + tooltip |
|
||||
| Coolify redeploy נכשל | toast עם פרטי השגיאה. ערך נשמר ב-Infisical, מסומן pending |
|
||||
| volume mount חסר ב-Coolify | endpoint registrations מחזיר `{registrations: [], error: "host_path_unavailable"}`. UI מציג הודעה |
|
||||
| ניסיון עריכה של secret | 400 עם הודעה ברורה |
|
||||
| ערך לא חוקי לפי type | 400 עם הודעת ולידציה ספציפית |
|
||||
| FastMCP introspection נכשלת | 500. לוג שגיאה. UI מציג fallback |
|
||||
|
||||
## 5. בטיחות
|
||||
|
||||
- **לא להציג ערכי secret** — ה-API מחזיר תמיד `****<last_4>` עבור secrets.
|
||||
- **Drift detection לא חושף** — השוואה על hash, לא על ערך גולמי.
|
||||
- **PATCH על secret חסום ב-server** — לא רק ב-UI.
|
||||
- **No raw `os.environ` dump** — ה-endpoint מחזיר רק keys ב-catalog.
|
||||
- **Audit log** — כל PATCH מתועד ל-`logger.info` (key + ערך אם לא-סודי).
|
||||
|
||||
## 6. שלבי מימוש (overview ל-plan)
|
||||
|
||||
1. Catalog + endpoint `GET /api/settings/mcp/env` (ללא עריכה).
|
||||
2. UI טאב Environment — read-only עם drift badges.
|
||||
3. PATCH endpoint + UI editor.
|
||||
4. Redeploy endpoint + UI button.
|
||||
5. Tools introspection + UI.
|
||||
6. Volume mounts הוראה (manual Coolify config) + Registrations endpoint + UI.
|
||||
7. בדיקות ידניות end-to-end.
|
||||
|
||||
## 7. שאלות פתוחות (להבהרה לפני plan)
|
||||
|
||||
- **סביבת Infisical** — `dev`? `nautilus`? להחליט סופית. ברירת מחדל ב-spec: `dev`. ייתכן ויהיה ניתן לקבוע ב-env var (`INFISICAL_ENV`).
|
||||
- **Path ב-Infisical** — `/legal-ai`? `/legal-ai/mcp`? להחליט לפי `_GUIDELINES/SAVE_SECRET_RULES`.
|
||||
- **Auth** — אין כרגע על `/settings`. להוסיף לפחות "are you sure" dialog לפני PATCH של ערך משמעותי?
|
||||
|
||||
## 8. בדיקות
|
||||
|
||||
**ידני (אין test suite ל-frontend):**
|
||||
- ✓ פתיחת `/settings` — Paperclip tab עובד כקודם.
|
||||
- ✓ Environment tab — מציג env vars מקבץ catalog בלבד.
|
||||
- ✓ Drift detection — שינוי ידני של env בקונטיינר → drift badge מופיע.
|
||||
- ✓ עריכת `MULTIMODAL_TEXT_WEIGHT` ל-`0.7` → נשמר ב-Infisical.
|
||||
- ✓ Redeploy → ערך חדש נכנס לתוקף בקונטיינר.
|
||||
- ✓ ניסיון עריכת `VOYAGE_API_KEY` → חסום + הודעה.
|
||||
- ✓ Tools tab — מציג את כל ה-tools של legal_mcp.
|
||||
- ✓ Registrations tab — מציג את `~/.claude.json` ו-Paperclip instances.
|
||||
|
||||
**Backend tests** ב-`web/tests/` (אם קיימים — אחרת לדלג):
|
||||
- catalog rejects unknown key
|
||||
- PATCH על secret נחסם
|
||||
- ולידציה של min/max
|
||||
409
docs/voyage-upgrades-plan.md
Normal file
409
docs/voyage-upgrades-plan.md
Normal file
@@ -0,0 +1,409 @@
|
||||
# שדרוגי Voyage — תכנית מפורטת
|
||||
|
||||
תכנית 3-שלבית לשדרוג שכבת ה-retrieval של עוזר משפטי. שלב A מבוצע
|
||||
בתאריך התכנית; שלבים B ו-C ממתינים לשיחה החדשה.
|
||||
|
||||
**הקשר**: Voyage = חיפוש (find), Claude = הבנה+כתיבה (read+write). שני
|
||||
המנועים מנותקים ארכיטקטונית — שינוי שכבת ה-retrieval לא משפיע על קלוד
|
||||
עצמו, רק על איזה chunks מגיעים אליו לקריאה.
|
||||
|
||||
---
|
||||
|
||||
## שלב A — מעבר ל-voyage-3 (✅ מבוצע)
|
||||
|
||||
### למה voyage-3 ולא voyage-law-2?
|
||||
|
||||
Benchmark על 3 שאילתות עברית-משפטית עם passages אמיתיים מהקורפוס:
|
||||
|
||||
| מודל | Perfect orderings | Total Separation |
|
||||
|---|---|---|
|
||||
| **voyage-3** | **3/3** | **+0.483** |
|
||||
| voyage-3.5 | 3/3 | +0.278 |
|
||||
| voyage-law-2 *(היה)* | 3/3 | +0.238 |
|
||||
| voyage-4 | 2/3 | +0.423 |
|
||||
| voyage-4-large | 2/3 | +0.353 |
|
||||
|
||||
voyage-3 **מנצח כפול** — דירוג מושלם + מרווחים גדולים פי-2 מ-voyage-law-2.
|
||||
מימד נשאר 1024 → אין שינוי schema.
|
||||
|
||||
### מה בוצע
|
||||
|
||||
1. **Coolify env**: `VOYAGE_MODEL=voyage-3` בקונטיינר
|
||||
2. **Local env (`~/.env`)**: `VOYAGE_MODEL=voyage-3`
|
||||
3. **Re-embed של 5 טבלאות** באמצעות `scripts/reembed_voyage.py`:
|
||||
- `document_chunks` — מסמכי תיקים (~6K rows)
|
||||
- `paragraph_embeddings` — קורפוס סגנון (כעת ריק)
|
||||
- `case_law_embeddings` — stubs מצוטטים אוטו'
|
||||
- `precedent_chunks` — פסיקה שהועלתה (~385)
|
||||
- `halachot.embedding` — 400 הלכות (rule_statement + reasoning)
|
||||
4. **MCP server restart** — טעינה מחדש של `embeddings.py` עם המודל החדש
|
||||
|
||||
### Verification
|
||||
|
||||
- `search_precedent_library` על "תכנית רחביה" → 403/17 holding ראשון
|
||||
- `search_decisions` על "השבחה" → תוצאות עקביות
|
||||
- ה-counts בטבלאות לא ירדו (כל row עודכן, לא נמחק)
|
||||
|
||||
### Rollback אם משהו נשבר
|
||||
|
||||
- `VOYAGE_MODEL=voyage-law-2` ב-Coolify + `~/.env`
|
||||
- הרצה מחדש של `scripts/reembed_voyage.py` (חוזרים לקודם)
|
||||
- 10 דקות סך-הכל
|
||||
|
||||
---
|
||||
|
||||
## שלב B — voyage-rerank-2 (Cross-encoder reranking)
|
||||
|
||||
> **שינוי מהותי מהתכנית המקורית.** המקור היה ל-context-3. POC רחב
|
||||
> (4 בנצ'מרקים) הראה ש-context-3 לא משפר עקבית, ובחלק מהמקרים מציג
|
||||
> רגרסיה. במקום זאת, **rerank-2** (cross-encoder) הצליח לתת שיפור של
|
||||
> +4.5% mean@3 על קורפוס מלא של 785 docs, **+11.6% על שאילתות
|
||||
> מעשיות** (P-category — בדיוק התרחיש של legal-writer/legal-researcher),
|
||||
> בלי שינוי schema, בלי re-embed, ובלי double storage.
|
||||
|
||||
### למה rerank-2 ולא context-3?
|
||||
|
||||
POC #4 (אהרון ברק, 18 שאילתות, claude-haiku-4-5 כ-judge):
|
||||
|
||||
| Retriever | mean@3 | mean@5 | MRR |
|
||||
|---|---|---|---|
|
||||
| voyage-3 (baseline) | 3.278 | 3.300 | 0.741 |
|
||||
| **voyage-3 + rerank-2** | **3.574** | **3.467** | **0.769** |
|
||||
| voyage-context-3 (windowed) | 3.481 | 3.378 | 0.685 |
|
||||
|
||||
POC #5 (קורפוס מלא 785 docs, 12 שאילתות):
|
||||
|
||||
| Retriever | mean@3 | קטגוריה P (practical) |
|
||||
|---|---|---|
|
||||
| voyage-3 | 4.306 | 3.78 |
|
||||
| **voyage-3 + rerank-2** | **4.500 (+4.5%)** | **4.22 (+11.6%)** |
|
||||
|
||||
context-3 גם נכשל בקטגוריות keyword שהן 60%+ מהשאילתות בפועל אצל דפנה.
|
||||
|
||||
### איך rerank-2 עובד
|
||||
|
||||
Two-stage retrieval:
|
||||
1. **שלב bi-encoder (כמו היום)**: voyage-3 מטמיע את ה-query, מחזיר
|
||||
top-50 chunks דרך cosine similarity על `pgvector` (מהיר, ~390ms).
|
||||
2. **שלב cross-encoder (חדש)**: rerank-2 מקבל `(query, document)` עבור
|
||||
כל אחד מ-50 הdocuments, ומחזיר ציון רלוונטיות מדויק יותר.
|
||||
הreranker רואה את ה-query ואת ה-doc ביחד דרך attention מלא,
|
||||
לעומת bi-encoder שרק מחשב cosine בין שני embeddings בלתי-תלויים.
|
||||
3. החזרה: top-K (10) המדורגים מחדש.
|
||||
|
||||
**עלות**: +702ms latency (bi-encoder=393ms → +rerank=1095ms).
|
||||
**עלות tokens**: zero לאחסון (רק חישוב per-query).
|
||||
|
||||
### תכנית יישום
|
||||
|
||||
#### B.1 — `voyage_rerank()` ב-`embeddings.py`
|
||||
|
||||
```python
|
||||
async def voyage_rerank(
|
||||
query: str, documents: list[str], top_k: int = 10,
|
||||
) -> list[tuple[int, float]]:
|
||||
"""Cross-encoder rerank via Voyage. Returns [(orig_index, score), ...]."""
|
||||
if not documents:
|
||||
return []
|
||||
client = _get_client()
|
||||
result = client.rerank(
|
||||
query=query, documents=documents,
|
||||
model=config.VOYAGE_RERANK_MODEL, # "rerank-2"
|
||||
top_k=top_k,
|
||||
)
|
||||
return [(r.index, r.relevance_score) for r in result.results]
|
||||
```
|
||||
|
||||
#### B.2 — Feature flag ב-`config.py`
|
||||
|
||||
```python
|
||||
VOYAGE_RERANK_MODEL = os.environ.get("VOYAGE_RERANK_MODEL", "rerank-2")
|
||||
VOYAGE_RERANK_ENABLED = (
|
||||
os.environ.get("VOYAGE_RERANK_ENABLED", "false").lower() == "true"
|
||||
)
|
||||
VOYAGE_RERANK_FETCH_K = int(os.environ.get("VOYAGE_RERANK_FETCH_K", "50"))
|
||||
```
|
||||
|
||||
הdefault הוא `false` — הקוד יישמר אך לא יורץ עד שיופעל ידנית.
|
||||
|
||||
#### B.3 — אינטגרציה ב-3 search functions
|
||||
|
||||
ב-`db.py`:
|
||||
- `search_similar` (document_chunks) — נוסיף פרמטר `rerank: bool = False`.
|
||||
אם True: שולפים top-`VOYAGE_RERANK_FETCH_K` במקום `limit`,
|
||||
מעבירים דרך rerank, מחזירים top-`limit`.
|
||||
- `search_precedent_library_semantic` — אותו דבר. הuance: היום יש
|
||||
boost של +0.05 ל-halachot. כש-rerank פעיל, ה-boost מתבטל ו-rerank
|
||||
מוחל על המאוחד (chunks + halachot ביחד) — cross-encoder יבחר נכון
|
||||
בלי boost מלאכותי.
|
||||
- `search_similar_paragraphs` / `search_similar_case_law` (ב-style
|
||||
corpus) — אותו דבר.
|
||||
|
||||
ב-`tools/search.py` — כל הtools (`search_decisions`, `search_case_documents`,
|
||||
`find_similar_cases`, `precedent_search_library`) יעבירו
|
||||
`rerank=config.VOYAGE_RERANK_ENABLED` לקריאות ה-DB.
|
||||
|
||||
#### B.4 — Schema
|
||||
|
||||
אין שינוי. אותם vectors, אותו pgvector.
|
||||
|
||||
#### B.5 — Rollout
|
||||
|
||||
1. שינוי קוד + push + deploy עם feature flag = `false`
|
||||
2. אימות ש-baseline ממשיך לעבוד (לא רגרסיה)
|
||||
3. הפעלה ידנית: `VOYAGE_RERANK_ENABLED=true` ב-Coolify env
|
||||
4. שאילתות אמיתיות מדפנה / סוכנים — observation
|
||||
5. אם רגרסיה — kill switch בשניות (`false` בחזרה)
|
||||
6. אם כל מתעקפם — להגדיר `true` כdefault (in-code) אחרי שבוע יציב
|
||||
|
||||
#### B.6 — Tier check
|
||||
|
||||
Voyage Tier 1: 2M TPM, 2000 RPM ל-rerank-2. עומס שלנו (~עשרות
|
||||
queries בשעה במקרה רגיל) — מתחת ל-1% מהמכסה.
|
||||
|
||||
---
|
||||
|
||||
## שלב C — voyage-multimodal-3 (✅ בוצע 2026-05-03)
|
||||
|
||||
> **תיקון שם המודל מהתכנית המקורית**: השם הסופי הוא
|
||||
> `voyage-multimodal-3` (לא 3.5). הוצמד לזה ש-POC #3 הריץ.
|
||||
|
||||
### מצב סופי בייצור
|
||||
|
||||
- `MULTIMODAL_ENABLED=true` ב-Coolify env
|
||||
- Schema V9 ב-DB (document_image_embeddings + precedent_image_embeddings)
|
||||
- 419 page-image embeddings על 8174-24 (146) + 8137-24 (273)
|
||||
- 819 text chunks קיבלו page_number (100% retrofit)
|
||||
- RRF hybrid merge עם boost text+image פעיל
|
||||
|
||||
### שינויים מהתכנית המקורית — שני תיקונים אמפיריים
|
||||
|
||||
1. **Score scaling — Reciprocal Rank Fusion במקום weighted sum.**
|
||||
ה-cosine של voyage-3 (~0.4-0.5) שיטתית גבוה מ-voyage-multimodal-3
|
||||
(~0.20-0.25). A/B ראשון על 7 שאילתות הראה: עם 0.65/0.35 weighted
|
||||
sum ו-MULTIMODAL_ENABLED=true, **0** image rows הופיעו ב-top-5,
|
||||
image side פשוט הוצף. עברנו ל-RRF (`rrf_score = w / (k + rank)`)
|
||||
שעמיד לסקיילים שונים. תוצאה: 5/5 results עם image contribution
|
||||
בכל שאילתה.
|
||||
|
||||
2. **Page tracking — chunker חדש + retrofit ל-819 chunks קיימים.**
|
||||
ה-chunker הישן זרק את ה-page_number של chunks. בלעדיו ה-boost
|
||||
text+image (join על `(document_id, page_number)`) לא יכול לפעול.
|
||||
נוסף `page_offsets` ל-`extractor.extract_text` (משלשה במקום זוג —
|
||||
מעודכן ב-6 callers); chunker מקבל אותו ומסמן page לכל chunk לפי
|
||||
offset של התווים הראשונים שלו. retrofit ל-chunks קיימים
|
||||
(`scripts/backfill_chunk_pages.py`) עובד **בלי re-OCR** —
|
||||
משתמש ב-stored extracted_text כמקור (matches existing chunk
|
||||
content verbatim) ו-PyMuPDF direct text reads כעיגוני page
|
||||
boundaries; pages סרוקים ללא טקסט ישיר עוברים אינטרפולציה.
|
||||
|
||||
### למה NOT לעשות re-OCR ב-retrofit
|
||||
|
||||
ניסיון ראשון השתמש ב-`extractor.extract_text` להפיק page_offsets
|
||||
חדשים. תוצאה: 1/29 chunks נמצאו (28 not found), כי OCR של Google
|
||||
Vision לא דטרמיניסטי — ה-OCR החדש שונה מה-OCR שהפיק את ה-chunks
|
||||
המקוריים. הגרסה החדשה משתמשת ב-stored `documents.extracted_text`
|
||||
שמתאים לחלוטין לתוכן ה-chunks. עלות: $0 (לעומת ~$0.0015/page).
|
||||
|
||||
### Files שהשתנו (יחסית למה שהמסמך הזה תיכנן)
|
||||
|
||||
קוד שנכתב/שונה (5 commits, 242f668 → 8a815ec):
|
||||
- `mcp-server/src/legal_mcp/config.py` — flags MULTIMODAL_*
|
||||
- `mcp-server/src/legal_mcp/services/extractor.py` — render + page_offsets
|
||||
- `mcp-server/src/legal_mcp/services/embeddings.py` — embed_images
|
||||
- `mcp-server/src/legal_mcp/services/db.py` — schema V9 + 4 store/search funcs
|
||||
- `mcp-server/src/legal_mcp/services/chunker.py` — page tracking
|
||||
- `mcp-server/src/legal_mcp/services/processor.py` — ingest integration
|
||||
- `mcp-server/src/legal_mcp/services/precedent_library.py` — same
|
||||
- `mcp-server/src/legal_mcp/services/hybrid_search.py` — חדש, RRF orchestrator
|
||||
- `mcp-server/src/legal_mcp/tools/search.py` — wired to hybrid
|
||||
- `mcp-server/src/legal_mcp/tools/documents.py` + `tools/workflow.py` + `web/app.py` — extract_text triple unpack
|
||||
- `scripts/multimodal_backfill.py` + `scripts/backfill_chunk_pages.py` — חדשים
|
||||
|
||||
### מה נשאר (deferred)
|
||||
|
||||
- UI thumbnails בתוצאות חיפוש (לא חוסם — דפנה מקבלת page numbers)
|
||||
- Backfill על שאר הקורפוס (מעבר ל-2 התיקים): לא דחוף, אפשר per-case
|
||||
- `text_weight` תיאום: כרגע 0.5 (vanilla RRF). אם דפנה תגיד שהיא רואה
|
||||
יותר מדי image-influence, מעלים ל-0.55-0.6 דרך env בלי deploy.
|
||||
|
||||
---
|
||||
|
||||
## שלב C המקורי (תכנון, לרפרנס)
|
||||
|
||||
### הבעיה שהוא פותר
|
||||
|
||||
תיקים סרוקים ודוחות שמאי מאבדים מידע ב-OCR:
|
||||
- ✗ פריסת טבלאות (שורות נתונים מתבלגנות)
|
||||
- ✗ חתימות וחותמות
|
||||
- ✗ דיאגרמות, מפות, תרשימים אדריכליים
|
||||
- ✗ נוסחאות מתמטיות
|
||||
|
||||
OCR קיים (Google Cloud Vision) ממיר תמונות לטקסט אבל מטפל בעמוד כשורה-
|
||||
אחר-שורה. תוצאה: בדוח שמאי "שווי לפני | שווי אחרי | ≈ 1.5M ש"ח" הופך
|
||||
ל-"שווי לפני שווי אחרי 1.5M ש"ח" — חיפוש "שומה ל-1.5M" לא תמיד מוצא.
|
||||
|
||||
### מה voyage-multimodal-3.5 עושה
|
||||
|
||||
API: `client.multimodal_embed(inputs=[[image, text?], ...])`. מקבל
|
||||
תמונה (PIL Image או URL) ומחזיר embedding שכולל:
|
||||
- את הטקסט שעל העמוד
|
||||
- את **המבנה הוויזואלי** (טבלה, חתימה, מיקומי גוש)
|
||||
- תרשימים ודיאגרמות
|
||||
|
||||
Searchable יחד עם text embeddings — query טקסטואלית רגילה מוצאת גם
|
||||
פסקאות עם טבלה רלוונטית.
|
||||
|
||||
### תכנית יישום
|
||||
|
||||
#### C.1 — Schema חדש
|
||||
|
||||
```sql
|
||||
CREATE TABLE document_image_embeddings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
|
||||
page_number INTEGER NOT NULL,
|
||||
image_thumbnail_path TEXT, -- לסרגל תוצאות חיפוש
|
||||
embedding vector(1024),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_doc_img_emb_vec
|
||||
ON document_image_embeddings USING ivfflat (embedding vector_cosine_ops);
|
||||
|
||||
CREATE TABLE precedent_image_embeddings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
|
||||
page_number INTEGER NOT NULL,
|
||||
image_thumbnail_path TEXT,
|
||||
embedding vector(1024),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_prec_img_emb_vec
|
||||
ON precedent_image_embeddings USING ivfflat (embedding vector_cosine_ops);
|
||||
```
|
||||
|
||||
#### C.2 — Pipeline שינוי
|
||||
|
||||
חדש ב-`extractor.py`:
|
||||
```python
|
||||
async def render_pages_as_images(pdf_path: str) -> list[bytes]:
|
||||
"""PyMuPDF render of each page → PNG bytes for multimodal embedding."""
|
||||
import fitz
|
||||
doc = fitz.open(pdf_path)
|
||||
images = []
|
||||
for page in doc:
|
||||
pix = page.get_pixmap(dpi=144) # decent resolution for embeddings
|
||||
images.append(pix.tobytes("png"))
|
||||
return images
|
||||
```
|
||||
|
||||
חדש ב-`embeddings.py`:
|
||||
```python
|
||||
async def embed_images(images: list[bytes], input_type: str = "document") -> list[list[float]]:
|
||||
"""Embed page images via voyage-multimodal-3.5."""
|
||||
from PIL import Image
|
||||
import io
|
||||
pil_images = [Image.open(io.BytesIO(img)) for img in images]
|
||||
response = _get_client().multimodal_embed(
|
||||
inputs=[[img] for img in pil_images],
|
||||
model="voyage-multimodal-3.5",
|
||||
input_type=input_type,
|
||||
)
|
||||
return response.embeddings
|
||||
```
|
||||
|
||||
#### C.3 — Integration ב-ingest pipelines
|
||||
|
||||
`processor.py:process_document` (תיק):
|
||||
```python
|
||||
# אחרי extract+chunk+embed הטקסטואלי:
|
||||
images = await extractor.render_pages_as_images(file_path)
|
||||
img_embs = await embeddings.embed_images(images)
|
||||
await db.store_document_image_embeddings(document_id, img_embs, thumbnails)
|
||||
```
|
||||
|
||||
`precedent_library.py:ingest_precedent`: אותו pattern, על
|
||||
`precedent_image_embeddings`.
|
||||
|
||||
#### C.4 — Hybrid search
|
||||
|
||||
חדש ב-`db.py:search_precedent_library_hybrid`:
|
||||
```python
|
||||
async def search_precedent_library_hybrid(query, limit=10):
|
||||
query_emb = await embeddings.embed_query(query)
|
||||
query_img_emb = await embeddings.embed_query_for_multimodal(query)
|
||||
|
||||
text_results = ... # cosine on precedent_chunks (top 30)
|
||||
image_results = ... # cosine on precedent_image_embeddings (top 30)
|
||||
|
||||
# Merge: weighted score (text 0.6, image 0.4 — tunable)
|
||||
merged = {}
|
||||
for r in text_results: merged[r.case_law_id] = r.score * 0.6
|
||||
for r in image_results:
|
||||
merged[r.case_law_id] = merged.get(r.case_law_id, 0) + r.score * 0.4
|
||||
|
||||
return sorted(merged.items(), key=lambda x: -x[1])[:limit]
|
||||
```
|
||||
|
||||
#### C.5 — UI: thumbnails בתוצאות חיפוש
|
||||
|
||||
ב-`/precedents` חיפוש סמנטי, התוצאות עם רכיב image יציגו thumbnail
|
||||
קטן של העמוד. לחיצה תפתח את ה-PDF במקום הרלוונטי.
|
||||
|
||||
#### C.6 — סדר עדיפויות לדיגום
|
||||
|
||||
1. **דוחות שמאי** — הזכייה הגדולה (טבלאות = ערכים מספריים שכרגע
|
||||
הולכים לאיבוד ב-OCR)
|
||||
2. **תיקים סרוקים ישנים** — שיפור ה-recall של חיפוש
|
||||
3. **פסיקה עם דיאגרמות** (תרשימי גוש/חלקה) — minor
|
||||
|
||||
#### C.7 — עלות + tier
|
||||
|
||||
voyage-multimodal-3.5 הוא מוצר נפרד. בdoc'ים פר-עמוד:
|
||||
- תיק ממוצע: 50-200 עמודים
|
||||
- 100 תיקים = 5,000-20,000 עמודים
|
||||
- Free tier: 200M tokens/month — אבל multimodal נמדד ב-tokens שונה
|
||||
(התמונה צורכת ~1000-2000 tokens לעמוד)
|
||||
|
||||
הערכה: 100 תיקים × 100 עמודים × 1500 tokens = 15M tokens. בthe
|
||||
free tier בקלות. צריך לבדוק תקרת שימוש בפועל בdocs של voyage.
|
||||
|
||||
#### C.8 — שלבים מומלצים
|
||||
|
||||
1. **POC** — תיק אחד עם דו"ח שמאי. embed → search → השוואה לתוצאות
|
||||
טקסט-בלבד.
|
||||
2. **A/B test** — חצי מהתיקים החדשים עם multimodal, חצי בלי. 4
|
||||
שבועות בדיקה — האם דפנה מוצאת תוצאות מדויקות יותר?
|
||||
3. **Rollout** — אם המבחן חיובי, לעבד את הקורפוס הקיים ברקע
|
||||
|
||||
### החלטות שנשארו פתוחות
|
||||
|
||||
- ✋ DPI לרינדור: 144 (סביר), 200 (איכות), 96 (מהיר)?
|
||||
- ✋ נשמור thumbnails ב-disk או רק את ה-embeddings?
|
||||
- ✋ משקלות hybrid search: 0.6/0.4 או יותר נטוי לטקסט?
|
||||
|
||||
---
|
||||
|
||||
## רצף עבודה בשיחה החדשה
|
||||
|
||||
> 1. פתחי `docs/voyage-upgrades-plan.md` (זה המסמך)
|
||||
> 2. אם A הצליח (verify ב-Coolify env), נמשיך ל-B (context-3)
|
||||
> 3. **B.5 קודם** — benchmark לפני re-embed גדול
|
||||
> 4. אם B מצליח, רץ ל-C — אבל ב-2 צעדים זהירים (POC → A/B → rollout)
|
||||
|
||||
---
|
||||
|
||||
## נספח: רשימה של קבצים שנגעו ב-Voyage היום
|
||||
|
||||
קוד שנכתב/שונה:
|
||||
- `scripts/reembed_voyage.py` — חדש, סקריפט re-embed
|
||||
- `~/.env` — `VOYAGE_MODEL=voyage-3`
|
||||
- Coolify env (legal-ai app) — `VOYAGE_MODEL=voyage-3`
|
||||
|
||||
קבצים שלא צריכים שינוי (CONFIRM):
|
||||
- `mcp-server/src/legal_mcp/services/embeddings.py` — קורא ל-config.VOYAGE_MODEL
|
||||
- `mcp-server/src/legal_mcp/config.py` — default ל-voyage-law-2 אבל env
|
||||
בקוולפיי + מקומית מנצח
|
||||
- כל הסוכנים (legal-writer, etc.) — לא קוראים ל-Voyage ישירות
|
||||
|
||||
עבור B + C: השינויים במסמך הזה (לא מבוצעים עדיין).
|
||||
@@ -20,6 +20,7 @@ dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"httpx>=0.27.0",
|
||||
"infisicalsdk>=1.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
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())
|
||||
@@ -47,6 +47,71 @@ VOYAGE_API_KEY = os.environ.get("VOYAGE_API_KEY", "")
|
||||
VOYAGE_MODEL = os.environ.get("VOYAGE_MODEL", "voyage-law-2")
|
||||
VOYAGE_DIMENSIONS = 1024
|
||||
|
||||
# Rerank — cross-encoder second-stage. Off by default; flip with env to
|
||||
# enable across all semantic search tools (search_decisions,
|
||||
# search_case_documents, find_similar_cases, search_precedent_library).
|
||||
VOYAGE_RERANK_MODEL = os.environ.get("VOYAGE_RERANK_MODEL", "rerank-2")
|
||||
VOYAGE_RERANK_ENABLED = (
|
||||
os.environ.get("VOYAGE_RERANK_ENABLED", "false").lower() == "true"
|
||||
)
|
||||
# How many candidates to fetch from bi-encoder before reranking.
|
||||
# 50 was the depth used in the POC; balances recall vs rerank cost.
|
||||
VOYAGE_RERANK_FETCH_K = int(os.environ.get("VOYAGE_RERANK_FETCH_K", "50"))
|
||||
|
||||
# Multimodal — page-image embeddings via voyage-multimodal-3. Off by
|
||||
# default; flip with env to enable per-page image embedding during
|
||||
# ingestion + hybrid (text+image) ranking at search time. POC #3
|
||||
# validated on a 89-page appraisal PDF (38s, 312K tokens, recovered
|
||||
# table structure + image-only scanned pages that text-OCR misses).
|
||||
MULTIMODAL_ENABLED = (
|
||||
os.environ.get("MULTIMODAL_ENABLED", "false").lower() == "true"
|
||||
)
|
||||
MULTIMODAL_MODEL = os.environ.get("MULTIMODAL_MODEL", "voyage-multimodal-3")
|
||||
# Render DPI for the image fed to the embedder. POC used 144 — sweet
|
||||
# spot between embedding quality and tokens/page (144 ≈ 3.5K tok/page).
|
||||
MULTIMODAL_DPI = int(os.environ.get("MULTIMODAL_DPI", "144"))
|
||||
# Separate, lower DPI for the JPEG thumbnail saved to disk for UI
|
||||
# preview. ~96dpi → ~20KB/page; ingestion-time, no re-render at view.
|
||||
MULTIMODAL_THUMB_DPI = int(os.environ.get("MULTIMODAL_THUMB_DPI", "96"))
|
||||
# Hybrid merge: Reciprocal Rank Fusion (RRF) bias for the *text* side.
|
||||
# voyage-3 cosine scores (~0.4-0.5) and voyage-multimodal-3 scores
|
||||
# (~0.20-0.25) live on different scales; a direct weighted sum lets
|
||||
# text always dominate. RRF is rank-based and robust to that. The
|
||||
# weight here biases the contribution of each side: 0.5 = balanced
|
||||
# (vanilla RRF), >0.5 favours text, <0.5 favours image. Tunable per
|
||||
# env without redeploy.
|
||||
MULTIMODAL_TEXT_WEIGHT = float(
|
||||
os.environ.get("MULTIMODAL_TEXT_WEIGHT", "0.5")
|
||||
)
|
||||
# RRF damping constant. Standard literature value is 60: lower values
|
||||
# concentrate weight at top ranks; higher values flatten the curve.
|
||||
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
|
||||
# confidence >= this value are inserted with review_status='approved'
|
||||
# instead of 'pending_review' (so they immediately appear in
|
||||
# search_precedent_library). Set to a value > 1.0 to disable auto-approval.
|
||||
# 0.80 baseline: 89% of historical extractions land here, manual spot-check
|
||||
# of 10 random samples confirmed quality. Tunable via env if drift is
|
||||
# observed (e.g. raise to 0.90 if false-positives appear).
|
||||
HALACHA_AUTO_APPROVE_THRESHOLD = float(
|
||||
os.environ.get("HALACHA_AUTO_APPROVE_THRESHOLD", "0.80")
|
||||
)
|
||||
|
||||
# Google Cloud Vision (OCR for scanned PDFs)
|
||||
GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "")
|
||||
|
||||
@@ -67,6 +132,43 @@ def find_case_dir(case_number: str) -> Path:
|
||||
CHUNK_SIZE_TOKENS = 600
|
||||
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
|
||||
ALLOWED_EXTERNAL_SERVICES = {
|
||||
"api.voyageai.com", # Voyage AI (embeddings)
|
||||
|
||||
@@ -23,12 +23,17 @@ logger = logging.getLogger("legal_mcp")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(server: FastMCP) -> AsyncIterator[None]:
|
||||
"""Initialize DB schema on startup, close pool on shutdown."""
|
||||
from legal_mcp.services.db import close_pool, init_schema
|
||||
"""Server startup is now non-blocking.
|
||||
|
||||
logger.info("Initializing database schema...")
|
||||
await init_schema()
|
||||
logger.info("Ezer Mishpati MCP server ready")
|
||||
Schema init was moved out of the lifespan to fix a race where Claude Code
|
||||
would call a tool before `tools/list` had been answered — manifesting as
|
||||
"No such tool available". Lifespan now returns immediately so the MCP
|
||||
handshake completes in milliseconds; the schema is initialized lazily on
|
||||
the first DB access via services/db.get_pool().
|
||||
"""
|
||||
from legal_mcp.services.db import close_pool
|
||||
|
||||
logger.info("Ezer Mishpati MCP server ready (schema init deferred)")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -47,6 +52,12 @@ mcp = FastMCP(
|
||||
|
||||
from legal_mcp.tools import ( # noqa: E402
|
||||
cases, documents, search, drafting, workflow, precedents,
|
||||
precedent_library as plib,
|
||||
internal_decisions as int_tools,
|
||||
legal_arguments as la_tools,
|
||||
missing_precedents as mp_tools,
|
||||
citations as cit_tools,
|
||||
training_enrichment as train_tools,
|
||||
)
|
||||
|
||||
|
||||
@@ -110,6 +121,13 @@ async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
||||
return await cases.case_delete(case_number, remove_files)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
|
||||
"""קליטת טקסט ההחלטה הסופית (`סופי-{case}.docx` בתיקיית exports).
|
||||
max_chars: 0=הכל, אחרת חיתוך לאורך הנתון. שימושי ל-Hermes Knowledge Curator."""
|
||||
return await cases.case_get_final_text(case_number, max_chars)
|
||||
|
||||
|
||||
# Precedent attachments (user-supplied legal support for the compose phase)
|
||||
@mcp.tool()
|
||||
async def precedent_attach(
|
||||
@@ -142,10 +160,163 @@ async def precedent_remove(precedent_id: str) -> str:
|
||||
async def precedent_search_library(
|
||||
query: str, practice_area: str = "", limit: int = 10,
|
||||
) -> str:
|
||||
"""חיפוש בספרייה הרוחבית של ציטוטים שנצברו בין תיקים."""
|
||||
"""חיפוש בציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
||||
שונה מ-search_precedent_library שמחפש בקורפוס הפסיקה הסמכותית."""
|
||||
return await precedents.precedent_search_library(query, practice_area, limit)
|
||||
|
||||
|
||||
# ── External Precedent Library — authoritative case-law corpus ─────
|
||||
# Distinct from precedent_search_library above (chair-attached quotes)
|
||||
# and from search_decisions (Daphna's style corpus).
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_library_upload(
|
||||
file_path: str,
|
||||
citation: str,
|
||||
case_name: str = "",
|
||||
court: str = "",
|
||||
decision_date: str = "",
|
||||
source_type: str = "",
|
||||
precedent_level: str = "",
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
subject_tags: list[str] | None = None,
|
||||
is_binding: bool = True,
|
||||
headnote: str = "",
|
||||
summary: str = "",
|
||||
) -> str:
|
||||
"""העלאת פסיקה חיצונית (פס"ד / החלטה של ועדה אחרת) לקורפוס הסמכותי. מחלץ הלכות אוטומטית — כולן ממתינות לאישור היו"ר. practice_area: rishuy_uvniya / betterment_levy / compensation_197."""
|
||||
return await plib.precedent_library_upload(
|
||||
file_path, citation, case_name, court, decision_date,
|
||||
source_type, precedent_level, practice_area, appeal_subtype,
|
||||
subject_tags, is_binding, headnote, summary,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_library_list(
|
||||
practice_area: str = "",
|
||||
court: str = "",
|
||||
precedent_level: str = "",
|
||||
source_type: str = "",
|
||||
search: str = "",
|
||||
source_kind: str = "external_upload",
|
||||
limit: int = 100,
|
||||
) -> 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(
|
||||
practice_area, court, precedent_level, source_type, search,
|
||||
source_kind, limit,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_library_get(case_law_id: str) -> str:
|
||||
"""פסיקה ספציפית בקורפוס + רשימת ההלכות שחולצו ממנה (כולל ממתינות לאישור)."""
|
||||
return await plib.precedent_library_get(case_law_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_library_delete(case_law_id: str) -> str:
|
||||
"""מחיקת פסיקה מהקורפוס (cascade: chunks + halachot)."""
|
||||
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()
|
||||
async def precedent_extract_halachot(case_law_id: str) -> str:
|
||||
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
|
||||
return await plib.precedent_extract_halachot(case_law_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_extract_metadata(case_law_id: str) -> str:
|
||||
"""חילוץ מטא-דאטה (case_name קצר, summary, headnote, key_quote, subject_tags, appeal_subtype, date, level, court, source_type) מהטקסט. ממלא רק שדות ריקים."""
|
||||
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()
|
||||
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
|
||||
"""ריקון תור בקשות חילוץ שנשלחו מ-UI. kind: 'metadata' או 'halacha'. מריץ extractor מקומית עם CLI על כל פריט בתור, ומנקה את הסימון אחרי הצלחה."""
|
||||
return await plib.precedent_process_pending(kind, limit)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_precedent_library(
|
||||
query: str,
|
||||
practice_area: str = "",
|
||||
court: str = "",
|
||||
precedent_level: str = "",
|
||||
appeal_subtype: str = "",
|
||||
subject_tag: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
) -> str:
|
||||
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית. מחזיר הלכות (מאושרות בלבד) + קטעי טקסט. השתמש כש-legal-writer צריך לצטט פסיקה מחייבת בבלוק י (CREAC: rule + explanation)."""
|
||||
return await plib.search_precedent_library(
|
||||
query, practice_area, court, precedent_level, appeal_subtype,
|
||||
None, subject_tag, limit, include_halachot,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def halacha_review(
|
||||
halacha_id: str,
|
||||
status: str,
|
||||
reviewer: str = "דפנה",
|
||||
rule_statement: str = "",
|
||||
reasoning_summary: str = "",
|
||||
subject_tags: list[str] | None = None,
|
||||
practice_areas: list[str] | None = None,
|
||||
) -> str:
|
||||
"""אישור / דחייה / עריכה של הלכה שחולצה אוטומטית. status: pending_review / approved / rejected / published."""
|
||||
return await plib.halacha_review(
|
||||
halacha_id, status, reviewer, rule_statement, reasoning_summary,
|
||||
subject_tags, practice_areas,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def halachot_pending(limit: int = 100) -> str:
|
||||
"""תור ההלכות הממתינות לאישור."""
|
||||
return await plib.halachot_pending(limit)
|
||||
|
||||
|
||||
# Documents
|
||||
@mcp.tool()
|
||||
async def document_upload(
|
||||
@@ -218,6 +389,28 @@ async def get_claims(
|
||||
return await documents.get_claims(case_number, party_role)
|
||||
|
||||
|
||||
# Legal arguments — aggregated (de-duped) propositions
|
||||
@mcp.tool()
|
||||
async def aggregate_claims_to_arguments(
|
||||
case_number: str,
|
||||
force: bool = False,
|
||||
) -> str:
|
||||
"""כינוס פרופוזיציות גולמיות (claims) לטיעונים משפטיים מובחנים — ~6-12 לכל צד.
|
||||
|
||||
משתמש ב-Claude headless לסיווג ואיגוד. force=True מוחק טיעונים קיימים לפני חישוב מחדש.
|
||||
"""
|
||||
return await la_tools.aggregate_claims_to_arguments(case_number, force=force)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_legal_arguments(
|
||||
case_number: str,
|
||||
party: str = "",
|
||||
) -> str:
|
||||
"""שליפת טיעונים משפטיים מאוגדים. party: appellant/respondent/committee/permit_applicant (ריק=הכל)."""
|
||||
return await la_tools.get_legal_arguments(case_number, party)
|
||||
|
||||
|
||||
# References
|
||||
@mcp.tool()
|
||||
async def extract_references(
|
||||
@@ -268,6 +461,40 @@ async def find_similar_cases(
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_internal_decisions(
|
||||
query: str,
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
district: str = "",
|
||||
chair_name: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
include_cited_by: bool = False,
|
||||
) -> str:
|
||||
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
|
||||
|
||||
מחזיר החלטות מהקורפוס הפנימי של ועדות הערר — נפרד מפסיקת בתי המשפט.
|
||||
השתמש בו במקביל ל-search_precedent_library להצגת שתי שכבות נפרדות.
|
||||
|
||||
Args:
|
||||
query: שאילתת חיפוש בעברית
|
||||
practice_area: rishuy_uvniya / betterment_levy / compensation_197
|
||||
appeal_subtype: סינון לפי תת-סוג ערר
|
||||
district: מחוז — ירושלים / מרכז / תל אביב / צפון / דרום / ארצי. ריק = כל המחוזות
|
||||
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
|
||||
limit: מספר תוצאות מקסימלי
|
||||
include_halachot: האם לכלול הלכות שחולצו
|
||||
include_cited_by: True = הוסף תוצאות עקיפות — לכל hit הוסף גם החלטות
|
||||
שהוא מצטט (מתוך citation graph). שימושי לחיפוש "כל הקשור ל-X"
|
||||
כשרוצים להרחיב מעבר לטקסט המקורי. default False.
|
||||
"""
|
||||
return await search.search_internal_decisions(
|
||||
query, practice_area, appeal_subtype, district, chair_name, limit, include_halachot,
|
||||
include_cited_by=include_cited_by,
|
||||
)
|
||||
|
||||
|
||||
# Drafting
|
||||
@mcp.tool()
|
||||
async def get_style_guide() -> str:
|
||||
@@ -451,6 +678,220 @@ async def ingest_final_version(
|
||||
return await workflow.ingest_final_version(case_number, file_path, final_text)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def internal_decision_migrate(
|
||||
source: str = "both",
|
||||
dry_run: bool = True,
|
||||
) -> str:
|
||||
"""העברת החלטות ועדת ערר קיימות לקורפוס הפנימי (פעולת admin).
|
||||
|
||||
source: 'style_corpus' | 'external_corpus' | 'both'
|
||||
dry_run: אם true — מציג מה יקרה ללא כתיבה
|
||||
"""
|
||||
import json as _json
|
||||
from legal_mcp.services import internal_decisions as int_svc
|
||||
if source not in {"style_corpus", "external_corpus", "both"}:
|
||||
return "source חייב להיות style_corpus / external_corpus / both"
|
||||
results: dict = {}
|
||||
if source in {"style_corpus", "both"}:
|
||||
results["style_corpus"] = await int_svc.migrate_from_style_corpus(dry_run=dry_run)
|
||||
if source in {"external_corpus", "both"}:
|
||||
results["external_corpus"] = await int_svc.migrate_from_external_corpus(dry_run=dry_run)
|
||||
return _json.dumps(results, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def internal_decision_enrich(
|
||||
dry_run: bool = True,
|
||||
) -> str:
|
||||
"""העשרת החלטות שהומגרו (חד-פעמי): תיקון מספר ערר + שם + תאריך + תור להלכות.
|
||||
|
||||
dry_run=True — מציג כמה רשומות יטופלו ללא כתיבה.
|
||||
dry_run=False — מריץ בפועל: metadata extraction (תיקון case_number/case_name/date) ואחר כך תור חילוץ הלכות.
|
||||
"""
|
||||
import json as _json
|
||||
from legal_mcp.services import internal_decisions as int_svc
|
||||
result = await int_svc.enrich_migrated_entries(dry_run=dry_run)
|
||||
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()
|
||||
async def record_chair_feedback(
|
||||
case_number: str,
|
||||
|
||||
@@ -103,7 +103,7 @@ async def extract_facts_from_document(
|
||||
f"שמאי: {appraiser_name}{chunk_label}\n\n"
|
||||
f"--- תחילת שומה ---\n{chunk}\n--- סוף שומה ---"
|
||||
)
|
||||
result = claude_session.query_json(prompt, timeout=180)
|
||||
result = await claude_session.query_json(prompt)
|
||||
if not isinstance(result, list):
|
||||
logger.warning(
|
||||
"extract_facts_from_document: chunk %d returned non-list (%s) for doc=%s",
|
||||
@@ -250,8 +250,19 @@ async def extract_appraiser_facts(case_id: UUID) -> dict:
|
||||
|
||||
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 {
|
||||
"status": "completed",
|
||||
"status": status,
|
||||
"appraisal_count": len(appraisals),
|
||||
"total_facts": total_facts,
|
||||
"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,
|
||||
)
|
||||
|
||||
# Restructure: sources first, then instructions
|
||||
prompt = (
|
||||
f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n"
|
||||
f"{source_context}\n\n"
|
||||
f"---\n\n"
|
||||
f"{formatted_prompt}"
|
||||
)
|
||||
# source_context is already embedded inside formatted_prompt via {source_context} in the
|
||||
# template. Do NOT prepend it again — doing so doubles the prompt size (was 465K chars).
|
||||
prompt = formatted_prompt
|
||||
|
||||
if instructions:
|
||||
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||
@@ -377,10 +373,23 @@ async def write_block(
|
||||
if not dir_doc.get("approved"):
|
||||
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)
|
||||
model_key = block_cfg["model"]
|
||||
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
||||
content = claude_session.query(prompt, timeout=timeout)
|
||||
content = await claude_session.query(prompt, timeout=timeout)
|
||||
|
||||
return _build_result(block_id, content, block_cfg)
|
||||
|
||||
@@ -414,16 +423,35 @@ def _build_case_context(case: dict, decision: dict | None) -> str:
|
||||
- תוצאה: {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:
|
||||
"""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.
|
||||
Place documents at the TOP of the prompt (before instructions) for 30% better recall.
|
||||
For grounding: instruct Claude to cite word-for-word from these documents.
|
||||
Per-block filtering prevents context overflow on large cases (9+ docs).
|
||||
"""
|
||||
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)
|
||||
context_parts = []
|
||||
for doc in docs:
|
||||
if allowed and doc["doc_type"] not in allowed:
|
||||
continue
|
||||
text = await db.get_document_text(UUID(doc["id"]))
|
||||
if text:
|
||||
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")
|
||||
|
||||
@@ -134,14 +134,14 @@ async def generate_directions(
|
||||
{doc_context or '(אין מסמכים בתיק)'}
|
||||
"""
|
||||
|
||||
result = claude_session.query_json(user_content, timeout=120)
|
||||
result = await claude_session.query_json(user_content)
|
||||
if result is None:
|
||||
logger.warning("Failed to parse brainstorm response: %s", raw[:300])
|
||||
logger.warning("Failed to parse brainstorm response")
|
||||
return {
|
||||
"key_claims": [],
|
||||
"directions": [],
|
||||
"recommended_order": "",
|
||||
"raw_response": raw,
|
||||
"raw_response": "",
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,14 +17,16 @@ from dataclasses import dataclass, field
|
||||
|
||||
from legal_mcp import config
|
||||
|
||||
# Hebrew legal section headers
|
||||
# Hebrew legal section headers.
|
||||
# Covers both appeals committee decisions and external court rulings —
|
||||
# court rulings use slightly different vocabulary (פסק דין, נימוקים, סוף דבר).
|
||||
SECTION_PATTERNS = [
|
||||
(r"רקע\s*עובדתי|רקע\s*כללי|העובדות|הרקע", "facts"),
|
||||
(r"טענות\s*העוררי[םן]|טענות\s*המערערי[םן]|עיקר\s*טענות\s*העוררי[םן]", "appellant_claims"),
|
||||
(r"טענות\s*המשיבי[םן]|תשובת\s*המשיבי[םן]|עיקר\s*טענות\s*המשיבי[םן]", "respondent_claims"),
|
||||
(r"דיון\s*והכרעה|דיון|הכרעה|ניתוח\s*משפטי|המסגרת\s*המשפטית", "legal_analysis"),
|
||||
(r"מסקנ[הות]|סיכום", "conclusion"),
|
||||
(r"החלטה|לפיכך\s*אני\s*מחליט|התוצאה", "ruling"),
|
||||
(r"דיון\s*והכרעה|דיון|הכרעה|ניתוח\s*משפטי|המסגרת\s*המשפטית|נימוקים", "legal_analysis"),
|
||||
(r"מסקנ[הות]|סיכום|סוף\s*דבר", "conclusion"),
|
||||
(r"פסק[- ]?דין|החלטה|לפיכך\s*אני\s*מחליט|התוצאה", "ruling"),
|
||||
(r"מבוא|פתיחה|לפניי", "intro"),
|
||||
]
|
||||
|
||||
@@ -31,8 +43,15 @@ def chunk_document(
|
||||
text: str,
|
||||
chunk_size: int = config.CHUNK_SIZE_TOKENS,
|
||||
overlap: int = config.CHUNK_OVERLAP_TOKENS,
|
||||
page_offsets: list[int] | None = None,
|
||||
) -> list[Chunk]:
|
||||
"""Split a legal document into chunks, respecting section boundaries."""
|
||||
"""Split a legal document into chunks, respecting section boundaries.
|
||||
|
||||
When ``page_offsets`` is supplied (from a PDF extraction), each chunk
|
||||
is tagged with the page number of its first character — used by the
|
||||
multimodal hybrid retriever to join (text chunk, image at same page)
|
||||
and surface text+image matches.
|
||||
"""
|
||||
if not text.strip():
|
||||
return []
|
||||
|
||||
@@ -50,16 +69,60 @@ def chunk_document(
|
||||
))
|
||||
idx += 1
|
||||
|
||||
if page_offsets:
|
||||
_assign_pages(chunks, text, page_offsets)
|
||||
return chunks
|
||||
|
||||
|
||||
def _assign_pages(chunks: list[Chunk], text: str, page_offsets: list[int]) -> None:
|
||||
"""Locate each chunk's first character in ``text`` and tag with the
|
||||
page that contains that offset. Mutates chunks in-place.
|
||||
|
||||
Chunks have overlap so we search forward from a position slightly
|
||||
past the previous chunk's start. Falls back to a global search if
|
||||
the forward scan misses (rare — happens only when overlap is bigger
|
||||
than the advance distance below).
|
||||
"""
|
||||
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 the chunk's halfway point — overlap is < 50% so
|
||||
# the next chunk's starting point will be after this cursor.
|
||||
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]]:
|
||||
"""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
|
||||
markers: list[tuple[int, str]] = []
|
||||
|
||||
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))
|
||||
|
||||
if not markers:
|
||||
@@ -76,11 +139,18 @@ def _split_into_sections(text: str) -> list[tuple[str, str]]:
|
||||
if 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):
|
||||
end = markers[i + 1][0] if i + 1 < len(markers) else len(text)
|
||||
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))
|
||||
|
||||
return sections
|
||||
@@ -128,3 +198,152 @@ def _split_section(text: str, chunk_size: int, overlap: int) -> list[str]:
|
||||
def _estimate_tokens(text: str) -> int:
|
||||
"""Rough token estimate for Hebrew text (~1.5 chars per token)."""
|
||||
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
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from uuid import UUID
|
||||
@@ -17,6 +18,21 @@ from legal_mcp.services import db, claude_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Each chunk targets ~12K chars (≈3K tokens of Hebrew). Smaller than the
|
||||
# previous 25K because:
|
||||
# • A single ``claude -p`` call on a 25K-char Hebrew prompt with cold
|
||||
# cache routinely hit ~150-180s. 12K chunks finish in ~60-90s.
|
||||
# • Per-chunk retry costs less when chunks are smaller.
|
||||
# • Parallel chunks benefit more — see CHUNK_CONCURRENCY.
|
||||
CHUNK_TARGET_CHARS = 12000
|
||||
|
||||
# How many chunks to send to Claude in parallel. Each subprocess holds
|
||||
# ~300 MB RSS plus its own MCP stack; concurrency=3 keeps the box usable.
|
||||
CHUNK_CONCURRENCY = 3
|
||||
|
||||
# How many retry attempts per failed chunk before giving up on it.
|
||||
CHUNK_RETRY_ATTEMPTS = 1
|
||||
|
||||
|
||||
EXTRACT_CLAIMS_PROMPT = """אתה מנתח מסמכים משפטיים בתחום תכנון ובניה. תפקידך לחלץ טענות מכתב טענות.
|
||||
|
||||
@@ -43,6 +59,103 @@ EXTRACT_CLAIMS_PROMPT = """אתה מנתח מסמכים משפטיים בתחו
|
||||
"""
|
||||
|
||||
|
||||
# Section markers we treat as natural chunk boundaries when present.
|
||||
# Hebrew legal briefs almost always use numbered sections like "10." or
|
||||
# letter-section headings (".א", ".ב"). Splitting between sections keeps
|
||||
# every chunk a self-contained argumentative unit.
|
||||
_SECTION_BOUNDARY_RE = re.compile(
|
||||
r"\n\s*("
|
||||
r"\d+\.\s+\S" # numbered section: "10. טענות"
|
||||
r"|[א-ת]\.\s+\S" # Hebrew letter section: "א. רקע"
|
||||
r"|##\s+\S" # markdown heading
|
||||
r"|פרק\s+\S" # "פרק" headings
|
||||
r")"
|
||||
)
|
||||
|
||||
|
||||
def _split_by_sections(text: str, target: int = CHUNK_TARGET_CHARS) -> list[str]:
|
||||
"""Split a long document into roughly ``target``-sized chunks at section
|
||||
boundaries. Falls back to paragraph breaks, then to hard splits if a
|
||||
section happens to be larger than ``target`` on its own.
|
||||
"""
|
||||
if len(text) <= target:
|
||||
return [text]
|
||||
|
||||
boundaries = [m.start() for m in _SECTION_BOUNDARY_RE.finditer(text)]
|
||||
boundaries = [0, *boundaries, len(text)]
|
||||
|
||||
chunks: list[str] = []
|
||||
start = 0
|
||||
for cut in boundaries[1:]:
|
||||
# Greedy: keep adding sections to the current chunk until adding
|
||||
# the next one would push past ``target``.
|
||||
if cut - start < target:
|
||||
continue
|
||||
end = cut
|
||||
if end - start > target * 1.5:
|
||||
# Section group exceeds 1.5× target — fall back to paragraph
|
||||
# break inside it to avoid one chunk being far too big.
|
||||
soft = text.rfind("\n\n", start, start + target)
|
||||
if soft > start + target // 2:
|
||||
end = soft
|
||||
chunks.append(text[start:end].strip())
|
||||
start = end
|
||||
if start < len(text):
|
||||
chunks.append(text[start:].strip())
|
||||
|
||||
# Hard splits for any chunk that is still too large (rare, but
|
||||
# documents without any section markers can fall through).
|
||||
final: list[str] = []
|
||||
for c in chunks:
|
||||
if len(c) <= target * 1.5:
|
||||
final.append(c)
|
||||
continue
|
||||
for i in range(0, len(c), target):
|
||||
final.append(c[i:i + target])
|
||||
return [c for c in final if c.strip()]
|
||||
|
||||
|
||||
async def _extract_chunk(
|
||||
chunk: str,
|
||||
chunk_index: int,
|
||||
chunk_total: int,
|
||||
context: str,
|
||||
) -> tuple[int, list[dict] | None]:
|
||||
"""Run extraction on one chunk with retry. Returns ``(chunk_index, claims_or_None)``.
|
||||
|
||||
None means the chunk failed both the initial call and every retry
|
||||
(caller can use this to mark the result as partial).
|
||||
"""
|
||||
chunk_label = f" (חלק {chunk_index + 1}/{chunk_total})" if chunk_total > 1 else ""
|
||||
prompt = (
|
||||
f"{EXTRACT_CLAIMS_PROMPT}\n\n"
|
||||
f"{context}{chunk_label}\n\n"
|
||||
f"--- תחילת מסמך ---\n{chunk}\n--- סוף מסמך ---"
|
||||
)
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(CHUNK_RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
claims = await claude_session.query_json(prompt)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
logger.warning(
|
||||
"extract_claims chunk %d/%d attempt %d raised: %s",
|
||||
chunk_index + 1, chunk_total, attempt + 1, e,
|
||||
)
|
||||
continue
|
||||
if isinstance(claims, list):
|
||||
return chunk_index, claims
|
||||
logger.warning(
|
||||
"extract_claims chunk %d/%d attempt %d returned non-list (%s)",
|
||||
chunk_index + 1, chunk_total, attempt + 1, type(claims).__name__,
|
||||
)
|
||||
logger.error(
|
||||
"extract_claims chunk %d/%d failed after %d attempts: %s",
|
||||
chunk_index + 1, chunk_total, CHUNK_RETRY_ATTEMPTS + 1, last_err,
|
||||
)
|
||||
return chunk_index, None
|
||||
|
||||
|
||||
async def extract_claims_with_ai(
|
||||
text: str,
|
||||
doc_type: str = "appeal",
|
||||
@@ -50,68 +163,62 @@ async def extract_claims_with_ai(
|
||||
) -> list[dict]:
|
||||
"""חילוץ טענות מכתב טענות באמצעות Claude.
|
||||
|
||||
Splits ``text`` at section boundaries, runs every chunk through
|
||||
Claude in parallel (bounded by ``CHUNK_CONCURRENCY``), retries each
|
||||
failed chunk once, and merges the results in original document order.
|
||||
Failed chunks are logged but don't block the overall extraction —
|
||||
we return what we got and surface the gap via the logs.
|
||||
|
||||
Args:
|
||||
text: טקסט המסמך
|
||||
doc_type: סוג המסמך (appeal/response)
|
||||
party_hint: רמז לזהות הצד (אם ידוע)
|
||||
|
||||
Returns:
|
||||
רשימת טענות עם party_role, claim_text, topic
|
||||
רשימת טענות עם party_role, claim_text, topic, claim_index.
|
||||
"""
|
||||
context = f"סוג המסמך: {doc_type}"
|
||||
if party_hint:
|
||||
context += f"\nהצד המגיש: {party_hint}"
|
||||
|
||||
# For very long documents, split into chunks and merge results
|
||||
max_chars_per_call = 25000
|
||||
chunks = []
|
||||
if len(text) > max_chars_per_call:
|
||||
# Split at paragraph boundaries
|
||||
pos = 0
|
||||
while pos < len(text):
|
||||
end = min(pos + max_chars_per_call, len(text))
|
||||
if end < len(text):
|
||||
# Find paragraph break near the limit
|
||||
break_pos = text.rfind("\n\n", pos, end)
|
||||
if break_pos > pos + max_chars_per_call // 2:
|
||||
end = break_pos
|
||||
chunks.append(text[pos:end])
|
||||
pos = end
|
||||
logger.info("Document split into %d chunks (%d chars total)", len(chunks), len(text))
|
||||
else:
|
||||
chunks = [text]
|
||||
|
||||
all_claims = []
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
chunk_label = f" (חלק {i+1}/{len(chunks)})" if len(chunks) > 1 else ""
|
||||
prompt = (
|
||||
f"{EXTRACT_CLAIMS_PROMPT}\n\n"
|
||||
f"{context}{chunk_label}\n\n"
|
||||
f"--- תחילת מסמך ---\n{chunk}\n--- סוף מסמך ---"
|
||||
chunks = _split_by_sections(text)
|
||||
if len(chunks) > 1:
|
||||
logger.info(
|
||||
"extract_claims: split %d chars into %d chunks (target=%d, concurrency=%d)",
|
||||
len(text), len(chunks), CHUNK_TARGET_CHARS, CHUNK_CONCURRENCY,
|
||||
)
|
||||
claims = claude_session.query_json(prompt, timeout=120)
|
||||
if claims is None:
|
||||
logger.warning("Failed to parse claims for chunk %d: %s", i, raw[:200])
|
||||
|
||||
sem = asyncio.Semaphore(CHUNK_CONCURRENCY)
|
||||
|
||||
async def _bounded(idx: int, c: str) -> tuple[int, list[dict] | None]:
|
||||
async with sem:
|
||||
return await _extract_chunk(c, idx, len(chunks), context)
|
||||
|
||||
results = await asyncio.gather(*[_bounded(i, c) for i, c in enumerate(chunks)])
|
||||
|
||||
# Merge in original order. Skip chunks that failed entirely.
|
||||
failed = [i for i, r in results if r is None]
|
||||
if failed:
|
||||
logger.warning(
|
||||
"extract_claims: %d/%d chunks failed (indices=%s) — returning partial result",
|
||||
len(failed), len(chunks), failed,
|
||||
)
|
||||
merged: list[dict] = []
|
||||
for idx, claims in sorted(results, key=lambda x: x[0]):
|
||||
if not claims:
|
||||
continue
|
||||
if isinstance(claims, list):
|
||||
all_claims.extend(claims)
|
||||
merged.extend(claims)
|
||||
|
||||
claims = all_claims
|
||||
if not claims:
|
||||
return []
|
||||
|
||||
if not isinstance(claims, list):
|
||||
return []
|
||||
|
||||
# Add claim_index
|
||||
for i, claim in enumerate(claims):
|
||||
claim["claim_index"] = i
|
||||
# Validate required fields
|
||||
# Add claim_index and drop entries missing required fields.
|
||||
cleaned: list[dict] = []
|
||||
for i, claim in enumerate(merged):
|
||||
if not isinstance(claim, dict):
|
||||
continue
|
||||
if "party_role" not in claim or "claim_text" not in claim:
|
||||
continue
|
||||
|
||||
return [c for c in claims if "party_role" in c and "claim_text" in c]
|
||||
claim["claim_index"] = i
|
||||
cleaned.append(claim)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _infer_claim_type(doc_type: str, source_name: str) -> str:
|
||||
|
||||
@@ -1,27 +1,53 @@
|
||||
"""Claude Code session bridge — runs prompts via `claude -p` instead of API.
|
||||
"""Claude Code session bridge — runs prompts via the local `claude` CLI.
|
||||
|
||||
All LLM calls in the project should use this module instead of calling
|
||||
the Anthropic API directly. This uses the local Claude Code CLI which
|
||||
runs on the user's claude.ai session — zero API cost.
|
||||
All LLM calls in legal-ai go through this module. We shell out to the local
|
||||
Claude Code CLI which uses the developer's claude.ai session — zero direct
|
||||
API cost.
|
||||
|
||||
**Architectural rule (do not violate):** this module only works when invoked
|
||||
from the local MCP server (the Python process at
|
||||
`/home/chaim/legal-ai/mcp-server/`, launched per `~/.claude.json`). It will
|
||||
**not** work when called from the legal-ai Docker container — that container
|
||||
has no `claude` CLI and no claude.ai session. Any code path under `web/`
|
||||
(FastAPI) that calls this module — directly or via an extractor like
|
||||
`halacha_extractor`, `claims_extractor`, `precedent_metadata_extractor`,
|
||||
`block_writer`, `qa_validator`, `learning_loop`, `local_classifier`,
|
||||
`appraiser_facts_extractor`, `brainstorm`, `style_analyzer` — is wrong.
|
||||
LLM-dependent operations must be exposed as MCP tools and triggered from
|
||||
agents (or the chair via Claude Code), where this module runs locally with
|
||||
CLI access.
|
||||
|
||||
Async history: originally synchronous (``subprocess.run``) with a 120 s
|
||||
timeout. That broke for large legal documents — sync subprocess stalled the
|
||||
asyncio loop, and 120 s was far too short for cold-cache Hebrew prompts
|
||||
(case 8174-24 hit three timeouts in a row). Fixed by going async with a
|
||||
30-minute ceiling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from legal_mcp.config import parse_llm_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default timeout for claude -p calls (seconds)
|
||||
DEFAULT_TIMEOUT = 120
|
||||
LONG_TIMEOUT = 300 # For complex tasks like block writing
|
||||
# Default ceiling for any single ``claude -p`` invocation, in seconds.
|
||||
# 30 min covers any single-document call we make in practice (chunking
|
||||
# handles the rest); the bound exists only to prevent runaway zombies.
|
||||
DEFAULT_TIMEOUT = 1800
|
||||
LONG_TIMEOUT = 3600 # opus block writing on full case context
|
||||
|
||||
|
||||
def query(prompt: str, timeout: int = DEFAULT_TIMEOUT, max_turns: int = 1) -> str:
|
||||
async def query(
|
||||
prompt: str,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
max_turns: int = 1,
|
||||
*,
|
||||
system: str | None = None,
|
||||
) -> str:
|
||||
"""Send a prompt to Claude Code headless and return the text response.
|
||||
|
||||
Passes the prompt via stdin (not argv) to avoid the OS ARG_MAX limit —
|
||||
@@ -29,15 +55,26 @@ def query(prompt: str, timeout: int = DEFAULT_TIMEOUT, max_turns: int = 1) -> st
|
||||
|
||||
Args:
|
||||
prompt: The prompt to send.
|
||||
timeout: Max seconds to wait.
|
||||
timeout: Max seconds before the subprocess is killed.
|
||||
max_turns: Max conversation turns (1 = single response).
|
||||
system: Optional repeated-instruction text. Prepended to ``prompt``
|
||||
for the CLI; we don't pass it as a separate arg because the
|
||||
CLI doesn't expose API-level caching. The parameter exists so
|
||||
extractors can structure their calls cleanly today, and to make
|
||||
a future SDK-backed path drop-in.
|
||||
|
||||
Returns:
|
||||
The text response from Claude.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If claude CLI is not available or fails.
|
||||
RuntimeError: if the CLI is unavailable (e.g., called from the
|
||||
container — see module docstring), or fails, or times out.
|
||||
"""
|
||||
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 = [
|
||||
"claude", "-p",
|
||||
"--output-format", "json",
|
||||
@@ -45,23 +82,41 @@ def query(prompt: str, timeout: int = DEFAULT_TIMEOUT, max_turns: int = 1) -> st
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError("Claude CLI not found. Install Claude Code or add 'claude' to PATH.")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(
|
||||
"Claude CLI not found. This module only works when invoked "
|
||||
"from the local MCP server — see the architectural rule in "
|
||||
"the module docstring. If this error came from a FastAPI "
|
||||
"endpoint in the container, refactor the call into an MCP "
|
||||
"tool that the chair triggers from Claude Code."
|
||||
)
|
||||
|
||||
try:
|
||||
stdout_b, stderr_b = await asyncio.wait_for(
|
||||
proc.communicate(input=full_prompt.encode("utf-8")),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# wait_for cancellation alone leaves the child running.
|
||||
try:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
raise RuntimeError(f"Claude CLI timed out after {timeout}s")
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()[:500] if result.stderr else "unknown error"
|
||||
raise RuntimeError(f"Claude CLI failed (exit {result.returncode}): {stderr}")
|
||||
if proc.returncode != 0:
|
||||
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
||||
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 = result.stdout.strip()
|
||||
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
||||
if not stdout:
|
||||
raise RuntimeError("Claude CLI returned empty response")
|
||||
|
||||
@@ -75,10 +130,187 @@ def query(prompt: str, timeout: int = DEFAULT_TIMEOUT, max_turns: int = 1) -> st
|
||||
return stdout
|
||||
|
||||
|
||||
def query_json(prompt: str, timeout: int = DEFAULT_TIMEOUT) -> dict | list | None:
|
||||
async def query_json(
|
||||
prompt: str,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
*,
|
||||
system: str | None = None,
|
||||
) -> dict | list | None:
|
||||
"""Send a prompt and parse the response as JSON.
|
||||
|
||||
Uses parse_llm_json for robust parsing (handles markdown wrapping, truncation).
|
||||
"""
|
||||
raw = query(prompt, timeout=timeout)
|
||||
raw = await query(prompt, timeout=timeout, system=system)
|
||||
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
@@ -291,6 +291,7 @@ _INTERIM_BLOCK_ORDER = [
|
||||
"block-bet", # panel (skipped if empty)
|
||||
"block-gimel", # parties (skipped if empty)
|
||||
"block-dalet", # "החלטה" title (skipped if empty)
|
||||
"block-he", # פתיחה ניטרלית (skipped if empty — opt-in for pre-ruling drafts)
|
||||
"block-vav", # רקע עובדתי
|
||||
"block-tet", # תכניות + היתרים (extended)
|
||||
"block-zayin", # טענות הצדדים
|
||||
|
||||
@@ -3,19 +3,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import voyageai
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from legal_mcp import config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import voyageai
|
||||
from PIL import Image as PILImage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: voyageai.Client | None = None
|
||||
# voyageai is imported lazily inside _get_client to keep MCP server startup
|
||||
# fast — loading voyageai eagerly costs ~450ms and Claude Code's first tool
|
||||
# call can hit a "No such tool available" race if the server isn't ready yet.
|
||||
_client: "voyageai.Client | None" = None
|
||||
|
||||
# Per-call cap for multimodal_embed. POC ran 89 pages (~312K tokens)
|
||||
# in a single call comfortably; 50 leaves safe headroom for densely-
|
||||
# OCR'd legal pages where tokens/page can exceed 4K.
|
||||
_MULTIMODAL_BATCH_SIZE = 50
|
||||
|
||||
|
||||
def _get_client() -> voyageai.Client:
|
||||
def _get_client() -> "voyageai.Client":
|
||||
global _client
|
||||
if _client is None:
|
||||
import voyageai
|
||||
_client = voyageai.Client(api_key=config.VOYAGE_API_KEY)
|
||||
return _client
|
||||
|
||||
@@ -53,3 +65,65 @@ async def embed_query(query: str) -> list[float]:
|
||||
"""Embed a single search query."""
|
||||
results = await embed_texts([query], input_type="query")
|
||||
return results[0]
|
||||
|
||||
|
||||
async def embed_images(
|
||||
images: "list[PILImage.Image]",
|
||||
input_type: str = "document",
|
||||
) -> list[list[float]]:
|
||||
"""Embed page images via voyage-multimodal-3.
|
||||
|
||||
Each input is a single PIL.Image (one page = one embedding).
|
||||
Returns a list of 1024-dim vectors, one per input image, in order.
|
||||
Batches at ``_MULTIMODAL_BATCH_SIZE`` to stay within Voyage's
|
||||
per-request limits on dense legal pages.
|
||||
"""
|
||||
if not images:
|
||||
return []
|
||||
client = _get_client()
|
||||
out: list[list[float]] = []
|
||||
for i in range(0, len(images), _MULTIMODAL_BATCH_SIZE):
|
||||
batch = images[i : i + _MULTIMODAL_BATCH_SIZE]
|
||||
result = client.multimodal_embed(
|
||||
inputs=[[img] for img in batch],
|
||||
model=config.MULTIMODAL_MODEL,
|
||||
input_type=input_type,
|
||||
truncation=True,
|
||||
)
|
||||
out.extend(result.embeddings)
|
||||
return out
|
||||
|
||||
|
||||
async def embed_query_for_multimodal(query: str) -> list[float]:
|
||||
"""Embed a text query in the multimodal vector space, so it can be
|
||||
cosine-compared against page-image embeddings."""
|
||||
client = _get_client()
|
||||
result = client.multimodal_embed(
|
||||
inputs=[[query]],
|
||||
model=config.MULTIMODAL_MODEL,
|
||||
input_type="query",
|
||||
)
|
||||
return result.embeddings[0]
|
||||
|
||||
|
||||
async def voyage_rerank(
|
||||
query: str, documents: list[str], top_k: int | None = None,
|
||||
) -> list[tuple[int, float]]:
|
||||
"""Cross-encoder rerank via Voyage. Returns [(orig_index, score), ...]
|
||||
sorted by relevance. Each tuple's index refers to the position in the
|
||||
*input* documents list (not a DB row id) — caller maps it back.
|
||||
|
||||
Used as a second stage after bi-encoder retrieval: fetch top-N
|
||||
candidates with cosine, then rerank to get top-K with cross-encoder
|
||||
attention over (query, doc).
|
||||
"""
|
||||
if not documents:
|
||||
return []
|
||||
client = _get_client()
|
||||
result = client.rerank(
|
||||
query=query,
|
||||
documents=documents,
|
||||
model=config.VOYAGE_RERANK_MODEL,
|
||||
top_k=top_k,
|
||||
)
|
||||
return [(r.index, float(r.relevance_score)) for r in result.results]
|
||||
|
||||
@@ -9,29 +9,35 @@ Post-processing: Hebrew abbreviation quote fixer.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import fitz # PyMuPDF
|
||||
from PIL import Image
|
||||
from docx import Document as DocxDocument
|
||||
from google.cloud import vision
|
||||
from striprtf.striprtf import rtf_to_text
|
||||
|
||||
from legal_mcp import config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from google.cloud import vision
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Google Cloud Vision client ───────────────────────────────────
|
||||
# ── Google Cloud Vision client (imported lazily — saves ~550ms at MCP startup) ──
|
||||
|
||||
_vision_client: vision.ImageAnnotatorClient | None = None
|
||||
_vision_client: "vision.ImageAnnotatorClient | None" = None
|
||||
|
||||
|
||||
def _get_vision_client() -> vision.ImageAnnotatorClient:
|
||||
def _get_vision_client() -> "vision.ImageAnnotatorClient":
|
||||
global _vision_client
|
||||
if _vision_client is None:
|
||||
from google.cloud import vision
|
||||
_vision_client = vision.ImageAnnotatorClient(
|
||||
client_options={"api_key": config.GOOGLE_CLOUD_VISION_API_KEY}
|
||||
)
|
||||
@@ -103,27 +109,51 @@ _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(
|
||||
'|'.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:
|
||||
"""Fix known Hebrew abbreviation quote replacements from Google Vision OCR."""
|
||||
return _ABBREV_PATTERN.sub(lambda m: _HEBREW_ABBREV_FIXES[m.group()], text)
|
||||
"""Fix known Hebrew abbreviation quote replacements.
|
||||
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def extract_text(file_path: str) -> tuple[str, int]:
|
||||
# Separator used when joining per-page text. Constant so chunker /
|
||||
# retrofit can reproduce the join when computing page offsets.
|
||||
PAGE_SEPARATOR = "\n\n"
|
||||
|
||||
|
||||
async def extract_text(file_path: str) -> tuple[str, int, list[int] | None]:
|
||||
"""Extract text from a document file.
|
||||
|
||||
Returns:
|
||||
Tuple of (extracted_text, page_count).
|
||||
page_count is 0 for non-PDF files.
|
||||
``(text, page_count, page_offsets)`` where:
|
||||
- ``text``: concatenated extracted text
|
||||
- ``page_count``: number of pages (0 for non-PDF)
|
||||
- ``page_offsets``: ``page_offsets[i]`` = char start offset of
|
||||
page (i+1) inside ``text``. ``None`` for non-PDFs (where the
|
||||
notion of pages doesn't apply). Used by the chunker to assign
|
||||
a ``page_number`` to each chunk.
|
||||
"""
|
||||
path = Path(file_path)
|
||||
suffix = path.suffix.lower()
|
||||
@@ -131,18 +161,34 @@ async def extract_text(file_path: str) -> tuple[str, int]:
|
||||
if suffix == ".pdf":
|
||||
return await _extract_pdf(path)
|
||||
elif suffix == ".docx":
|
||||
return _extract_docx(path), 0
|
||||
return _extract_docx(path), 0, None
|
||||
elif suffix == ".doc":
|
||||
return _extract_doc(path), 0
|
||||
return _extract_doc(path), 0, None
|
||||
elif suffix == ".rtf":
|
||||
return _extract_rtf(path), 0
|
||||
return _extract_rtf(path), 0, None
|
||||
elif suffix in (".txt", ".md"):
|
||||
return path.read_text(encoding="utf-8"), 0
|
||||
return path.read_text(encoding="utf-8"), 0, None
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type: {suffix}")
|
||||
|
||||
|
||||
async def _extract_pdf(path: Path) -> tuple[str, int]:
|
||||
def _join_pages(pages_text: list[str]) -> tuple[str, list[int]]:
|
||||
"""Join per-page text with PAGE_SEPARATOR while recording the start
|
||||
offset of each page in the joined output."""
|
||||
offsets: list[int] = []
|
||||
parts: list[str] = []
|
||||
cursor = 0
|
||||
for i, pg in enumerate(pages_text):
|
||||
offsets.append(cursor)
|
||||
parts.append(pg)
|
||||
cursor += len(pg)
|
||||
if i < len(pages_text) - 1:
|
||||
parts.append(PAGE_SEPARATOR)
|
||||
cursor += len(PAGE_SEPARATOR)
|
||||
return "".join(parts), offsets
|
||||
|
||||
|
||||
async def _extract_pdf(path: Path) -> tuple[str, int, list[int]]:
|
||||
"""Extract text from PDF.
|
||||
|
||||
Try direct text first, fall back to Google Cloud Vision for scanned
|
||||
@@ -157,7 +203,7 @@ async def _extract_pdf(path: Path) -> tuple[str, int]:
|
||||
text = page.get_text().strip()
|
||||
|
||||
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))
|
||||
else:
|
||||
reason = "insufficient text" if len(text) <= 50 else "low quality OCR layer"
|
||||
@@ -170,11 +216,32 @@ async def _extract_pdf(path: Path) -> tuple[str, int]:
|
||||
pages_text.append(ocr_text)
|
||||
|
||||
doc.close()
|
||||
return "\n\n".join(pages_text), page_count
|
||||
joined, offsets = _join_pages(pages_text)
|
||||
return joined, page_count, offsets
|
||||
|
||||
|
||||
def page_at_offset(offset: int, page_offsets: list[int]) -> int:
|
||||
"""Look up the page number containing a given char offset.
|
||||
|
||||
page_offsets[i] is the start of page (i+1) in the joined text;
|
||||
a chunk starting at ``offset`` belongs to the highest-indexed page
|
||||
whose start is ``<= offset``. Returns 1-based page number.
|
||||
"""
|
||||
if not page_offsets:
|
||||
return 1
|
||||
# Linear scan is fine — page_offsets is short (≤ ~200 for our PDFs).
|
||||
page = 1
|
||||
for i, start in enumerate(page_offsets):
|
||||
if start <= offset:
|
||||
page = i + 1
|
||||
else:
|
||||
break
|
||||
return page
|
||||
|
||||
|
||||
def _ocr_with_google_vision(image_bytes: bytes, page_num: int) -> str:
|
||||
"""OCR a single page image using Google Cloud Vision API."""
|
||||
from google.cloud import vision # lazy: keeps MCP startup fast
|
||||
client = _get_vision_client()
|
||||
image = vision.Image(content=image_bytes)
|
||||
|
||||
@@ -220,6 +287,65 @@ def _extract_rtf(path: Path) -> str:
|
||||
return rtf_to_text(rtf_content)
|
||||
|
||||
|
||||
# ── Multimodal page rendering (V9) ───────────────────────────────
|
||||
|
||||
|
||||
def _pixmap_to_pil(pix: fitz.Pixmap) -> Image.Image:
|
||||
"""Convert a PyMuPDF pixmap to PIL.Image (RGB) without going through
|
||||
PNG bytes. Faster than tobytes('png') → Image.open()."""
|
||||
if pix.alpha:
|
||||
# Drop alpha channel — voyage multimodal expects RGB.
|
||||
pix = fitz.Pixmap(pix, 0)
|
||||
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
||||
|
||||
|
||||
def render_pages_for_multimodal(
|
||||
pdf_path: str | Path,
|
||||
embed_dpi: int,
|
||||
thumb_dpi: int | None = None,
|
||||
thumbnail_dir: Path | None = None,
|
||||
) -> list[tuple[Image.Image, Path | None]]:
|
||||
"""Render each PDF page as PIL.Image at ``embed_dpi`` for the
|
||||
multimodal embedder, and optionally save a smaller JPEG thumbnail
|
||||
at ``thumb_dpi`` to ``thumbnail_dir`` for UI preview.
|
||||
|
||||
Returns ``[(pil_image, thumb_path_or_None), ...]`` in page order.
|
||||
The full-DPI image stays in memory only — only the thumbnail is
|
||||
persisted to disk.
|
||||
"""
|
||||
src = Path(pdf_path)
|
||||
if not src.is_file():
|
||||
raise FileNotFoundError(f"PDF not found: {src}")
|
||||
if thumbnail_dir is not None:
|
||||
thumbnail_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
out: list[tuple[Image.Image, Path | None]] = []
|
||||
doc = fitz.open(str(src))
|
||||
try:
|
||||
for page_idx, page in enumerate(doc):
|
||||
page_num = page_idx + 1
|
||||
pix = page.get_pixmap(dpi=embed_dpi)
|
||||
img = _pixmap_to_pil(pix)
|
||||
|
||||
thumb_path: Path | None = None
|
||||
if thumbnail_dir is not None and thumb_dpi:
|
||||
thumb_path = thumbnail_dir / f"p{page_num:03d}.jpg"
|
||||
# Downsample the same render rather than re-rendering
|
||||
# with PyMuPDF — far faster.
|
||||
ratio = thumb_dpi / embed_dpi
|
||||
thumb_size = (
|
||||
max(1, int(img.width * ratio)),
|
||||
max(1, int(img.height * ratio)),
|
||||
)
|
||||
thumb = img.resize(thumb_size, Image.Resampling.LANCZOS)
|
||||
thumb.save(thumb_path, "JPEG", quality=75, optimize=True)
|
||||
|
||||
out.append((img, thumb_path))
|
||||
finally:
|
||||
doc.close()
|
||||
return out
|
||||
|
||||
|
||||
# ── Nevo preamble stripping ──────────────────────────────────────
|
||||
|
||||
_NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:",
|
||||
|
||||
@@ -6,15 +6,23 @@ rotated in Infisical, repos created with the old token will fail to
|
||||
push silently — only logged at WARNING level. ``commit_and_push``
|
||||
re-injects the *current* token into the existing origin URL on every
|
||||
call, so push survives token rotation.
|
||||
|
||||
This module also runs a periodic ``sweep_loop`` that catches files
|
||||
written outside the API path (most importantly: agents writing research
|
||||
artefacts directly to the case dir). The full case repo is the user's
|
||||
backup, so anything in the dir must end up on Gitea.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from legal_mcp import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -22,8 +30,8 @@ def _gitea_token() -> str:
|
||||
return os.environ.get("GITEA_ACCESS_TOKEN") or os.environ.get("GITEA_TOKEN", "")
|
||||
|
||||
|
||||
def _git_env() -> dict:
|
||||
return {
|
||||
def _git_env(case_dir: str | Path | None = None) -> dict:
|
||||
env = {
|
||||
"GIT_AUTHOR_NAME": "Ezer Mishpati",
|
||||
"GIT_AUTHOR_EMAIL": "legal@local",
|
||||
"GIT_COMMITTER_NAME": "Ezer Mishpati",
|
||||
@@ -31,6 +39,13 @@ def _git_env() -> dict:
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
}
|
||||
if case_dir is not None:
|
||||
# Trust the case dir even when the running uid differs from the
|
||||
# owner (prod container is uniform-root, but host runs may not be).
|
||||
env["GIT_CONFIG_COUNT"] = "1"
|
||||
env["GIT_CONFIG_KEY_0"] = "safe.directory"
|
||||
env["GIT_CONFIG_VALUE_0"] = str(case_dir)
|
||||
return env
|
||||
|
||||
|
||||
def _refresh_remote_url(case_dir: Path, env: dict) -> bool:
|
||||
@@ -68,7 +83,7 @@ def commit_and_push(case_dir: str | Path, message: str) -> bool:
|
||||
if not (case_dir / ".git").exists():
|
||||
return False
|
||||
|
||||
env = _git_env()
|
||||
env = _git_env(case_dir)
|
||||
|
||||
subprocess.run(["git", "add", "."], cwd=case_dir, capture_output=True, env=env)
|
||||
commit = subprocess.run(
|
||||
@@ -90,3 +105,104 @@ def commit_and_push(case_dir: str | Path, message: str) -> bool:
|
||||
logger.warning("Git push failed in %s: %s", case_dir, push.stderr)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ── Periodic sweep ────────────────────────────────────────────────
|
||||
#
|
||||
# The user's expectation is that "anything I or an agent puts into a case
|
||||
# dir ends up on Gitea". Explicit commit_and_push calls cover the API
|
||||
# write paths, but agents write research/draft files directly to disk.
|
||||
# A short periodic sweep is the safety net.
|
||||
|
||||
_SWEEP_INTERVAL_SEC = 30
|
||||
|
||||
|
||||
def _porcelain_changes(case_dir: Path, env: dict) -> list[str]:
|
||||
"""Return list of `git status --porcelain` lines, or [] if clean/error."""
|
||||
res = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=case_dir, capture_output=True, text=True, env=env,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
return [ln for ln in res.stdout.splitlines() if ln.strip()]
|
||||
|
||||
|
||||
def _auto_message(changes: list[str]) -> str:
|
||||
"""Build a Hebrew commit message from porcelain output.
|
||||
|
||||
Groups by top-level subdir under the case dir so a sweep that picks up
|
||||
one DOCX export plus one research file produces a useful summary
|
||||
instead of "auto-sync".
|
||||
"""
|
||||
groups: dict[str, int] = {}
|
||||
sample: dict[str, str] = {}
|
||||
for line in changes:
|
||||
path = line[3:].strip().strip('"')
|
||||
if "->" in path: # rename
|
||||
path = path.split("->", 1)[1].strip().strip('"')
|
||||
first = path.split("/", 1)[0]
|
||||
groups[first] = groups.get(first, 0) + 1
|
||||
sample.setdefault(first, path)
|
||||
|
||||
label_map = {
|
||||
"documents": "מסמכים",
|
||||
"drafts": "טיוטות",
|
||||
"exports": "גרסאות",
|
||||
"case.json": "מטא",
|
||||
"notes.md": "הערות",
|
||||
}
|
||||
parts: list[str] = []
|
||||
for top, count in groups.items():
|
||||
label = label_map.get(top, top)
|
||||
parts.append(f"{label} ({count})" if count > 1 else label)
|
||||
summary = " · ".join(parts) or "שינויים"
|
||||
return f"אוטו: {summary}"
|
||||
|
||||
|
||||
def sweep_once() -> dict:
|
||||
"""Walk every case dir and commit+push any dirty changes.
|
||||
|
||||
Synchronous (subprocess-based) but cheap — `git status --porcelain` on
|
||||
a clean dir is a sub-millisecond operation. Returns a small report
|
||||
suitable for logging.
|
||||
"""
|
||||
base: Path = config.CASES_DIR
|
||||
if not base.exists():
|
||||
return {"checked": 0, "synced": 0, "errors": 0}
|
||||
|
||||
checked = synced = errors = 0
|
||||
for case_dir in base.iterdir():
|
||||
if not case_dir.is_dir() or not (case_dir / ".git").exists():
|
||||
continue
|
||||
checked += 1
|
||||
changes = _porcelain_changes(case_dir, _git_env(case_dir))
|
||||
if not changes:
|
||||
continue
|
||||
msg = _auto_message(changes)
|
||||
ok = commit_and_push(case_dir, msg)
|
||||
if ok:
|
||||
synced += 1
|
||||
logger.info("auto-sync committed %d change(s) in %s", len(changes), case_dir.name)
|
||||
else:
|
||||
errors += 1
|
||||
return {"checked": checked, "synced": synced, "errors": errors}
|
||||
|
||||
|
||||
async def sweep_loop(interval_sec: int = _SWEEP_INTERVAL_SEC) -> None:
|
||||
"""Background task: run sweep_once forever every interval_sec.
|
||||
|
||||
Cancellation-safe; logs and continues on transient errors.
|
||||
"""
|
||||
logger.info("git_sync.sweep_loop started (interval=%ds)", interval_sec)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(interval_sec)
|
||||
# Run the sync subprocess work in a thread to avoid blocking
|
||||
# the FastAPI event loop.
|
||||
await asyncio.to_thread(sweep_once)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("git_sync.sweep_loop cancelled")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("git_sync sweep iteration failed: %s", exc)
|
||||
|
||||
473
mcp-server/src/legal_mcp/services/halacha_extractor.py
Normal file
473
mcp-server/src/legal_mcp/services/halacha_extractor.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""Extract binding legal rules (הלכות) from external court rulings.
|
||||
|
||||
Runs Claude (via the local headless ``claude -p`` bridge) over the
|
||||
legal_analysis / ruling / conclusion chunks of a precedent, returns a
|
||||
structured list of halachot, validates each one against the source text,
|
||||
embeds the rule statement, and stores everything as ``pending_review`` in
|
||||
the ``halachot`` table.
|
||||
|
||||
All extraction is idempotent — calling ``extract(case_law_id)`` twice
|
||||
deletes prior rows for that precedent first.
|
||||
|
||||
Trust model:
|
||||
Per chair decision, NO halacha is auto-published. Every extracted
|
||||
halacha enters with ``review_status='pending_review'``. The chair
|
||||
approves/rejects via the UI, and only ``approved`` (or ``published``)
|
||||
rows are visible to ``search_precedent_library`` and the writing
|
||||
agents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.config import parse_llm_json
|
||||
from legal_mcp.services import claude_session, db, embeddings, proofreader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Concurrency model mirrors claims_extractor — each ``claude -p`` subprocess
|
||||
# holds ~300 MB RSS, so we cap parallel chunks to keep the box healthy.
|
||||
CHUNK_CONCURRENCY = 3
|
||||
CHUNK_RETRY_ATTEMPTS = 1
|
||||
|
||||
# If at least this fraction of chunks crash and the precedent yields zero
|
||||
# halachot, treat the run as `extraction_failed` rather than `no_halachot`.
|
||||
# Picked at 0.5 so a precedent that genuinely has no holdings (e.g. a remand
|
||||
# ruling that just sends the case back) isn't misflagged just because a few
|
||||
# chunks timed out, while a real rate-limit storm — which kills nearly every
|
||||
# call — is correctly distinguished and re-tried by the caller.
|
||||
EXTRACTION_FAILURE_THRESHOLD = 0.5
|
||||
|
||||
# Sections from which to extract. facts/intro/appellant_claims/respondent_claims
|
||||
# never contain holdings, only positions, so we skip them.
|
||||
EXTRACTABLE_SECTIONS = ("legal_analysis", "ruling", "conclusion")
|
||||
|
||||
|
||||
# Two prompts — choose by source's is_binding flag.
|
||||
#
|
||||
# The binding prompt extracts strict halachot (rules a future panel MUST
|
||||
# follow). It rejects obiter dicta, factual findings, and citations of
|
||||
# other rulings that the present court only mentioned in passing.
|
||||
#
|
||||
# The persuasive prompt is for sources that don't establish binding law
|
||||
# (most appeals committee decisions, district courts on planning matters,
|
||||
# etc.). For those, the value is in **how the panel reasoned and applied**
|
||||
# established law to facts — not in new halachot. The user explicitly
|
||||
# wants to be able to cite "another committee reached the same conclusion"
|
||||
# even though it is not binding.
|
||||
#
|
||||
# The schema's rule_type field accepts six values:
|
||||
# binding | interpretive | procedural | obiter | application | persuasive
|
||||
|
||||
HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה בדיני תכנון ובניה (ועדות ערר, היטל השבחה, פיצויים לפי סעיף 197 לחוק התכנון והבניה). תפקידך: לחלץ הלכות מחייבות מתוך פסק דין/החלטה משפטית של ערכאה עליונה (עליון / מנהלי).
|
||||
|
||||
## הגדרות מחייבות
|
||||
|
||||
הלכה (binding rule) = כלל משפטי שהפסק קובע או מאמץ ומיישם, באופן שניתן להסתמך עליו בהחלטות עתידיות.
|
||||
|
||||
לא-הלכה (אין לחלץ):
|
||||
- אמרת אגב (obiter dicta) — הערות שאינן הכרחיות להכרעה.
|
||||
- ממצאים עובדתיים ספציפיים לתיק ("העורר לא הוכיח X").
|
||||
- ציטוטי הלכות מפסקי דין אחרים שלא אומצו במפורש בפסק זה.
|
||||
- הצהרות על דין קיים שאינן מיושמות בהכרעה.
|
||||
|
||||
הבחנה קריטית: כאשר הפסק מצטט הלכה מפסק קודם, חלץ אותה רק אם בית המשפט בפסק הנוכחי **מאמץ ומחיל** אותה (לא רק מזכיר אותה ברקע).
|
||||
|
||||
## תחומים אפשריים (practice_areas) — תחומי ועדת הערר בלבד
|
||||
- rishuy_uvniya — רישוי ובניה (תיקי 1xxx: היתרים, שימוש חורג, תכניות, קווי בניין, גובה, חניה)
|
||||
- betterment_levy — היטל השבחה (תיקי 8xxx: שומה, מערכות, תכניות המקנות בה, מועד קובע, סופיות ההחלטה)
|
||||
- compensation_197 — פיצויים לפי ס' 197 (תיקי 9xxx: פגיעה במקרקעין, ירידת ערך, ס' 200/פטור)
|
||||
|
||||
הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד.
|
||||
|
||||
## סוגי הלכה (rule_type)
|
||||
- binding — הלכה מחייבת שהוחלה על התיק.
|
||||
- interpretive — פרשנות סעיף חוק/תכנית שאומצה.
|
||||
- procedural — כלל פרוצדורלי (סמכות, מועדים, הליכי שמיעה).
|
||||
- obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך).
|
||||
|
||||
## פלט נדרש
|
||||
החזר JSON array בלבד, ללא markdown, ללא הסברים. דוגמה:
|
||||
[
|
||||
{
|
||||
"rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.",
|
||||
"rule_type": "binding",
|
||||
"reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).",
|
||||
"supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.",
|
||||
"page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.",
|
||||
"practice_areas": ["betterment_levy"],
|
||||
"subject_tags": ["מועד_קביעת_שומה", "סופיות_ההחלטה"],
|
||||
"cites": ["עע\\"מ 3975/22"],
|
||||
"confidence": 0.85
|
||||
}
|
||||
]
|
||||
|
||||
## כללי איכות
|
||||
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תמציא הלכה.
|
||||
2. **מספר הלכות** — פסק רגיל מכיל 1-4 הלכות מחייבות. אל תמתח את הרשימה. אם אין הלכה — החזר [].
|
||||
3. **לא לפצל יתר על המידה** — אם שני סעיפים מבטאים את אותו עיקרון, אחד את הניסוח.
|
||||
4. **שפה** — rule_statement בעברית משפטית מקצועית, לא צמצום מילולי של הציטוט.
|
||||
5. **subject_tags** — 2-5 תגיות בעברית, snake_case (חניה, קווי_בניין, שיקול_דעת, פגם_פרוצדורלי, סמכות, מועדים, פגיעה_במקרקעין, ירידת_ערך).
|
||||
6. **confidence** — 0..1. מתחת ל-0.7 = ספק לגבי היות זה הלכה מחייבת.
|
||||
"""
|
||||
|
||||
|
||||
HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמחה בדיני תכנון ובניה. תפקידך: לחלץ עקרונות, יישומים ומסקנות מתוך החלטה של ועדת ערר אחרת או של בית משפט שאינו ערכאה עליונה לסוגיה.
|
||||
|
||||
## חשוב — מה לחלץ ומה לא
|
||||
|
||||
המקור הזה **אינו** מקור להלכות מחייבות חדשות (binding rules). הלכות מחייבות מגיעות מהעליון/מנהלי. עם זאת, יש כאן ערך משמעותי שצריך לחלץ — איך הפנל הזה ניתח ויישם את הדין הקיים. כשנכתוב החלטה עתידית, נצטט מהמקור הזה כ"גם ועדת הערר ב-X הגיעה למסקנה דומה" — לא כסמכות מחייבת, אלא כתמיכה משכנעת.
|
||||
|
||||
**יש לחלץ:**
|
||||
- **יישום של הלכה ידועה** (rule_type=`application`) — הפנל החיל הלכה ידועה (של עליון/מנהלי) על עובדות הנידונות. תצטט את ניסוח הכלל **כפי שהוצג כאן** (לא בהכרח כפי שנקבע במקור) ואת התוצאה.
|
||||
- **עקרון פרשני שאומץ** (rule_type=`interpretive`) — איך הפנל פירש סעיף חוק / תכנית, באופן שניתן לאמץ.
|
||||
- **כלל פרוצדורלי** (rule_type=`procedural`) — קביעות בנושאי סמכות, מועדים, הליך.
|
||||
- **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת.
|
||||
|
||||
**אין לחלץ:**
|
||||
- ממצאים עובדתיים ספציפיים לתיק ("העורר לא הוכיח X").
|
||||
- ציטוטים מפסקי דין אחרים ללא ניתוח של הפנל.
|
||||
- אמרות אגב חסרות חשיבות.
|
||||
|
||||
## תחומים אפשריים (practice_areas) — תחומי ועדת הערר בלבד
|
||||
- rishuy_uvniya — רישוי ובניה (תיקי 1xxx: היתרים, שימוש חורג, תכניות, קווי בניין, גובה, חניה)
|
||||
- betterment_levy — היטל השבחה (תיקי 8xxx: שומה, מערכות, תכניות המקנות בה, מועד קובע, סופיות ההחלטה)
|
||||
- compensation_197 — פיצויים לפי ס' 197 (תיקי 9xxx: פגיעה במקרקעין, ירידת ערך, ס' 200/פטור)
|
||||
|
||||
## פלט נדרש
|
||||
החזר JSON array בלבד, ללא markdown, ללא הסברים:
|
||||
[
|
||||
{
|
||||
"rule_statement": "ניסוח הכלל / המסקנה / היישום בלשון משפטית מדויקת, 1-3 משפטים.",
|
||||
"rule_type": "application",
|
||||
"reasoning_summary": "תמצית ההיגיון של הפנל (1-2 משפטים).",
|
||||
"supporting_quote": "ציטוט מילולי מדויק מהקלט שתומך בכלל. חייב להופיע מילה במילה.",
|
||||
"page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות.",
|
||||
"practice_areas": ["betterment_levy"],
|
||||
"subject_tags": ["מועד_קביעת_שומה", "תכנית_רחביה"],
|
||||
"cites": ["עע\\"מ 3975/22"],
|
||||
"confidence": 0.85
|
||||
}
|
||||
]
|
||||
|
||||
## כללי איכות
|
||||
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה.
|
||||
2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אם אין מה לחלץ — החזר [].
|
||||
3. **rule_type מדויק** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. persuasive = מסקנה כללית בעלת ערך כאסמכתא.
|
||||
4. **לא לפצל יתר על המידה** — שני סעיפים זהים מבחינה רעיונית = פריט אחד.
|
||||
5. **שפה** — עברית משפטית מקצועית, גוף שלישי.
|
||||
6. **subject_tags** — 2-5 תגיות בעברית, snake_case.
|
||||
7. **confidence** — 0..1. דייק.
|
||||
"""
|
||||
|
||||
|
||||
_VALID_PRACTICE_AREAS = {"rishuy_uvniya", "betterment_levy", "compensation_197"}
|
||||
_VALID_RULE_TYPES = {
|
||||
"binding", "interpretive", "procedural", "obiter",
|
||||
"application", "persuasive",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_for_comparison(text: str) -> str:
|
||||
"""Normalize Hebrew text for substring matching.
|
||||
|
||||
Collapses whitespace and unifies the half-dozen Hebrew quote-mark
|
||||
variants. Use ``proofreader._fix_hebrew_quotes`` for the quote part
|
||||
so we stay consistent with the proofreader pipeline.
|
||||
"""
|
||||
fixed = proofreader._fix_hebrew_quotes(text)
|
||||
# Collapse all whitespace (newlines, tabs, multiple spaces) to a single space.
|
||||
return re.sub(r"\s+", " ", fixed).strip()
|
||||
|
||||
|
||||
def _verify_quote(supporting_quote: str, full_text: str) -> bool:
|
||||
"""Return True if ``supporting_quote`` appears verbatim in ``full_text``
|
||||
after Hebrew quote/whitespace normalization.
|
||||
|
||||
The LLM occasionally trims a leading/trailing word from the quote;
|
||||
we accept the quote if at least 90% of its characters match a
|
||||
contiguous substring of the source.
|
||||
"""
|
||||
if not supporting_quote.strip():
|
||||
return False
|
||||
normalized_quote = _normalize_for_comparison(supporting_quote)
|
||||
normalized_text = _normalize_for_comparison(full_text)
|
||||
if not normalized_quote:
|
||||
return False
|
||||
if normalized_quote in normalized_text:
|
||||
return True
|
||||
# Fallback: try the inner 90% of the quote (drops boundary trim).
|
||||
if len(normalized_quote) >= 30:
|
||||
trim = max(2, len(normalized_quote) // 20)
|
||||
inner = normalized_quote[trim:-trim]
|
||||
if inner and inner in normalized_text:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _coerce_halacha(raw: dict, is_binding: bool = True) -> dict | None:
|
||||
"""Validate and normalize one LLM-returned halacha dict.
|
||||
|
||||
Returns ``None`` if the entry is missing required fields. ``is_binding``
|
||||
only affects the default rule_type when the LLM returned an unknown
|
||||
value — for binding sources we default to ``binding``, otherwise to
|
||||
``persuasive`` (never pretend an appeals committee created halacha).
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
rule_statement = (raw.get("rule_statement") or "").strip()
|
||||
supporting_quote = (raw.get("supporting_quote") or "").strip()
|
||||
if not rule_statement or not supporting_quote:
|
||||
return None
|
||||
|
||||
default_rule_type = "binding" if is_binding else "persuasive"
|
||||
rule_type = (raw.get("rule_type") or default_rule_type).strip().lower()
|
||||
if rule_type not in _VALID_RULE_TYPES:
|
||||
rule_type = default_rule_type
|
||||
# Guard: don't let a non-binding source produce 'binding' rule_type
|
||||
if not is_binding and rule_type == "binding":
|
||||
rule_type = "persuasive"
|
||||
|
||||
practice_areas_raw = raw.get("practice_areas") or []
|
||||
if isinstance(practice_areas_raw, str):
|
||||
practice_areas_raw = [practice_areas_raw]
|
||||
practice_areas = [p for p in practice_areas_raw if p in _VALID_PRACTICE_AREAS]
|
||||
|
||||
subject_tags_raw = raw.get("subject_tags") or []
|
||||
if isinstance(subject_tags_raw, str):
|
||||
subject_tags_raw = [subject_tags_raw]
|
||||
subject_tags = [str(t).strip() for t in subject_tags_raw if str(t).strip()]
|
||||
|
||||
cites_raw = raw.get("cites") or []
|
||||
if isinstance(cites_raw, str):
|
||||
cites_raw = [cites_raw]
|
||||
cites = [str(c).strip() for c in cites_raw if str(c).strip()]
|
||||
|
||||
try:
|
||||
confidence = float(raw.get("confidence", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.0
|
||||
confidence = max(0.0, min(1.0, confidence))
|
||||
|
||||
return {
|
||||
"rule_statement": rule_statement,
|
||||
"rule_type": rule_type,
|
||||
"reasoning_summary": (raw.get("reasoning_summary") or "").strip(),
|
||||
"supporting_quote": supporting_quote,
|
||||
"page_reference": (raw.get("page_reference") or "").strip(),
|
||||
"practice_areas": practice_areas,
|
||||
"subject_tags": subject_tags,
|
||||
"cites": cites,
|
||||
"confidence": confidence,
|
||||
}
|
||||
|
||||
|
||||
async def _extract_chunk(
|
||||
chunk_text: str,
|
||||
section_type: str,
|
||||
chunk_index: int,
|
||||
chunk_total: int,
|
||||
context: str,
|
||||
is_binding: bool,
|
||||
) -> tuple[list[dict], bool]:
|
||||
"""Run the halacha extractor on one chunk with retry.
|
||||
|
||||
Returns ``(halachot, succeeded)`` so the caller can distinguish "Claude
|
||||
said there are no halachot here" (`(_, True)`) from "every attempt
|
||||
crashed/timed out" (`(_, False)`). Without this distinction a precedent
|
||||
that hit a rate-limit storm looks identical to one that genuinely has no
|
||||
halachot — and gets silently marked `no_halachot`.
|
||||
|
||||
The prompt branches on ``is_binding`` so non-binding sources (other
|
||||
appeals committees, district courts) yield application/persuasive
|
||||
entries rather than a forced 0-result strict halacha pass.
|
||||
"""
|
||||
base_prompt = (
|
||||
HALACHA_EXTRACTION_PROMPT_BINDING if is_binding
|
||||
else HALACHA_EXTRACTION_PROMPT_PERSUASIVE
|
||||
)
|
||||
chunk_label = f" (חלק {chunk_index + 1}/{chunk_total})" if chunk_total > 1 else ""
|
||||
# Pass the static instruction prompt as `system` so the SDK path can cache
|
||||
# it (5-min ephemeral). Only the per-chunk content varies via `prompt`.
|
||||
user_msg = (
|
||||
f"## הקלט\n"
|
||||
f"סוג קטע: {section_type}\n"
|
||||
f"{context}{chunk_label}\n\n"
|
||||
f"--- תחילת הטקסט ---\n{chunk_text}\n--- סוף הטקסט ---"
|
||||
)
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(CHUNK_RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
result = await claude_session.query_json(user_msg, system=base_prompt)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
logger.warning(
|
||||
"halacha_extractor chunk %d/%d attempt %d raised: %s",
|
||||
chunk_index + 1, chunk_total, attempt + 1, e,
|
||||
)
|
||||
continue
|
||||
if isinstance(result, list):
|
||||
return result, True
|
||||
logger.warning(
|
||||
"halacha_extractor chunk %d/%d attempt %d returned non-list (%s)",
|
||||
chunk_index + 1, chunk_total, attempt + 1, type(result).__name__,
|
||||
)
|
||||
logger.error(
|
||||
"halacha_extractor chunk %d/%d failed after %d attempts: %s",
|
||||
chunk_index + 1, chunk_total, CHUNK_RETRY_ATTEMPTS + 1, last_err,
|
||||
)
|
||||
return [], False
|
||||
|
||||
|
||||
async def extract(case_law_id: UUID | str) -> dict:
|
||||
"""Extract halachot from an uploaded precedent and store them.
|
||||
|
||||
Idempotent: replaces any existing halachot for this case_law_id.
|
||||
All inserted rows start as ``review_status='pending_review'``.
|
||||
|
||||
Returns:
|
||||
``{"status": "...", "extracted": N, "verified": M, "stored": K, ...}``
|
||||
"""
|
||||
if isinstance(case_law_id, str):
|
||||
case_law_id = UUID(case_law_id)
|
||||
|
||||
record = await db.get_case_law(case_law_id)
|
||||
if not record:
|
||||
return {"status": "not_found", "extracted": 0, "stored": 0}
|
||||
|
||||
is_binding = bool(record.get("is_binding"))
|
||||
|
||||
# Try the targeted sections first (legal_analysis / ruling / conclusion).
|
||||
# If the chunker labeled everything as 'other' (common when a ruling
|
||||
# uses non-standard headings or the section markers aren't bracketed
|
||||
# cleanly), fall back to ALL chunks — better to over-include than to
|
||||
# silently skip a ruling that has reasoning under an unexpected label.
|
||||
chunks = await db.list_precedent_chunks(
|
||||
case_law_id, section_types=EXTRACTABLE_SECTIONS,
|
||||
)
|
||||
if not chunks:
|
||||
chunks = await db.list_precedent_chunks(case_law_id)
|
||||
if chunks:
|
||||
logger.info(
|
||||
"halacha_extractor: case_law=%s — no targeted sections, "
|
||||
"falling back to all %d chunks",
|
||||
case_law_id, len(chunks),
|
||||
)
|
||||
if not chunks:
|
||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
||||
return {"status": "no_chunks", "extracted": 0, "stored": 0}
|
||||
|
||||
await db.set_case_law_halacha_status(case_law_id, "processing")
|
||||
await db.delete_halachot(case_law_id)
|
||||
|
||||
citation = record.get("case_number", "")
|
||||
court = record.get("court", "")
|
||||
date_str = str(record.get("date") or "")
|
||||
context = f"מקור: {citation} — {court}, {date_str}"
|
||||
|
||||
sem = asyncio.Semaphore(CHUNK_CONCURRENCY)
|
||||
|
||||
async def _bounded(idx: int, chunk_row: dict) -> tuple[list[dict], bool]:
|
||||
async with sem:
|
||||
return await _extract_chunk(
|
||||
chunk_row["content"], chunk_row["section_type"],
|
||||
idx, len(chunks), context, is_binding,
|
||||
)
|
||||
|
||||
chunk_results = await asyncio.gather(
|
||||
*[_bounded(i, c) for i, c in enumerate(chunks)]
|
||||
)
|
||||
raw_halachot: list[dict] = []
|
||||
failed_chunks = 0
|
||||
for items, ok in chunk_results:
|
||||
raw_halachot.extend(items)
|
||||
if not ok:
|
||||
failed_chunks += 1
|
||||
|
||||
# If most chunks failed (rate limit storm, claude_session crash, etc.)
|
||||
# do NOT touch the DB status — leave it 'processing' so the caller can
|
||||
# retry without the request falling out of the queue. The caller
|
||||
# (`process_pending_extractions`) is responsible for either retrying or
|
||||
# finalising the status as 'failed' after retries are exhausted. This
|
||||
# is the bug that produced 317/10's silent `no_halachot` after a
|
||||
# 129-chunk neighbour saturated the API.
|
||||
failure_rate = failed_chunks / len(chunks) if chunks else 0
|
||||
if failure_rate >= EXTRACTION_FAILURE_THRESHOLD and not raw_halachot:
|
||||
logger.error(
|
||||
"halacha_extractor: case_law=%s extraction_failed — "
|
||||
"%d/%d chunks failed (rate=%.0f%%), no halachot retrieved. "
|
||||
"DB status left as 'processing' for caller-level retry.",
|
||||
case_law_id, failed_chunks, len(chunks), failure_rate * 100,
|
||||
)
|
||||
return {
|
||||
"status": "extraction_failed",
|
||||
"extracted": 0,
|
||||
"stored": 0,
|
||||
"failed_chunks": failed_chunks,
|
||||
"total_chunks": len(chunks),
|
||||
}
|
||||
|
||||
if not raw_halachot:
|
||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
||||
return {
|
||||
"status": "no_halachot",
|
||||
"extracted": 0,
|
||||
"stored": 0,
|
||||
"failed_chunks": failed_chunks,
|
||||
"total_chunks": len(chunks),
|
||||
}
|
||||
|
||||
# Validate against the full text of the precedent for the quote check.
|
||||
full_text = record.get("full_text") or ""
|
||||
|
||||
cleaned: list[dict] = []
|
||||
for raw in raw_halachot:
|
||||
coerced = _coerce_halacha(raw, is_binding=is_binding)
|
||||
if coerced is None:
|
||||
continue
|
||||
coerced["quote_verified"] = _verify_quote(
|
||||
coerced["supporting_quote"], full_text,
|
||||
)
|
||||
cleaned.append(coerced)
|
||||
|
||||
if not cleaned:
|
||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
||||
return {"status": "no_valid_halachot", "extracted": len(raw_halachot), "stored": 0}
|
||||
|
||||
# Embed rule_statement + reasoning_summary so semantic search hits the
|
||||
# rule directly rather than the surrounding chunk centroid.
|
||||
embed_inputs = [
|
||||
f"{h['rule_statement']} — {h['reasoning_summary']}".strip(" —")
|
||||
for h in cleaned
|
||||
]
|
||||
try:
|
||||
vectors = await embeddings.embed_texts(embed_inputs, input_type="document")
|
||||
except Exception as e:
|
||||
logger.error("halacha_extractor: embeddings failed: %s", e)
|
||||
vectors = [None] * len(cleaned)
|
||||
|
||||
for halacha, vec in zip(cleaned, vectors):
|
||||
halacha["embedding"] = vec
|
||||
|
||||
stored = await db.store_halachot(case_law_id, cleaned)
|
||||
|
||||
verified = sum(1 for h in cleaned if h["quote_verified"])
|
||||
await db.set_case_law_halacha_status(case_law_id, "completed")
|
||||
|
||||
logger.info(
|
||||
"halacha_extractor: case_law=%s extracted=%d cleaned=%d verified=%d stored=%d",
|
||||
case_law_id, len(raw_halachot), len(cleaned), verified, stored,
|
||||
)
|
||||
return {
|
||||
"status": "completed",
|
||||
"extracted": len(raw_halachot),
|
||||
"valid": len(cleaned),
|
||||
"verified": verified,
|
||||
"stored": stored,
|
||||
}
|
||||
389
mcp-server/src/legal_mcp/services/hybrid_search.py
Normal file
389
mcp-server/src/legal_mcp/services/hybrid_search.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""Hybrid (text + image) search wrappers.
|
||||
|
||||
Layered on top of ``rerank.maybe_rerank``. When ``MULTIMODAL_ENABLED`` is
|
||||
true the result comes from a weighted merge of:
|
||||
|
||||
• 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
|
||||
|
||||
rerank-2 is a *text* cross-encoder, so image-side rows are NOT passed
|
||||
through it; they keep their cosine score and merge alongside the
|
||||
(possibly reranked) text rows. Image-only pages with no overlapping
|
||||
text chunk are surfaced as ``match_type='image'`` so scanned-only or
|
||||
visual-heavy content still appears in results.
|
||||
|
||||
When ``MULTIMODAL_ENABLED`` is false this module degenerates to plain
|
||||
``rerank.maybe_rerank`` — callers can wrap unconditionally and let env
|
||||
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
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import db, embeddings, rerank
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def search_documents_hybrid(
|
||||
query: str,
|
||||
query_text_embedding: list[float],
|
||||
*,
|
||||
limit: int,
|
||||
case_id: UUID | None = None,
|
||||
section_type: str | None = None,
|
||||
practice_area: str | None = None,
|
||||
appeal_subtype: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Hybrid wrapper for document-chunk search (search_decisions /
|
||||
search_case_documents / find_similar_cases)."""
|
||||
fetch_k = max(limit, config.VOYAGE_RERANK_FETCH_K) if config.MULTIMODAL_ENABLED else limit
|
||||
text_results = await rerank.maybe_rerank(
|
||||
query=query,
|
||||
base_search=lambda **kw: db.search_similar(
|
||||
query_embedding=query_text_embedding, **kw,
|
||||
),
|
||||
limit=fetch_k,
|
||||
case_id=case_id,
|
||||
section_type=section_type,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype,
|
||||
)
|
||||
if not config.MULTIMODAL_ENABLED:
|
||||
return text_results[:limit]
|
||||
|
||||
try:
|
||||
query_img_emb = await embeddings.embed_query_for_multimodal(query)
|
||||
img_rows = await db.search_document_images_similar(
|
||||
query_img_emb,
|
||||
limit=fetch_k,
|
||||
case_id=case_id,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Hybrid: image side failed, returning text only: %s", e)
|
||||
return text_results[:limit]
|
||||
|
||||
merged = _merge(
|
||||
text_results, img_rows,
|
||||
id_field="document_id",
|
||||
text_weight=config.MULTIMODAL_TEXT_WEIGHT,
|
||||
)
|
||||
return merged[:limit]
|
||||
|
||||
|
||||
async def search_precedent_library_hybrid(
|
||||
query: str,
|
||||
query_text_embedding: list[float],
|
||||
*,
|
||||
limit: int,
|
||||
practice_area: str = "",
|
||||
court: str = "",
|
||||
precedent_level: str = "",
|
||||
appeal_subtype: str = "",
|
||||
is_binding: bool | None = None,
|
||||
subject_tag: str = "",
|
||||
include_halachot: bool = True,
|
||||
source_kind: str = "external_upload",
|
||||
district: str = "",
|
||||
chair_name: str = "",
|
||||
max_per_case_law: int = 2,
|
||||
) -> list[dict]:
|
||||
"""Hybrid wrapper for precedent-library search.
|
||||
|
||||
source_kind='external_upload' → court rulings (default)
|
||||
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 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]:
|
||||
sem_rows = await db.search_precedent_library_semantic(
|
||||
query_embedding=query_text_embedding,
|
||||
practice_area=practice_area,
|
||||
court=court,
|
||||
precedent_level=precedent_level,
|
||||
appeal_subtype=appeal_subtype,
|
||||
is_binding=is_binding,
|
||||
subject_tag=subject_tag,
|
||||
limit=limit,
|
||||
include_halachot=include_halachot,
|
||||
source_kind=source_kind,
|
||||
district=district,
|
||||
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(
|
||||
query=query, base_search=_base, limit=fetch_k,
|
||||
)
|
||||
if not config.MULTIMODAL_ENABLED:
|
||||
return _diversify_by_case_law(text_results, limit, max_per_case_law)
|
||||
|
||||
try:
|
||||
query_img_emb = await embeddings.embed_query_for_multimodal(query)
|
||||
img_rows = await db.search_precedent_images_similar(
|
||||
query_img_emb,
|
||||
limit=fetch_k,
|
||||
practice_area=practice_area,
|
||||
court=court,
|
||||
precedent_level=precedent_level,
|
||||
appeal_subtype=appeal_subtype,
|
||||
is_binding=is_binding,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Hybrid: image side failed, returning text only: %s", e)
|
||||
return _diversify_by_case_law(text_results, limit, max_per_case_law)
|
||||
|
||||
merged = _merge(
|
||||
text_results, img_rows,
|
||||
id_field="case_law_id",
|
||||
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]
|
||||
|
||||
|
||||
def _merge(
|
||||
text_rows: list[dict],
|
||||
img_rows: list[dict],
|
||||
id_field: str,
|
||||
text_weight: float,
|
||||
) -> list[dict]:
|
||||
"""Reciprocal Rank Fusion of text + image rows.
|
||||
|
||||
Why RRF: voyage-3 cosine scores (~0.4-0.5) and voyage-multimodal-3
|
||||
scores (~0.2-0.25) live on different scales — a direct weighted
|
||||
sum lets text always dominate. RRF combines by *rank* in each list,
|
||||
making the merge robust to score-scale differences.
|
||||
|
||||
Per item::
|
||||
|
||||
rrf_score = text_weight / (k + text_rank)
|
||||
+ image_weight / (k + image_rank)
|
||||
|
||||
A row that appears in only one list contributes that list's term
|
||||
only. Rows joined at ``(id_field, page_number)`` get both terms —
|
||||
surfaced as ``match_type='text+image'`` with the thumbnail attached.
|
||||
|
||||
Halachot in precedent rows have no page_number; they remain
|
||||
text-only under RRF (the case-level image boost is dropped — RRF
|
||||
works on rank, not raw scores).
|
||||
"""
|
||||
from legal_mcp import config as _cfg
|
||||
img_weight = 1.0 - text_weight
|
||||
k = _cfg.MULTIMODAL_RRF_K
|
||||
|
||||
# Index image rows by their join key for boost detection.
|
||||
img_rank_by_key: dict[tuple, int] = {}
|
||||
img_row_by_key: dict[tuple, dict] = {}
|
||||
for rank, r in enumerate(img_rows, 1):
|
||||
key = (str(r[id_field]), r.get("page_number"))
|
||||
img_rank_by_key[key] = rank
|
||||
img_row_by_key[key] = r
|
||||
|
||||
seen_image_keys: set = set()
|
||||
merged: list[dict] = []
|
||||
for rank, r in enumerate(text_rows, 1):
|
||||
rid = str(r[id_field])
|
||||
page = r.get("page_number")
|
||||
key = (rid, page) if page is not None else None
|
||||
img_rank = img_rank_by_key.get(key) if key else None
|
||||
text_term = text_weight / (k + rank)
|
||||
image_term = img_weight / (k + img_rank) if img_rank else 0.0
|
||||
d = dict(r)
|
||||
d["text_score"] = float(r.get("score", 0.0))
|
||||
d["text_rank"] = rank
|
||||
if img_rank:
|
||||
img_hit = img_row_by_key[key]
|
||||
d["image_score"] = float(img_hit.get("score", 0.0))
|
||||
d["image_rank"] = img_rank
|
||||
d["image_thumbnail_path"] = img_hit.get("image_thumbnail_path")
|
||||
d["match_type"] = "text+image"
|
||||
seen_image_keys.add(key)
|
||||
else:
|
||||
d["image_score"] = 0.0
|
||||
d["match_type"] = "text"
|
||||
d["score"] = text_term + image_term
|
||||
merged.append(d)
|
||||
|
||||
for rank, r in enumerate(img_rows, 1):
|
||||
key = (str(r[id_field]), r.get("page_number"))
|
||||
if key in seen_image_keys:
|
||||
continue
|
||||
d = dict(r)
|
||||
d["text_score"] = 0.0
|
||||
d["image_score"] = float(r.get("score", 0.0))
|
||||
d["image_rank"] = rank
|
||||
d["score"] = img_weight / (k + rank)
|
||||
d["match_type"] = "image"
|
||||
d["content"] = ""
|
||||
d["section_type"] = "image"
|
||||
merged.append(d)
|
||||
|
||||
merged.sort(key=lambda x: -float(x["score"]))
|
||||
return merged
|
||||
421
mcp-server/src/legal_mcp/services/internal_decisions.py
Normal file
421
mcp-server/src/legal_mcp/services/internal_decisions.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""Orchestrator for the Internal Committee Decisions corpus.
|
||||
|
||||
Ingest pipeline:
|
||||
text/file → INSERT case_law (source_kind='internal_committee')
|
||||
→ chunk → embed → store precedent_chunks
|
||||
→ queue halacha extraction
|
||||
|
||||
Migration helpers:
|
||||
migrate_from_style_corpus() — re-index style_corpus entries as searchable
|
||||
migrate_from_external_corpus() — reclassify external appeals-committee rows
|
||||
|
||||
All ועדות ערר (any district) belong here.
|
||||
Judicial decisions (Supreme Court, Administrative Court) stay in external_upload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor
|
||||
from legal_mcp.services.practice_area import derive_proceeding_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
|
||||
|
||||
_VALID_DISTRICTS = {"", "ירושלים", "מרכז", "תל אביב", "צפון", "דרום", "ארצי"}
|
||||
|
||||
_COURT_TO_DISTRICT = [
|
||||
("ירושלים", "ירושלים"),
|
||||
("תל אביב", "תל אביב"),
|
||||
('ת"א', "תל אביב"),
|
||||
("מרכז", "מרכז"),
|
||||
("חיפה", "צפון"),
|
||||
("צפון", "צפון"),
|
||||
("דרום", "דרום"),
|
||||
("ארצי", "ארצי"),
|
||||
("ארצית", "ארצי"),
|
||||
]
|
||||
|
||||
|
||||
def _coerce_date(value) -> date | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return date.fromisoformat(value[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
base = Path(name).name
|
||||
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"internal-{uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _district_from_court(court: str) -> str:
|
||||
for keyword, district in _COURT_TO_DISTRICT:
|
||||
if keyword in court:
|
||||
return district
|
||||
return ""
|
||||
|
||||
|
||||
async def ingest_internal_decision(
|
||||
*,
|
||||
case_number: str,
|
||||
case_name: str = "",
|
||||
court: str = "",
|
||||
decision_date=None,
|
||||
chair_name: str = "",
|
||||
district: str = "",
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
subject_tags: list[str] | None = None,
|
||||
summary: str = "",
|
||||
is_binding: bool = True,
|
||||
file_path: str | Path | None = None,
|
||||
text: str | None = None,
|
||||
document_id: UUID | None = None,
|
||||
queue_halachot: bool = True,
|
||||
proceeding_type: str = "",
|
||||
) -> dict:
|
||||
"""Ingest an appeals-committee decision into the internal corpus.
|
||||
|
||||
Either file_path or text must be provided.
|
||||
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}
|
||||
"""
|
||||
if not file_path and not text:
|
||||
raise ValueError("either file_path or text is required")
|
||||
if not case_number.strip():
|
||||
raise ValueError("case_number is required")
|
||||
|
||||
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:
|
||||
src = Path(file_path)
|
||||
if not src.is_file():
|
||||
raise FileNotFoundError(f"file not found: {src}")
|
||||
dest_dir = INTERNAL_DECISIONS_DIR / (resolved_district or "other")
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
staged = dest_dir / f"{uuid4().hex[:8]}_{_safe_filename(src.name)}"
|
||||
shutil.copy2(src, staged)
|
||||
raw_text, page_count, page_offsets = await extractor.extract_text(str(staged))
|
||||
raw_text = extractor.strip_nevo_preamble(raw_text or "").strip()
|
||||
if not raw_text:
|
||||
raise ValueError("no extractable text in file")
|
||||
else:
|
||||
raw_text = (text or "").strip()
|
||||
if not raw_text:
|
||||
raise ValueError("text is empty")
|
||||
page_count = 0
|
||||
page_offsets = None
|
||||
|
||||
record = await db.create_internal_committee_decision(
|
||||
case_number=case_number.strip(),
|
||||
case_name=(case_name.strip() or case_number.strip()),
|
||||
full_text=raw_text,
|
||||
court=court.strip(),
|
||||
decision_date=_coerce_date(decision_date),
|
||||
chair_name=chair_name.strip(),
|
||||
district=resolved_district,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype.strip(),
|
||||
subject_tags=list(subject_tags or []),
|
||||
summary=summary.strip(),
|
||||
is_binding=is_binding,
|
||||
document_id=document_id,
|
||||
proceeding_type=resolved_proc,
|
||||
)
|
||||
case_law_id = UUID(str(record["id"]))
|
||||
|
||||
try:
|
||||
# Parent-doc retrieval (TaskMaster #48) — same gated branch as
|
||||
# ingest_precedent. Internal committee decisions are typically
|
||||
# longer than external court rulings (full transcript + ruling),
|
||||
# so the parent-doc benefit is even larger here.
|
||||
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_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
|
||||
chunk_dicts = [
|
||||
{
|
||||
"chunk_index": c.chunk_index,
|
||||
"content": c.content,
|
||||
"section_type": c.section_type,
|
||||
"page_number": c.page_number,
|
||||
"embedding": v,
|
||||
}
|
||||
for c, v in zip(chunks, chunk_vectors)
|
||||
]
|
||||
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_halacha_status(case_law_id, "pending")
|
||||
if queue_halachot:
|
||||
await db.request_halacha_extraction(case_law_id)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"case_law_id": str(case_law_id),
|
||||
"chunks": stored,
|
||||
"halachot_pending": True,
|
||||
}
|
||||
|
||||
except Exception:
|
||||
logger.exception("ingest_internal_decision failed for %s", case_number)
|
||||
await db.set_case_law_extraction_status(case_law_id, "failed")
|
||||
raise
|
||||
|
||||
|
||||
async def migrate_from_style_corpus(dry_run: bool = False, queue_halachot: bool = True) -> dict:
|
||||
"""Re-index all style_corpus entries as searchable internal committee decisions.
|
||||
|
||||
Does NOT delete style_corpus rows — they remain for style analysis.
|
||||
Skips entries that already exist in case_law as internal_committee.
|
||||
"""
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT decision_number, decision_date, full_text,
|
||||
practice_area, appeal_subtype, subject_categories
|
||||
FROM style_corpus
|
||||
ORDER BY decision_date NULLS LAST"""
|
||||
)
|
||||
|
||||
results = {"total": len(rows), "ingested": 0, "skipped": 0, "failed": 0, "dry_run": dry_run}
|
||||
|
||||
for row in rows:
|
||||
case_number = (row["decision_number"] or "").strip()
|
||||
if not case_number:
|
||||
results["skipped"] += 1
|
||||
continue
|
||||
|
||||
if not dry_run:
|
||||
existing = await pool.fetchval(
|
||||
"SELECT id FROM case_law WHERE case_number = $1 AND source_kind = 'internal_committee'",
|
||||
case_number,
|
||||
)
|
||||
if existing:
|
||||
results["skipped"] += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
results["ingested"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
subject_tags = list(row["subject_categories"] or [])
|
||||
raw_pa = row["practice_area"] or ""
|
||||
subtype = row["appeal_subtype"] or ""
|
||||
# style_corpus stores 'appeals_committee' (source_type) instead of practice_area
|
||||
_subtype_to_pa = {
|
||||
"building_permit": "rishuy_uvniya",
|
||||
"betterment_levy": "betterment_levy",
|
||||
"compensation_197": "compensation_197",
|
||||
}
|
||||
practice_area = raw_pa if raw_pa in ("rishuy_uvniya", "betterment_levy", "compensation_197") \
|
||||
else _subtype_to_pa.get(subtype, "")
|
||||
await ingest_internal_decision(
|
||||
case_number=case_number,
|
||||
court="ועדת הערר לתכנון ובנייה — מחוז ירושלים",
|
||||
decision_date=row["decision_date"],
|
||||
chair_name="דפנה תמיר",
|
||||
district="ירושלים",
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=subtype,
|
||||
subject_tags=subject_tags,
|
||||
text=row["full_text"],
|
||||
queue_halachot=queue_halachot,
|
||||
)
|
||||
results["ingested"] += 1
|
||||
logger.info("Migrated style_corpus entry: %s", case_number)
|
||||
except Exception as e:
|
||||
logger.error("Failed to migrate %s: %s", case_number, e)
|
||||
results["failed"] += 1
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def migrate_from_external_corpus(dry_run: bool = False) -> dict:
|
||||
"""Reclassify external appeals-committee decisions to source_kind='internal_committee'.
|
||||
|
||||
Identifies rows by source_type='appeals_committee' and updates source_kind + district.
|
||||
Existing precedent_chunks remain — no re-embedding needed.
|
||||
"""
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT id, case_number, court
|
||||
FROM case_law
|
||||
WHERE source_kind = 'external_upload'
|
||||
AND source_type = 'appeals_committee'"""
|
||||
)
|
||||
|
||||
results = {"total": len(rows), "updated": 0, "dry_run": dry_run}
|
||||
|
||||
if dry_run:
|
||||
results["updated"] = len(rows)
|
||||
results["preview"] = [
|
||||
{"case_number": r["case_number"], "court": r["court"], "district": _district_from_court(r["court"] or "")}
|
||||
for r in rows
|
||||
]
|
||||
return results
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
for row in rows:
|
||||
district = _district_from_court(row["court"] or "")
|
||||
await conn.execute(
|
||||
"""UPDATE case_law
|
||||
SET source_kind = 'internal_committee',
|
||||
district = CASE WHEN $2 <> '' THEN $2 ELSE district END
|
||||
WHERE id = $1""",
|
||||
row["id"], district,
|
||||
)
|
||||
results["updated"] = len(rows)
|
||||
|
||||
logger.info("Migrated %d external appeals-committee rows to internal_committee", len(rows))
|
||||
return results
|
||||
|
||||
|
||||
async def enrich_migrated_entries(dry_run: bool = False) -> dict:
|
||||
"""One-time enrichment: run metadata extraction + halacha extraction on all
|
||||
internal_committee entries that are waiting (halacha_status='pending',
|
||||
metadata never requested).
|
||||
|
||||
Metadata extraction will:
|
||||
- Fix case_number from the decision header text
|
||||
- Fill case_name from the parties line
|
||||
- Fill date if missing
|
||||
|
||||
Halacha extraction queues the LLM-based halacha extraction job.
|
||||
"""
|
||||
from legal_mcp.services import precedent_metadata_extractor, db as _db
|
||||
|
||||
pool = await _db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT id, case_number
|
||||
FROM case_law
|
||||
WHERE source_kind = 'internal_committee'
|
||||
AND halacha_extraction_status = 'pending'
|
||||
AND metadata_extraction_requested_at IS NULL
|
||||
ORDER BY created_at"""
|
||||
)
|
||||
|
||||
results = {
|
||||
"total": len(rows),
|
||||
"metadata_updated": 0,
|
||||
"halachot_queued": 0,
|
||||
"failed": 0,
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
return results
|
||||
|
||||
for row in rows:
|
||||
case_law_id = row["id"]
|
||||
try:
|
||||
meta = await precedent_metadata_extractor.extract_and_apply(
|
||||
case_law_id, overwrite_case_number=True
|
||||
)
|
||||
if meta.get("status") in ("completed", "no_changes"):
|
||||
results["metadata_updated"] += 1
|
||||
logger.info(
|
||||
"enrich_migrated: %s → fields=%s",
|
||||
row["case_number"], meta.get("fields"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("enrich_migrated metadata failed for %s: %s", row["case_number"], e)
|
||||
results["failed"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
await _db.request_halacha_extraction(case_law_id)
|
||||
results["halachot_queued"] += 1
|
||||
except Exception as e:
|
||||
logger.error("enrich_migrated halacha queue failed for %s: %s", row["case_number"], e)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def search_internal(
|
||||
query: str,
|
||||
*,
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
district: str = "",
|
||||
chair_name: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
) -> list[dict]:
|
||||
"""Semantic search over internal committee decisions."""
|
||||
from legal_mcp.services import hybrid_search
|
||||
|
||||
if not query.strip():
|
||||
return []
|
||||
query_vec = await embeddings.embed_query(query)
|
||||
return await hybrid_search.search_precedent_library_hybrid(
|
||||
query=query,
|
||||
query_text_embedding=query_vec,
|
||||
limit=limit,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype,
|
||||
include_halachot=include_halachot,
|
||||
source_kind="internal_committee",
|
||||
district=district,
|
||||
chair_name=chair_name,
|
||||
)
|
||||
@@ -90,10 +90,10 @@ async def analyze_changes(draft_text: str, final_text: str) -> dict:
|
||||
--- גרסה סופית ---
|
||||
{final_sample}
|
||||
"""
|
||||
result = claude_session.query_json(prompt, timeout=120)
|
||||
result = await claude_session.query_json(prompt)
|
||||
if result is None:
|
||||
logger.warning("Failed to parse lessons response")
|
||||
return {"changes": [], "new_expressions": [], "overall_assessment": raw[:200]}
|
||||
return {"changes": [], "new_expressions": [], "overall_assessment": ""}
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ SUMMARY_STRATEGIES = {
|
||||
|
||||
DISCUSSION_RULES: dict[str, list[str]] = {
|
||||
"universal": [
|
||||
"פרק הדיון = אסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
||||
"פרק הדיון = מאסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
||||
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
|
||||
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
|
||||
],
|
||||
@@ -485,6 +485,7 @@ CONTENT_CHECKLISTS: dict[str, str] = {
|
||||
- שווי מקרקעין — מצב קודם ומצב חדש (שיטת השוואה / יחידות תועלת)
|
||||
- עלויות עודפות (חניה, מטלות ציבוריות, תשתיות)
|
||||
- מקדמי זמינות, שיעורי הפקעה
|
||||
- הכרעה מפוצלת (bifurcation) — כשהוועדה מאשרת חבות אך ממנה שמאי מייעץ: ביטויי גישור ("ניתן יהיה לעלות בפני השמאי המייעץ"), נוסחת מינוי, הפניה לתקנות סדרי דין התשס"ט-2008, הוראות המשך (30 יום להשגות). ללא סיכום — ישירות לחתימה. ראה: 8070/25
|
||||
|
||||
### ד. שאלות משפטיות (לפי רלוונטיות)
|
||||
- פטורים — דירת מגורים (ס' 19(ג)(1)), שטח עד 140 מ"ר, תא משפחתי
|
||||
@@ -493,6 +494,7 @@ CONTENT_CHECKLISTS: dict[str, str] = {
|
||||
- מקרקעי ישראל — הסדרים מיוחדים (ס' 21 לתוספת השלישית)
|
||||
- שומות מוסכמות — תוקף, משמעות, "בלתי נצפה מראש"
|
||||
- פרשנות תכניות — ייעוד, שימושים מותרים, מדיניות ועדה מקומית
|
||||
- טענת "תכנית צל = זכות מוקנית" — ניתוח תלת-שכבתי: (1) נורמטיבית — תכנית צל = המחשה, לא מקור נורמטיבי; (2) פרוצדורלית — הקלה ניתנת פר-מבקש, לא זכות כללית; (3) שמאית — משקל הסתברותי בהערכת ההשבחה, לא במישור המשפטי. ראה: 8070/25
|
||||
|
||||
### ה. ניתוח שמאי (כשיש שומה מכרעת)
|
||||
- האם השומה מבוססת על מסד עובדתי הולם?
|
||||
|
||||
@@ -2,14 +2,34 @@
|
||||
|
||||
Two orthogonal axes used to separate legal domains across the system:
|
||||
|
||||
practice_area — top-level domain (multi-tenant axis). Examples:
|
||||
appeals_committee, national_insurance, labor_law.
|
||||
appeal_subtype — refines within a domain. For appeals_committee:
|
||||
building_permit (1xxx), betterment_levy (8xxx),
|
||||
compensation_197 (9xxx), unknown.
|
||||
practice_area — top-level domain. **Two taxonomies coexist** (see below).
|
||||
appeal_subtype — refines within a domain.
|
||||
|
||||
Both columns are denormalized into documents/chunks/decisions/style_corpus
|
||||
so vector searches can filter cheaply.
|
||||
⚠️ TWO TAXONOMIES — DO NOT CONFUSE
|
||||
==================================
|
||||
|
||||
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
|
||||
@@ -18,19 +38,58 @@ import re
|
||||
|
||||
# ── Enums ──────────────────────────────────────────────────────────
|
||||
|
||||
PRACTICE_AREAS: set[str] = {
|
||||
# Multi-tenant axis (legacy)
|
||||
MULTI_TENANT_PRACTICE_AREAS: set[str] = {
|
||||
"appeals_committee",
|
||||
"national_insurance",
|
||||
"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] = {
|
||||
"building_permit",
|
||||
"betterment_levy",
|
||||
"compensation_197",
|
||||
# בל"מ — בקשה להארכת מועד להגשת ערר. מסלולים נפרדים לפי domain:
|
||||
"extension_request_building_permit", # 1xxx — סעיף 152, 30 ימים
|
||||
"extension_request_betterment_levy", # 8xxx — סעיף 14 לתוספת ג', 45 ימים
|
||||
"extension_request_compensation", # 9xxx — סעיף 198(ד), 30 ימים
|
||||
"unknown",
|
||||
}
|
||||
|
||||
# בל"מ subtypes — קל לזהות ע"י prefix
|
||||
BLAM_SUBTYPES: set[str] = {
|
||||
"extension_request_building_permit",
|
||||
"extension_request_betterment_levy",
|
||||
"extension_request_compensation",
|
||||
}
|
||||
|
||||
# מיפוי domain → בל"מ subtype
|
||||
_DOMAIN_TO_BLAM_SUBTYPE: dict[str, str] = {
|
||||
"rishuy_uvniya": "extension_request_building_permit",
|
||||
"betterment_levy": "extension_request_betterment_levy",
|
||||
"compensation_197": "extension_request_compensation",
|
||||
}
|
||||
|
||||
# מיפוי first-digit → בל"מ subtype (אותו מבנה כמו _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE)
|
||||
_APPEALS_COMMITTEE_DIGIT_TO_BLAM = {
|
||||
"1": "extension_request_building_permit",
|
||||
"8": "extension_request_betterment_levy",
|
||||
"9": "extension_request_compensation",
|
||||
}
|
||||
|
||||
DEFAULT_PRACTICE_AREA = "appeals_committee"
|
||||
|
||||
# Subtypes per practice_area (extend when adding domains)
|
||||
@@ -38,8 +97,74 @@ SUBTYPES_BY_AREA: dict[str, set[str]] = {
|
||||
"appeals_committee": APPEALS_COMMITTEE_SUBTYPES,
|
||||
"national_insurance": {"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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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})")
|
||||
|
||||
|
||||
_DOMAIN_TO_SUBTYPE: dict[str, str] = {
|
||||
"rishuy_uvniya": "building_permit",
|
||||
"betterment_levy": "betterment_levy",
|
||||
"compensation_197": "compensation_197",
|
||||
}
|
||||
|
||||
|
||||
def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) -> str:
|
||||
"""Infer the appeal_subtype from case_number.
|
||||
|
||||
For appeals_committee, the convention is:
|
||||
For appeals_committee (axis A), the convention is:
|
||||
1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197.
|
||||
|
||||
For domain values (axis B — rishuy_uvniya/betterment_levy/compensation_197),
|
||||
the subtype is implicit in the practice_area itself — we map directly
|
||||
without parsing the case number.
|
||||
|
||||
Handles multiple formats: ARAR-25-8126, 8126/25, 1170, ערר 1024-25.
|
||||
"""
|
||||
# Axis B: practice_area is already a domain value — map directly.
|
||||
if practice_area in DOMAIN_PRACTICE_AREAS:
|
||||
return _DOMAIN_TO_SUBTYPE.get(practice_area, "unknown")
|
||||
if practice_area != "appeals_committee":
|
||||
return "unknown"
|
||||
cn = case_number or ""
|
||||
@@ -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")
|
||||
|
||||
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""True iff the user-supplied subtype disagrees with what derive_subtype
|
||||
would have produced (and the derived value is not 'unknown')."""
|
||||
would have produced (and the derived value is not 'unknown').
|
||||
|
||||
Note: בל"מ variants (extension_request_*) are NOT considered overrides
|
||||
of their parent domain — extension_request_building_permit on a 1xxx
|
||||
case is consistent with the case-number convention.
|
||||
"""
|
||||
derived = derive_subtype(case_number, practice_area)
|
||||
return derived != "unknown" and derived != appeal_subtype
|
||||
if derived == "unknown":
|
||||
return False
|
||||
if derived == appeal_subtype:
|
||||
return False
|
||||
# בל"מ variants of the same domain are not overrides.
|
||||
if appeal_subtype in BLAM_SUBTYPES:
|
||||
# extension_request_building_permit ↔ building_permit (1xxx) — same domain
|
||||
if _SUBTYPE_TO_DOMAIN.get(appeal_subtype) == _SUBTYPE_TO_DOMAIN.get(derived):
|
||||
return False
|
||||
return True
|
||||
|
||||
633
mcp-server/src/legal_mcp/services/precedent_library.py
Normal file
633
mcp-server/src/legal_mcp/services/precedent_library.py
Normal file
@@ -0,0 +1,633 @@
|
||||
"""Orchestrator for the External Precedent Library.
|
||||
|
||||
Ingest pipeline (one upload):
|
||||
file → extract_text → proofread → INSERT case_law (source_kind='external_upload')
|
||||
→ chunk → embed → store precedent_chunks
|
||||
→ halacha_extractor.extract → embed halachot → store halachot
|
||||
→ set extraction_status='completed'
|
||||
|
||||
Progress is reported via a caller-supplied async callback so the
|
||||
web layer can pipe updates into the existing Redis ProgressStore /
|
||||
SSE plumbing without this module knowing about Redis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor, hybrid_search, rerank # noqa: F401
|
||||
|
||||
# Note: halacha_extractor and precedent_metadata_extractor are NOT imported
|
||||
# at module load. They are imported lazily inside the dedicated re-extract
|
||||
# entry points so that `ingest_precedent` (called from the FastAPI container,
|
||||
# where `claude` CLI is unavailable) cannot accidentally pull them in. See
|
||||
# the architectural rule in services/claude_session.py.
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ProgressCb = Callable[[str, int, str], Awaitable[None]]
|
||||
|
||||
|
||||
PRECEDENT_LIBRARY_DIR = Path(config.DATA_DIR) / "precedent-library"
|
||||
|
||||
|
||||
_VALID_PRACTICE_AREAS = {"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
|
||||
_VALID_SOURCE_TYPES = {"", "court_ruling", "appeals_committee"}
|
||||
_VALID_PRECEDENT_LEVELS = {
|
||||
"", "עליון", "מנהלי", "ועדת_ערר_ארצית", "ועדת_ערר_מחוזית",
|
||||
"supreme", "administrative", "national_appeals_committee", "district_appeals_committee",
|
||||
}
|
||||
|
||||
|
||||
async def _noop_progress(_status: str, _percent: int, _msg: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
"""Strip path separators and unsafe chars from a user-provided name."""
|
||||
base = Path(name).name
|
||||
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"upload-{uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _stage_file(src_path: Path, source_type: str) -> Path:
|
||||
"""Copy the uploaded file into data/precedent-library/<source_type>/.
|
||||
|
||||
Returns the destination path. Source file is not deleted (caller decides).
|
||||
"""
|
||||
sub = source_type if source_type in {"court_ruling", "appeals_committee"} else "other"
|
||||
dest_dir = PRECEDENT_LIBRARY_DIR / sub
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = _safe_filename(src_path.name)
|
||||
dest = dest_dir / f"{uuid4().hex[:8]}_{safe_name}"
|
||||
shutil.copy2(src_path, dest)
|
||||
return dest
|
||||
|
||||
|
||||
def _coerce_date(value) -> date | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return date.fromisoformat(value[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def ingest_precedent(
|
||||
*,
|
||||
file_path: str | Path,
|
||||
citation: str,
|
||||
case_name: str = "",
|
||||
court: str = "",
|
||||
decision_date=None,
|
||||
source_type: str = "",
|
||||
precedent_level: str = "",
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
subject_tags: list[str] | None = None,
|
||||
is_binding: bool = True,
|
||||
headnote: str = "",
|
||||
summary: str = "",
|
||||
document_id: UUID | None = None,
|
||||
progress: ProgressCb | None = None,
|
||||
) -> dict:
|
||||
"""Ingest a single uploaded precedent through the full pipeline.
|
||||
|
||||
Required: file_path + citation. Everything else has a sensible default.
|
||||
|
||||
Returns:
|
||||
``{"status": "...", "case_law_id": "...", "chunks": N, "halachot": M}``
|
||||
"""
|
||||
progress = progress or _noop_progress
|
||||
src = Path(file_path)
|
||||
if not src.is_file():
|
||||
raise FileNotFoundError(f"file not found: {src}")
|
||||
if not citation.strip():
|
||||
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:
|
||||
raise ValueError(f"invalid practice_area: {practice_area!r}")
|
||||
if source_type not in _VALID_SOURCE_TYPES:
|
||||
raise ValueError(f"invalid source_type: {source_type!r}")
|
||||
|
||||
await progress("staging", 5, "מעתיק את הקובץ לאחסון")
|
||||
|
||||
staged = _stage_file(src, source_type)
|
||||
|
||||
await progress("extracting", 15, "מחלץ טקסט מהקובץ")
|
||||
try:
|
||||
text, page_count, page_offsets = await extractor.extract_text(str(staged))
|
||||
except Exception as e:
|
||||
await progress("failed", 100, f"כשל בחילוץ טקסט: {e}")
|
||||
raise
|
||||
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
await progress("failed", 100, "לא נמצא טקסט בקובץ")
|
||||
raise ValueError("no extractable text in file")
|
||||
|
||||
# Strip any Nevo preamble that might wrap court rulings downloaded from Nevo.
|
||||
text = extractor.strip_nevo_preamble(text)
|
||||
|
||||
await progress("storing_metadata", 25, "שומר את הפסיקה במסד הנתונים")
|
||||
record = await db.create_external_case_law(
|
||||
case_number=citation.strip(),
|
||||
case_name=case_name.strip() or citation.strip(),
|
||||
full_text=text,
|
||||
court=court.strip(),
|
||||
decision_date=_coerce_date(decision_date),
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype.strip(),
|
||||
subject_tags=list(subject_tags or []),
|
||||
summary=summary.strip(),
|
||||
headnote=headnote.strip(),
|
||||
source_type=source_type,
|
||||
precedent_level=precedent_level,
|
||||
is_binding=is_binding,
|
||||
document_id=document_id,
|
||||
)
|
||||
case_law_id = UUID(str(record["id"]))
|
||||
|
||||
try:
|
||||
# Parent-doc retrieval (TaskMaster #48): when enabled, emit
|
||||
# two tiers (parents + children). Only children are embedded
|
||||
# and indexed; parents carry retrieval context. When disabled,
|
||||
# fall back to legacy single-tier chunking — identical
|
||||
# behaviour to pre-V17.
|
||||
if config.PARENT_DOC_RETRIEVAL_ENABLED:
|
||||
await progress(
|
||||
"chunking", 40,
|
||||
f"מחלק את הטקסט ל-chunks היררכיים ({page_count} עמ')",
|
||||
)
|
||||
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,
|
||||
}
|
||||
|
||||
children = [c for c in h_chunks if c.role == "child"]
|
||||
parents = [c for c in h_chunks if c.role == "parent"]
|
||||
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,
|
||||
}
|
||||
|
||||
await progress("embedding", 55, f"מייצר embeddings ל-{len(chunks)} chunks")
|
||||
chunk_texts = [c.content for c in chunks]
|
||||
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
|
||||
|
||||
chunk_dicts = [
|
||||
{
|
||||
"chunk_index": c.chunk_index,
|
||||
"content": c.content,
|
||||
"section_type": c.section_type,
|
||||
"page_number": c.page_number,
|
||||
"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.
|
||||
# Non-fatal: text path already succeeded. Only PDFs.
|
||||
if config.MULTIMODAL_ENABLED and page_count > 0 and staged.suffix.lower() == ".pdf":
|
||||
try:
|
||||
await progress(
|
||||
"embedding_images", 70,
|
||||
f"מטמיע {page_count} עמודי תמונה (multimodal)",
|
||||
)
|
||||
await _embed_precedent_pages(case_law_id, staged, page_count)
|
||||
except Exception as e:
|
||||
logger.warning("Precedent multimodal embedding failed (non-fatal): %s", e)
|
||||
|
||||
# Pipeline split: the container does the non-LLM half (extract +
|
||||
# chunk + embed + store). LLM-driven extraction (metadata, halachot)
|
||||
# runs separately via the MCP tool `precedent_process_pending` from
|
||||
# local Claude Code, where `claude` CLI is available.
|
||||
#
|
||||
# We auto-queue both extractions so the chair doesn't need to click
|
||||
# any button — the moment they (or me) run `precedent_process_pending`
|
||||
# in chat, both kinds get processed.
|
||||
await db.set_case_law_extraction_status(case_law_id, "completed")
|
||||
await db.set_case_law_halacha_status(case_law_id, "pending")
|
||||
await db.request_metadata_extraction(case_law_id)
|
||||
await db.request_halacha_extraction(case_law_id)
|
||||
|
||||
await progress(
|
||||
"completed",
|
||||
100,
|
||||
f"הוכנס לספרייה: {stored_chunks} chunks. "
|
||||
f"חילוץ הלכות ומטא-דאטה ממתינים בתור — "
|
||||
f"להפעיל מ-Claude Code: precedent_process_pending.",
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"case_law_id": str(case_law_id),
|
||||
"chunks": stored_chunks,
|
||||
"halachot": 0,
|
||||
"halachot_pending": True,
|
||||
"metadata_filled": [],
|
||||
"pages": page_count,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("precedent_library.ingest_precedent failed: %s", e)
|
||||
await db.set_case_law_extraction_status(case_law_id, "failed")
|
||||
await progress("failed", 100, f"כשל בעיבוד: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def reextract_halachot(
|
||||
case_law_id: UUID | str,
|
||||
progress: ProgressCb | None = None,
|
||||
) -> dict:
|
||||
"""Re-run the halacha extractor on an existing precedent. Idempotent.
|
||||
|
||||
**MCP-tool-only path.** This function calls into ``halacha_extractor``,
|
||||
which calls ``claude_session`` — the local CLI is required. Invoking
|
||||
this from the FastAPI container will raise ``Claude CLI not found``.
|
||||
See the architectural rule in ``services/claude_session.py``.
|
||||
"""
|
||||
from legal_mcp.services import halacha_extractor
|
||||
|
||||
progress = progress or _noop_progress
|
||||
if isinstance(case_law_id, str):
|
||||
case_law_id = UUID(case_law_id)
|
||||
|
||||
record = await db.get_case_law(case_law_id)
|
||||
if not record:
|
||||
raise ValueError("precedent not found")
|
||||
# Was restricted to source_kind='external_upload'; opened 2026-05-06 so
|
||||
# internal_committee rows can also be re-extracted when ingest produced
|
||||
# bad data. See note in db.request_metadata_extraction.
|
||||
|
||||
await progress("extracting_halachot", 50, "מחלץ הלכות מחדש")
|
||||
result = await halacha_extractor.extract(case_law_id)
|
||||
# Clear the queue timestamp on completion so the UI badge / worker queue
|
||||
# don't keep showing this row. The queue worker (process_pending_extractions)
|
||||
# already does this; mirror it here so per-record extraction drains too.
|
||||
if result.get("status") in ("completed", "no_halachot"):
|
||||
await db.clear_extraction_request(case_law_id, kind="halacha")
|
||||
await progress(
|
||||
"completed",
|
||||
100,
|
||||
f"הופקו {result.get('stored', 0)} הלכות (ממתינות לאישור)",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# Wait this many seconds between precedents in a multi-precedent run.
|
||||
# Anthropic rate-limits across the org, so back-to-back extractions of large
|
||||
# rulings (e.g. 129 chunks for one, then 79 for another) can spill the second
|
||||
# precedent into a 429 storm. Observed 2026-05-03: 1110/20 succeeded with 9
|
||||
# halachot, 317/10 immediately after returned silent no_halachot.
|
||||
INTER_PRECEDENT_COOLDOWN_SEC = 30
|
||||
|
||||
# How many times to retry a precedent that came back as 'extraction_failed'
|
||||
# (i.e. >50% chunks crashed). Each retry uses a longer cooldown.
|
||||
PRECEDENT_RETRY_ATTEMPTS = 1
|
||||
PRECEDENT_RETRY_COOLDOWN_SEC = 60
|
||||
|
||||
|
||||
async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -> dict:
|
||||
"""Drain the extraction queue (UI-button-stamped requests).
|
||||
|
||||
The button in the web UI cannot run claude_session itself (it lives in
|
||||
the container, no CLI). It just stamps ``metadata_extraction_requested_at``
|
||||
on the row. This function — called from local Claude Code via the MCP
|
||||
tool — picks each stamped row up, runs the extractor, and clears the
|
||||
timestamp.
|
||||
|
||||
Sequencing: precedents are processed serially (never in parallel) and
|
||||
each is followed by a short cooldown so the Anthropic rate-limit
|
||||
counter has time to drain before the next big precedent starts. If
|
||||
halacha extraction comes back as ``extraction_failed`` we retry the
|
||||
same precedent once with a longer cooldown — matching the empirical
|
||||
pattern where the second precedent in a back-to-back run gets
|
||||
rate-limited but recovers after a brief pause.
|
||||
|
||||
Args:
|
||||
kind: 'metadata' or 'halacha'.
|
||||
limit: max rows to process this run.
|
||||
"""
|
||||
from legal_mcp.services import halacha_extractor, precedent_metadata_extractor
|
||||
|
||||
if kind not in {"metadata", "halacha"}:
|
||||
raise ValueError("kind must be 'metadata' or 'halacha'")
|
||||
|
||||
pending = await db.list_pending_extraction_requests(kind=kind, limit=limit)
|
||||
if not pending:
|
||||
return {"status": "no_pending", "kind": kind, "processed": 0, "results": []}
|
||||
|
||||
async def _run_once(cid: UUID) -> dict:
|
||||
if kind == "metadata":
|
||||
return await precedent_metadata_extractor.extract_and_apply(cid)
|
||||
return await halacha_extractor.extract(cid)
|
||||
|
||||
results: list[dict] = []
|
||||
processed = 0
|
||||
for idx, row in enumerate(pending):
|
||||
if idx > 0:
|
||||
await asyncio.sleep(INTER_PRECEDENT_COOLDOWN_SEC)
|
||||
cid = UUID(str(row["id"]))
|
||||
attempts = 0
|
||||
result: dict = {}
|
||||
try:
|
||||
result = await _run_once(cid)
|
||||
# Retry only on systematic extraction failure (rate-limit storm).
|
||||
# Don't retry on 'no_halachot' — that means Claude looked and
|
||||
# genuinely found nothing.
|
||||
while (
|
||||
result.get("status") == "extraction_failed"
|
||||
and attempts < PRECEDENT_RETRY_ATTEMPTS
|
||||
):
|
||||
attempts += 1
|
||||
logger.warning(
|
||||
"process_pending_extractions: %s returned extraction_failed "
|
||||
"(%d/%d chunks crashed), retry %d/%d after %ds cooldown",
|
||||
cid,
|
||||
result.get("failed_chunks", 0),
|
||||
result.get("total_chunks", 0),
|
||||
attempts, PRECEDENT_RETRY_ATTEMPTS,
|
||||
PRECEDENT_RETRY_COOLDOWN_SEC,
|
||||
)
|
||||
await asyncio.sleep(PRECEDENT_RETRY_COOLDOWN_SEC)
|
||||
result = await _run_once(cid)
|
||||
|
||||
# Finalise: success or terminal failure both clear the request
|
||||
# so the queue moves on. (Use 'failed' DB state for terminal
|
||||
# extraction_failed so the UI shows the warning chip.)
|
||||
if kind == "halacha" and result.get("status") == "extraction_failed":
|
||||
await db.set_case_law_halacha_status(cid, "failed")
|
||||
await db.clear_extraction_request(cid, kind=kind)
|
||||
processed += 1
|
||||
results.append({
|
||||
"case_law_id": str(cid),
|
||||
"case_number": row.get("case_number", ""),
|
||||
"status": result.get("status", "unknown"),
|
||||
"fields": result.get("fields", []),
|
||||
"stored": result.get("stored", 0),
|
||||
"retry_attempts": attempts,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("process_pending_extractions failed for %s: %s", cid, e)
|
||||
results.append({
|
||||
"case_law_id": str(cid),
|
||||
"case_number": row.get("case_number", ""),
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
"retry_attempts": attempts,
|
||||
})
|
||||
# Don't clear the request — it stays for the next run.
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"kind": kind,
|
||||
"processed": processed,
|
||||
"total_pending": len(pending),
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
async def reextract_metadata(
|
||||
case_law_id: UUID | str,
|
||||
progress: ProgressCb | None = None,
|
||||
) -> dict:
|
||||
"""Re-run metadata extraction on an existing precedent.
|
||||
|
||||
Only fills empty fields (subject_tags, summary, headnote, key_quote,
|
||||
appeal_subtype, and case_name when it equals the citation). User
|
||||
values are preserved.
|
||||
|
||||
**MCP-tool-only path** — same constraint as :func:`reextract_halachot`.
|
||||
"""
|
||||
from legal_mcp.services import precedent_metadata_extractor
|
||||
|
||||
progress = progress or _noop_progress
|
||||
if isinstance(case_law_id, str):
|
||||
case_law_id = UUID(case_law_id)
|
||||
|
||||
record = await db.get_case_law(case_law_id)
|
||||
if not record:
|
||||
raise ValueError("precedent not found")
|
||||
# See note in db.request_metadata_extraction — opened to all source kinds.
|
||||
|
||||
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)")
|
||||
result = await precedent_metadata_extractor.extract_and_apply(case_law_id)
|
||||
# Clear the queue timestamp so the UI / worker stop showing this row.
|
||||
# See note in reextract_halachot.
|
||||
if result.get("status") in ("completed", "no_changes"):
|
||||
await db.clear_extraction_request(case_law_id, kind="metadata")
|
||||
fields = result.get("fields") or []
|
||||
msg = (
|
||||
f"מולאו {len(fields)} שדות: {', '.join(fields)}"
|
||||
if fields
|
||||
else "לא נמצא מה למלא (כל השדות מאוכלסים או לא ניתן לחלץ)"
|
||||
)
|
||||
await progress("completed", 100, msg)
|
||||
return result
|
||||
|
||||
|
||||
async def delete_precedent(case_law_id: UUID | str) -> bool:
|
||||
"""Delete a precedent and cascade chunks + halachot."""
|
||||
if isinstance(case_law_id, str):
|
||||
case_law_id = UUID(case_law_id)
|
||||
return await db.delete_case_law(case_law_id)
|
||||
|
||||
|
||||
async def get_precedent(case_law_id: UUID | str) -> dict | None:
|
||||
"""Get a precedent with its halachot and related cases attached."""
|
||||
if isinstance(case_law_id, str):
|
||||
case_law_id = UUID(case_law_id)
|
||||
record = await db.get_case_law(case_law_id)
|
||||
if not record:
|
||||
return None
|
||||
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
|
||||
|
||||
|
||||
async def list_precedents(
|
||||
practice_area: str = "",
|
||||
court: str = "",
|
||||
precedent_level: str = "",
|
||||
source_type: str = "",
|
||||
search: str = "",
|
||||
source_kind: str = "external_upload",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
return await db.list_external_case_law(
|
||||
practice_area=practice_area,
|
||||
court=court,
|
||||
precedent_level=precedent_level,
|
||||
source_type=source_type,
|
||||
search=search,
|
||||
source_kind=source_kind,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
async def search_library(
|
||||
query: str,
|
||||
practice_area: str = "",
|
||||
court: str = "",
|
||||
precedent_level: str = "",
|
||||
appeal_subtype: str = "",
|
||||
is_binding: bool | None = None,
|
||||
subject_tag: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
) -> list[dict]:
|
||||
"""Semantic search merging halachot (rule-level) and chunks (passage-level).
|
||||
|
||||
Only ``approved`` / ``published`` halachot are returned, per chair-review
|
||||
policy. Chunks are returned regardless of halacha review status.
|
||||
|
||||
When ``VOYAGE_RERANK_ENABLED`` is set, results are passed through
|
||||
voyage rerank-2 (cross-encoder). The +0.05 halacha boost from
|
||||
``search_precedent_library_semantic`` is preserved before rerank
|
||||
but the rerank scores ultimately decide the order.
|
||||
"""
|
||||
if not query.strip():
|
||||
return []
|
||||
query_vec = await embeddings.embed_query(query)
|
||||
|
||||
return await hybrid_search.search_precedent_library_hybrid(
|
||||
query=query,
|
||||
query_text_embedding=query_vec,
|
||||
limit=limit,
|
||||
practice_area=practice_area,
|
||||
court=court,
|
||||
precedent_level=precedent_level,
|
||||
appeal_subtype=appeal_subtype,
|
||||
is_binding=is_binding,
|
||||
subject_tag=subject_tag,
|
||||
include_halachot=include_halachot,
|
||||
)
|
||||
|
||||
|
||||
async def _embed_precedent_pages(
|
||||
case_law_id: UUID,
|
||||
pdf_path: Path,
|
||||
page_count: int,
|
||||
) -> dict:
|
||||
"""Render precedent PDF pages → embed via voyage-multimodal → store.
|
||||
|
||||
Thumbnails go to
|
||||
``data/precedent-library/thumbnails/{case_law_id}/p{N:03d}.jpg``.
|
||||
"""
|
||||
thumb_dir = PRECEDENT_LIBRARY_DIR / "thumbnails" / str(case_law_id)
|
||||
rendered = await asyncio.to_thread(
|
||||
extractor.render_pages_for_multimodal,
|
||||
pdf_path,
|
||||
config.MULTIMODAL_DPI,
|
||||
config.MULTIMODAL_THUMB_DPI,
|
||||
thumb_dir,
|
||||
)
|
||||
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,
|
||||
)
|
||||
logger.info(
|
||||
"Multimodal: stored %d page-image embeddings for case_law %s",
|
||||
stored, case_law_id,
|
||||
)
|
||||
return {"pages_embedded": stored}
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Auto-extract precedent metadata from a freshly-uploaded ruling.
|
||||
|
||||
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:
|
||||
short case_name, summary, headnote, key_quote, subject_tags,
|
||||
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
|
||||
chair already typed in the upload form is preserved. This is enforced
|
||||
in ``apply_to_record``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date as date_type
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.config import parse_llm_json
|
||||
from legal_mcp.services import claude_session, db
|
||||
|
||||
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
|
||||
# (header + opening of discussion is enough for naming + summary). For
|
||||
# subject tags we sample the discussion section too.
|
||||
_HEAD_CHARS = 12_000
|
||||
_TAIL_CHARS = 6_000
|
||||
|
||||
|
||||
# Note: this template is concatenated with f-strings at call-time rather
|
||||
# than using .format(), because the JSON example below contains '{' / '}'
|
||||
# which str.format would interpret as placeholders and crash with
|
||||
# KeyError on the field names.
|
||||
METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא את פסק הדין/ההחלטה הבא וחלץ ממנו מטא-דאטה לקטלוג הקורפוס.
|
||||
|
||||
המטרה: למלא שדות בטופס העלאה שהמשתמש הזין באופן חלקי. **אל תמציא** — אם המידע לא מופיע בטקסט, השאר ריק (מחרוזת ריקה / מערך ריק).
|
||||
|
||||
## פלט נדרש
|
||||
החזר JSON אחד (object — לא array) בפורמט הבא, ללא markdown וללא הסברים:
|
||||
|
||||
{
|
||||
"case_name_short": "שם קצר ל-3-6 מילים (למשל 'אהרון ברק' או 'ב. קרן-נכסים'). אל תכלול מספר תיק. שם המבקש/העורר העיקרי. אם זו החלטה מאוחדת — שם הצד המוביל.",
|
||||
"appeal_subtype": "תת-סוג ספציפי בתוך תחום המשפט (למשל 'תכנית רחביה', 'מימוש במכר', 'תמ\\"א 38', 'שימוש חורג', 'סופיות ההחלטה'). מילה אחת או צירוף קצר.",
|
||||
"summary": "תקציר עניני 2-3 משפטים: מה הייתה השאלה, מה הוכרע. בלי שיפוט.",
|
||||
"headnote": "headnote בסגנון נבו: 1-2 משפטים שמסכמים את העיקרון שנקבע/יושם בפסק. למשל 'תכנית רחביה — היטל השבחה במימוש במכר — אין לחייב כשהזכויות צפות'.",
|
||||
"key_quote": "ציטוט מילולי בודד, 30-100 מילים, שמייצג את לב הפסק. חייב להופיע מילה במילה בטקסט. אם אין ציטוט מתאים — מחרוזת ריקה.",
|
||||
"subject_tags": ["תגיות", "נושא", "בעברית"],
|
||||
"decision_date_iso": "YYYY-MM-DD — תאריך מתן ההחלטה כפי שמופיע בטקסט (בכותרת או בחתימה הסופית). אם לא ניתן לזהות במדויק — מחרוזת ריקה.",
|
||||
"precedent_level": "אחד מ-4: 'עליון' / 'מנהלי' / 'ועדת_ערר_ארצית' / 'ועדת_ערר_מחוזית'. בחר לפי הערכאה שמסומנת בכותרת הפסק. אם לא ברור — מחרוזת ריקה.",
|
||||
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
|
||||
"proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY' → 'ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר' → 'בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.",
|
||||
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
|
||||
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.",
|
||||
"chair_name": "שם יו\\\"ר ההרכב — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. חפש בכותרת/חתימה: 'עו\\\"ד דפנה תמיר, יו\\\"ר ועדת הערר', 'בפני: עו\\\"ד פלוני אלמוני (יו\\\"ר)'. השאר שם פרטי+משפחה בלי תוארים ('עו\\\"ד', 'אדריכל'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
||||
"district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים' → 'ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו' → 'תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
||||
"citation_formatted": "המראה מקום המלא לפי **כללי הציטוט האחיד**, בפורמט Markdown — שמות הצדדים בלבד מוקפים בכפול-כוכבית (`**…**`), הכל השאר רגיל. ראה כללים מפורטים בסעיף 12 למטה."
|
||||
}
|
||||
|
||||
## כללי איכות
|
||||
1. **case_name_short** — שם בולט וקצר. בלי 'נ\\'' / 'נגד' / מספרי תיק.
|
||||
2. **appeal_subtype** — אופציונלי. אם הסוגיה רחבה ולא מסווגת — השאר ריק.
|
||||
3. **summary** — תיאור ניטרלי, גוף שלישי.
|
||||
4. **headnote** — לא מצטטים, מסכמים. סגנון נבו: ביטוי קצר אחד.
|
||||
5. **key_quote** — חייב להיות הדבקה מילולית מהקלט. אם אין ציטוט בולט — השאר ריק.
|
||||
6. **subject_tags** — 3-7 תגיות בעברית, snake_case (חניה, קווי_בניין, שיקול_דעת, פגם_פרוצדורלי, סמכות, מועדים, פגיעה_במקרקעין, ירידת_ערך, תכנית_רחביה, מימוש_במכר, וכד'). שייך לתחום של ועדת ערר תכנון ובניה.
|
||||
7. **decision_date_iso** — תאריך מדויק בלבד. אם בטקסט יש "ניתנה היום, ט' באלול תשפ"א, 5 בספטמבר 2022" — הפלט: "2022-09-05".
|
||||
8. **precedent_level** — קבע לפי הערכאה: בית המשפט העליון = "עליון"; בית משפט מחוזי בשבתו כבית משפט לעניינים מנהליים = "מנהלי"; ועדת ערר ארצית = "ועדת_ערר_ארצית"; ועדת ערר מחוזית (כמו ועדות תכנון ובניה ירושלים/מחוז המרכז וכד') = "ועדת_ערר_מחוזית". השתמש ב-underscore כפי שמופיע — לא ברווח.
|
||||
9. **source_type** — שני ערכים בלבד: "court_ruling" כשהמסמך הוא פסק דין/החלטה של בית משפט (עליון/בג"ץ/מנהלי/מחוזי); "appeals_committee" כשהמסמך הוא החלטה של ועדת ערר (ארצית או מחוזית). זה משלים את `precedent_level` — שני השדות צריכים להיות תואמים.
|
||||
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)`.
|
||||
- אם לא ניתן לזהות איזשהו רכיב במדויק — השאר את **כל** השדה ריק. אל תניח / תמציא.
|
||||
"""
|
||||
|
||||
|
||||
def _build_text_window(full_text: str) -> str:
|
||||
"""Return the head + tail of the ruling, with a marker if truncated.
|
||||
|
||||
Most rulings have the parties/subject in the head and the conclusion
|
||||
in the tail; the middle is the discussion which is captured via the
|
||||
halacha extractor independently. Sending head+tail keeps the prompt
|
||||
cheap while preserving naming and conclusion context.
|
||||
"""
|
||||
if len(full_text) <= _HEAD_CHARS + _TAIL_CHARS:
|
||||
return full_text
|
||||
return (
|
||||
full_text[:_HEAD_CHARS]
|
||||
+ "\n\n[... חלק האמצע הושמט עקב אורך — ראה את החלק האחרון של הפסק להלן ...]\n\n"
|
||||
+ full_text[-_TAIL_CHARS:]
|
||||
)
|
||||
|
||||
|
||||
async def extract_metadata(case_law_id: UUID | str) -> dict:
|
||||
"""Run metadata extraction. Returns a dict with the suggested values.
|
||||
|
||||
Does NOT write to the DB — caller decides what to merge.
|
||||
"""
|
||||
if isinstance(case_law_id, str):
|
||||
case_law_id = UUID(case_law_id)
|
||||
|
||||
record = await db.get_case_law(case_law_id)
|
||||
if not record:
|
||||
return {}
|
||||
full_text = (record.get("full_text") or "").strip()
|
||||
if not full_text:
|
||||
return {}
|
||||
|
||||
citation = record.get("case_number") or ""
|
||||
court = record.get("court") or ""
|
||||
date_str = str(record.get("date") or "")
|
||||
practice_area = record.get("practice_area") or ""
|
||||
|
||||
context = (
|
||||
f"מראה מקום: {citation}\n"
|
||||
f"ערכאה: {court}\n"
|
||||
f"תאריך: {date_str}\n"
|
||||
f"תחום: {practice_area}"
|
||||
)
|
||||
text_window = _build_text_window(full_text)
|
||||
# Static instructions go via `system` so the SDK path can cache them
|
||||
# across uploads. Per-precedent content goes in the user prompt.
|
||||
user_msg = (
|
||||
f"## הקלט\n{context}\n\n"
|
||||
f"--- תחילת הטקסט ---\n{text_window}\n--- סוף הטקסט ---"
|
||||
)
|
||||
|
||||
try:
|
||||
result = await claude_session.query_json(
|
||||
user_msg, system=METADATA_EXTRACTION_PROMPT,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("precedent_metadata_extractor: query failed: %s", e)
|
||||
return {}
|
||||
|
||||
if not isinstance(result, dict):
|
||||
logger.warning(
|
||||
"precedent_metadata_extractor: expected dict, got %s",
|
||||
type(result).__name__,
|
||||
)
|
||||
return {}
|
||||
|
||||
# Normalize keys / types
|
||||
out: dict = {}
|
||||
if isinstance(result.get("case_name_short"), str):
|
||||
out["case_name_short"] = result["case_name_short"].strip()
|
||||
if isinstance(result.get("appeal_subtype"), str):
|
||||
out["appeal_subtype"] = result["appeal_subtype"].strip()
|
||||
if isinstance(result.get("summary"), str):
|
||||
out["summary"] = result["summary"].strip()
|
||||
if isinstance(result.get("headnote"), str):
|
||||
out["headnote"] = result["headnote"].strip()
|
||||
if isinstance(result.get("key_quote"), str):
|
||||
out["key_quote"] = result["key_quote"].strip()
|
||||
tags = result.get("subject_tags") or []
|
||||
if isinstance(tags, list):
|
||||
out["subject_tags"] = [str(t).strip() for t in tags if str(t).strip()]
|
||||
if isinstance(result.get("decision_date_iso"), str):
|
||||
out["decision_date_iso"] = result["decision_date_iso"].strip()
|
||||
if isinstance(result.get("precedent_level"), str):
|
||||
# Validate against the closed enum used elsewhere in the system
|
||||
lvl = result["precedent_level"].strip()
|
||||
if lvl in {"עליון", "מנהלי", "ועדת_ערר_ארצית", "ועדת_ערר_מחוזית"}:
|
||||
out["precedent_level"] = lvl
|
||||
if isinstance(result.get("source_type"), str):
|
||||
st = result["source_type"].strip()
|
||||
if st in {"court_ruling", "appeals_committee"}:
|
||||
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):
|
||||
out["court"] = result["court"].strip()
|
||||
if isinstance(result.get("case_number_clean"), str):
|
||||
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
|
||||
|
||||
|
||||
async def apply_to_record(
|
||||
case_law_id: UUID | str,
|
||||
suggested: dict,
|
||||
overwrite_case_number: bool = False,
|
||||
) -> dict:
|
||||
"""Merge suggested metadata into the case_law row, filling ONLY empty fields.
|
||||
|
||||
Empty rules:
|
||||
- string field == "" → fill from suggested
|
||||
- list field == [] → fill from suggested
|
||||
- if suggested key is missing or empty, skip
|
||||
|
||||
case_name has special handling: if the current case_name equals the
|
||||
case_number (a tell-tale sign of the upload form sending the long
|
||||
citation into both fields), treat it as empty and overwrite.
|
||||
|
||||
overwrite_case_number: when True, update case_number from case_number_clean
|
||||
even if the field already has a value (used for one-time migration enrichment).
|
||||
"""
|
||||
if isinstance(case_law_id, str):
|
||||
case_law_id = UUID(case_law_id)
|
||||
record = await db.get_case_law(case_law_id)
|
||||
if not record:
|
||||
return {"updated": False, "fields": []}
|
||||
|
||||
fields_to_update: dict = {}
|
||||
|
||||
cur_case_name = (record.get("case_name") or "").strip()
|
||||
cur_case_number = (record.get("case_number") or "").strip()
|
||||
suggested_case_name = (suggested.get("case_name_short") or "").strip()
|
||||
if suggested_case_name and (
|
||||
not cur_case_name or cur_case_name == cur_case_number
|
||||
):
|
||||
fields_to_update["case_name"] = suggested_case_name
|
||||
|
||||
if not (record.get("appeal_subtype") or "").strip():
|
||||
s = (suggested.get("appeal_subtype") or "").strip()
|
||||
if s:
|
||||
fields_to_update["appeal_subtype"] = s
|
||||
|
||||
if not (record.get("summary") or "").strip():
|
||||
s = (suggested.get("summary") or "").strip()
|
||||
if s:
|
||||
fields_to_update["summary"] = s
|
||||
|
||||
if not (record.get("headnote") or "").strip():
|
||||
s = (suggested.get("headnote") or "").strip()
|
||||
if s:
|
||||
fields_to_update["headnote"] = s
|
||||
|
||||
if not (record.get("key_quote") or "").strip():
|
||||
s = (suggested.get("key_quote") or "").strip()
|
||||
if s:
|
||||
fields_to_update["key_quote"] = s
|
||||
|
||||
cur_tags = record.get("subject_tags") or []
|
||||
# Treat character-by-character corruption as empty. Early ingest
|
||||
# pipelines stored a JSON string (`'["היטל השבחה"]'`) into a TEXT[]
|
||||
# column, which Postgres split into individual chars:
|
||||
# `['[', '"', 'ה', 'י', 'ט', 'ל', ' ', 'ה', 'ש', ...]`. Detection:
|
||||
# 3+ elements where every element is at most 2 chars (legitimate
|
||||
# tags are multi-character Hebrew words like `היטל_השבחה`).
|
||||
is_corrupt = (
|
||||
len(cur_tags) >= 3
|
||||
and all(isinstance(t, str) and len(t) <= 2 for t in cur_tags)
|
||||
)
|
||||
if not cur_tags or is_corrupt:
|
||||
sug_tags = suggested.get("subject_tags") or []
|
||||
if sug_tags:
|
||||
fields_to_update["subject_tags"] = sug_tags
|
||||
|
||||
# decision_date — only fill if currently null. The DB column is DATE,
|
||||
# so we parse the LLM's ISO string into a date object before passing
|
||||
# it to update_case_law (asyncpg won't coerce a string to DATE).
|
||||
if record.get("date") is None:
|
||||
iso = (suggested.get("decision_date_iso") or "").strip()
|
||||
if iso:
|
||||
try:
|
||||
fields_to_update["date"] = date_type.fromisoformat(iso[:10])
|
||||
except ValueError:
|
||||
logger.debug(
|
||||
"metadata_extractor: ignoring invalid decision_date_iso=%r",
|
||||
iso,
|
||||
)
|
||||
|
||||
if not (record.get("precedent_level") or "").strip():
|
||||
lvl = (suggested.get("precedent_level") or "").strip()
|
||||
if lvl:
|
||||
fields_to_update["precedent_level"] = lvl
|
||||
|
||||
if not (record.get("source_type") or "").strip():
|
||||
st = (suggested.get("source_type") or "").strip()
|
||||
if st:
|
||||
fields_to_update["source_type"] = st
|
||||
|
||||
if not (record.get("court") or "").strip():
|
||||
c = (suggested.get("court") or "").strip()
|
||||
if 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:
|
||||
cn = (suggested.get("case_number_clean") or "").strip()
|
||||
if 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:
|
||||
return {"updated": False, "fields": []}
|
||||
|
||||
await db.update_case_law(case_law_id, **fields_to_update)
|
||||
return {"updated": True, "fields": list(fields_to_update.keys())}
|
||||
|
||||
|
||||
async def extract_and_apply(
|
||||
case_law_id: UUID | str,
|
||||
overwrite_case_number: bool = False,
|
||||
) -> dict:
|
||||
"""Convenience wrapper: extract → merge into row → return summary."""
|
||||
suggested = await extract_metadata(case_law_id)
|
||||
if not suggested:
|
||||
return {"status": "no_metadata", "fields": []}
|
||||
result = await apply_to_record(case_law_id, suggested, overwrite_case_number=overwrite_case_number)
|
||||
return {
|
||||
"status": "completed" if result["updated"] else "no_changes",
|
||||
"fields": result["fields"],
|
||||
"suggested": suggested,
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor, references_extractor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,7 +32,7 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
|
||||
try:
|
||||
# Step 1: Extract text
|
||||
logger.info("Extracting text from %s", doc["file_path"])
|
||||
text, page_count = await extractor.extract_text(doc["file_path"])
|
||||
text, page_count, page_offsets = await extractor.extract_text(doc["file_path"])
|
||||
|
||||
await db.update_document(
|
||||
document_id,
|
||||
@@ -68,9 +70,9 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
|
||||
except Exception as e:
|
||||
logger.warning("Classification failed (non-fatal): %s", e)
|
||||
|
||||
# Step 2: Chunk
|
||||
# Step 2: Chunk (page_offsets propagates page_number into chunks)
|
||||
logger.info("Chunking document (%d chars)", len(text))
|
||||
chunks = chunker.chunk_document(text)
|
||||
chunks = chunker.chunk_document(text, page_offsets=page_offsets)
|
||||
|
||||
if not chunks:
|
||||
await db.update_document(document_id, extraction_status="completed")
|
||||
@@ -95,6 +97,21 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
|
||||
|
||||
stored = await db.store_chunks(document_id, case_id, chunk_dicts)
|
||||
|
||||
# Step 4.5: Multimodal page-image embeddings (V9). Gated by
|
||||
# MULTIMODAL_ENABLED. Renders each PDF page → embeds via
|
||||
# voyage-multimodal-3 → stores per-page row with thumbnail.
|
||||
# Non-fatal on failure (text path already succeeded).
|
||||
multimodal_result = {"pages_embedded": 0}
|
||||
if config.MULTIMODAL_ENABLED and page_count > 0:
|
||||
try:
|
||||
pdf_path = Path(doc["file_path"])
|
||||
if pdf_path.suffix.lower() == ".pdf":
|
||||
multimodal_result = await _embed_document_pages(
|
||||
document_id, case_id, pdf_path, page_count,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Multimodal embedding failed (non-fatal): %s", e)
|
||||
|
||||
# Step 5: Extract references (plans, case law, legislation) — non-fatal
|
||||
refs_result = {"plans": 0, "case_law": 0, "case_law_linked": 0, "legislation": 0}
|
||||
try:
|
||||
@@ -124,9 +141,63 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
|
||||
"case_law": refs_result["case_law"],
|
||||
"legislation": refs_result["legislation"],
|
||||
},
|
||||
"multimodal": multimodal_result,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Document processing failed: %s", e)
|
||||
await db.update_document(document_id, extraction_status="failed")
|
||||
return {"status": "failed", "error": str(e)}
|
||||
|
||||
|
||||
async def _embed_document_pages(
|
||||
document_id: UUID,
|
||||
case_id: UUID,
|
||||
pdf_path: Path,
|
||||
page_count: int,
|
||||
) -> dict:
|
||||
"""Render PDF pages → embed via voyage-multimodal → store per-page rows.
|
||||
|
||||
Thumbnails are saved under
|
||||
``data/cases/{case_number}/thumbnails/{document_id}/p{N:03d}.jpg``
|
||||
so the UI can show small previews next to image-side search hits.
|
||||
"""
|
||||
# Layout: data/cases/{case_number}/documents/originals/{file}.pdf
|
||||
# → case_dir = pdf_path.parent.parent.parent
|
||||
case_dir = pdf_path.parent.parent.parent
|
||||
thumb_dir = case_dir / "thumbnails" / str(document_id)
|
||||
|
||||
logger.info("Multimodal: rendering %d pages @ %ddpi", page_count, config.MULTIMODAL_DPI)
|
||||
rendered = await asyncio.to_thread(
|
||||
extractor.render_pages_for_multimodal,
|
||||
pdf_path,
|
||||
config.MULTIMODAL_DPI,
|
||||
config.MULTIMODAL_THUMB_DPI,
|
||||
thumb_dir,
|
||||
)
|
||||
images = [pil for pil, _ in rendered]
|
||||
thumb_paths = [thumb for _, thumb in rendered]
|
||||
|
||||
logger.info("Multimodal: embedding %d pages via %s", len(images), config.MULTIMODAL_MODEL)
|
||||
img_embs = await embeddings.embed_images(images)
|
||||
|
||||
page_records = []
|
||||
for i, (emb, thumb) in enumerate(zip(img_embs, thumb_paths)):
|
||||
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_document_image_embeddings(
|
||||
document_id, case_id, page_records,
|
||||
model_name=config.MULTIMODAL_MODEL,
|
||||
)
|
||||
logger.info("Multimodal: stored %d page-image embeddings", stored)
|
||||
return {"pages_embedded": stored, "model": config.MULTIMODAL_MODEL}
|
||||
|
||||
@@ -144,9 +144,9 @@ async def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict:
|
||||
## בלוק הדיון:
|
||||
{discussion}"""
|
||||
|
||||
parsed = claude_session.query_json(prompt, timeout=120)
|
||||
parsed = await claude_session.query_json(prompt)
|
||||
if parsed is None:
|
||||
logger.warning("Failed to parse claims check: %s", raw[:300])
|
||||
logger.warning("Failed to parse claims check")
|
||||
# Fallback: assume all covered (don't block export on parse failure)
|
||||
return {"name": "claims_coverage", "passed": True,
|
||||
"errors": ["שגיאה בפענוח תוצאות — לא ניתן לבדוק"], "severity": "warning"}
|
||||
|
||||
103
mcp-server/src/legal_mcp/services/rerank.py
Normal file
103
mcp-server/src/legal_mcp/services/rerank.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Optional cross-encoder reranking layer for semantic search.
|
||||
|
||||
Wraps a base search function with two-stage retrieval:
|
||||
1. fetch ``VOYAGE_RERANK_FETCH_K`` candidates via the bi-encoder (cosine)
|
||||
2. pass them to voyage rerank-2, return top-``limit``
|
||||
|
||||
When the feature flag is off (or ``force_rerank=False``) the helper just
|
||||
calls the base function with ``limit`` and returns its results unchanged
|
||||
— so callers can wrap unconditionally and let env control behaviour.
|
||||
|
||||
The helper extracts the rerank text from each row using the first
|
||||
non-empty field among ``content``, ``rule_statement``,
|
||||
``reasoning_summary`` (matches the schema used by ``search_similar``
|
||||
and ``search_precedent_library_semantic``).
|
||||
|
||||
Decision validated by POC #5 (785-doc precedent corpus, 12 queries):
|
||||
- mean@3: 4.306 → 4.500 (+4.5%)
|
||||
- practical-category queries: 3.78 → 4.22 (+11.6%)
|
||||
- latency: +702ms per query
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import embeddings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SearchFn = Callable[..., Awaitable[list[dict]]]
|
||||
|
||||
|
||||
def _rerank_text(row: dict) -> str:
|
||||
"""First non-empty text field that voyage rerank should see."""
|
||||
for key in ("content", "rule_statement", "reasoning_summary",
|
||||
"supporting_quote"):
|
||||
v = row.get(key)
|
||||
if v:
|
||||
return str(v)
|
||||
return ""
|
||||
|
||||
|
||||
async def maybe_rerank(
|
||||
query: str,
|
||||
base_search: SearchFn,
|
||||
limit: int,
|
||||
*,
|
||||
force_rerank: bool | None = None,
|
||||
fetch_k: int | None = None,
|
||||
**base_kwargs: Any,
|
||||
) -> list[dict]:
|
||||
"""Two-stage retrieval helper.
|
||||
|
||||
Args:
|
||||
query: original query string (needed for the rerank API).
|
||||
base_search: any async function that takes ``limit=…`` and the
|
||||
other ``base_kwargs`` and returns ``list[dict]``.
|
||||
limit: final number of results to return.
|
||||
force_rerank: override the env flag. ``None`` → use config.
|
||||
fetch_k: override the bi-encoder fetch depth.
|
||||
**base_kwargs: forwarded to ``base_search``.
|
||||
|
||||
Returns:
|
||||
List of dict rows. When rerank is active, each row's ``score``
|
||||
is replaced with the rerank-2 relevance score (0..1).
|
||||
"""
|
||||
enabled = (config.VOYAGE_RERANK_ENABLED
|
||||
if force_rerank is None else force_rerank)
|
||||
if not enabled:
|
||||
return await base_search(limit=limit, **base_kwargs)
|
||||
|
||||
depth = fetch_k or config.VOYAGE_RERANK_FETCH_K
|
||||
candidates = await base_search(limit=depth, **base_kwargs)
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
texts = [_rerank_text(c) for c in candidates]
|
||||
# Drop candidates with empty rerank text (shouldn't happen but be safe)
|
||||
keep = [(i, t) for i, t in enumerate(texts) if t]
|
||||
if not keep:
|
||||
logger.warning("rerank: all candidates empty, falling back to base")
|
||||
return candidates[:limit]
|
||||
keep_idx = [i for i, _ in keep]
|
||||
keep_texts = [t for _, t in keep]
|
||||
|
||||
try:
|
||||
ranked = await embeddings.voyage_rerank(
|
||||
query, keep_texts, top_k=limit,
|
||||
)
|
||||
except Exception as e:
|
||||
# Fail open — if Voyage rerank is down, return bi-encoder ordering
|
||||
logger.warning("rerank failed, falling back to base: %s", e)
|
||||
return candidates[:limit]
|
||||
|
||||
out: list[dict] = []
|
||||
for keep_pos, score in ranked:
|
||||
orig_idx = keep_idx[keep_pos]
|
||||
row = dict(candidates[orig_idx])
|
||||
row["score"] = float(score)
|
||||
out.append(row)
|
||||
return out
|
||||
@@ -55,6 +55,9 @@ def _is_placeholder(text: str) -> bool:
|
||||
for ph in CHAIR_POSITION_PLACEHOLDERS:
|
||||
if ph in stripped:
|
||||
return True
|
||||
# Extended placeholders: [ימולא ע"י יו"ר הוועדה — extra descriptive text]
|
||||
if re.match(r'^\[ימולא\b', stripped):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ async def _analyze_single_pass(rows, appeal_subtype: str = "") -> dict:
|
||||
decisions_text += f"\n\n--- החלטה {row['decision_number'] or 'ללא מספר'} ---\n"
|
||||
decisions_text += row["full_text"]
|
||||
|
||||
raw = claude_session.query(
|
||||
raw = await claude_session.query(
|
||||
ANALYSIS_PROMPT.format(decisions=decisions_text),
|
||||
timeout=claude_session.LONG_TIMEOUT,
|
||||
)
|
||||
@@ -176,7 +176,7 @@ async def _analyze_multi_pass(rows, appeal_subtype: str = "") -> dict:
|
||||
decision_text = f"--- החלטה {row['decision_number'] or 'ללא מספר'} ---\n"
|
||||
decision_text += row["full_text"]
|
||||
|
||||
raw = claude_session.query(
|
||||
raw = await claude_session.query(
|
||||
SINGLE_DECISION_PROMPT.format(decision=decision_text),
|
||||
timeout=claude_session.LONG_TIMEOUT,
|
||||
)
|
||||
@@ -189,7 +189,7 @@ async def _analyze_multi_pass(rows, appeal_subtype: str = "") -> dict:
|
||||
return {"error": "לא הצלחתי לחלץ דפוסים מההחלטות"}
|
||||
|
||||
# Pass 2: Synthesize across all decisions
|
||||
raw = claude_session.query(
|
||||
raw = await claude_session.query(
|
||||
SYNTHESIS_PROMPT.format(
|
||||
num_decisions=len(rows),
|
||||
patterns=json.dumps(all_patterns, ensure_ascii=False, indent=2),
|
||||
|
||||
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
|
||||
@@ -13,7 +13,7 @@ from uuid import UUID
|
||||
import httpx
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import audit, db, git_sync, practice_area as pa
|
||||
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,12 +28,17 @@ def _gitea_token() -> str:
|
||||
return os.environ.get("GITEA_ACCESS_TOKEN") or os.environ.get("GITEA_TOKEN", "")
|
||||
|
||||
|
||||
async def _setup_gitea_remote(case_number: str, title: str, case_dir: Path) -> bool:
|
||||
"""Create Gitea repo and configure git remote. Best-effort — returns False on failure."""
|
||||
async def _setup_gitea_remote(case_number: str, title: str, case_dir: Path) -> dict:
|
||||
"""Create Gitea repo and configure git remote.
|
||||
|
||||
Returns a dict with: ok (bool), url (str|None), error (str|None).
|
||||
Never raises — failures are reported via the dict so callers can surface
|
||||
them to the UI instead of silently swallowing them.
|
||||
"""
|
||||
token = _gitea_token()
|
||||
if not token:
|
||||
logger.info("No GITEA_TOKEN — skipping Gitea repo creation for %s", case_number)
|
||||
return False
|
||||
return {"ok": False, "url": None, "error": "no_token"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(verify=False, timeout=30) as client:
|
||||
@@ -59,8 +64,9 @@ async def _setup_gitea_remote(case_number: str, title: str, case_dir: Path) -> b
|
||||
repo = resp.json()
|
||||
|
||||
clone_url = repo.get("clone_url", "")
|
||||
html_url = repo.get("html_url", "")
|
||||
if not clone_url:
|
||||
return False
|
||||
return {"ok": False, "url": None, "error": "no_clone_url"}
|
||||
|
||||
auth_url = clone_url.replace("https://", f"https://chaim:{token}@")
|
||||
|
||||
@@ -94,15 +100,20 @@ async def _setup_gitea_remote(case_number: str, title: str, case_dir: Path) -> b
|
||||
cwd=case_dir, capture_output=True, text=True, env=git_env,
|
||||
)
|
||||
if push.returncode != 0:
|
||||
logger.warning("Gitea push failed for %s: %s", case_number, push.stderr)
|
||||
return False
|
||||
stderr = push.stderr.strip()
|
||||
logger.warning("Gitea push failed for %s: %s", case_number, stderr)
|
||||
return {"ok": False, "url": html_url or None, "error": f"push_failed: {stderr[:200]}"}
|
||||
|
||||
logger.info("Gitea repo created and pushed for %s", case_number)
|
||||
return True
|
||||
return {"ok": True, "url": html_url or None, "error": None}
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
msg = f"http_{exc.response.status_code}"
|
||||
logger.warning("Gitea setup failed for %s: %s", case_number, msg)
|
||||
return {"ok": False, "url": None, "error": msg}
|
||||
except Exception as exc:
|
||||
logger.warning("Gitea setup failed for %s: %s", case_number, exc)
|
||||
return False
|
||||
return {"ok": False, "url": None, "error": f"{type(exc).__name__}: {exc}"[:200]}
|
||||
|
||||
|
||||
async def case_create(
|
||||
@@ -117,8 +128,9 @@ async def case_create(
|
||||
hearing_date: str = "",
|
||||
notes: str = "",
|
||||
expected_outcome: str = "",
|
||||
practice_area: str = "appeals_committee",
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
proceeding_type: str = "",
|
||||
) -> str:
|
||||
"""יצירת תיק ערר חדש.
|
||||
|
||||
@@ -134,9 +146,12 @@ async def case_create(
|
||||
hearing_date: תאריך דיון (YYYY-MM-DD)
|
||||
notes: הערות
|
||||
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
|
||||
practice_area: תחום משפטי (appeals_committee / national_insurance / labor_law)
|
||||
practice_area: תחום משפטי — domain value (rishuy_uvniya / betterment_levy /
|
||||
compensation_197). ריק או "appeals_committee" = יוסק
|
||||
אוטומטית ממספר התיק (1xxx→רישוי, 8xxx→השבחה, 9xxx→197)
|
||||
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
|
||||
ריק = יוסק אוטומטית ממספר התיק
|
||||
proceeding_type: 'ערר' / 'בל"מ'. ריק = יוסק מ-appeal_subtype/subject.
|
||||
"""
|
||||
from datetime import date as date_type
|
||||
|
||||
@@ -144,12 +159,27 @@ async def case_create(
|
||||
if hearing_date:
|
||||
h_date = date_type.fromisoformat(hearing_date)
|
||||
|
||||
# Resolve appeal_subtype: explicit override > auto-derive > 'unknown'
|
||||
derived_subtype = pa.derive_subtype(case_number, practice_area)
|
||||
# Auto-derive practice_area when missing or set to the legacy multi-tenant
|
||||
# value. The DB's cases_practice_area_check rejects 'appeals_committee',
|
||||
# so we MUST map it to a domain value before INSERT. If derivation fails
|
||||
# (unknown case number format), fall back to '' which the constraint allows.
|
||||
if not practice_area or practice_area == "appeals_committee":
|
||||
practice_area = pa.derive_domain_practice_area(case_number)
|
||||
|
||||
# Resolve appeal_subtype: explicit override > auto-derive > 'unknown'.
|
||||
# derive_subtype_with_blam inspects the subject to detect בל"מ
|
||||
# (בקשה להארכת מועד) and returns an extension_request_* variant when
|
||||
# appropriate. Falls back to regular derive_subtype when subject is empty.
|
||||
derived_subtype = pa.derive_subtype_with_blam(case_number, subject, practice_area)
|
||||
if not appeal_subtype:
|
||||
appeal_subtype = derived_subtype
|
||||
pa.validate(practice_area, appeal_subtype)
|
||||
|
||||
# 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_number=case_number,
|
||||
title=title,
|
||||
@@ -164,6 +194,7 @@ async def case_create(
|
||||
expected_outcome=expected_outcome,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype,
|
||||
proceeding_type=resolved_proc,
|
||||
)
|
||||
|
||||
# If the user overrode the case-number convention (e.g. case 8500 marked
|
||||
@@ -214,11 +245,10 @@ async def case_create(
|
||||
except Exception:
|
||||
pass # git not available — non-critical
|
||||
|
||||
# Create Gitea repo and configure remote (best-effort)
|
||||
try:
|
||||
await _setup_gitea_remote(case_number, title, case_dir)
|
||||
except Exception:
|
||||
pass # Gitea not available — non-critical
|
||||
# Create Gitea repo and configure remote — surface result so callers can
|
||||
# show failures (e.g. stale token) and offer a retry button instead of
|
||||
# silently producing a case with no remote.
|
||||
case["gitea"] = await _setup_gitea_remote(case_number, title, case_dir)
|
||||
|
||||
return json.dumps(case, default=str, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -227,7 +257,10 @@ async def case_list(status: str = "", limit: int = 50) -> str:
|
||||
"""רשימת תיקי ערר עם אפשרות סינון לפי סטטוס.
|
||||
|
||||
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: מספר תוצאות מקסימלי
|
||||
"""
|
||||
cases = await db.list_cases(status=status or None, limit=limit)
|
||||
@@ -261,6 +294,11 @@ async def case_update(
|
||||
decision_date: str = "",
|
||||
tags: list[str] | None = None,
|
||||
expected_outcome: str = "",
|
||||
appellants: list[str] | None = None,
|
||||
respondents: list[str] | None = None,
|
||||
property_address: str = "",
|
||||
permit_number: str = "",
|
||||
proceeding_type: str = "",
|
||||
) -> str:
|
||||
"""עדכון פרטי תיק.
|
||||
|
||||
@@ -274,6 +312,11 @@ async def case_update(
|
||||
decision_date: תאריך החלטה (YYYY-MM-DD)
|
||||
tags: תגיות
|
||||
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
|
||||
appellants: רשימת עוררים חדשה
|
||||
respondents: רשימת משיבים חדשה
|
||||
property_address: כתובת נכס חדשה
|
||||
permit_number: מספר תכנית/בקשה חדש
|
||||
proceeding_type: 'ערר' / 'בל"מ' — ריק = ללא שינוי
|
||||
"""
|
||||
from datetime import date as date_type
|
||||
|
||||
@@ -305,13 +348,33 @@ async def case_update(
|
||||
if notes:
|
||||
fields["notes"] = notes
|
||||
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:
|
||||
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:
|
||||
fields["tags"] = tags
|
||||
if 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)
|
||||
|
||||
@@ -360,3 +423,66 @@ async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
||||
result["removed_files"] = True
|
||||
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
|
||||
"""קליטת טקסט ההחלטה הסופית (`סופי-{case}.docx` בתיקיית exports).
|
||||
|
||||
בניגוד ל-`document_get_text` שעובד על שורות בטבלת `documents`,
|
||||
הקובץ הסופי הוא רק קובץ בתיקייה (נוצר על ידי `api_mark_final`).
|
||||
תומך בכל הפורמטים ש-extractor.extract_text מטפל בהם — מנסה
|
||||
`.docx` תחילה, ואז `.pdf`, `.doc`, `.rtf`, `.txt`, `.md`.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
max_chars: אם >0, חתוך את הטקסט המוחזר לאורך הזה. 0 = הכל.
|
||||
"""
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
exports_dir = case_dir / "exports"
|
||||
final_stem = f"סופי-{case_number}"
|
||||
|
||||
final_path = None
|
||||
for ext in (".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"):
|
||||
candidate = exports_dir / f"{final_stem}{ext}"
|
||||
if candidate.exists():
|
||||
final_path = candidate
|
||||
break
|
||||
|
||||
if final_path is None:
|
||||
return json.dumps({
|
||||
"status": "not_found",
|
||||
"case_number": case_number,
|
||||
"expected_path": str(exports_dir / f"{final_stem}.docx"),
|
||||
"tried_extensions": [".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"],
|
||||
"hint": (
|
||||
"ההחלטה הסופית עדיין לא סומנה כ'סופית' ב-UI. "
|
||||
"דפנה צריכה ללחוץ 'סמן כסופי' על קובץ הטיוטה הנכון."
|
||||
),
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
try:
|
||||
text, page_count, _ = await extractor.extract_text(str(final_path))
|
||||
except Exception as e:
|
||||
logger.exception("case_get_final_text: extraction failed for %s", case_number)
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"case_number": case_number,
|
||||
"file_path": str(final_path),
|
||||
"error": str(e),
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
text = text or ""
|
||||
truncated = False
|
||||
if max_chars > 0 and len(text) > max_chars:
|
||||
text = text[:max_chars]
|
||||
truncated = True
|
||||
|
||||
return json.dumps({
|
||||
"status": "ok",
|
||||
"case_number": case_number,
|
||||
"file_path": str(final_path),
|
||||
"text_length": len(text),
|
||||
"page_count": page_count,
|
||||
"truncated": truncated,
|
||||
"text": text,
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
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)})
|
||||
@@ -144,7 +144,7 @@ async def document_upload_training(
|
||||
shutil.copy2(str(source), str(dest))
|
||||
|
||||
# Extract text and strip Nevo preamble
|
||||
text, page_count = await extractor.extract_text(str(dest))
|
||||
text, page_count, _ = await extractor.extract_text(str(dest))
|
||||
text = extractor.strip_nevo_preamble(text)
|
||||
|
||||
# Parse date
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import db, embeddings, research_md
|
||||
from legal_mcp.services import db, embeddings, git_sync, research_md
|
||||
from legal_mcp.services.lessons import (
|
||||
CITATION_GUIDANCE,
|
||||
DECISION_TEMPLATES,
|
||||
@@ -403,6 +403,9 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
|
||||
path = await docx_exporter.export_decision(case_id, output_path or None)
|
||||
# Register this export as the new source of truth
|
||||
await db.set_active_draft_path(case_id, path)
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
if case_dir.exists():
|
||||
git_sync.commit_and_push(case_dir, f"ייצוא DOCX: {Path(path).name}")
|
||||
return json.dumps({
|
||||
"status": "completed",
|
||||
"path": path,
|
||||
@@ -421,7 +424,7 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
|
||||
# Blocks written for the interim draft, in display order.
|
||||
# This is the same content the chair sees in the final decision (same template,
|
||||
# same skill, same prompts) — minus opening, ruling, summary, signatures.
|
||||
_INTERIM_BLOCKS = ["block-vav", "block-tet", "block-zayin", "block-chet"]
|
||||
_INTERIM_BLOCKS = ["block-he", "block-vav", "block-tet", "block-zayin", "block-chet"]
|
||||
|
||||
|
||||
async def extract_appraiser_facts(case_number: str) -> str:
|
||||
@@ -528,6 +531,9 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
||||
case_id, output_path or None, mode="interim",
|
||||
)
|
||||
await db.set_active_draft_path(case_id, path)
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
if case_dir.exists():
|
||||
git_sync.commit_and_push(case_dir, f"טיוטת ביניים: {Path(path).name}")
|
||||
return json.dumps({
|
||||
"status": "completed",
|
||||
"mode": "interim",
|
||||
@@ -571,6 +577,9 @@ async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
||||
try:
|
||||
retrofit_result = docx_retrofit.retrofit_bookmarks(edit_path)
|
||||
await db.set_active_draft_path(case_id, str(edit_path))
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
if case_dir.exists():
|
||||
git_sync.commit_and_push(case_dir, f"גרסת עריכה: {edit_path.name}")
|
||||
return json.dumps({
|
||||
"status": "completed",
|
||||
"active_draft_path": str(edit_path),
|
||||
@@ -681,6 +690,12 @@ async def revise_draft(case_number: str, revisions_json: str,
|
||||
active_path, output_path, revisions, author=author,
|
||||
)
|
||||
await db.set_active_draft_path(case_id, str(output_path))
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
if case_dir.exists():
|
||||
git_sync.commit_and_push(
|
||||
case_dir,
|
||||
f"revise: טיוטה-v{next_ver} ({result.applied} שינויים, {result.failed} נכשלו)",
|
||||
)
|
||||
return json.dumps({
|
||||
"status": "completed",
|
||||
"output_path": str(output_path),
|
||||
|
||||
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)
|
||||
338
mcp-server/src/legal_mcp/tools/precedent_library.py
Normal file
338
mcp-server/src/legal_mcp/tools/precedent_library.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""MCP tools for the External Precedent Library.
|
||||
|
||||
This is distinct from:
|
||||
|
||||
- ``precedents`` (case_precedents table) — chair-attached quotes scoped to
|
||||
a specific case section. Use ``precedent_search_library`` for that.
|
||||
- ``style_corpus`` (Daphna's prior decisions) — searched via
|
||||
``search_decisions`` for style/voice.
|
||||
|
||||
The precedent library is the **authoritative law** corpus: external court
|
||||
rulings and other appeals committees' decisions, with halachot extracted
|
||||
and reviewed by the chair.
|
||||
|
||||
All halachot enter as ``pending_review`` and are invisible to search until
|
||||
the chair approves them — per project review policy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import db, precedent_library, telemetry
|
||||
|
||||
|
||||
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 precedent_library_upload(
|
||||
file_path: str,
|
||||
citation: str,
|
||||
case_name: str = "",
|
||||
court: str = "",
|
||||
decision_date: str = "",
|
||||
source_type: str = "",
|
||||
precedent_level: str = "",
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
subject_tags: list[str] | None = None,
|
||||
is_binding: bool = True,
|
||||
headnote: str = "",
|
||||
summary: str = "",
|
||||
) -> str:
|
||||
"""העלאת פסיקה חיצונית לקורפוס הסמכותי + חילוץ הלכות אוטומטי.
|
||||
|
||||
Args:
|
||||
file_path: נתיב מלא לקובץ PDF/DOCX/RTF/TXT/MD.
|
||||
citation: מראה המקום ("עע\\"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית").
|
||||
case_name: שם קצר.
|
||||
court: ערכאה (עליון / מנהלי / ועדת ערר ארצית / ועדת ערר מחוזית).
|
||||
decision_date: ISO date (YYYY-MM-DD), אופציונלי.
|
||||
source_type: court_ruling / appeals_committee.
|
||||
precedent_level: עליון / מנהלי / ועדת_ערר_ארצית / ועדת_ערר_מחוזית.
|
||||
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
|
||||
subject_tags: תגיות נושא (חניה, קווי_בניין, וכד').
|
||||
|
||||
Returns: JSON עם case_law_id, מספר chunks, מספר הלכות שנכנסו לתור אישור.
|
||||
"""
|
||||
if not citation.strip():
|
||||
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:
|
||||
result = await precedent_library.ingest_precedent(
|
||||
file_path=file_path,
|
||||
citation=citation,
|
||||
case_name=case_name,
|
||||
court=court,
|
||||
decision_date=decision_date or None,
|
||||
source_type=source_type,
|
||||
precedent_level=precedent_level,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype,
|
||||
subject_tags=subject_tags or [],
|
||||
is_binding=is_binding,
|
||||
headnote=headnote,
|
||||
summary=summary,
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
|
||||
|
||||
async def precedent_library_list(
|
||||
practice_area: str = "",
|
||||
court: str = "",
|
||||
precedent_level: str = "",
|
||||
source_type: str = "",
|
||||
search: str = "",
|
||||
source_kind: str = "external_upload",
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים."""
|
||||
rows = await precedent_library.list_precedents(
|
||||
practice_area=practice_area,
|
||||
court=court,
|
||||
precedent_level=precedent_level,
|
||||
source_type=source_type,
|
||||
search=search,
|
||||
source_kind=source_kind,
|
||||
limit=limit,
|
||||
)
|
||||
return _ok(rows)
|
||||
|
||||
|
||||
async def precedent_library_get(case_law_id: str) -> str:
|
||||
"""פסיקה ספציפית עם כל ההלכות שלה (כולל ממתינות לאישור)."""
|
||||
try:
|
||||
cid = UUID(case_law_id)
|
||||
except ValueError:
|
||||
return _err("case_law_id לא תקין")
|
||||
record = await precedent_library.get_precedent(cid)
|
||||
if not record:
|
||||
return _err("פסיקה לא נמצאה")
|
||||
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:
|
||||
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
|
||||
try:
|
||||
cid = UUID(case_law_id)
|
||||
except ValueError:
|
||||
return _err("case_law_id לא תקין")
|
||||
ok = await precedent_library.delete_precedent(cid)
|
||||
return _ok({"deleted": ok, "case_law_id": case_law_id})
|
||||
|
||||
|
||||
async def precedent_extract_halachot(case_law_id: str) -> str:
|
||||
"""הרצה מחדש של חילוץ ההלכות לפסיקה קיימת. הלכות קודמות נמחקות."""
|
||||
try:
|
||||
cid = UUID(case_law_id)
|
||||
except ValueError:
|
||||
return _err("case_law_id לא תקין")
|
||||
try:
|
||||
result = await precedent_library.reextract_halachot(cid)
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
|
||||
|
||||
async def precedent_extract_metadata(case_law_id: str) -> str:
|
||||
"""חילוץ מטא-דאטה (case_name קצר, summary, headnote, key_quote, subject_tags, appeal_subtype, date, level, court, source_type) מהטקסט. ממלא רק שדות ריקים — לא דורס מה שכבר הוזן."""
|
||||
try:
|
||||
cid = UUID(case_law_id)
|
||||
except ValueError:
|
||||
return _err("case_law_id לא תקין")
|
||||
try:
|
||||
result = await precedent_library.reextract_metadata(cid)
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
|
||||
|
||||
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
|
||||
"""ריקון תור בקשות חילוץ שנערמו ע"י כפתורי ה-UI. kind: 'metadata' או 'halacha'.
|
||||
|
||||
הכפתור ב-UI מסמן ב-DB שהפסיקה מבקשת חילוץ. כלי זה (שרץ מקומית עם CLI)
|
||||
סורק את התור ומריץ את ה-extractor לכל פריט. אחרי הצלחה הסימון מתנקה.
|
||||
"""
|
||||
if kind not in {"metadata", "halacha"}:
|
||||
return _err("kind חייב להיות 'metadata' או 'halacha'")
|
||||
try:
|
||||
result = await precedent_library.process_pending_extractions(
|
||||
kind=kind, limit=limit,
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(str(e))
|
||||
return _ok(result)
|
||||
|
||||
|
||||
async def search_precedent_library(
|
||||
query: str,
|
||||
practice_area: str = "",
|
||||
court: str = "",
|
||||
precedent_level: str = "",
|
||||
appeal_subtype: str = "",
|
||||
is_binding: bool | None = None,
|
||||
subject_tag: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
) -> str:
|
||||
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית.
|
||||
|
||||
מחזיר תוצאות מעורבות: הלכות (rule-level, מאושרות בלבד) + קטעי טקסט
|
||||
(passage-level). הלכות מקבלות boost קל בדירוג כי הן מזוקקות מראש.
|
||||
|
||||
Args:
|
||||
query: שאילתת חיפוש בעברית.
|
||||
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
|
||||
court: סינון לפי ערכאה (substring).
|
||||
precedent_level: עליון / מנהלי / ועדת_ערר_ארצית / ועדת_ערר_מחוזית.
|
||||
appeal_subtype: סינון לתת-סוג.
|
||||
is_binding: True/False (None = ללא סינון).
|
||||
subject_tag: סינון לפי תגית נושא (לדוגמה "מועד_קביעת_שומה").
|
||||
limit: מספר תוצאות מקסימלי.
|
||||
include_halachot: האם לכלול הלכות (ברירת מחדל: כן).
|
||||
|
||||
Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}.
|
||||
"""
|
||||
if not query or len(query.strip()) < 2:
|
||||
return json.dumps([], ensure_ascii=False)
|
||||
q = query.strip()
|
||||
t0 = time.perf_counter()
|
||||
results = await precedent_library.search_library(
|
||||
query=q,
|
||||
practice_area=practice_area,
|
||||
court=court,
|
||||
precedent_level=precedent_level,
|
||||
appeal_subtype=appeal_subtype,
|
||||
is_binding=is_binding,
|
||||
subject_tag=subject_tag,
|
||||
limit=limit,
|
||||
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)
|
||||
|
||||
|
||||
async def halacha_review(
|
||||
halacha_id: str,
|
||||
status: str,
|
||||
reviewer: str = "דפנה",
|
||||
rule_statement: str = "",
|
||||
reasoning_summary: str = "",
|
||||
subject_tags: list[str] | None = None,
|
||||
practice_areas: list[str] | None = None,
|
||||
) -> str:
|
||||
"""אישור / דחייה / עריכה של הלכה שחולצה אוטומטית.
|
||||
|
||||
Args:
|
||||
halacha_id: מזהה ההלכה.
|
||||
status: pending_review / approved / rejected / published.
|
||||
reviewer: שם המאשר (ברירת מחדל: דפנה).
|
||||
rule_statement: עריכת ניסוח הכלל (ריק = ללא שינוי).
|
||||
reasoning_summary: עריכת תמצית ההיגיון (ריק = ללא שינוי).
|
||||
subject_tags: עריכת תגיות (None = ללא שינוי).
|
||||
practice_areas: עריכת תחומים (None = ללא שינוי).
|
||||
"""
|
||||
if status not in {"pending_review", "approved", "rejected", "published"}:
|
||||
return _err(
|
||||
"status לא חוקי. ערכים תקינים: "
|
||||
"pending_review / approved / rejected / published"
|
||||
)
|
||||
try:
|
||||
hid = UUID(halacha_id)
|
||||
except ValueError:
|
||||
return _err("halacha_id לא תקין")
|
||||
|
||||
row = await db.update_halacha(
|
||||
halacha_id=hid,
|
||||
review_status=status,
|
||||
reviewer=reviewer,
|
||||
rule_statement=rule_statement or None,
|
||||
reasoning_summary=reasoning_summary or None,
|
||||
subject_tags=subject_tags,
|
||||
practice_areas=practice_areas,
|
||||
)
|
||||
if row is None:
|
||||
return _err("הלכה לא נמצאה")
|
||||
return _ok(row)
|
||||
|
||||
|
||||
async def halachot_pending(limit: int = 100) -> str:
|
||||
"""תור ההלכות הממתינות לאישור (review_status='pending_review')."""
|
||||
rows = await db.list_halachot(review_status="pending_review", limit=limit)
|
||||
return _ok(rows)
|
||||
@@ -4,9 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import db, embeddings
|
||||
from legal_mcp.services import db, embeddings, hybrid_search, telemetry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,11 +31,16 @@ async def search_decisions(
|
||||
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
|
||||
"""
|
||||
# Auto-resolve practice_area from case_number if available
|
||||
resolved_case_id: UUID | None = None
|
||||
if case_number and not practice_area:
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if case:
|
||||
practice_area = case.get("practice_area") or ""
|
||||
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
|
||||
try:
|
||||
resolved_case_id = UUID(case["id"])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
resolved_case_id = None
|
||||
|
||||
if not practice_area:
|
||||
logger.warning(
|
||||
@@ -43,13 +49,25 @@ async def search_decisions(
|
||||
)
|
||||
|
||||
query_emb = await embeddings.embed_query(query)
|
||||
results = await db.search_similar(
|
||||
query_embedding=query_emb,
|
||||
t0 = time.perf_counter()
|
||||
results = await hybrid_search.search_documents_hybrid(
|
||||
query=query,
|
||||
query_text_embedding=query_emb,
|
||||
limit=limit,
|
||||
section_type=section_type or None,
|
||||
practice_area=practice_area 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:
|
||||
return "לא נמצאו תוצאות."
|
||||
@@ -58,11 +76,13 @@ async def search_decisions(
|
||||
for r in results:
|
||||
formatted.append({
|
||||
"score": round(float(r["score"]), 4),
|
||||
"case_number": r["case_number"],
|
||||
"document": r["document_title"],
|
||||
"section": r["section_type"],
|
||||
"page": r["page_number"],
|
||||
"content": r["content"],
|
||||
"case_number": r.get("case_number"),
|
||||
"document": r.get("document_title"),
|
||||
"section": r.get("section_type"),
|
||||
"page": r.get("page_number"),
|
||||
"content": r.get("content", ""),
|
||||
"match_type": r.get("match_type", "text"),
|
||||
"image_thumbnail": r.get("image_thumbnail_path"),
|
||||
})
|
||||
|
||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
||||
@@ -84,12 +104,24 @@ async def search_case_documents(
|
||||
if not case:
|
||||
return f"תיק {case_number} לא נמצא."
|
||||
|
||||
case_uuid = UUID(case["id"])
|
||||
query_emb = await embeddings.embed_query(query)
|
||||
# Restricted to case_id — practice_area filter would be redundant.
|
||||
results = await db.search_similar(
|
||||
query_embedding=query_emb,
|
||||
t0 = time.perf_counter()
|
||||
results = await hybrid_search.search_documents_hybrid(
|
||||
query=query,
|
||||
query_text_embedding=query_emb,
|
||||
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:
|
||||
@@ -99,10 +131,12 @@ async def search_case_documents(
|
||||
for r in results:
|
||||
formatted.append({
|
||||
"score": round(float(r["score"]), 4),
|
||||
"document": r["document_title"],
|
||||
"section": r["section_type"],
|
||||
"page": r["page_number"],
|
||||
"content": r["content"],
|
||||
"document": r.get("document_title"),
|
||||
"section": r.get("section_type"),
|
||||
"page": r.get("page_number"),
|
||||
"content": r.get("content", ""),
|
||||
"match_type": r.get("match_type", "text"),
|
||||
"image_thumbnail": r.get("image_thumbnail_path"),
|
||||
})
|
||||
|
||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
||||
@@ -124,11 +158,16 @@ async def find_similar_cases(
|
||||
appeal_subtype: סוג ערר לסינון
|
||||
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
|
||||
"""
|
||||
resolved_case_id: UUID | None = None
|
||||
if case_number and not practice_area:
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if case:
|
||||
practice_area = case.get("practice_area") or ""
|
||||
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
|
||||
try:
|
||||
resolved_case_id = UUID(case["id"])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
resolved_case_id = None
|
||||
|
||||
if not practice_area:
|
||||
logger.warning(
|
||||
@@ -137,24 +176,40 @@ async def find_similar_cases(
|
||||
)
|
||||
|
||||
query_emb = await embeddings.embed_query(description)
|
||||
results = await db.search_similar(
|
||||
query_embedding=query_emb,
|
||||
limit=limit * 3, # Get more to deduplicate by case
|
||||
# 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.
|
||||
t0 = time.perf_counter()
|
||||
results = await hybrid_search.search_documents_hybrid(
|
||||
query=description,
|
||||
query_text_embedding=query_emb,
|
||||
limit=limit * 3,
|
||||
practice_area=practice_area 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:
|
||||
return "לא נמצאו תיקים דומים."
|
||||
|
||||
# Deduplicate by case_number, keep best score per case
|
||||
# Deduplicate by case_number, keep best score per case.
|
||||
# image-only rows still carry case_number from the join.
|
||||
seen_cases = {}
|
||||
for r in results:
|
||||
cn = r["case_number"]
|
||||
cn = r.get("case_number")
|
||||
if not cn:
|
||||
continue
|
||||
if cn not in seen_cases or r["score"] > seen_cases[cn]["score"]:
|
||||
seen_cases[cn] = r
|
||||
|
||||
# Sort by score and limit
|
||||
top_cases = sorted(seen_cases.values(), key=lambda x: x["score"], reverse=True)[:limit]
|
||||
|
||||
formatted = []
|
||||
@@ -162,8 +217,173 @@ async def find_similar_cases(
|
||||
formatted.append({
|
||||
"score": round(float(r["score"]), 4),
|
||||
"case_number": r["case_number"],
|
||||
"document": r["document_title"],
|
||||
"relevant_section": r["content"][:500],
|
||||
"document": r.get("document_title"),
|
||||
"relevant_section": (r.get("content") or "")[:500],
|
||||
"match_type": r.get("match_type", "text"),
|
||||
})
|
||||
|
||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def search_internal_decisions(
|
||||
query: str,
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
district: str = "",
|
||||
chair_name: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
include_cited_by: bool = False,
|
||||
) -> str:
|
||||
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
|
||||
|
||||
Args:
|
||||
query: שאילתת חיפוש בעברית
|
||||
practice_area: rishuy_uvniya / betterment_levy / compensation_197
|
||||
appeal_subtype: סינון לפי תת-סוג ערר
|
||||
district: מחוז — ירושלים / מרכז / תל אביב / צפון / דרום / ארצי. ריק = כל המחוזות
|
||||
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
|
||||
limit: מספר תוצאות מקסימלי
|
||||
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
|
||||
|
||||
# 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(
|
||||
query,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype,
|
||||
district=district,
|
||||
chair_name=chair_name,
|
||||
limit=primary_limit,
|
||||
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:
|
||||
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 = []
|
||||
seen_case_law_ids: set[str] = set()
|
||||
for r in primary:
|
||||
clid = str(r.get("case_law_id") or "")
|
||||
if clid:
|
||||
seen_case_law_ids.add(clid)
|
||||
formatted.append(_format_internal_row(r, match_type="primary"))
|
||||
|
||||
if include_cited_by and seen_case_law_ids:
|
||||
from uuid import UUID
|
||||
from legal_mcp.services import citation_extractor
|
||||
|
||||
try:
|
||||
source_uuids = [UUID(s) for s in seen_case_law_ids]
|
||||
cited_map = await citation_extractor.get_cited_case_law_ids(source_uuids)
|
||||
except Exception as e:
|
||||
logger.warning("include_cited_by lookup failed: %s", e)
|
||||
cited_map = {}
|
||||
|
||||
# Flatten + dedup the cited case_law_ids that aren't already in
|
||||
# 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)
|
||||
|
||||
|
||||
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})
|
||||
@@ -3,10 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def workflow_status(case_number: str) -> str:
|
||||
"""סטטוס תהליך עבודה מלא לתיק - מסמכים, עיבוד, טיוטות.
|
||||
@@ -308,17 +311,36 @@ async def ingest_final_version(
|
||||
# Extract text from file if provided
|
||||
if file_path and not final_text:
|
||||
from legal_mcp.services import extractor
|
||||
final_text, _ = await extractor.extract_text(file_path)
|
||||
final_text, _, _ = await extractor.extract_text(file_path)
|
||||
|
||||
if not final_text:
|
||||
return "לא סופק טקסט — יש לספק file_path או final_text."
|
||||
|
||||
try:
|
||||
result = await learning_loop.process_final_version(case_id, final_text)
|
||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
||||
except ValueError as e:
|
||||
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
|
||||
|
||||
# Auto-ingest into internal committee decisions corpus (best-effort).
|
||||
try:
|
||||
from legal_mcp.services import internal_decisions as int_svc
|
||||
await int_svc.ingest_internal_decision(
|
||||
case_number=case_number,
|
||||
case_name=case.get("title", ""),
|
||||
decision_date=case.get("decision_date"),
|
||||
chair_name=case.get("chair_name", ""),
|
||||
district="ירושלים",
|
||||
practice_area=case.get("practice_area", ""),
|
||||
appeal_subtype=case.get("appeal_subtype", ""),
|
||||
text=final_text,
|
||||
)
|
||||
result["internal_corpus_ingested"] = True
|
||||
except Exception as e:
|
||||
logger.warning("ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e)
|
||||
result["internal_corpus_ingested"] = False
|
||||
|
||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ── Chair feedback tools ──────────────────────────────────────────
|
||||
|
||||
|
||||
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)"
|
||||
)
|
||||
114
scripts/.archive/extract_claims_8174.py
Normal file
114
scripts/.archive/extract_claims_8174.py
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-shot: extract appellant claims for case 8174-24.
|
||||
|
||||
The analyst (CMPA-13) finished but `extract_claims` timed out three times on
|
||||
the main 25K-char appeal document, so we have only 19 committee/response
|
||||
claims in DB and zero appellant claims. This script reruns extraction with
|
||||
a higher timeout and parallel chunks.
|
||||
|
||||
Targets:
|
||||
• כתב ערר 18.12.24 (appeal, 25,474 chars) — appellant claims
|
||||
• השלמת מסמכים תמ״א 38 (decision, 3,718 chars) — supplementary appeal filing
|
||||
|
||||
After phase 1.1-1.3 lands, this script becomes obsolete.
|
||||
|
||||
Usage: /home/chaim/legal-ai/mcp-server/.venv/bin/python scripts/extract_claims_8174.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
# Ensure we can import legal_mcp from this repo's mcp-server tree
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "src"))
|
||||
|
||||
from legal_mcp.services import claims_extractor, claude_session, db
|
||||
|
||||
|
||||
# ── Patch claude_session to use 30-min ceiling ───────────────────────
|
||||
# The hard-coded timeout=120 in claims_extractor.extract_claims_with_ai is
|
||||
# what kept failing. Force every claude_session call here to use 1800s.
|
||||
_orig_query_json = claude_session.query_json
|
||||
_orig_query = claude_session.query
|
||||
|
||||
|
||||
def _patched_query_json(prompt: str, timeout: int = 120):
|
||||
return _orig_query_json(prompt, timeout=max(timeout, 1800))
|
||||
|
||||
|
||||
def _patched_query(prompt: str, timeout: int = 120, max_turns: int = 1):
|
||||
return _orig_query(prompt, timeout=max(timeout, 1800), max_turns=max_turns)
|
||||
|
||||
|
||||
claude_session.query_json = _patched_query_json
|
||||
claude_session.query = _patched_query
|
||||
|
||||
|
||||
CASE_NUMBER = "8174-24"
|
||||
|
||||
TARGETS = [
|
||||
# (doc_id, title hint, doc_type override, party_hint)
|
||||
("655f96f7-d406-44ac-bb53-6b2c1ab2909c", "כתב ערר 18.12.24", "appeal", "יואל גולדמן"),
|
||||
("13b4795a-4fb7-460e-bddf-a5d282a1a67f", "השלמת מסמכים תמ״א 38", "appeal", "יואל גולדמן"),
|
||||
]
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
case = await db.get_case_by_number(CASE_NUMBER)
|
||||
if not case:
|
||||
print(f"ERROR: case {CASE_NUMBER} not found")
|
||||
return 1
|
||||
case_id = UUID(case["id"])
|
||||
print(f"=== Case {CASE_NUMBER} — {case['title']} ===")
|
||||
print()
|
||||
|
||||
for doc_id, label, doc_type, party_hint in TARGETS:
|
||||
text = await db.get_document_text(UUID(doc_id))
|
||||
if not text:
|
||||
print(f"SKIP {label} — no extracted_text")
|
||||
continue
|
||||
|
||||
chars = len(text)
|
||||
print(f"--- {label} ({chars:,} chars, doc_type={doc_type}) ---")
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
result = await claims_extractor.extract_and_store_claims(
|
||||
case_id=case_id,
|
||||
document_id=UUID(doc_id),
|
||||
text=text,
|
||||
doc_type=doc_type,
|
||||
party_hint=party_hint,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" FAILED: {e}")
|
||||
continue
|
||||
dt = time.monotonic() - t0
|
||||
print(f" done in {dt:.1f}s — {json.dumps(result, ensure_ascii=False)}")
|
||||
print()
|
||||
|
||||
# Final tally
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT party_role, claim_type, source_document, count(*) as n
|
||||
FROM claims WHERE case_id = $1
|
||||
GROUP BY 1, 2, 3 ORDER BY 1, 3""",
|
||||
case_id,
|
||||
)
|
||||
print("=== Final claims breakdown ===")
|
||||
total = 0
|
||||
for r in rows:
|
||||
n = r["n"]
|
||||
total += n
|
||||
print(f" {r['party_role']:12} {r['claim_type']:10} ({n:3}) ← {r['source_document']}")
|
||||
print(f" TOTAL: {total} claims")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
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)"
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
| Script | Type | Purpose | Scheduled |
|
||||
|--------|------|---------|-----------|
|
||||
| `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_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 חוזר. | חד-פעמי (בוצע) |
|
||||
| `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) |
|
||||
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
||||
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
||||
@@ -16,6 +21,22 @@
|
||||
| `convert_decision_template.py` | python | המרת `data/training/טיוטת החלטה.dotx` → `skills/docx/decision_template.docx` לטעינה ב-python-docx | להריץ כשמתעדכנת התבנית |
|
||||
| `deploy-track-changes.sh` | bash | סנכרון skills CMP↔CMPA + בדיקות + הנחיות deploy לארכיטקטורת Track Changes | ידני |
|
||||
| `retrofit_case.py` | python | retrofit רטרואקטיבי — מזריק bookmarks לקובץ קיים של תיק ספציפי ומגדיר אותו כ-active_draft | ידני (חד-פעמי לתיק) |
|
||||
| `reembed_voyage.py` | python | Re-embed כל הוקטורים ב-DB עם המודל ב-`VOYAGE_MODEL` (לאחר שינוי מודל). 5 טבלאות, 1024 דמ', batches של 100. ראה `docs/voyage-upgrades-plan.md` | ידני (אחרי החלפת `VOYAGE_MODEL`) |
|
||||
| `voyage_context3_poc.py` | python | POC #1 — voyage-3 vs voyage-context-3 על פסיקה אחת קצרה (קלמנוביץ, 63 chunks). הכרעה: context-3 לא מציג שיפור עקבי | בנצ'מרק חד-פעמי, נשמר לרפרנס |
|
||||
| `voyage_context3_poc_long.py` | python | POC #2 — voyage-context-3 על פסיקה ארוכה (אהרון ברק 219 chunks) עם sliding windows. הכרעה: context-3 לא משתפר על פסיקה גדולה | בנצ'מרק חד-פעמי, נשמר לרפרנס |
|
||||
| `voyage_multimodal_poc.py` | python | POC #3 — voyage-multimodal-3 על דוח שמאי (89 עמודים). הכרעה: שיפור משמעותי לטבלאות + 22 עמודי image-only שhttp text-OCR מאבד | בנצ'מרק חד-פעמי, מוכן לשלב C |
|
||||
| `voyage_rerank_judge_poc.py` | python | POC #4 — voyage-3 vs rerank-2 vs context-3 על אהרון ברק, 18 שאילתות, claude-haiku-4-5 כ-judge. הכרעה: rerank-2 ניצח עם +9% mean@3 | בנצ'מרק חד-פעמי |
|
||||
| `voyage_rerank_corpus_poc.py` | python | POC #5 — voyage-3 vs rerank-2 על קורפוס מלא (785 docs). הכרעה: +4.5% mean@3 כללי, +11.6% על P queries (practical) | בנצ'מרק חד-פעמי, אישר את שלב B |
|
||||
| `multimodal_backfill.py` | python | Backfill voyage-multimodal-3 page embeddings על מסמכי תיקים קיימים. idempotent (skips by default), forces `MULTIMODAL_ENABLED=true` ל-run, רץ מהקונטיינר. שלב C — ראה `docs/voyage-upgrades-plan.md` | ידני per-case (`python multimodal_backfill.py 8174-24 8137-24`) |
|
||||
| `backfill_chunk_pages.py` | python | Backfill `page_number` ב-`document_chunks` קיימים. legacy chunker לא tracked עמודים → `page_number=NULL` חוסם boost של multimodal hybrid (text+image join על אותו עמוד). re-extracts כל PDF (re-OCR אם צריך, ~$0.0015/page), מחשב page_offsets, ומעדכן chunks. idempotent | ידני per-case (`python backfill_chunk_pages.py 8174-24 8137-24`) |
|
||||
| `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/` — סקריפטים שהושלמו
|
||||
|
||||
@@ -32,6 +53,7 @@
|
||||
| `export-decision-docx.py` | ייצוא החלטה ל-DOCX | MCP: `export_docx()` |
|
||||
| `extract-citations.py` | חילוץ ציטוטי פסיקה מבלוק י | MCP service: `references_extractor.py` |
|
||||
| `extract-claims.py` | חילוץ טענות מבלוק ז | MCP: `extract_claims()` + `claims_extractor.py` |
|
||||
| `extract_claims_8174.py` | חד-פעמי — חילוץ טענות חסרות לתיק 8174-24 אחרי timeout של האנליסט (43 טענות עורר נוספו 30/04/26) | phase 1: `claude_session` async + 30min timeout + chunking סמנטי |
|
||||
| `extract_all_google_vision.py` | OCR בכמות עם Google Vision | MCP: `document_upload()` pipeline |
|
||||
| `extract_originals.py` | חילוץ טקסט מ-PDF עם Claude Opus | MCP service: `extractor.py` |
|
||||
| `extract_originals_ocr.py` | חילוץ OCR מלא מ-PDF | MCP service: `extractor.py` |
|
||||
@@ -41,6 +63,9 @@
|
||||
| `seed-appeals.py` | seeding תיקי ערר ראשוניים ל-DB | MCP: `case_create()` |
|
||||
| `seed-knowledge.py` | seeding לקחים, ביטויי מעבר, פסיקה | MCP: `record_chair_feedback()`, `precedent_attach()` |
|
||||
| `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 בלבד)
|
||||
|
||||
|
||||
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()))
|
||||
346
scripts/backfill_chunk_pages.py
Normal file
346
scripts/backfill_chunk_pages.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""Backfill page_number on existing document_chunks (no re-OCR).
|
||||
|
||||
Why this exists: the legacy chunker did not track which page each chunk
|
||||
came from. After the page-tracking fix, new uploads carry page_number
|
||||
correctly, but existing chunks have ``page_number=NULL`` in the DB.
|
||||
That blocks the multimodal hybrid retriever's text+image boost (which
|
||||
joins (chunk, image) on (document_id, page_number)).
|
||||
|
||||
What it does (per case, per document):
|
||||
|
||||
1. Load stored ``documents.extracted_text`` from the DB. This is
|
||||
the exact text that was used to produce the existing chunks —
|
||||
so chunk content lookups against it match verbatim.
|
||||
2. Open the PDF with PyMuPDF and call ``page.get_text()`` on each
|
||||
page (cheap, no OCR). For pages with usable direct text we get
|
||||
a clean snippet; for fully-scanned pages we get little/nothing.
|
||||
3. Anchor: for each page with a usable snippet, search the snippet
|
||||
in ``extracted_text`` to recover that page's start offset.
|
||||
4. Interpolate: for OCR-only pages with no anchor, position is
|
||||
linearly interpolated between the nearest anchored neighbors
|
||||
(or uniformly when no anchors exist at all).
|
||||
5. For every chunk row (sorted by chunk_index), find the chunk's
|
||||
content in ``extracted_text`` (verbatim match), look up the
|
||||
page from the offsets, and ``UPDATE document_chunks SET
|
||||
page_number = ?``.
|
||||
|
||||
Idempotent: a second run with no --force is a no-op.
|
||||
|
||||
Cost: zero. Runs in seconds even for the 89-page appraisal report.
|
||||
|
||||
Usage:
|
||||
docker cp scripts/backfill_chunk_pages.py <c>:/tmp/
|
||||
docker exec <c> python /tmp/backfill_chunk_pages.py 8174-24 8137-24
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
def _setup_paths():
|
||||
here = Path(__file__).resolve().parent
|
||||
mcp_src = here.parent / "mcp-server" / "src"
|
||||
if mcp_src.is_dir() and str(mcp_src) not in sys.path:
|
||||
sys.path.insert(0, str(mcp_src))
|
||||
|
||||
|
||||
_setup_paths()
|
||||
import fitz # PyMuPDF # noqa: E402
|
||||
from legal_mcp.services import db # noqa: E402
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("backfill_chunk_pages")
|
||||
|
||||
|
||||
# Snippet length for page anchoring. Long enough to be unique, short
|
||||
# enough to survive minor whitespace variation between PyMuPDF direct
|
||||
# extraction and the stored OCR text.
|
||||
ANCHOR_SNIPPET_LEN = 80
|
||||
# Minimum direct-text length on a page to attempt anchoring at all.
|
||||
MIN_DIRECT_LEN = 60
|
||||
|
||||
|
||||
def _resolve_local_path(db_path: str) -> Path:
|
||||
p = Path(db_path)
|
||||
if p.is_file():
|
||||
return p
|
||||
if str(p).startswith("/data/"):
|
||||
local = Path("/home/chaim/legal-ai") / Path(*p.parts[1:])
|
||||
if local.is_file():
|
||||
return local
|
||||
return p
|
||||
|
||||
|
||||
def _norm_whitespace(s: str) -> str:
|
||||
"""Collapse runs of whitespace; helps cross-source matching where
|
||||
PyMuPDF direct extraction may differ from the stored OCR text in
|
||||
line-break placement."""
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
def _find_anchored_snippet(
|
||||
extracted_text: str, snippet: str, search_start: int = 0,
|
||||
) -> int:
|
||||
"""Search for ``snippet`` in ``extracted_text``, tolerant to
|
||||
whitespace differences. Returns the offset in the original
|
||||
extracted_text, or -1."""
|
||||
# Direct match first — fastest path
|
||||
idx = extracted_text.find(snippet, search_start)
|
||||
if idx >= 0:
|
||||
return idx
|
||||
# Whitespace-normalized fallback
|
||||
norm_text = _norm_whitespace(extracted_text)
|
||||
norm_snip = _norm_whitespace(snippet)
|
||||
if not norm_snip:
|
||||
return -1
|
||||
norm_idx = norm_text.find(norm_snip)
|
||||
if norm_idx < 0:
|
||||
return -1
|
||||
# Map norm offset back to original — count chars until we've passed
|
||||
# `norm_idx` non-collapsed characters in the original.
|
||||
orig_pos = 0
|
||||
norm_pos = 0
|
||||
in_ws = False
|
||||
for ch in extracted_text:
|
||||
if norm_pos == norm_idx:
|
||||
return orig_pos
|
||||
if ch.isspace():
|
||||
if not in_ws:
|
||||
norm_pos += 1
|
||||
in_ws = True
|
||||
else:
|
||||
in_ws = False
|
||||
norm_pos += 1
|
||||
orig_pos += 1
|
||||
return -1
|
||||
|
||||
|
||||
def _compute_page_offsets(pdf_path: Path, extracted_text: str) -> list[int]:
|
||||
"""Return ``page_offsets`` (start char offset of each page in
|
||||
``extracted_text``), using direct PyMuPDF reads for anchoring and
|
||||
linear interpolation for OCR-only pages."""
|
||||
doc = fitz.open(str(pdf_path))
|
||||
n_pages = len(doc)
|
||||
anchors: list[int | None] = [None] * n_pages
|
||||
|
||||
last_pos = 0
|
||||
for i, page in enumerate(doc):
|
||||
direct = page.get_text().strip()
|
||||
if len(direct) < MIN_DIRECT_LEN:
|
||||
continue
|
||||
# Take the first ANCHOR_SNIPPET_LEN chars after stripping
|
||||
snippet = direct[:ANCHOR_SNIPPET_LEN]
|
||||
pos = _find_anchored_snippet(extracted_text, snippet, last_pos)
|
||||
if pos < 0:
|
||||
# try a global search before giving up
|
||||
pos = _find_anchored_snippet(extracted_text, snippet, 0)
|
||||
if pos >= 0:
|
||||
anchors[i] = pos
|
||||
last_pos = pos
|
||||
doc.close()
|
||||
|
||||
# Force first page to start at 0 if not already anchored
|
||||
if anchors[0] is None:
|
||||
anchors[0] = 0
|
||||
|
||||
# Fill gaps via linear interpolation between the nearest anchors;
|
||||
# extrapolate beyond the last anchor by the average page length.
|
||||
page_offsets: list[int] = [0] * n_pages
|
||||
for i in range(n_pages):
|
||||
if anchors[i] is not None:
|
||||
page_offsets[i] = anchors[i]
|
||||
continue
|
||||
# Find prev anchored
|
||||
prev_i = i - 1
|
||||
while prev_i >= 0 and anchors[prev_i] is None:
|
||||
prev_i -= 1
|
||||
# Find next anchored
|
||||
next_i = i + 1
|
||||
while next_i < n_pages and anchors[next_i] is None:
|
||||
next_i += 1
|
||||
prev_pos = anchors[prev_i] if prev_i >= 0 else 0
|
||||
if next_i < n_pages:
|
||||
next_pos = anchors[next_i]
|
||||
ratio = (i - prev_i) / (next_i - prev_i)
|
||||
page_offsets[i] = int(prev_pos + ratio * (next_pos - prev_pos))
|
||||
else:
|
||||
# Extrapolate: assume uniform distribution beyond last anchor
|
||||
# using page-density inferred from prior anchors (or fall
|
||||
# back to total_text/n_pages).
|
||||
avg = len(extracted_text) / max(1, n_pages)
|
||||
page_offsets[i] = int(prev_pos + avg * (i - prev_i))
|
||||
# Monotone-clip just in case interpolation ever goes backwards
|
||||
for i in range(1, n_pages):
|
||||
if page_offsets[i] < page_offsets[i - 1]:
|
||||
page_offsets[i] = page_offsets[i - 1]
|
||||
return page_offsets
|
||||
|
||||
|
||||
def _page_at_offset(offset: int, page_offsets: list[int]) -> int:
|
||||
if not page_offsets:
|
||||
return 1
|
||||
page = 1
|
||||
for i, start in enumerate(page_offsets):
|
||||
if start <= offset:
|
||||
page = i + 1
|
||||
else:
|
||||
break
|
||||
return page
|
||||
|
||||
|
||||
async def _backfill_document(
|
||||
document_id: UUID,
|
||||
title: str,
|
||||
db_file_path: str,
|
||||
force: bool,
|
||||
) -> dict:
|
||||
pool = await db.get_pool()
|
||||
|
||||
chunks = await pool.fetch(
|
||||
"SELECT id, chunk_index, content, page_number FROM document_chunks "
|
||||
"WHERE document_id = $1 ORDER BY chunk_index",
|
||||
document_id,
|
||||
)
|
||||
if not chunks:
|
||||
return {"status": "no_chunks"}
|
||||
|
||||
n_null = sum(1 for c in chunks if c["page_number"] is None)
|
||||
if not force and n_null == 0:
|
||||
logger.info(" skip (all %d chunks already tagged): %s", len(chunks), title)
|
||||
return {"status": "skipped", "chunks": len(chunks)}
|
||||
|
||||
pdf_path = _resolve_local_path(db_file_path)
|
||||
if not pdf_path.is_file():
|
||||
logger.warning(" file missing: %s (%s)", pdf_path, title)
|
||||
return {"status": "missing"}
|
||||
if pdf_path.suffix.lower() != ".pdf":
|
||||
return {"status": "not_pdf"}
|
||||
|
||||
doc_row = await pool.fetchrow(
|
||||
"SELECT extracted_text FROM documents WHERE id = $1", document_id,
|
||||
)
|
||||
extracted_text = doc_row["extracted_text"] if doc_row else None
|
||||
if not extracted_text:
|
||||
return {"status": "no_extracted_text"}
|
||||
|
||||
t0 = time.time()
|
||||
page_offsets = _compute_page_offsets(pdf_path, extracted_text)
|
||||
n_anchored = sum(1 for i in range(len(page_offsets)) if i == 0 or page_offsets[i] > page_offsets[i - 1])
|
||||
|
||||
# The chunker joins paragraphs with single `\n` while extracted_text
|
||||
# has `\n\n` between pages, so verbatim search misses cross-page
|
||||
# chunks. Use the whitespace-tolerant helper that returns an offset
|
||||
# in the *original* text.
|
||||
pos = 0
|
||||
updated = 0
|
||||
not_found = 0
|
||||
for c in chunks:
|
||||
content = c["content"]
|
||||
if not content:
|
||||
continue
|
||||
# Use a unique slice from the chunk to anchor in extracted_text
|
||||
# — anchoring on the chunk's first ~120 chars is enough to
|
||||
# disambiguate across the document.
|
||||
snippet = content[: min(len(content), 120)]
|
||||
idx = _find_anchored_snippet(extracted_text, snippet, pos)
|
||||
if idx < 0:
|
||||
idx = _find_anchored_snippet(extracted_text, snippet, 0)
|
||||
if idx < 0:
|
||||
not_found += 1
|
||||
continue
|
||||
page = _page_at_offset(idx, page_offsets)
|
||||
await pool.execute(
|
||||
"UPDATE document_chunks SET page_number = $1 WHERE id = $2",
|
||||
page, c["id"],
|
||||
)
|
||||
updated += 1
|
||||
pos = idx + max(1, len(content) // 2)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
logger.info(
|
||||
" %s — %d pages, %d anchors, updated %d/%d chunks (%d not found) in %.2fs",
|
||||
title, len(page_offsets), n_anchored, updated, len(chunks), not_found, elapsed,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"elapsed_sec": round(elapsed, 2),
|
||||
"pages": len(page_offsets),
|
||||
"anchors": n_anchored,
|
||||
"chunks_total": len(chunks),
|
||||
"chunks_updated": updated,
|
||||
"chunks_not_found": not_found,
|
||||
}
|
||||
|
||||
|
||||
async def backfill_cases(case_numbers: list[str], force: bool) -> dict:
|
||||
pool = await db.get_pool()
|
||||
summary: dict = {}
|
||||
for cn in case_numbers:
|
||||
logger.info("=" * 60)
|
||||
logger.info("Case %s", cn)
|
||||
case = await db.get_case_by_number(cn)
|
||||
if not case:
|
||||
logger.warning("Case not found: %s", cn)
|
||||
summary[cn] = {"status": "case_not_found"}
|
||||
continue
|
||||
case_id = UUID(str(case["id"]))
|
||||
docs = await pool.fetch(
|
||||
"SELECT id, title, file_path FROM documents WHERE case_id = $1 ORDER BY title",
|
||||
case_id,
|
||||
)
|
||||
logger.info(" %d documents", len(docs))
|
||||
per_doc: list[dict] = []
|
||||
for d in docs:
|
||||
r = await _backfill_document(
|
||||
UUID(str(d["id"])), d["title"], d["file_path"], force,
|
||||
)
|
||||
per_doc.append({"document_id": str(d["id"]), "title": d["title"], **r})
|
||||
summary[cn] = {
|
||||
"documents_total": len(docs),
|
||||
"ok": sum(1 for r in per_doc if r["status"] == "ok"),
|
||||
"skipped": sum(1 for r in per_doc if r["status"] == "skipped"),
|
||||
"missing": sum(1 for r in per_doc if r["status"] == "missing"),
|
||||
"no_chunks": sum(1 for r in per_doc if r["status"] == "no_chunks"),
|
||||
"no_extracted_text": sum(1 for r in per_doc if r["status"] == "no_extracted_text"),
|
||||
"chunks_updated": sum(r.get("chunks_updated", 0) for r in per_doc),
|
||||
"documents": per_doc,
|
||||
}
|
||||
return summary
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Backfill page_number on existing chunks (no OCR)")
|
||||
parser.add_argument("cases", nargs="+", help="Case numbers (e.g. 8174-24 8137-24)")
|
||||
parser.add_argument(
|
||||
"--force", action="store_true",
|
||||
help="Re-process even if all chunks already have page_number (default: skip)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
summary = asyncio.run(backfill_cases(args.cases, force=args.force))
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("SUMMARY")
|
||||
print("=" * 60)
|
||||
for cn, s in summary.items():
|
||||
if s.get("status") == "case_not_found":
|
||||
print(f" {cn}: NOT FOUND")
|
||||
continue
|
||||
print(
|
||||
f" {cn}: {s['documents_total']} docs — "
|
||||
f"ok {s['ok']}, skipped {s['skipped']}, "
|
||||
f"missing {s['missing']}, chunks_updated {s['chunks_updated']}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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())
|
||||
134
scripts/fix_paperclipai_skills_drift.py
Normal file
134
scripts/fix_paperclipai_skills_drift.py
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fix paperclipai/* skill drift across CMP+CMPA agents.
|
||||
|
||||
Goal: zero drift on paperclipai/* skills between master(CMP) and mirror(CMPA).
|
||||
|
||||
Rules:
|
||||
* Remove ``paperclipai/paperclip/paperclip-dev`` from all 14 agents (not relevant
|
||||
for legal work — it's for maintaining Paperclip itself).
|
||||
* Ensure ``paperclipai/paperclip/paperclip-converting-plans-to-tasks`` exists
|
||||
on CEO + analyst agents in both companies (planning skill).
|
||||
* Remove ``paperclipai/paperclip/paperclip-converting-plans-to-tasks`` from any
|
||||
other agent in either company that currently has it.
|
||||
|
||||
Local/* and company/* skills are not touched — they're scoped to a company
|
||||
by design and drift is expected.
|
||||
|
||||
Usage::
|
||||
|
||||
PAPERCLIP_BOARD_API_KEY=pbk_... python scripts/fix_paperclipai_skills_drift.py # dry-run
|
||||
PAPERCLIP_BOARD_API_KEY=pbk_... python scripts/fix_paperclipai_skills_drift.py --apply # commit
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
|
||||
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY")
|
||||
|
||||
COMPANIES = {
|
||||
"licensing": ("CMP ", "42a7acd0-30c5-4cbd-ac97-7424f65df294"),
|
||||
"betterment": ("CMPA", "8639e837-4c9d-47fa-a76b-95788d651896"),
|
||||
}
|
||||
|
||||
DEV_SKILL = "paperclipai/paperclip/paperclip-dev"
|
||||
CONVERTING_SKILL = "paperclipai/paperclip/paperclip-converting-plans-to-tasks"
|
||||
|
||||
# Hebrew names of the agents that should retain converting-plans-to-tasks.
|
||||
CONVERTING_TARGETS = {"עוזר משפטי", "מנתח משפטי"}
|
||||
|
||||
|
||||
def headers() -> dict[str, str]:
|
||||
if not PAPERCLIP_BOARD_API_KEY:
|
||||
sys.exit("PAPERCLIP_BOARD_API_KEY not set — fetch from Infisical first.")
|
||||
return {
|
||||
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def fetch_company_agents(client: httpx.AsyncClient, company_id: str) -> list[dict]:
|
||||
r = await client.get(f"{PAPERCLIP_API_URL}/api/companies/{company_id}/agents", headers=headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def compute_changes(agent: dict) -> tuple[bool, list[str], list[str]]:
|
||||
skill_sync = (agent.get("adapterConfig") or {}).get("paperclipSkillSync") or {}
|
||||
old = list(skill_sync.get("desiredSkills") or [])
|
||||
new = [s for s in old if s != DEV_SKILL]
|
||||
if agent["name"] in CONVERTING_TARGETS:
|
||||
if CONVERTING_SKILL not in new:
|
||||
new.append(CONVERTING_SKILL)
|
||||
else:
|
||||
new = [s for s in new if s != CONVERTING_SKILL]
|
||||
return (sorted(old) != sorted(new), old, new)
|
||||
|
||||
|
||||
async def patch_agent(
|
||||
client: httpx.AsyncClient, agent_id: str, current_skill_sync: dict, new_skills: list[str]
|
||||
) -> None:
|
||||
body = {
|
||||
"adapterConfig": {
|
||||
"paperclipSkillSync": {**current_skill_sync, "desiredSkills": new_skills},
|
||||
}
|
||||
}
|
||||
r = await client.patch(
|
||||
f"{PAPERCLIP_API_URL}/api/agents/{agent_id}", headers=headers(), json=body, timeout=15
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--apply", action="store_true", help="commit changes (default: dry-run)")
|
||||
args = parser.parse_args()
|
||||
|
||||
mode = "APPLY" if args.apply else "DRY-RUN"
|
||||
print(f"=== {mode}: fixing paperclipai/* skill drift ===\n")
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
all_agents: list[dict] = []
|
||||
for label, (_, cid) in COMPANIES.items():
|
||||
agents = await fetch_company_agents(client, cid)
|
||||
for a in agents:
|
||||
a["_company_label"] = COMPANIES[label][0]
|
||||
all_agents.extend(agents)
|
||||
|
||||
changes_planned = 0
|
||||
for a in sorted(all_agents, key=lambda x: (x["_company_label"], x["name"])):
|
||||
changed, old, new = compute_changes(a)
|
||||
label = a["_company_label"]
|
||||
if not changed:
|
||||
print(f" {label} {a['name']:20} no change")
|
||||
continue
|
||||
changes_planned += 1
|
||||
removed = sorted(set(old) - set(new))
|
||||
added = sorted(set(new) - set(old))
|
||||
print(f" {label} {a['name']:20} -{len(removed)} +{len(added)}")
|
||||
for s in removed:
|
||||
print(f" - {s}")
|
||||
for s in added:
|
||||
print(f" + {s}")
|
||||
if args.apply:
|
||||
skill_sync = (a.get("adapterConfig") or {}).get("paperclipSkillSync") or {}
|
||||
try:
|
||||
await patch_agent(client, a["id"], skill_sync, new)
|
||||
print(" ✓ patched")
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f" ✗ failed: {e.response.status_code} {e.response.text[:200]}")
|
||||
raise
|
||||
|
||||
print(f"\n{mode}: {changes_planned} agents would change")
|
||||
if not args.apply and changes_planned > 0:
|
||||
print("Run with --apply to commit.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(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()
|
||||
186
scripts/multimodal_backfill.py
Normal file
186
scripts/multimodal_backfill.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Multimodal backfill — embed page images for existing case documents.
|
||||
|
||||
Iterates over documents already in the DB and renders + embeds + stores
|
||||
per-page voyage-multimodal-3 vectors. Skips documents that already have
|
||||
image embeddings (idempotent).
|
||||
|
||||
Independent of the processor pipeline — does NOT re-extract text or
|
||||
re-chunk; only the multimodal step.
|
||||
|
||||
Designed to run from inside the FastAPI/MCP container (where /data is
|
||||
mounted and writable). Locally it requires sudo for the thumbnails dir
|
||||
under /home/chaim/legal-ai/data/cases/...
|
||||
|
||||
Usage::
|
||||
|
||||
# In container (Coolify):
|
||||
docker exec -it <legal-ai-container> python -m legal_mcp.cli \\
|
||||
multimodal_backfill --cases 8174-24 8137-24
|
||||
|
||||
# Or as a script (sets MULTIMODAL_ENABLED=true automatically):
|
||||
/opt/api/mcp-server/.venv/bin/python /opt/api/scripts/multimodal_backfill.py 8174-24 8137-24
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
def _setup_paths():
|
||||
"""Ensure mcp-server src is on path even when run as a standalone script."""
|
||||
here = Path(__file__).resolve().parent
|
||||
mcp_src = here.parent / "mcp-server" / "src"
|
||||
if mcp_src.is_dir() and str(mcp_src) not in sys.path:
|
||||
sys.path.insert(0, str(mcp_src))
|
||||
|
||||
|
||||
_setup_paths()
|
||||
# Force the flag on for this run regardless of env — backfill is the
|
||||
# whole point of running this script. The deploy-time default stays off.
|
||||
os.environ["MULTIMODAL_ENABLED"] = "true"
|
||||
|
||||
from legal_mcp import config # noqa: E402
|
||||
from legal_mcp.services import db, embeddings, extractor, processor # noqa: E402
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("multimodal_backfill")
|
||||
|
||||
|
||||
def _resolve_local_path(db_path: str) -> Path:
|
||||
"""Map container path /data/... to host /home/chaim/legal-ai/data/...
|
||||
when running locally; pass-through when already absolute and present."""
|
||||
p = Path(db_path)
|
||||
if p.is_file():
|
||||
return p
|
||||
if str(p).startswith("/data/"):
|
||||
local = Path("/home/chaim/legal-ai") / Path(*p.parts[1:])
|
||||
if local.is_file():
|
||||
return local
|
||||
return p
|
||||
|
||||
|
||||
async def _backfill_document(
|
||||
document_id: UUID,
|
||||
case_id: UUID,
|
||||
title: str,
|
||||
db_file_path: str,
|
||||
skip_if_exists: bool,
|
||||
) -> dict:
|
||||
pool = await db.get_pool()
|
||||
if skip_if_exists:
|
||||
existing = await pool.fetchval(
|
||||
"SELECT count(*) FROM document_image_embeddings WHERE document_id = $1",
|
||||
document_id,
|
||||
)
|
||||
if existing and existing > 0:
|
||||
logger.info(" skip (%d rows already): %s", existing, title)
|
||||
return {"status": "skipped", "rows": int(existing)}
|
||||
|
||||
pdf_path = _resolve_local_path(db_file_path)
|
||||
if not pdf_path.is_file():
|
||||
logger.warning(" file missing: %s (%s)", pdf_path, title)
|
||||
return {"status": "missing"}
|
||||
if pdf_path.suffix.lower() != ".pdf":
|
||||
logger.info(" not a PDF, skipping: %s", title)
|
||||
return {"status": "not_pdf"}
|
||||
|
||||
page_count = await pool.fetchval(
|
||||
"SELECT page_count FROM documents WHERE id = $1", document_id,
|
||||
)
|
||||
if not page_count:
|
||||
# Open to count
|
||||
import fitz
|
||||
d = fitz.open(str(pdf_path))
|
||||
page_count = len(d)
|
||||
d.close()
|
||||
|
||||
logger.info(" embedding %s (%d pages)", title, page_count)
|
||||
t0 = time.time()
|
||||
result = await processor._embed_document_pages(
|
||||
document_id, case_id, pdf_path, page_count,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
logger.info(" done in %.1fs: %s", elapsed, result)
|
||||
return {"status": "ok", "elapsed_sec": round(elapsed, 1), **result}
|
||||
|
||||
|
||||
async def backfill_cases(case_numbers: list[str], skip_if_exists: bool = True) -> dict:
|
||||
"""Embed page images for every PDF document in the given cases."""
|
||||
await db.init_schema() # in case schema V9 hasn't been applied
|
||||
pool = await db.get_pool()
|
||||
summary: dict = {}
|
||||
for cn in case_numbers:
|
||||
logger.info("=" * 60)
|
||||
logger.info("Case %s", cn)
|
||||
case = await db.get_case_by_number(cn)
|
||||
if not case:
|
||||
logger.warning("Case not found: %s", cn)
|
||||
summary[cn] = {"status": "case_not_found"}
|
||||
continue
|
||||
case_id = UUID(str(case["id"]))
|
||||
docs = await pool.fetch(
|
||||
"SELECT id, title, file_path FROM documents WHERE case_id = $1 ORDER BY title",
|
||||
case_id,
|
||||
)
|
||||
logger.info(" %d documents", len(docs))
|
||||
per_doc: list[dict] = []
|
||||
for d in docs:
|
||||
doc_id = UUID(str(d["id"]))
|
||||
title = d["title"]
|
||||
r = await _backfill_document(
|
||||
doc_id, case_id, title, d["file_path"], skip_if_exists,
|
||||
)
|
||||
per_doc.append({"document_id": str(doc_id), "title": title, **r})
|
||||
summary[cn] = {
|
||||
"documents_total": len(docs),
|
||||
"embedded": sum(1 for r in per_doc if r["status"] == "ok"),
|
||||
"skipped": sum(1 for r in per_doc if r["status"] == "skipped"),
|
||||
"missing": sum(1 for r in per_doc if r["status"] == "missing"),
|
||||
"not_pdf": sum(1 for r in per_doc if r["status"] == "not_pdf"),
|
||||
"documents": per_doc,
|
||||
}
|
||||
return summary
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Multimodal backfill for case documents")
|
||||
parser.add_argument(
|
||||
"cases", nargs="+", help="Case numbers to backfill (e.g. 8174-24 8137-24)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--re-embed", action="store_true",
|
||||
help="Re-embed even if image embeddings already exist (default: skip)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logger.info("MULTIMODAL_MODEL=%s DPI=%d THUMB_DPI=%d",
|
||||
config.MULTIMODAL_MODEL, config.MULTIMODAL_DPI, config.MULTIMODAL_THUMB_DPI)
|
||||
summary = asyncio.run(
|
||||
backfill_cases(args.cases, skip_if_exists=not args.re_embed)
|
||||
)
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("SUMMARY")
|
||||
print("=" * 60)
|
||||
for cn, s in summary.items():
|
||||
if s.get("status") == "case_not_found":
|
||||
print(f" {cn}: NOT FOUND")
|
||||
continue
|
||||
print(
|
||||
f" {cn}: {s['documents_total']} docs — "
|
||||
f"embedded {s['embedded']}, skipped {s['skipped']}, "
|
||||
f"missing {s['missing']}, non-pdf {s['not_pdf']}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
52
scripts/pc.sh
Executable file
52
scripts/pc.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# pc.sh — Paperclip API wrapper for agents.
|
||||
#
|
||||
# Usage:
|
||||
# pc.sh <method> <path> [body_json] [extra_curl_args...]
|
||||
#
|
||||
# Adds:
|
||||
# - Authorization: Bearer $PAPERCLIP_API_KEY
|
||||
# - X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID (audit trail; falls back to JWT claims if empty)
|
||||
# - Content-Type: application/json (when body provided)
|
||||
# - Base URL: $PAPERCLIP_API_URL
|
||||
#
|
||||
# Examples:
|
||||
# ~/legal-ai/scripts/pc.sh GET "/api/agents/me/inbox-lite"
|
||||
# ~/legal-ai/scripts/pc.sh POST "/api/issues/$ISSUE_ID/checkout"
|
||||
# ~/legal-ai/scripts/pc.sh POST "/api/issues/$ISSUE_ID/comments" '{"body":"שלום"}'
|
||||
# ~/legal-ai/scripts/pc.sh PATCH "/api/issues/$ISSUE_ID" '{"status":"done"}'
|
||||
# ~/legal-ai/scripts/pc.sh DELETE "/api/issues/$ISSUE_ID"
|
||||
#
|
||||
# Sourcing as a function (optional):
|
||||
# source ~/legal-ai/scripts/pc.sh && pc POST "/api/issues/$ISSUE_ID/checkout"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
pc() {
|
||||
local method="${1:-}"
|
||||
local path="${2:-}"
|
||||
local body="${3:-}"
|
||||
if [ $# -ge 3 ]; then shift 3; else shift "$#"; fi
|
||||
|
||||
if [ -z "$method" ] || [ -z "$path" ]; then
|
||||
echo "usage: pc.sh <METHOD> <PATH> [BODY_JSON] [extra curl args...]" >&2
|
||||
return 2
|
||||
fi
|
||||
: "${PAPERCLIP_API_URL:?PAPERCLIP_API_URL not set}"
|
||||
: "${PAPERCLIP_API_KEY:?PAPERCLIP_API_KEY not set}"
|
||||
|
||||
local args=(-s -X "$method"
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
|
||||
-H "X-Paperclip-Run-Id: ${PAPERCLIP_RUN_ID:-}")
|
||||
|
||||
if [ -n "$body" ]; then
|
||||
args+=(-H "Content-Type: application/json" -d "$body")
|
||||
fi
|
||||
|
||||
curl "${args[@]}" "$@" "${PAPERCLIP_API_URL}${path}"
|
||||
}
|
||||
|
||||
# When invoked directly (not sourced), forward args to pc().
|
||||
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
|
||||
pc "$@"
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user