Compare commits
33 Commits
feat/mcp-s
...
fix/write-
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1,197 +1,165 @@
|
|||||||
# HEARTBEAT.md — רשימת ביצוע לכל ריצה
|
# HEARTBEAT.md — רשימת ביצוע לכל ריצה (Project-Specific)
|
||||||
|
|
||||||
## שפה — כלל עליון
|
> **🎯 קובץ זה — 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).
|
||||||
- Comments ב-Paperclip
|
|
||||||
- הודעות סטטוס
|
|
||||||
- תיאורי שגיאות
|
|
||||||
- סיכומים ודיווחים
|
|
||||||
- חשיבה פנימית (thinking)
|
|
||||||
|
|
||||||
אין יוצאים מן הכלל. גם שמות tools, פקודות, ונתיבי קבצים — ההסבר סביבם בעברית.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
הרץ את הרשימה הזו בכל heartbeat.
|
## שפה — כלל עליון
|
||||||
|
|
||||||
## 1. זיהוי וסינון חברה
|
**כל הפלט שלך חייב להיות בעברית בלבד.** כולל: comments, סטטוס, שגיאות, סיכומים, ו-thinking פנימי. אין יוצאים מן הכלל. גם שמות tools, פקודות, ונתיבי קבצים — ההסבר סביבם בעברית. ה-skill הרשמי באנגלית — תרגם אם נדרש.
|
||||||
|
|
||||||
- וודא שאתה יודע מי אתה: `$PAPERCLIP_AGENT_ID`
|
---
|
||||||
- בדוק הקשר: `$PAPERCLIP_TASK_ID`, `$PAPERCLIP_WAKE_REASON`
|
|
||||||
- **זהה את החברה שלך**: `$PAPERCLIP_COMPANY_ID`
|
|
||||||
|
|
||||||
### ⚠️ סינון תיקים לפי חברה — כלל ברזל
|
## §0. כל קריאה ל-Paperclip API — דרך `pc.sh` בלבד
|
||||||
|
|
||||||
**אתה אחראי רק על תיקים ששייכים לחברה שלך.** הספרה הראשונה של מספר התיק קובעת:
|
**ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו:
|
||||||
|
|
||||||
| חברה | 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. בדוק תיבת דואר
|
|
||||||
|
|
||||||
```bash
|
```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`
|
מוסיף אוטומטית: `Authorization`, `X-Paperclip-Run-Id` (audit), `Content-Type`, base URL.
|
||||||
- אם `PAPERCLIP_TASK_ID` מוגדר — תעדף אותו
|
|
||||||
|
|
||||||
## 2b. קרא תגובות אחרונות על ה-issue
|
**דוגמאות:**
|
||||||
|
```bash
|
||||||
|
~/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"}'
|
||||||
|
```
|
||||||
|
|
||||||
לפני שאתה מתחיל לעבוד, בדוק אם יש comments חדשים מחיים:
|
**ל-body גדול עם backticks** — `Write` ל-temp file, אז `pc.sh ... "" -H "Content-Type: application/json" -d @/tmp/comment.json`. ראה §דיווח למה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §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
|
```bash
|
||||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
CONTEXT=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/heartbeat-context?wakeCommentId=$LATEST_COMMENT_ID")
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]'
|
ATTACHMENTS=$(echo "$CONTEXT" | jq '.attachments')
|
||||||
```
|
```
|
||||||
|
|
||||||
- אם יש comment מחיים (authorUserId, לא authorAgentId) שנכתב **אחרי** ה-comment האחרון שלך — **קרא אותו בתשומת לב**
|
**1.5ג. APPROVAL_ID flow** — אם חיים ענה על interaction (ראה `legal-ceo.md` §B/§C/§D), קרא תשובה דרך:
|
||||||
- אם ה-comment מכיל הוראות עבודה — **עקוב אחריהן**
|
```bash
|
||||||
- אם ה-comment מזכיר קובץ שהועלה — בדוק attachments (ראה 2c)
|
~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID/interactions/$PAPERCLIP_APPROVAL_ID" | jq '{status, kind, response}'
|
||||||
- אם ה-comment מבקש להעביר לסוכן אחר — **עצור**, פרסם comment שמאשר, והעֵר את ה-CEO
|
```
|
||||||
|
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
|
||||||
|
|
||||||
## 2c. בדוק קבצים מצורפים
|
---
|
||||||
|
|
||||||
אם comment מחיים מזכיר קובץ או טיוטה:
|
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
|
||||||
|
|
||||||
|
ה-attachments זמינים ב-`$CONTEXT.attachments` (מ-§1.5ב):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
|
echo "$CONTEXT" | jq '.attachments[] | {filename, contentPath, contentType, byteSize}'
|
||||||
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
|
CONTENT_PATH=$(echo "$CONTEXT" | jq -r '.attachments[0].contentPath')
|
||||||
WHERE ia.issue_id = '{issue-id}'
|
FULL_PATH="/home/chaim/.paperclip/instances/default/data/storage/$CONTENT_PATH"
|
||||||
ORDER BY ia.created_at DESC LIMIT 5;"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- נתיב מלא לקובץ: `/home/chaim/.paperclip/instances/default/data/storage/{object_key}`
|
קבצי DOCX/PDF — קרא עם `Read` tool ב-`$FULL_PATH`.
|
||||||
- קבצי DOCX — קרא אותם עם `Read`
|
|
||||||
- השתמש בתוכן הקובץ כקלט לעבודתך
|
|
||||||
|
|
||||||
## 3. Checkout ועבודה
|
⚠️ **`psql` ישיר ל-`issue_attachments` — אסור.** ה-API הוא ה-source of truth (Gap #21).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §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
|
```bash
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" '{"body": "סיכום..."}'
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/checkout"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- עבוד על המשימה לפי ההוראות ב-AGENTS.md שלך
|
**ל-body ארוך עם markdown/backticks/נתיבים — חובה שתי פעולות נפרדות:**
|
||||||
- השתמש בכלים המשפטיים (legal-ai MCP)
|
|
||||||
|
|
||||||
### ⚠️ self-recovery — issue ב-`todo` עם תוצרים קיימים
|
1. כתוב את ה-JSON לקובץ זמני דרך **Write tool** (לא bash heredoc):
|
||||||
|
|
||||||
ל-Paperclip יש באג ידוע: לאחר ש-issue מתעדכן ל-`done`, מנגנון `issue.released` מחזיר אותו ל-`todo` תוך כ-30 שניות (תועד ב-`docs/paperclip-quirks.md §1`). זה גורם ל-wakeup חוזר של אותו סוכן על משימה שכבר בוצעה.
|
|
||||||
|
|
||||||
**לפני שאתה מתחיל עבודה — בדוק שהמשימה לא בוצעה כבר**:
|
|
||||||
|
|
||||||
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 קודמים על ה-issue** — אם הסוכן הקודם פרסם "הושלם בהצלחה" מסוף-מצב
|
|
||||||
|
|
||||||
**אם הכל קיים ותקין**: אל תבצע עבודה כפולה. במקום זאת:
|
|
||||||
- פרסם comment קצר: "אין שינוי — כל התוצרים קיימים מהריצה הקודמת (X פריטים ב-DB, קובץ Y בדיסק). סוגר את ה-issue."
|
|
||||||
- `PATCH /api/issues/{id}` → `done`
|
|
||||||
- צא נקי
|
|
||||||
|
|
||||||
**אם משהו חסר/שונה**: עבוד על מה שחסר בלבד, לא על הכל מחדש.
|
|
||||||
|
|
||||||
## 4. דיווח — חובה!
|
|
||||||
|
|
||||||
**לפני שאתה מסיים, תמיד:**
|
|
||||||
|
|
||||||
### 4א. פרסם comment על ה-issue
|
|
||||||
|
|
||||||
**ל-body קצר (<500 תווים, בלי backticks/קוד/נתיבים):**
|
|
||||||
```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": "סיכום העבודה..."}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**ל-body ארוך / markdown עם נתיבים בbacktick / קוד — חובה שתי פעולות נפרדות:**
|
|
||||||
|
|
||||||
1. כתוב את ה-JSON לקובץ זמני דרך **Write tool** (לא דרך bash heredoc):
|
|
||||||
```
|
```
|
||||||
Write(file_path="/tmp/comment-{issue-id}.json",
|
Write(file_path="/tmp/comment-{issue-id}.json",
|
||||||
content=json.dumps({"body": markdown_body}, ensure_ascii=False))
|
content=json.dumps({"body": markdown_body}, ensure_ascii=False))
|
||||||
```
|
```
|
||||||
|
|
||||||
2. אז `curl -d @file` שקורא את הקובץ ישירות — בלי shell expansion:
|
2. אז `pc.sh` עם `-d @file` שקורא את הקובץ ישירות:
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" "" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" -d @/tmp/comment-{issue-id}.json
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" \
|
|
||||||
-d @/tmp/comment-{issue-id}.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**⚠️ למה לא bash heredoc / `python3 -c`:** backticks ב-markdown (`` `path/to/file` ``) ייפרשו על ידי bash כ-command substitution גם כשהם בתוך מחרוזת Python. תקבל שגיאת `Permission denied` מטעה (`bash` מנסה להריץ את הנתיב כפקודה). הפתרון של temp-file חוסם את כל ה-shell quoting traps. תועד ב-`docs/paperclip-quirks.md §2`.
|
⚠️ **למה לא bash heredoc / `python3 -c`:** backticks ב-markdown (`` `path/to/file` ``) ייפרשו על-ידי bash כ-command substitution גם בתוך מחרוזת Python. תקבל `Permission denied` מטעה. תועד ב-`docs/paperclip-quirks.md §2`.
|
||||||
|
|
||||||
### 4ב. קבע סטטוס — done או blocked
|
### §4ב. סטטוס: `done` או `blocked` — לא ביניים
|
||||||
|
|
||||||
**אם המשימה הושלמה בהצלחה** (כל המסמכים חולצו, כל הבדיקות עברו, אין חסימות):
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}' # הצליח
|
||||||
-H "Content-Type: application/json" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}' # נכשל / חסום
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "done"}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**אם המשימה נכשלה או חסומה** (מסמך לא חולץ, timeout, חוסר מידע, שגיאה שלא ניתנת לפתרון):
|
**אסור** `done` עם כשל שלא טופל. אם משהו נכשל → `blocked` + comment עם פירוט.
|
||||||
```bash
|
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "blocked"}'
|
|
||||||
```
|
|
||||||
**אסור** לסיים issue כ-"done" אם יש כשל שלא טופל. "done" = הכל הושלם בהצלחה. אם משהו נכשל — "blocked".
|
|
||||||
|
|
||||||
### 4ג. העֵר את העוזר המשפטי (CEO) — חובה!
|
### §4ג. wake CEO לפי חברה
|
||||||
אחרי כל סיום משימה (done או blocked), **העֵר את העוזר המשפטי של החברה שלך** כדי שיבדוק תוצאות ויחליט על הצעד הבא:
|
|
||||||
|
|
||||||
**⚠️ בחר CEO לפי חברה:**
|
**⚠️ CEO שונה לכל חברה** (ראה §1). UUID hardcoded **אסור** — תמיד דרך `$PAPERCLIP_COMPANY_ID`:
|
||||||
| חברה | COMPANY_ID | CEO Agent ID |
|
|
||||||
|------|------------|-------------|
|
|
||||||
| רישוי ובניה (CMP) | `42a7acd0-...` | `752cebdd-6748-4a04-aacd-c7ab0294ef33` |
|
|
||||||
| היטלי השבחה (CMPA) | `8639e837-...` | `cdbfa8bc-3d61-41a4-a2e7-677ec7d34562` |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# קבע CEO_ID לפי חברה:
|
|
||||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
||||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562"
|
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA
|
||||||
else
|
else
|
||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33"
|
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
|
||||||
-H "Content-Type: application/json" \
|
'{"source":"automation","triggerDetail":"system","reason":"סוכן [שם] סיים [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
||||||
"$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"}}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**⚠️ כללי ברזל — Paperclip API:**
|
⚠️ **חובה `payload.issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי cwd).
|
||||||
1. **אסור** `INSERT INTO agent_wakeup_requests` — לא יוצר heartbeat_run, הסוכן לא יתעורר לעולם
|
⚠️ **wakeup לחברה אחרת נדחה** — `Agent key cannot access another company`.
|
||||||
2. **חובה** `payload.issueId` בכל wakeup — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי cwd)
|
⚠️ **אסור** `INSERT INTO agent_wakeup_requests` ישיר — לא יוצר heartbeat_run, הסוכן לא מתעורר.
|
||||||
3. **agent JWT לא יכול להעיר סוכנים אחרים** — רק את עצמו. כדי להעיר סוכן אחר → צור issue + הקצה אליו (Paperclip מפעיל wakeup אוטומטי)
|
|
||||||
|
|
||||||
**נתיבי 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
|
```bash
|
||||||
python3 /home/chaim/legal-ai/scripts/notify.py \
|
python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||||
@@ -199,22 +167,29 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
|||||||
"תוכן ההודעה עם סיכום מה נדרש"
|
"תוכן ההודעה עם סיכום מה נדרש"
|
||||||
```
|
```
|
||||||
|
|
||||||
**מתי לשלוח — תמיד:**
|
**מתי לשלוח (תמיד):** סיום כל משימה (סיכום קצר), בקשת תוצאה/כיוון, QA fail, החלטה מוכנה לדפנה, מצב שדורש פעולה אנושית, שגיאה לא פתירה.
|
||||||
- **סיום כל משימה** — עם סיכום קצר של מה בוצע
|
|
||||||
- בקשה לקביעת תוצאה (דחייה/קבלה/חלקית)
|
|
||||||
- בקשה לאישור כיוון נימוק
|
|
||||||
- דוח QA שנכשל (צריך החלטה על תיקונים)
|
|
||||||
- החלטה מוכנה לביקורת דפנה
|
|
||||||
- כל מצב שדורש פעולה אנושית ולא יכול להתקדם לבד
|
|
||||||
- שגיאה שלא ניתן לפתור ללא התערבות
|
|
||||||
|
|
||||||
**מתי לא לשלוח:**
|
**מתי לא:** עדכוני סטטוס ביניים, שגיאות טכניות שאפשר לפתור לבד.
|
||||||
- עדכוני סטטוס ביניים (רק בסיום)
|
|
||||||
- שגיאות טכניות שאפשר לפתור לבד
|
|
||||||
|
|
||||||
## 6. Release
|
---
|
||||||
|
|
||||||
|
## §6. Release
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/release"
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/release"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## נתיבי 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).
|
||||||
|
|||||||
146
.claude/agents/hermes-curator.md
Normal file
146
.claude/agents/hermes-curator.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
---
|
||||||
|
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>" }
|
||||||
|
```
|
||||||
|
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 — שם הקונטקסט שלי מצטבר
|
||||||
@@ -170,11 +170,75 @@ tools:
|
|||||||
- **לא להמציא פסיקה** — אם יש אזכור במסמכי התיק, ניתן להתייחס. אם לא — נסח ללא הפניה
|
- **לא להמציא פסיקה** — אם יש אזכור במסמכי התיק, ניתן להתייחס. אם לא — נסח ללא הפניה
|
||||||
- שימוש במונחים מקובלים בפסיקה הישראלית (מתאים לחיפוש ב-nevo/law-mate)
|
- שימוש במונחים מקובלים בפסיקה הישראלית (מתאים לחיפוש ב-nevo/law-mate)
|
||||||
|
|
||||||
## שלב 5: חיפוש פנימי בקורפוס
|
## שלב 5: חיפוש בשלושת הקורפוסים — חובה, עם תיעוד queries
|
||||||
חפש תקדימים רלוונטיים בקורפוס הפנימי:
|
|
||||||
- `search_decisions` — בהחלטות קודמות של דפנה
|
**חובה לבצע** — לא הצעה. בלי השלב הזה הניתוח חסר תקדימי-עליון רלוונטיים, וה-writer לא יוכל לכתוב CREAC מלא. נבחן ב-QA.
|
||||||
- `find_similar_cases` — תיקים דומים
|
|
||||||
הוסף תוצאות רלוונטיות תחת כל סוגיה כ-"תקדימים מהקורפוס הפנימי".
|
### 5א. חיפוש בקורפוס הסמכותי (`search_precedent_library`) — חובה
|
||||||
|
|
||||||
|
לכל **טענת סף** ולכל **סוגיה מרכזית** שזיהית — הרץ לפחות שאילתה אחת ל-`search_precedent_library` עם פילטרים:
|
||||||
|
|
||||||
|
| סיווג תיק | practice_area |
|
||||||
|
|------------|---------------|
|
||||||
|
| 1xxx (רישוי ובניה) | `rishuy_uvniya` |
|
||||||
|
| 8xxx (היטל השבחה) | `histael_hashbacha` |
|
||||||
|
| 9xxx (פיצויים ס' 197) | `pitsuim_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: בדיקת שלמות — לפני שמסיימים!
|
## שלב 6: בדיקת שלמות — לפני שמסיימים!
|
||||||
|
|
||||||
@@ -224,19 +288,11 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
|||||||
|
|
||||||
**אם הכל עבר בהצלחה (בדיקות שלב 6 + טענות + עובדות שמאי):**
|
**אם הכל עבר בהצלחה (בדיקות שלב 6 + טענות + עובדות שמאי):**
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "done"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**אם בדיקות שלב 6 נכשלו או חילוץ נכשל:**
|
**אם בדיקות שלב 6 נכשלו או חילוץ נכשל:**
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "blocked"}'
|
|
||||||
```
|
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם ניסיון חוזר נכשל, סטטוס = `blocked` + comment עם פירוט.
|
**אסור** לסיים `done` עם פלט חסר — אם ניסיון חוזר נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||||
|
|
||||||
5. **שלח מייל**:
|
5. **שלח מייל**:
|
||||||
@@ -255,11 +311,7 @@ else
|
|||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/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"}}'```
|
||||||
-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"}}'
|
|
||||||
```
|
|
||||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||||
|
|
||||||
@@ -337,8 +389,12 @@ X שאלות עומדות להכרעה:
|
|||||||
- סעיף X לחוק...
|
- סעיף X לחוק...
|
||||||
(הערה: התחל מלשון הטקסט הנורמטיבי. תקדים נדרש רק כשהטקסט עמום.)
|
(הערה: התחל מלשון הטקסט הנורמטיבי. תקדים נדרש רק כשהטקסט עמום.)
|
||||||
|
|
||||||
**תקדימים מהקורפוס הפנימי:**
|
**תקדימים מהקורפוס הסמכותי (search_precedent_library):**
|
||||||
- [אם נמצאו]
|
- [תקדים שנבחר עם citation, headnote, רלוונטיות]
|
||||||
|
- (חובה לפחות שאילתה אחת ב-Q1 בסעיף 7א — גם אם 0 תוצאות, יש לתעד שם)
|
||||||
|
|
||||||
|
**תקדימים מהקאנון של דפנה (search_decisions):**
|
||||||
|
- [אם נמצאו — חיסכון או הבחנה?]
|
||||||
|
|
||||||
**עמדת ועדת הערר:**
|
**עמדת ועדת הערר:**
|
||||||
[ימולא ע"י יו"ר הוועדה — עמדה/הנחיה לגבי סוגיה זו שתשמש את סוכן הכתיבה]
|
[ימולא ע"י יו"ר הוועדה — עמדה/הנחיה לגבי סוגיה זו שתשמש את סוכן הכתיבה]
|
||||||
@@ -362,6 +418,9 @@ X שאלות עומדות להכרעה:
|
|||||||
- **סדר דיון מומלץ**: הסדר המומלץ לדיון בסוגיות בהחלטה
|
- **סדר דיון מומלץ**: הסדר המומלץ לדיון בסוגיות בהחלטה
|
||||||
- **תלויות**: סוגיות שהכרעתן תלויה בהכרעה בסוגיה אחרת
|
- **תלויות**: סוגיות שהכרעתן תלויה בהכרעה בסוגיה אחרת
|
||||||
- **הערכה כללית**: לאן נוטה הניתוח ומהם הסיכויים הכלליים של הערר
|
- **הערכה כללית**: לאן נוטה הניתוח ומהם הסיכויים הכלליים של הערר
|
||||||
|
|
||||||
|
## 7א. שאילתות לקורפוסים — log מלא
|
||||||
|
[סעיף חובה לפי שלב 5ד — log כל קריאה ל-search_precedent_library, search_decisions, find_similar_cases. גם 0 results.]
|
||||||
```
|
```
|
||||||
|
|
||||||
## שלב 8: העמקת ניתוח (pass 2) — אחרי אישור כיוון
|
## שלב 8: העמקת ניתוח (pass 2) — אחרי אישור כיוון
|
||||||
@@ -373,10 +432,14 @@ X שאלות עומדות להכרעה:
|
|||||||
### 8א. אימות פסיקה
|
### 8א. אימות פסיקה
|
||||||
סרוק את עמדות היו"ר וזהה כל אזכור פסיקה (בג"ץ, עע"מ, עת"מ, ע"א, ערר וכו').
|
סרוק את עמדות היו"ר וזהה כל אזכור פסיקה (בג"ץ, עע"מ, עת"מ, ע"א, ערר וכו').
|
||||||
לכל פסק דין שמוזכר:
|
לכל פסק דין שמוזכר:
|
||||||
1. חפש בקורפוס הפנימי (`search_decisions`, `find_similar_cases`)
|
1. חפש ב**קורפוס הסמכותי** (`search_precedent_library`) — חובה ראשונה. שם נמצאות הלכות מאושרות עם supporting_quote מוכן לציטוט.
|
||||||
2. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות
|
2. חפש בקאנון דפנה (`search_decisions`, `find_similar_cases`)
|
||||||
3. **אם נמצא** — חלץ ציטוט מדויק, הקשר, רלוונטיות
|
3. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות
|
||||||
4. **אם לא נמצא** — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש
|
4. **אם נמצא ב-precedent_library** — צטט citation+supporting_quote מדויקים מהקורפוס.
|
||||||
|
5. **אם נמצא רק במסמכי התיק** — סמן: "מקור: כתבי טענות, דורש אימות מול הקורפוס".
|
||||||
|
6. **אם לא נמצא בכלל** — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש.
|
||||||
|
|
||||||
|
הוסף לסעיף "7א. שאילתות לקורפוסים" כל query נוסף שהורצה ב-pass 2.
|
||||||
|
|
||||||
הוסף לכל סוגיה תת-סעיף:
|
הוסף לכל סוגיה תת-סעיף:
|
||||||
|
|
||||||
@@ -419,11 +482,7 @@ X שאלות עומדות להכרעה:
|
|||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"מנתח משפטי סיים העמקת ניתוח (pass 2) [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/agents/$CEO_ID/wakeup" \
|
|
||||||
-d '{"source":"automation","triggerDetail":"system","reason":"מנתח משפטי סיים העמקת ניתוח (pass 2) [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
|
||||||
```
|
|
||||||
**⚠️ אם ה-API מחזיר שגיאה — אל תיגע ב-DB.** `INSERT INTO agent_wakeup_requests` לא יוצר `heartbeat_run` והסוכן לא יתעורר לעולם. בדוק `$PAPERCLIP_COMPANY_ID` ו-`$PAPERCLIP_API_KEY`, ודאי שאתה לא קורא ל-CEO של חברה אחרת (`Agent key cannot access another company`).
|
**⚠️ אם ה-API מחזיר שגיאה — אל תיגע ב-DB.** `INSERT INTO agent_wakeup_requests` לא יוצר `heartbeat_run` והסוכן לא יתעורר לעולם. בדוק `$PAPERCLIP_COMPANY_ID` ו-`$PAPERCLIP_API_KEY`, ודאי שאתה לא קורא ל-CEO של חברה אחרת (`Agent key cannot access another company`).
|
||||||
|
|
||||||
## כללים קריטיים
|
## כללים קריטיים
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ tools:
|
|||||||
- mcp__legal-ai__precedent_library_list
|
- mcp__legal-ai__precedent_library_list
|
||||||
- mcp__legal-ai__halacha_review
|
- mcp__legal-ai__halacha_review
|
||||||
- mcp__legal-ai__halachot_pending
|
- mcp__legal-ai__halachot_pending
|
||||||
|
- mcp__legal-ai__extract_appraiser_facts
|
||||||
|
- mcp__legal-ai__write_interim_draft
|
||||||
|
- mcp__legal-ai__export_interim_draft
|
||||||
---
|
---
|
||||||
|
|
||||||
# עוזר משפטי — מנהל תהליך כתיבת החלטות
|
# עוזר משפטי — מנהל תהליך כתיבת החלטות
|
||||||
@@ -84,6 +87,7 @@ tools:
|
|||||||
| כותב החלטה | 7ed8686f-24bc-49a3-bc02-67ca15b895a9 | כתיבת בלוקים ה-יב (Opus) |
|
| כותב החלטה | 7ed8686f-24bc-49a3-bc02-67ca15b895a9 | כתיבת בלוקים ה-יב (Opus) |
|
||||||
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
||||||
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
||||||
|
| מנהל ידע (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 חדש = תת-משימה
|
## כלל: כל issue חדש = תת-משימה
|
||||||
|
|
||||||
@@ -92,10 +96,7 @@ tools:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# שלב 1: יצירת issue
|
# שלב 1: יצירת issue
|
||||||
ISSUE_ID=$(curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
ISSUE_ID=$(~/legal-ai/scripts/pc.sh POST "/api/companies/$PAPERCLIP_COMPANY_ID/issues" '{"title": "[ערר CASE_NUMBER] ....", "description": "...", "parentId": "'$PAPERCLIP_TASK_ID'", "assigneeAgentId": "..."}' \
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
|
|
||||||
-d '{"title": "[ערר CASE_NUMBER] ....", "description": "...", "parentId": "'$PAPERCLIP_TASK_ID'", "assigneeAgentId": "..."}' \
|
|
||||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||||
|
|
||||||
# שלב 2 (חובה!): קישור ל-case number בעוזר המשפטי
|
# שלב 2 (חובה!): קישור ל-case number בעוזר המשפטי
|
||||||
@@ -223,7 +224,9 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
|
|
||||||
**מתי:** כשיש טענות מחולצות + מחקר תקדימים, אבל אין תוצאה עדיין
|
**מתי:** כשיש טענות מחולצות + מחקר תקדימים, אבל אין תוצאה עדיין
|
||||||
|
|
||||||
פרסם comment ב-Paperclip:
|
**שיטה — dual dispatch:** קודם פרסם comment עם הסיכום המלא (לתיעוד), ואז צור interaction עם כפתורים (לחיים).
|
||||||
|
|
||||||
|
#### B.1 פרסם comment עם הסיכום
|
||||||
|
|
||||||
```
|
```
|
||||||
## סיכום תיק {case_number} — מוכן להחלטה
|
## סיכום תיק {case_number} — מוכן להחלטה
|
||||||
@@ -259,135 +262,151 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
- כלל: ...
|
- כלל: ...
|
||||||
- עובדות: ...
|
- עובדות: ...
|
||||||
- שאלה: ...
|
- שאלה: ...
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**מה התוצאה הצפויה?**
|
|
||||||
1. 🔴 **דחייה** — הערר נדחה
|
|
||||||
2. 🟡 **קבלה חלקית** — מתקבל עם תנאים
|
|
||||||
3. 🟢 **קבלה מלאה** — הערר מתקבל
|
|
||||||
|
|
||||||
@chaim — הגב עם מספר (1/2/3) + הערות אם יש
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**אחרי פרסום ה-comment:** עדכן את ה-issue הראשי ל-`status=in_review` (ראה "כלל קריטי: ניהול סטטוס issue" בראש הסעיף).
|
#### B.2 צור interaction לבחירת תוצאה + טיפול בטענות
|
||||||
|
|
||||||
לאחר שחיים בחר תוצאה, שאל אותו לסמן טיפול בכל טענה:
|
```bash
|
||||||
|
~/legal-ai/scripts/pc.sh POST "/api/issues/$PAPERCLIP_TASK_ID/interactions" '{
|
||||||
```
|
"kind": "ask_user_questions",
|
||||||
## טיפול בטענות — {case_number}
|
"idempotencyKey": "outcome:'"$PAPERCLIP_TASK_ID"':v1",
|
||||||
|
"title": "תוצאה וטיפול בטענות — {case_number}",
|
||||||
סמן לכל טענה את סוג הטיפול:
|
"summary": "ראה את הסיכום ב-comment לעיל. שתי שאלות מובנות.",
|
||||||
|
"continuationPolicy": "wake_assignee",
|
||||||
| # | טענה | טיפול |
|
"payload": {
|
||||||
|---|------|-------|
|
"version": 1,
|
||||||
| 1 | {טענה 1} | דיון מלא / קיבוץ / דילוג |
|
"submitLabel": "המשך לכיוונים",
|
||||||
| 2 | {טענה 2} | דיון מלא / קיבוץ / דילוג |
|
"questions": [
|
||||||
| 3 | {טענה 3} | דיון מלא / קיבוץ / דילוג |
|
{
|
||||||
| ... | ... | ... |
|
"id": "outcome",
|
||||||
|
"prompt": "מה התוצאה?",
|
||||||
**הסבר:**
|
"selectionMode": "single",
|
||||||
- **דיון מלא** — ניתוח סילוגיסטי מלא (כלל → עובדות → מסקנה)
|
"required": true,
|
||||||
- **קיבוץ** — טענות שמכוונות לאותה נקודה ייאגדו יחד
|
"options": [
|
||||||
- **דילוג** — "לא מצאנו ממש" או "אין צורך להכריע נוכח מסקנתנו"
|
{"id":"reject", "label":"דחייה", "description":"הערר נדחה"},
|
||||||
|
{"id":"partial","label":"קבלה חלקית","description":"מתקבל עם תנאים"},
|
||||||
@chaim — סמן בטבלה והחזר
|
{"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: קליטת תוצאה וכיוונים סילוגיסטיים
|
### שלב C: קליטת תוצאה וכיוונים סילוגיסטיים
|
||||||
|
|
||||||
**מתי:** חיים הגיב עם מספר תוצאה + טיפול בטענות
|
**מתי:** התעוררת עם `$PAPERCLIP_APPROVAL_ID` שמצביע על interaction מ-§B (תשובת תוצאה+טענות).
|
||||||
|
|
||||||
0. **החזר את ה-issue הראשי ל-`status=in_progress`** (קיבלת קלט והמשכת לעבוד).
|
0. **החזר את ה-issue הראשי ל-`status=in_progress`** (קיבלת קלט והמשכת לעבוד).
|
||||||
1. קרא את ה-comment של חיים
|
1. **קרא את תשובת חיים מה-API** (לא מ-comment חופשי):
|
||||||
2. זהה את הבחירה (1=rejected, 2=partial, 3=accepted)
|
```bash
|
||||||
3. הרץ `set_outcome(case_number, outcome, reasoning)`
|
~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID/interactions/$PAPERCLIP_APPROVAL_ID" \
|
||||||
4. **חשוב סילוגיסטית** על 2-3 כיוונים לנימוק — אתה כבר Claude, אתה יודע את הטענות והתקדימים. בנה כל כיוון כסילוגיזם מלא.
|
| 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 ולוקח יותר מדי זמן.
|
> **הערה טכנית:** אל תקרא ל-`brainstorm_directions` — זה מפעיל Claude בתוך Claude ולוקח יותר מדי זמן.
|
||||||
|
|
||||||
5. פרסם comment עם **סדר סוגיות מוצע**:
|
4. פרסם comment קצר עם **סדר סוגיות מוצע** (לתיעוד thread):
|
||||||
|
|
||||||
```
|
```
|
||||||
## כיוונים אפשריים לנימוק — {outcome_hebrew}
|
## כיוונים לנימוק — {outcome_hebrew}
|
||||||
|
|
||||||
### סדר הסוגיות המוצע
|
### סדר הסוגיות המוצע
|
||||||
1. {שאלת סף — אם רלוונטית}
|
1. {שאלת סף — אם רלוונטית}
|
||||||
2. {הסוגיה המכריעה}
|
2. {הסוגיה המכריעה}
|
||||||
3. {סוגיות נוספות לפי חוזק}
|
3. {סוגיות נוספות לפי חוזק}
|
||||||
|
|
||||||
---
|
(הכיוונים המלאים — בinteraction למטה)
|
||||||
|
|
||||||
### כיוון 1: {title}
|
|
||||||
|
|
||||||
**כלל (הנחה עליונה):**
|
|
||||||
{הוראת תכנית / סעיף חוק / הלכה פסוקה}
|
|
||||||
|
|
||||||
**עובדות (הנחה תחתונה):**
|
|
||||||
{העובדות הספציפיות של הערר שנבחנות לאור הכלל}
|
|
||||||
|
|
||||||
**מסקנה:**
|
|
||||||
{התוצאה שנובעת מהחלת הכלל על העובדות}
|
|
||||||
|
|
||||||
**תקדימים תומכים:** {precedents}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### כיוון 2: {title}
|
|
||||||
|
|
||||||
**כלל (הנחה עליונה):**
|
|
||||||
{...}
|
|
||||||
|
|
||||||
**עובדות (הנחה תחתונה):**
|
|
||||||
{...}
|
|
||||||
|
|
||||||
**מסקנה:**
|
|
||||||
{...}
|
|
||||||
|
|
||||||
**תקדימים תומכים:** {precedents}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### כיוון 3: {title}
|
|
||||||
|
|
||||||
**כלל (הנחה עליונה):**
|
|
||||||
{...}
|
|
||||||
|
|
||||||
**עובדות (הנחה תחתונה):**
|
|
||||||
{...}
|
|
||||||
|
|
||||||
**מסקנה:**
|
|
||||||
{...}
|
|
||||||
|
|
||||||
**תקדימים תומכים:** {precedents}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@chaim — איזה כיוון מועדף? (1/2/3)
|
|
||||||
אפשר גם לשלב כיוונים או להוסיף הערות.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**אחרי פרסום ה-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: אישור כיוון והפעלת כתיבה
|
### שלב D: אישור כיוון והפעלת כתיבה
|
||||||
|
|
||||||
**מתי:** חיים הגיב עם בחירת כיוון
|
**מתי:** התעוררת עם `$PAPERCLIP_APPROVAL_ID` שמצביע על interaction מ-§C (תשובת כיוון).
|
||||||
|
|
||||||
0. **החזר את ה-issue הראשי ל-`status=in_progress`** (קיבלת קלט והמשכת לעבוד).
|
0. **החזר את ה-issue הראשי ל-`status=in_progress`** (קיבלת קלט והמשכת לעבוד).
|
||||||
1. קרא את ה-comment של חיים
|
1. **קרא את תשובת חיים מה-API:**
|
||||||
2. זהה כיוון (1/2/3) + הערות נוספות
|
```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** — לפני שליחה לכותב, ודא:
|
3. **אימות שלמות chair_directions** — לפני שליחה לכותב, ודא:
|
||||||
- [ ] טיפול בטענות (דיון מלא / קיבוץ / דילוג) מוגדר לכל טענה
|
- [ ] טיפול בטענות (דיון מלא / קיבוץ / דילוג) מוגדר לכל טענה (מ-§B)
|
||||||
- [ ] כיוון סילוגיסטי נבחר ומאושר
|
- [ ] כיוון סילוגיסטי נבחר ומאושר (מ-§C — interaction status=`answered`)
|
||||||
- [ ] סדר סוגיות מוגדר
|
- [ ] סדר סוגיות מוגדר
|
||||||
- [ ] תקן ביקורת מצוין
|
- [ ] תקן ביקורת מצוין
|
||||||
- אם חסר פריט כלשהו — **שאל את חיים** לפני שממשיכים
|
- אם חסר פריט כלשהו — צור interaction חדש (`request_confirmation` או `ask_user_questions`) **לפני** שממשיכים. אסור לקרוא לחיים בcomment חופשי.
|
||||||
4. הרץ `approve_direction(case_number, direction_index, additional_notes)`
|
4. הרץ `approve_direction(case_number, direction_index, additional_notes)`
|
||||||
5. עדכן סטטוס: `case_update(status=direction_approved)`
|
5. עדכן סטטוס: `case_update(status=direction_approved)`
|
||||||
6. צור issue חדש ב-Paperclip:
|
6. צור issue חדש ב-Paperclip:
|
||||||
@@ -396,7 +415,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
- תיאור: "כיוון אושר. בצע pass 2: אמת פסיקה מעמדות היו"ר, העמק עובדות לאור הכיוון שנבחר."
|
- תיאור: "כיוון אושר. בצע pass 2: אמת פסיקה מעמדות היו"ר, העמק עובדות לאור הכיוון שנבחר."
|
||||||
7. פרסם comment: "כיוון אושר. הועבר למנתח להעמקת ניתוח לפני כתיבה."
|
7. פרסם comment: "כיוון אושר. הועבר למנתח להעמקת ניתוח לפני כתיבה."
|
||||||
|
|
||||||
**מתי לחזור אחורה:** אם חיים שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב B או C בהתאם.
|
**מתי לחזור אחורה:** אם חיים דחה את ה-interaction (`status=rejected`) או שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב B או C בהתאם וצור interaction חדש עם `idempotencyKey` מעודכן (לדוגמה `:v2`).
|
||||||
|
|
||||||
### שלב D2: אחרי העמקת ניתוח (pass 2)
|
### שלב D2: אחרי העמקת ניתוח (pass 2)
|
||||||
|
|
||||||
@@ -474,6 +493,72 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
- השתמש ב-`revise_draft` בלבד במצב ג'.
|
- השתמש ב-`revise_draft` בלבד במצב ג'.
|
||||||
- אם המשתמש ביקש שינוי מאסיבי (שכתוב מלא של בלוק) — עדיף להציע לו לעבוד על זה בעריכה נוספת מצדו ולא לייצר revisions ארוכים.
|
- אם המשתמש ביקש שינוי מאסיבי (שכתוב מלא של בלוק) — עדיף להציע לו לעבוד על זה בעריכה נוספת מצדו ולא לייצר 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`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
|
**סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
|
||||||
@@ -592,22 +677,18 @@ case_prefix="${case_number:0:1}"
|
|||||||
|
|
||||||
0. **החזר את ה-issue הראשי ל-`status=in_progress`** — אם ה-issue ב-`in_review` (כי המתנת לחיים) או ב-`blocked` (כי Paperclip חסם אוטומטית), הראשון דבר: עדכן ל-`in_progress` כדי לסמן שאתה עובד עליו.
|
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
|
```bash
|
||||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
CONTEXT=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/heartbeat-context")
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **בדוק attachments** — אם חיים ציין קובץ שהועלה:
|
2. **בדוק attachments** — אם חיים ציין קובץ שהועלה, הוא כבר ב-`$CONTEXT.attachments`:
|
||||||
```bash
|
```bash
|
||||||
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
|
echo "$CONTEXT" | jq '.attachments[] | {filename, contentPath, contentType, byteSize}'
|
||||||
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;"
|
|
||||||
```
|
```
|
||||||
נתיב מלא לקובץ: `/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. **אם יש טיוטה/קובץ — קרא אותו מילה במילה.** חפש בתוכו:
|
3. **אם יש טיוטה/קובץ — קרא אותו מילה במילה.** חפש בתוכו:
|
||||||
- הוראות עריכה (טקסט כמו "צריך לערוך", "להוסיף", "חסר", "הוראות כתיבה")
|
- הוראות עריכה (טקסט כמו "צריך לערוך", "להוסיף", "חסר", "הוראות כתיבה")
|
||||||
@@ -658,34 +739,35 @@ case_prefix="${case_number:0:1}"
|
|||||||
## נתיבי API — חובה!
|
## נתיבי API — חובה!
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# קרא comments על issue
|
# קרא comments על issue (אבל בד"כ עדיף heartbeat-context — ראה HEARTBEAT.md §1.7)
|
||||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh GET "/api/issues/{issue-id}/comments" | jq '.[-1].body'
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '.[-1].body'
|
|
||||||
|
|
||||||
# פרסם comment
|
# פרסם comment
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" '{"body": "..."}'
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" \
|
|
||||||
-d '{"body": "..."}'
|
|
||||||
|
|
||||||
# צור issue חדש (עם הקצאה לסוכן → מפעיל wakeup אוטומטי!)
|
# צור issue חדש (עם הקצאה לסוכן → מפעיל wakeup אוטומטי!)
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh POST "/api/companies/42a7acd0-30c5-4cbd-ac97-7424f65df294/issues" \
|
||||||
-H "Content-Type: application/json" \
|
'{"title":"...","projectId":"25c1b4a1-2c0e-4a2d-9938-8ae56ccda6f1","assigneeAgentId":"{agent-id}","description":"...","status":"todo"}'
|
||||||
"$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"}'
|
|
||||||
|
|
||||||
# עדכן issue
|
# עדכן issue
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
# צור interaction מובנה לחיים (ראה §B/§C למעלה למבנה payload)
|
||||||
-d '{"status": "done"}'
|
~/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).
|
**⚠️ agent JWT לא יכול להעיר סוכנים אחרים ישירות.** כדי להעיר סוכן → **צור issue חדש + הקצה אליו** (Paperclip מפעיל wakeup אוטומטי על assignment).
|
||||||
|
|
||||||
חפש ב-comment של חיים:
|
## מתי להשתמש בinteraction לעומת comment
|
||||||
- מספר (1/2/3) → בחירה
|
|
||||||
- "כיוון" + מספר → אישור כיוון
|
| מצב | פתרון |
|
||||||
- טבלת טיפול בטענות → סימון claim_handling
|
|------|--------|
|
||||||
- שאלה → ענה
|
| נדרשת בחירה מובנית מחיים (תוצאה, כיוון, אישור) | **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.
|
||||||
|
|||||||
@@ -122,19 +122,11 @@ tools:
|
|||||||
|
|
||||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "done"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "blocked"}'
|
|
||||||
```
|
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||||
@@ -146,11 +138,7 @@ else
|
|||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/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"}}'```
|
||||||
-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"}}'
|
|
||||||
```
|
|
||||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||||
|
|
||||||
|
|||||||
@@ -92,19 +92,11 @@ tools:
|
|||||||
|
|
||||||
**אם הכל עבר בהצלחה:**
|
**אם הכל עבר בהצלחה:**
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "done"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**אם נכשלו תיקונים קריטיים או יש markers `[?]` רבים:**
|
**אם נכשלו תיקונים קריטיים או יש markers `[?]` רבים:**
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "blocked"}'
|
|
||||||
```
|
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם נכשל, סטטוס = `blocked` + comment עם פירוט.
|
**אסור** לסיים `done` עם פלט חסר — אם נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||||
@@ -117,10 +109,6 @@ else
|
|||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/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"}}'```
|
||||||
-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"}}'
|
|
||||||
```
|
|
||||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||||
|
|||||||
@@ -79,6 +79,29 @@ tools:
|
|||||||
- סעיפים 1, 2, 3... ללא איפוס בין בלוקים
|
- סעיפים 1, 2, 3... ללא איפוס בין בלוקים
|
||||||
- ללא כפילויות במספור
|
- ללא כפילויות במספור
|
||||||
|
|
||||||
|
### 7א. שלמות חיפוש בקורפוסים (corpus_queries_logged) — critical
|
||||||
|
|
||||||
|
ה-analyst וה-researcher חייבים לתעד queries לקורפוסים שלהם. בלי תיעוד — אין דרך לוודא שתקדימי עליון רלוונטיים לא הוחמצו.
|
||||||
|
|
||||||
|
בדוק:
|
||||||
|
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)
|
### 7. עמידה במתודולוגיה (methodology_compliance)
|
||||||
ראה `docs/decision-methodology.md` לעקרונות המלאים. בדוק:
|
ראה `docs/decision-methodology.md` לעקרונות המלאים. בדוק:
|
||||||
- לכל סוגיה בבלוק י — ניתן לזהות מבנה סילוגיסטי: כלל + עובדות + מסקנה?
|
- לכל סוגיה בבלוק י — ניתן לזהות מבנה סילוגיסטי: כלל + עובדות + מסקנה?
|
||||||
@@ -137,6 +160,7 @@ tools:
|
|||||||
| משקלות | warning | מדווח, לא חוסם |
|
| משקלות | warning | מדווח, לא חוסם |
|
||||||
| כפילות | warning | מדווח, לא חוסם |
|
| כפילות | warning | מדווח, לא חוסם |
|
||||||
| מספור | warning | מדווח, לא חוסם |
|
| מספור | warning | מדווח, לא חוסם |
|
||||||
|
| **שאילתות לקורפוסים** | **critical** | **חוסם ייצוא** |
|
||||||
| מתודולוגיה | critical | חוסם ייצוא |
|
| מתודולוגיה | critical | חוסם ייצוא |
|
||||||
| **קול דפנה** | **critical** | **חוסם ייצוא** |
|
| **קול דפנה** | **critical** | **חוסם ייצוא** |
|
||||||
|
|
||||||
@@ -173,19 +197,11 @@ tools:
|
|||||||
|
|
||||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "done"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "blocked"}'
|
|
||||||
```
|
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||||
@@ -197,10 +213,6 @@ else
|
|||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/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"}}'```
|
||||||
-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"}}'
|
|
||||||
```
|
|
||||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||||
|
|||||||
@@ -85,22 +85,77 @@ tools:
|
|||||||
- **האם זה תקדם מהקאנון של דפנה?** (בדוק `docs/daphna-precedent-network.md` — אם כן, ציין שזה התקדם המועדף שלה לסוגיה)
|
- **האם זה תקדם מהקאנון של דפנה?** (בדוק `docs/daphna-precedent-network.md` — אם כן, ציין שזה התקדם המועדף שלה לסוגיה)
|
||||||
4. הפק הפניות (`extract_references`)
|
4. הפק הפניות (`extract_references`)
|
||||||
|
|
||||||
### שלב 2ב: בדיקה מצטלבת מול הקאנון של דפנה
|
### שלב 2ב: חיפוש מובנה בשלושת הקורפוסים — חובה, עם תיעוד queries
|
||||||
אחרי שאספת את הפסיקה הרלוונטית בתיק:
|
|
||||||
1. **לכל סוגיה משפטית** בתיק — בדוק ב-`daphna-precedent-network.md`:
|
|
||||||
- האם יש תקדם מועדף של דפנה לסוגיה?
|
|
||||||
- האם הוא הוצג בכתבי הטענות? אם לא — סמן כתקדם שיש להוסיף
|
|
||||||
2. **תקדמים אישיים**: `search_decisions` בקטגוריה זהה לתיק. אם דפנה כבר הכריעה בסוגיה דומה:
|
|
||||||
- אם תוצאה דומה: תקדם לחיסכון דוקטרינרי ("כפי שקבענו ב-X")
|
|
||||||
- אם תוצאה הפוכה: ציין כי **חובה** הבחנה (distinguishing)
|
|
||||||
3. **קורפוס פסיקה סמכותית**: `search_precedent_library` — חיפוש סמנטי בהלכות שאושרו ע"י דפנה (פסיקת עליון/מנהלי/ועדות ערר אחרות). מחזיר rule_statement + supporting_quote + citation מוכנים לציטוט בבלוק י. אם הצדדים הפנו לפסק דין שלא בקורפוס — הוסף אותו דרך `precedent_attach` (לתיק) או דרך ממשק ההעלאה ב-`/precedents` (לקורפוס הקבוע).
|
|
||||||
4. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
|
||||||
|
|
||||||
**שלושת המקורות — אל תבלבל:**
|
**חובה לבצע** — לא הצעה. הניתוח קודם הראה (ערר 1200-25) שאם הקורפוס לא נסרק במפורש, מפספסים תקדימי עליון רלוונטיים שיושבים בו. ה-QA יחזיר `needs_revision` אם סעיף ה-queries חסר.
|
||||||
- `search_decisions` = החלטות דפנה (style_corpus).
|
|
||||||
- `search_precedent_library` = פסיקה חיצונית סמכותית עם הלכות מאושרות.
|
**שלושת הקורפוסים — אל תבלבל:**
|
||||||
|
- `search_precedent_library` = פסיקה חיצונית סמכותית עם הלכות מאושרות (עליון/מנהלי/ועדות ערר אחרות) + supporting_quote מוכן.
|
||||||
|
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
||||||
- `precedent_search_library` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
- `precedent_search_library` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
||||||
|
|
||||||
|
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
||||||
|
|
||||||
|
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
|
||||||
|
|
||||||
|
| סיווג תיק | practice_area |
|
||||||
|
|------------|---------------|
|
||||||
|
| 1xxx (רישוי ובניה) | `rishuy_uvniya` |
|
||||||
|
| 8xxx (היטל השבחה) | `histael_hashbacha` |
|
||||||
|
| 9xxx (פיצויים ס' 197) | `pitsuim_197` |
|
||||||
|
|
||||||
|
אם הסוגיה ב-`appeal_subtype` ידוע (כמו "שימוש חורג", "סטייה ניכרת") — הוסף `appeal_subtype` לפילטר.
|
||||||
|
|
||||||
|
```
|
||||||
|
search_precedent_library(
|
||||||
|
query="...",
|
||||||
|
practice_area="rishuy_uvniya",
|
||||||
|
appeal_subtype="שימוש חורג",
|
||||||
|
limit=10
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2ב.2 — קאנון דפנה (`search_decisions`)
|
||||||
|
|
||||||
|
לכל סוגיה — בדוק אם דפנה כבר הכריעה:
|
||||||
|
- אם תוצאה דומה: תקדם לחיסכון דוקטרינרי ("כפי שקבענו ב-X")
|
||||||
|
- אם תוצאה הפוכה: ציין כי **חובה** הבחנה (distinguishing)
|
||||||
|
|
||||||
|
#### 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 לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו.
|
||||||
|
|
||||||
|
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||||
|
|
||||||
### שלב 3: מיפוי תכנית
|
### שלב 3: מיפוי תכנית
|
||||||
1. קרא הוראות התכנית **במלואן** — לא רק את הסעיף הנטען
|
1. קרא הוראות התכנית **במלואן** — לא רק את הסעיף הנטען
|
||||||
2. זהה סעיפים רלוונטיים למחלוקת
|
2. זהה סעיפים רלוונטיים למחלוקת
|
||||||
@@ -158,19 +213,11 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
|||||||
|
|
||||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "done"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "blocked"}'
|
|
||||||
```
|
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||||
@@ -182,11 +229,7 @@ else
|
|||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/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"}}'```
|
||||||
-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"}}'
|
|
||||||
```
|
|
||||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||||
|
|
||||||
|
|||||||
@@ -210,19 +210,11 @@ case_update(case_number, status="drafted")
|
|||||||
|
|
||||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "done"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
|
||||||
-d '{"status": "blocked"}'
|
|
||||||
```
|
|
||||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||||
|
|
||||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||||
@@ -234,11 +226,7 @@ else
|
|||||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
~/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"}}'```
|
||||||
-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"}}'
|
|
||||||
```
|
|
||||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,7 +3,10 @@ data/cases/
|
|||||||
data/training/
|
data/training/
|
||||||
data/exports/
|
data/exports/
|
||||||
data/backups/
|
data/backups/
|
||||||
|
data/precedent-library/
|
||||||
data/.auto-sync.log
|
data/.auto-sync.log
|
||||||
|
data/*.db
|
||||||
|
*.bak-pre-*
|
||||||
mcp-server/.venv/
|
mcp-server/.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"migrationNoticeShown": true
|
"migrationNoticeShown": true,
|
||||||
|
"currentTag": "legal-ai",
|
||||||
|
"lastSwitched": "2026-05-03T20:31:48.957Z",
|
||||||
|
"branchTagMapping": {}
|
||||||
}
|
}
|
||||||
@@ -1155,13 +1155,229 @@
|
|||||||
],
|
],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "14",
|
||||||
|
"title": "Upgrade: speed up halacha+metadata extraction",
|
||||||
|
"description": "Halacha extraction on long rulings is slow (5-15 min for typical court ruling, 30-50 min for a 207-chunk appeals committee decision). Root cause: each chunk spawns a separate `claude -p` subprocess (5-10 sec startup overhead each), Hebrew prompts on cold cache run 30-90 sec, and there's no prompt-cache sharing between chunks. Acceleration options to evaluate later when speed becomes a real blocker.\n\nOptions (each can be combined):\n\n1. Concurrency 3 -> 6 in halacha_extractor.CHUNK_CONCURRENCY. ~2x faster wall-clock. Cost: 6x ~300MB RSS = 1.8GB peak — verify on Nautilus headroom.\n\n2. Larger chunks 12K -> 18-25K chars (CHUNK_TARGET_CHARS in claims_extractor.py / halacha_extractor.py). Fewer waves. Risk: timeout on cold cache (currently 1800s ceiling), and may degrade extraction precision for very long sections.\n\n3. Anthropic SDK direct with 5-min ephemeral prompt caching on the static instruction prefix (already wired the parameter as system= in claude_session.query). Estimated 5-10x faster because cache reads are ~10% of cold cost. Costs ~$0.30-2 per long ruling on Sonnet 4.6. Chair previously rejected this path for ALL traffic ('we work only with claude session'). Compromise: SDK only for the precedent-library corpus build (static, one-time), claude session for live decision drafting (interactive, frequent).\n\n4. Two-tier prompt: a short 'classification' pass with claude -p deciding which chunks contain halachot, then deep extraction only on positive chunks. Could cut total LLM time by 40-60% on rulings with lots of factual chapters.\n\n5. Already implemented (Apr 3, 2026): skip non-extractable sections — only run on chunks where section_type IN (legal_analysis, ruling, conclusion); fallback to all chunks when chunker labels nothing. So that win is already banked.\n\nRe-evaluate when: a chair drops a 200K+ char ruling into the queue and the wait becomes painful, OR when the precedent-library has 50+ pending entries and bulk processing matters.",
|
||||||
|
"details": "",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "deferred",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "low",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-03T16:03:07.222Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "15",
|
||||||
|
"title": "Backfill multimodal — החלטה על rollout מורחב לאחר A/B עם דפנה",
|
||||||
|
"description": "תזכורת לבדוק עם דפנה אם voyage-multimodal-3 על 8174-24 + 8137-24 עוזר בפועל, ולהחליט אם להריץ backfill על שאר הקורפוס (~236 docs, ~17,700 pages, ~2 שעות זמן API, ~350MB disk).",
|
||||||
|
"details": "תאריך יעד מומלץ: ~2026-05-10 (שבוע מהיום, 2026-05-03).\n\nקריטריונים להחלטה (אם מתקיים אחד — להריץ rollout):\n • דפנה זיהתה לפחות פעמיים ערך מוסף ב-8174-24 או 8137-24 (תקדים שלא הייתה מוצאת בלי image side, או חתימה/טבלה/תרשים שצף ב-top results)\n • היא ביקשה במפורש להפעיל על תיק נוסף ספציפי\n • היא מבקשת לעבור ל-search מצטלב (search_decisions, find_similar_cases) מעבר לתיק הנוכחי\n\nאם דפנה לא ראתה ערך — להחליט: לבטל / לכוונן MULTIMODAL_TEXT_WEIGHT (0.5 → 0.55-0.65) / לחכות עוד שבוע.\n\nאם החליטו להריץ — סדר עדיפויות:\n 1. שמאי-heavy: 8xxx (היטל השבחה) ו-9xxx (פיצויים) — שם הערך הגדול ביותר\n 2. תיקי 1xxx (רישוי ובניה) אחרון\n\nהרצה:\n CONTAINER=$(sudo docker ps --format '{{.Names}}' | grep gyjo | head -1)\n sudo docker cp scripts/multimodal_backfill.py $CONTAINER:/tmp/\n sudo docker cp scripts/backfill_chunk_pages.py $CONTAINER:/tmp/\n sudo docker exec $CONTAINER python /tmp/multimodal_backfill.py 8xxx-yy 9xxx-yy ...\n sudo docker exec $CONTAINER python /tmp/backfill_chunk_pages.py 8xxx-yy 9xxx-yy ...\n\nרפרנסים:\n • docs/voyage-upgrades-plan.md סעיף 'שלב C — voyage-multimodal-3 (✅ בוצע)'\n • commits 242f668..d12cdb1 על main\n • זיכרון: project_multimodal_stage_c.md, feedback_hybrid_retrieval_rrf.md",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "pending",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "low",
|
||||||
|
"subtasks": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "16",
|
||||||
|
"title": "[Paperclip Gap 1] runtime_config ריק — חסרים graceSec/cooldownSec/maxConcurrentRuns",
|
||||||
|
"description": "runtime_config = '{}' לכל 14 הסוכנים. מסתבר שעיקר ההגדרות החשובות (timeoutSec=3600, maxTurnsPerRun=500) יושבות ב-adapter_config ולא ב-runtime_config — אז המצב פחות חמור. אבל graceSec/cooldownSec/maxConcurrentRuns עדיין חסרים.",
|
||||||
|
"details": "תיקון לניתוח המקורי שגוי בעקבות בדיקה ב-DB:\n\nמה שכן יש לנו (ב-adapter_config, לא runtime_config):\n- timeoutSec: 3600 (לכל הסוכנים)\n- maxTurnsPerRun: 500 (לכל הסוכנים)\n- model + effort=high (לכל הסוכנים)\n- paperclipSkillSync.desiredSkills (5/7 סוכנים — חסר אצל הגהת מסמכים ומנתח משפטי)\n\nמה שבאמת חסר ב-runtime_config:\n- heartbeat.graceSec — זמן grace לפני SIGKILL אחרי timeout. מהקוד: Math.max(1, graceSec)*1000. אם לא מוגדר → 1ms grace. בעיה אם הסוכן נחתך באמצע commit ל-DB.\n- heartbeat.cooldownSec — default ביצירה חדשה: 10. אצלנו לא מוגדר.\n- heartbeat.maxConcurrentRuns — default מ-AGENT_DEFAULT_MAX_CONCURRENT_RUNS (כנראה 1).\n- heartbeat.wakeOnDemand — default=true בקוד. אצלנו לא מוגדר אבל בפועל true.\n- heartbeat.enabled — default=false (timer off). זה הרצוי אצלנו.\n\nפעולה (Phase 1):\n1. עדכון runtime_config של כל סוכן: { heartbeat: { graceSec: 60, cooldownSec: 10, maxConcurrentRuns: 1, wakeOnDemand: true } }\n2. בעיקר graceSec — בלעדיו commit באמצע יכול להיכשל\n3. cooldownSec=10 (זהה לdefault ב-UI ליצירת agent חדש)\n\nהשפעה: minimal — רוב המקרים עובדים עם defaults. graceSec הוא העיקר.",
|
||||||
|
"testStrategy": "1. SELECT name, runtime_config->'heartbeat' FROM agents → לראות שכל סוכן מקבל graceSec/cooldownSec/maxConcurrentRuns/wakeOnDemand.\n2. בדיקה: סוכן ארוך נחתך ב-timeout — לבדוק שהיתה הזדמנות לציין graceful shutdown ב-30-60 שניות",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-04T07:47:02.008Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "17",
|
||||||
|
"title": "[Paperclip Gap 2] תקציבים = 0 לכל הסוכנים — אין budget enforcement",
|
||||||
|
"description": "budget_monthly_cents = 0 ו-spent_monthly_cents = 0 לכל 14 הסוכנים. Paperclip מציע cost control מובנה — אנחנו מתעלמים.",
|
||||||
|
"details": "ממצא: SELECT name, budget_monthly_cents, spent_monthly_cents FROM agents → הכל אפס.\n\nסיכון: לולאה חבויה יכולה לשרוף מאות $. אין auto-pause ב-80% spend (דפוס ש-CEO HEARTBEAT הרשמי מצפה לו).\n\nפעולה (Phase 3):\n1. מדידה: כמה כל סוכן באמת מוציא בחודש כיום (דרך לוגי claude-code, או Anthropic dashboard).\n2. הגדרת budget_monthly_cents סביר לכל סוכן (כותב Opus ≫ מנתח Sonnet).\n3. בדיקה שהמנגנון מפסיק כשמגיעים ל-100%.\n\nשאלה לחיים לפני ביצוע: באיזו רזולוציה למדוד? לפי Anthropic invoice, או לפי טוקנים בלוגים של claude_session?",
|
||||||
|
"testStrategy": "בדיקה ידנית: להגדיר budget קטן לסוכן ניסוי (1 cent), לעורר אותו על משימה, לוודא שמתעורר ונחסם. לעקוב ב-spent_monthly_cents.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-04T10:18:08.046Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "18",
|
||||||
|
"title": "[Paperclip Gap 3] חסר X-Paperclip-Run-Id header בקריאות API",
|
||||||
|
"description": "ה-skill הרשמי קובע: 'You MUST include -H X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on ALL API requests that modify issues'. ב-HEARTBEAT.md שלנו אין זכר לכך.",
|
||||||
|
"details": "ממצא: grep -n 'X-Paperclip-Run-Id' .claude/agents/ → 0 hits. כל curl ב-checkout/comments/PATCH issues — בלי הheader.\n\nסיכון: audit trail שבור. שאלה 'איזו ריצה שינתה את ה-issue X?' אין לה תשובה ב-DB.\n\nפעולה (Phase 1):\n1. עדכון .claude/agents/HEARTBEAT.md — דוגמאות ה-curl יכללו את הheader\n2. עדכון 6 קבצי הסוכנים (legal-ceo.md, legal-analyst.md, legal-researcher.md, legal-writer.md, legal-qa.md, legal-exporter.md) — כל מקום שיש curl POST/PATCH\n3. בדיקה שיש env var $PAPERCLIP_RUN_ID זמין בכל heartbeat",
|
||||||
|
"testStrategy": "בלוגי Paperclip (heartbeat_runs טבלה) לראות שהפעולות שלנו מקושרות ל-run_id. SELECT * FROM activity_log WHERE run_id IS NOT NULL ORDER BY created_at DESC LIMIT 10.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-04T08:49:44.646Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19",
|
||||||
|
"title": "[Paperclip Gap 4] לא משתמשים ב-/api/issues/{id}/interactions לאישורים",
|
||||||
|
"description": "Paperclip מציע API מובנה לאישור/שאלות (request_confirmation, ask_user_questions, suggest_tasks) עם idempotency keys ו-auto-wake. אנחנו עדיין כותבים 'חיים, מה לעשות?' כ-comment חופשי.",
|
||||||
|
"details": "סוגי interaction:\n- ask_user_questions — שאלות מובנות\n- request_confirmation — yes/no עם idempotency key (confirmation:{issueId}:plan:{revisionId})\n- suggest_tasks — הצעת עץ משימות\n- continuationPolicy: wake_assignee — wake אוטומטי על מענה\n- supersedeOnUserComment: true — בטל אם חיים עונה\n\nסיכון: אין UI מובנה לחיים (כפתורים), רק טקסט. אם הסוכן מתעורר פעמיים — שתי שאלות זהות.\n\nפעולה (Phase 2):\n1. בlegal-ceo.md — להחליף 'אם חיים לא הגדיר outcome: שאל בcomment' ב-request_confirmation\n2. בbrainstorm_directions — suggest_tasks במקום רשימת bullet\n3. בlegal-qa.md — request_confirmation לאישור export\n\nשאלה לחיים: האם תרצה לראות UI חדש או להישאר ב-Markdown comments?",
|
||||||
|
"testStrategy": "יצירת request_confirmation מסוכן ניסוי, בדיקה ב-UI שמופיעים כפתורי אישור/דחייה, בדיקה שהסוכן מתעורר אוטומטית עם PAPERCLIP_APPROVAL_ID env.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"16",
|
||||||
|
"17",
|
||||||
|
"18"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-04T11:18:59.050Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "20",
|
||||||
|
"title": "[Paperclip Gap 5] לא משתמשים ב-PAPERCLIP_WAKE_PAYLOAD_JSON fast-path",
|
||||||
|
"description": "בwake שמכוון ל-issue ספציפי, ה-env var מכיל כבר issue summary + comments חדשים דחוסים. ה-skill הרשמי אומר 'skip Steps 1-4 entirely'. שלנו תמיד fetcher גם ה-API.",
|
||||||
|
"details": "ממצא: HEARTBEAT.md סעיפים 2-2c תמיד פונים ל-API גם אם ה-payload כבר מכיל את הכל.\n\nתועלת: חיסכון 3-4 קריאות API לכל ריצה. בwakeups תכופים (CEO על comments) — חיסכון ניכר.\n\nפעולה (Phase 2):\n1. הוספה ל-HEARTBEAT.md בראש הסעיפים: 'אם $PAPERCLIP_WAKE_PAYLOAD_JSON קיים — קרא אותו ראשון. רק אם fallbackFetchNeeded:true או חסר הקשר רחב — fetch'.\n2. דוגמה לפענוח JSON: jq עם key paths\n3. בדיקה איזה wake reasons בכלל מקבלים payload (כנראה comment-driven בלבד)",
|
||||||
|
"testStrategy": "בWakeup דרך API עם payload, לבדוק בלוגי הסוכן שאין fetch לcomments. timeit על מספר ריצות לפני/אחרי.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"18"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-04T09:15:46.339Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "21",
|
||||||
|
"title": "[Paperclip Gap 6] שאילתות psql ישירות ל-issue_attachments — שובר אבסטרקציה",
|
||||||
|
"description": "HEARTBEAT.md סעיף 2c משתמש ב-psql ישיר ל-issue_attachments + assets. אם schema ישתנה (כפי שצפוי בעדכוני Paperclip) — כל הסוכנים נשברים.",
|
||||||
|
"details": "ממצא: 6 קבצי סוכן + HEARTBEAT.md מכילים PGPASSWORD=paperclip psql ... FROM issue_attachments ia JOIN assets a.\n\nסיכון: breakage בעדכון Paperclip. כפילות לוגיקה (copy-paste בכל סוכן).\n\nפעולה (Phase 2):\n1. בדיקה אם קיים endpoint רשמי /api/issues/{id}/attachments (curl + grep ב-server/src/routes)\n2. אם כן — להחליף את כל ה-psql\n3. אם לא — להעביר את ה-psql למקום יחיד: helper ב-mcp-server (mcp__legal-ai__list_issue_attachments tool)\n4. אופציה ג: לפתוח issue ב-paperclipai/paperclip לבקש endpoint\n\nתלוי במחקר API.",
|
||||||
|
"testStrategy": "אחרי החלפה: grep -rn 'issue_attachments' .claude/agents/ → 0 hits. בדיקה שסוכן עדיין רואה attachments בריצה.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"20"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-04T09:28:18.058Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "22",
|
||||||
|
"title": "[Paperclip Gap 7] לא משתמשים ב-/api/issues/{id}/heartbeat-context",
|
||||||
|
"description": "Endpoint רשמי שמחזיר issue + ancestors + goal/project + comment cursor בקריאה אחת. אנחנו עושים 3 קריאות נפרדות.",
|
||||||
|
"details": "ה-skill הרשמי: 'Prefer GET /api/issues/{issueId}/heartbeat-context first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.'\n\nשלנו: HEARTBEAT.md סעיפים 2 + 2b → שלוש קריאות (inbox-lite, issue, comments).\n\nפעולה (Phase 2):\n1. הוספת endpoint כצעד 6 ב-HEARTBEAT.md לפני 'Do the work'\n2. הסרת קריאות מיותרות שכבר ב-context\n3. שמירת comment cursor (after={last-seen-id}) לקריאות עוקבות",
|
||||||
|
"testStrategy": "בדיקה שהendpoint מחזיר את כל המידע הדרוש. ספירת קריאות API לפני/אחרי בריצה אמיתית.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"20"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-04T09:28:14.247Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "23",
|
||||||
|
"title": "[Paperclip Gap 8+11] HEARTBEAT.md ארוך + אין שימוש ב-skills של Paperclip",
|
||||||
|
"description": "HEARTBEAT.md שלנו 220 שורות (vs upstream 85). Paperclip מציע 8 skills מוכנים (paperclip, paperclip-create-agent, וכו') שאנחנו לא משתמשים באף אחד.",
|
||||||
|
"details": "תיקון לניתוח: מסתבר ש-CEO + 4 סוכנים אחרים כן משתמשים ב-paperclipSkillSync עם 4 paperclip skills (paperclip, paperclip-create-agent, paperclip-create-plugin, para-memory-files). חסר אצל: הגהת מסמכים ומנתח משפטי (skills_count=0).\n\nממצא: ls skills/ ב-paperclip repo → 8 skills. שלנו: 0 skills של Paperclip בשימוש.\n\nרלוונטיים לנו:\n- paperclip — API patterns + heartbeat checklist (יכול להחליף חלק מ-HEARTBEAT.md)\n- paperclip-create-agent — אם נוסיף סוכן\n- paperclip-create-plugin — לעדכוני plugin-legal-ai\n- paperclip-converting-plans-to-tasks — יכול להחליף brainstorm_directions\n- diagnose-why-work-stopped — לתחזוקה\n\nפעולה (Phase 3):\n1. קריאת skills/paperclip/SKILL.md מלא\n2. הזרקת skill לסביבת הסוכנים (כנראה דרך CLI: paperclipai agent local-cli)\n3. שכתוב HEARTBEAT.md לפי הדפוס: project-specific only, delegation לskill הרשמי לכלל ה-API\n4. יעד: ~120 שורות ב-HEARTBEAT.md שלנו\n\nשאלה לחיים: האם להזריק skills כסימלינקים ל-symlinks קיימים, או דרך paperclipai CLI?",
|
||||||
|
"testStrategy": "אחרי שכתוב: סוכן ניסוי קורא את HEARTBEAT.md + paperclip skill, מבצע heartbeat מלא בלי שגיאות. השוואת אורך לפני/אחרי.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"16",
|
||||||
|
"17",
|
||||||
|
"18",
|
||||||
|
"19",
|
||||||
|
"20",
|
||||||
|
"21",
|
||||||
|
"22"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-04T16:44:27.553Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "24",
|
||||||
|
"title": "[Paperclip Gap 9] לבדוק bootstrapPromptTemplate deprecated באף סוכן",
|
||||||
|
"description": "מ-docs/agents-runtime.md: 'bootstrapPromptTemplate is deprecated... should be migrated to the managed instructions bundle system.' לבדוק האם adapter_config שלנו משתמש בזה.",
|
||||||
|
"details": "פעולה (Phase 1):\n1. SELECT name, adapter_config->'promptTemplate' as pt, adapter_config->'bootstrapPromptTemplate' as bpt FROM agents WHERE adapter_type = 'claude_local';\n2. אם בשימוש אצל סוכן כלשהו — מיגרציה למבנה החדש\n3. ייעוד: לבדוק תיעוד managed instructions bundle ב-paperclip docs\n\nהערה: זה כנראה לא ישפיע אצלנו (אנחנו משתמשים ב-symlinks ל-AGENTS.md/HEARTBEAT.md ישירות) — אבל חובה לוודא.",
|
||||||
|
"testStrategy": "SELECT הנ\"ל. אם 0 שורות מחזירות bpt לא-NULL — סגור את המשימה.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-04T08:19:27.766Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "25",
|
||||||
|
"title": "[Paperclip Gap 10] סוכנים מוכפלים בין 2 חברות — אין סנכרון",
|
||||||
|
"description": "14 שורות = 7 סוכנים × 2 חברות (1xxx, 8xxx). כל שינוי בהגדרות הסוכן צריך להיעשות פעמיים. אין מנגנון סנכרון או הורשה.",
|
||||||
|
"details": "ממצא: SELECT name, COUNT(*) FROM agents GROUP BY name → 2 לכל אחד.\n\nסיכון: drift בין החברות. שינוי runtime_config ל-CEO של 1xxx יכול לפספס את CEO של 8xxx.\n\nפעולה (Phase 3):\n1. בדיקה: האם Paperclip תומך ב-shared agents או chainOfCommand? (לקרוא docs/companies/)\n2. אם כן — מיגרציה למבנה משותף\n3. אם לא — סקריפט סנכרון: scripts/sync_agents_across_companies.py שמעתיק כל שינוי מחברה לחברה\n\nשאלה לחיים: בעתיד אם יהיו עוד סוגי ערר (10xxx?) — להוסיף עוד חברה או להשאיר 2?",
|
||||||
|
"testStrategy": "אם סקריפט: dry-run שמראה הבדלים בין 2 ה-CEOs. ואז apply ולוודא runtime_config זהה.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"16"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-04T09:52:14.263Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "26",
|
||||||
|
"title": "[Paperclip Gap 12] עדכון @paperclipai/plugin-sdk + capabilities חדשות",
|
||||||
|
"description": "ה-plugin שלנו: @paperclipai/plugin-sdk@^2026.325.0, apiVersion: 1, minimumHostVersion: 2026.325.0. ה-host: 2026.428.0. ייתכן capabilities חדשות (issue.interactions.create, וכו').",
|
||||||
|
"details": "פעולה (Phase 4 — אחרי שדרוג Paperclip stable):\n1. cd /home/chaim/plugin-legal-ai && npm view @paperclipai/plugin-sdk version\n2. אם חדשה: npm install @paperclipai/plugin-sdk@latest\n3. קריאת adapter-plugin.md המעודכן ב-paperclip repo\n4. בדיקה אם apiVersion: 2 קיים\n5. הוספת capabilities חדשות אם רלוונטי (בעיקר issue.interactions.create אחרי gap #4)\n6. npm run build && reinstall plugin\n\nתלוי בgap #19 (interactions API) — אם אנחנו רוצים שהplugin יוכל ליצור interactions, חייב capability חדש.",
|
||||||
|
"testStrategy": "אחרי npm install: בדיקה ש-plugin עולה ב-Paperclip בלי last_error. SELECT status, last_error FROM plugins WHERE plugin_key='marcusgroup.legal-ai'.",
|
||||||
|
"status": "pending",
|
||||||
|
"dependencies": [
|
||||||
|
"27",
|
||||||
|
"19"
|
||||||
|
],
|
||||||
|
"priority": "low",
|
||||||
|
"subtasks": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "27",
|
||||||
|
"title": "[Paperclip Phase 4] שדרוג Paperclip לגרסה stable הבאה (לא 2026.428.0)",
|
||||||
|
"description": "כרגע אנחנו על 2026.428.0 — הגרסה היציבה האחרונה. כשיופיע stable חדש (כנראה 2026.5xx.x), לבצע שדרוג מבוקר.",
|
||||||
|
"details": "טריגר: npm view paperclipai dist-tags.latest מחזיר משהו ≠ 2026.428.0.\n\nפעולה:\n1. קריאת releases/v2026.5xx.x.md ב-GitHub\n2. בדיקת שינויים שעלולים להשפיע (CUSTOMIZATIONS.md סעיפים: hebrew, RTL, plugin driver, heartbeat)\n3. גיבוי: pg_dump של paperclip DB + cp -r ~/.npm/_npx/43414d9b790239bb /tmp/\n4. pm2 stop paperclip\n5. rm -rf ~/.npm/_npx/43414d9b790239bb\n6. npx paperclipai@latest run (יוריד גרסה חדשה)\n7. הרצה מחדש: ~/.paperclip/hebrew/apply-hebrew.sh && ~/.paperclip/issue-link-fix/apply-issue-link-fix.sh\n8. pm2 restart paperclip\n9. בדיקה ב-pc.nautilus.marcusgroup.org: עברית + plugin פעיל + סוכן מתעורר על comment\n\nתלוי בלי dependencies (יכול להיות מבוצע בכל עת אחרי שיש stable חדש).",
|
||||||
|
"testStrategy": "אחרי שדרוג: cat ~/.npm/_npx/43414d9b790239bb/node_modules/paperclipai/package.json | grep version → גרסה חדשה. UI עברית. test wakeup על issue.",
|
||||||
|
"status": "pending",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "low",
|
||||||
|
"subtasks": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "28",
|
||||||
|
"title": "[Paperclip Auxiliary] להפעיל skill-sync ל-2 סוכנים שפיספסו",
|
||||||
|
"description": "הגהת מסמכים ומנתח משפטי לא קיבלו אף פעם revision מסוג skill-sync (לעומת 5 האחרים שכן). לבצע sync.",
|
||||||
|
"details": "ממצא: בדיקה ב-agent_config_revisions:\n- עוזר משפטי: 3 skill-sync revisions (יש 7 skills)\n- חוקר תקדימים: 3 (יש 5)\n- מייצא טיוטה: 5 (יש 5)\n- בודק איכות: 1 (יש 5)\n- כותב החלטה: 1 (יש 5)\n- הגהת מסמכים: 0 (יש 0) ❌\n- מנתח משפטי: 0 (יש 0) ❌\n\nאופציות:\n1. UI: agent settings → 'sync skills'\n2. API: POST /api/agents/{id}/skills-sync (לאתר)\n3. CLI: paperclipai agent skill-sync (לבדוק אם קיים)\n4. SQL ידני (לא מומלץ — דורף revision tracking)\n\nSkills להעתקה (לפי בודק איכות):\n- paperclipai/paperclip/paperclip\n- paperclipai/paperclip/paperclip-create-agent\n- paperclipai/paperclip/paperclip-create-plugin\n- paperclipai/paperclip/para-memory-files\n- (אופציונלי) local/eba6210d5a/legal-decision",
|
||||||
|
"testStrategy": "SELECT name, jsonb_array_length(adapter_config->'paperclipSkillSync'->'desiredSkills') FROM agents WHERE name IN ('הגהת מסמכים', 'מנתח משפטי') → 4-5. revision חדש ב-agent_config_revisions עם source='skill-sync'.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-04T09:46:32.092Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "29",
|
||||||
|
"title": "[legal-ai UI] מסך הגדרות סוכנים — הצגה + עריכה + שמירה",
|
||||||
|
"description": "מסך אדמין ב-legal-ai UI שמציג את כל הגדרות הסוכנים (model, timeout, runtime_config, skills, budget) ומאפשר עריכה ושמירה. מונע SQL ישיר.",
|
||||||
|
"details": "מטרה: ממשק אדמין מרכזי במקום שעריכה תהיה רק ב-UI של Paperclip + SQL ישיר + CUSTOMIZATIONS.md.\n\nשדות (לכל סוכן × 2 חברות):\n1. adapter_config: model, effort, timeoutSec, maxTurnsPerRun, extraArgs[], paperclipSkillSync.desiredSkills[]\n2. runtime_config.heartbeat: graceSec, cooldownSec, wakeOnDemand, maxConcurrentRuns, enabled, intervalSec\n3. budget_monthly_cents (לקראת gap #2)\n4. status / pause_reason (קריאה + כפתור pause/resume)\n\nאופציות מימוש:\nA. עמוד חדש ב-legal-ai/web-ui (Next.js 16) — קורא Paperclip DB דרך FastAPI endpoint חדש (/api/admin/paperclip-agents)\nB. קריאה ל-Paperclip API (/api/companies/{id}/agents) — REST טהור, פחות שדות זמינים\nC. iframe ל-Paperclip UI — שטחי\n\nהמלצה: A. שולט מלא + ולידציה משפטית (timeoutSec >= 1800 כי OCR).\n\nתלוי ב: gap #25 (סוכנים מוכפלים) — אם נעבור לshared, המסך יתאים.\n\nשאלות פתוחות לחיים:\n- auth: מי יכול לגשת? (כיום אין auth ב-legal-ai)\n- bulk edit ל-2 חברות יחד או נפרד?\n- חשיפת skill marketplace (להוסיף/להוריד skills) או רק קריאה?",
|
||||||
|
"testStrategy": "1. עמוד עולה ב-/admin/agents בlegal-ai UI. 2. עריכת timeoutSec ושמירה → SELECT ב-DB מאמת. 3. revision חדש ב-agent_config_revisions עם source מתאים.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"16",
|
||||||
|
"17",
|
||||||
|
"25"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-04T17:29:25.686Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-05-03T10:19:15.134Z",
|
"lastModified": "2026-05-04T17:29:25.687Z",
|
||||||
"taskCount": 13,
|
"taskCount": 29,
|
||||||
"completedCount": 12,
|
"completedCount": 24,
|
||||||
"tags": [
|
"tags": [
|
||||||
"legal-ai"
|
"legal-ai"
|
||||||
]
|
]
|
||||||
|
|||||||
31
CLAUDE.md
31
CLAUDE.md
@@ -48,6 +48,7 @@
|
|||||||
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
|
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
|
||||||
| [`docs/product-specification.md`](docs/product-specification.md) | איפיון מוצר מלא — personas, תהליכים עסקיים, דרישות | להתמצאות עסקית/מוצרית |
|
| [`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 | לפני הוספת חברה/סוג ערר חדש |
|
| [`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/audit-report.md`](docs/audit-report.md) | דוח audit של המערכת | רקע כללי |
|
||||||
| [`docs/case-migration-tracker.md`](docs/case-migration-tracker.md) | מעקב מיגרציה של תיקים קיימים | לצורך מעקב |
|
| [`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/case-deletion-runbook.md`](docs/case-deletion-runbook.md) | runbook מלא למחיקת תיק — legal-ai DB + disk + Paperclip + Gitea, FK ordering, fallback ל-SQL ישיר | לפני reset שלם של תיק (מבחן, מחיקה בטעות) |
|
||||||
@@ -117,6 +118,8 @@
|
|||||||
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
|
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
|
||||||
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
|
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
|
||||||
├── mcp-server/ ← MCP server + services + tools
|
├── mcp-server/ ← MCP server + services + tools
|
||||||
|
├── adapters/ ← Paperclip external adapters (ראה למטה)
|
||||||
|
│ └── deepseek-paperclip-adapter/ ← `deepseek_local` (Hermes-pinned ל-DeepSeek profile)
|
||||||
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
|
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
|
||||||
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
|
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
|
||||||
```
|
```
|
||||||
@@ -160,6 +163,34 @@
|
|||||||
- ה-CEO קורא את ה-comment, מחליט על ניתוב, ויוצר issue לסוכן המתאים
|
- ה-CEO קורא את ה-comment, מחליט על ניתוב, ויוצר issue לסוכן המתאים
|
||||||
- כל הסוכנים חייבים לקרוא comments אחרונים לפני שהם מתחילים לעבוד (HEARTBEAT שלבים 2b-2c)
|
- כל הסוכנים חייבים לקרוא 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`.
|
||||||
|
|
||||||
|
### 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) כתבנית.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## עקרונות כתיבה קריטיים
|
## עקרונות כתיבה קריטיים
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -400,6 +400,54 @@
|
|||||||
- **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md)
|
- **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md)
|
||||||
- **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה
|
- **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 6.11 לקחים מערר 1200-25 (קרית ענבים, מאי 2026)
|
||||||
|
|
||||||
|
השוואה בין טיוטת הכותב לעריכת דפנה חשפה 7 דפוסי סגנון שלא היו מתועדים:
|
||||||
|
|
||||||
|
### א. סדר בלוקים — תכניות לפני טענות (1xxx)
|
||||||
|
בתיקי רישוי, דפנה מעדיפה שבלוק ט (תכניות חלות) יופיע **לפני** בלוק ז (טענות). הרציונל: הקורא צריך להכיר את המסגרת הנורמטיבית לפני שהוא קורא את טענות הצדדים.
|
||||||
|
|
||||||
|
**סדר נכון ל-1xxx:** ה → ו → **ט** → ו.ב (רקע מורחב) → ז → ח → י → יא → יב
|
||||||
|
|
||||||
|
### ב. תבנית "להלן מתוך" — חובה
|
||||||
|
כל התייחסות למסמך מקור מלווה ב-"להלן מתוך [שם המסמך]:" כ-placeholder לציטוט/צילום. **12 מופעים** בעריכה, **0** בטיוטה. זהו דפוס סגנוני מרכזי שחייב להיות אוטומטי.
|
||||||
|
|
||||||
|
דוגמאות:
|
||||||
|
- "להלן מתוך הוראות התכנית:"
|
||||||
|
- "להלן מתוך פרוטוקול הדיון בוועדה המקומית:"
|
||||||
|
- "להלן מתוך הבקשה להיתר:"
|
||||||
|
- "להלן מתוך מטרת התכנית:"
|
||||||
|
- "להלן מתוך תשריט מצב מוצע:"
|
||||||
|
|
||||||
|
### ג. רקע עובדתי מורחב — ציר זמן מלא
|
||||||
|
בלוק ו חייב לספר את "הסיפור" של התיק: הגשת בקשה → פרסום → מספר התנגדויות → ישיבות ועדה מקומית (תאריך + תוצאה לכל אחת) → החלטה סופית → הגשת ערר. הטיוטה נתנה שורה אחת (90 מילים); דפנה הרחיבה ל-3 ישיבות מפורטות (~420 מילים).
|
||||||
|
|
||||||
|
### ד. ניתוח "גשר תכנוני"
|
||||||
|
כשמבקש שימוש חורג גם מקדם תכנית — דפנה מנתחת: האם השימוש המבוקש **תואם** את התכנון העתידי (→ גשר לגיטימי, כמו בכוכבה תורן)? או **סותר** (→ סטייה כפולה)? מסגרת ניתוח שלמה (249 מילים) שלא הייתה בטיוטה.
|
||||||
|
|
||||||
|
### ה. עיגון כמותי
|
||||||
|
דפנה מוסיפה נתונים מספריים ספציפיים: "4,404.98 מ"ר לכלל היישוב vs 1,425 מ"ר מבוקש — 32%". המספרים מעגנים את ההחלטה במציאות ומקשים על ערעור.
|
||||||
|
|
||||||
|
### ו. כותרות שטוחות (Heading 2 בלבד)
|
||||||
|
דפנה השתמשה ב-Heading 2 לכל הסעיפים, כולל תת-נושאים בדיון. **אין Heading 3**. כל סעיף עומד בפני עצמו.
|
||||||
|
|
||||||
|
### ז. הבחנת תקדימים inline
|
||||||
|
במקום סעיף נפרד "הבחנה מתקדימי העוררת" — ההבחנות מנוסחות inline: "באשר ל-[שם פסק דין]" → מה ההבדל → סיכום. דוגמה: "באשר לבג"ץ 6525/15 עמק שווה... אולם ההבדל מהותי".
|
||||||
|
|
||||||
|
### ביטויי מעבר חדשים (מעריכה 1200-25)
|
||||||
|
| ביטוי | הקשר |
|
||||||
|
|-------|-------|
|
||||||
|
| "עינינו הרואות" | ממצא מתוך מסמך |
|
||||||
|
| "הנה כי כן" | לפיכך (פורמלי) |
|
||||||
|
| "נשוב כאן ונבחין" | חזרה להבחנת תקדים |
|
||||||
|
| "נוסיף ונבהיר" | הוספת הבהרה |
|
||||||
|
| "מסקנת הדברים" | סיכום סעיף |
|
||||||
|
| "משכבר קבענו" | הפניה לקביעה קודמת |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. מה עדיין לא ראינו
|
## 7. מה עדיין לא ראינו
|
||||||
|
|||||||
@@ -385,3 +385,64 @@ The draft's biggest structural error was adding the "נבאר" doctrinal paragra
|
|||||||
- [ ] Update voice-fingerprint: add new transition phrases
|
- [ ] Update voice-fingerprint: add new transition phrases
|
||||||
- [ ] Update architecture-by-outcome: add "clean acceptance" archetype
|
- [ ] Update architecture-by-outcome: add "clean acceptance" archetype
|
||||||
- [ ] Fix agent opening punctuation: "ונפרט;" not "נפרט."
|
- [ ] Fix agent opening punctuation: "ונפרט;" not "נפרט."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lessons from ערר 1200-25 (קרית ענבים — שימוש חורג, דחייה)
|
||||||
|
|
||||||
|
### Source
|
||||||
|
- Our draft: `data/cases/1200-25/exports/טיוטה-v1.docx` (3,181 words)
|
||||||
|
- Daphna's edit: `data/cases/1200-25/exports/עריכה-v1.docx` (4,313 words, +35%)
|
||||||
|
- Date: May 2026
|
||||||
|
|
||||||
|
### What the Edit Changed
|
||||||
|
|
||||||
|
#### 1. Block Order — Plans Before Claims
|
||||||
|
- **Draft:** ה→ו→ז→ח→ט→י→יא→יב (plans after procedures)
|
||||||
|
- **Edit:** ה→ו→**ט**→ו.ב→ז→ח→י→יא→יב (plans BEFORE claims)
|
||||||
|
- **Lesson:** In licensing cases (1xxx), the reader must understand the normative framework (plans) before reading the parties' arguments about those plans. Block ט should precede Block ז. The new order: opening → brief background → **applicable plans** → expanded background (application + committee proceedings) → claims → procedures → discussion.
|
||||||
|
|
||||||
|
#### 2. "להלן מתוך" Document Insertion Pattern
|
||||||
|
- **Draft:** 0 occurrences
|
||||||
|
- **Edit:** 12 occurrences of "להלן מתוך [document name]:"
|
||||||
|
- **Lesson:** Every reference to a source document must be accompanied by "להלן מתוך [שם המסמך]:" as a placeholder for a direct quote/image. This is a MANDATORY pattern, not optional. Examples: "להלן מתוך הוראות התכנית:", "להלן מתוך פרוטוקול הדיון:", "להלן מתוך הבקשה להיתר:"
|
||||||
|
|
||||||
|
#### 3. Expanded Factual Background (Block ו)
|
||||||
|
- **Draft:** ~90 words (3%), one paragraph
|
||||||
|
- **Edit:** ~420 words (10%), covering: (a) the application details, (b) 3 committee meetings with dates and outcomes, (c) the final decision
|
||||||
|
- **Lesson:** Block ו must tell the full "story" of the case: when the application was filed → when it was published → how many objections → when committee meetings were held → what was decided at each meeting → when the appeal was filed. Each meeting should have date + outcome.
|
||||||
|
|
||||||
|
#### 4. Bridge Planning Analysis ("גשר תכנוני")
|
||||||
|
- **Draft:** Not present
|
||||||
|
- **Edit:** 249 words — new analytical framework
|
||||||
|
- **Lesson:** When an applicant for deviation/variance is also promoting a plan for the same land, the decision must analyze: (a) is the pending plan harmonious with the requested use? If yes → the deviation can serve as a "bridge" until the plan is approved (cite כוכבה תורן). If no → the contradiction STRENGTHENS the rejection. The writer must check `search_case_documents` for pending plans and compare them with the requested use.
|
||||||
|
|
||||||
|
#### 5. Competing Plans Analysis
|
||||||
|
- **Draft:** Not present (1,033 words added)
|
||||||
|
- **Edit:** Detailed comparison of the site-specific plan (151-1382787) vs the comprehensive plan (151-1337534)
|
||||||
|
- **Lesson:** When there's a site-specific plan AND a comprehensive plan, the decision must: (a) describe each plan's scope, (b) compare the permitted uses, (c) show quantitative contradictions (e.g., "the comprehensive plan allocates 4,404 m² for ALL commerce in the settlement, while the request alone is for 1,425 m² — 32%"), (d) conclude whether there's harmony or contradiction. This is often the STRONGEST argument in the decision.
|
||||||
|
|
||||||
|
#### 6. Heading Level — Flat Structure
|
||||||
|
- **Draft:** Mixed Heading 2 + Heading 3 (nested subsections)
|
||||||
|
- **Edit:** All Heading 2 (flat structure)
|
||||||
|
- **Lesson:** Each section stands independently. No nesting. In the discussion, each analytical step is a separate Heading 2 section.
|
||||||
|
|
||||||
|
#### 7. Inline Precedent Distinguishing
|
||||||
|
- **Draft:** Separate section "הבחנה מתקדימי העוררת" (Heading 3)
|
||||||
|
- **Edit:** Each precedent distinguished inline with "באשר ל-[case name]" → what's different → conclusion
|
||||||
|
- **Lesson:** Don't create a separate "distinguishing" section. Address each precedent where it naturally comes up in the discussion, using "באשר ל..." as the opener.
|
||||||
|
|
||||||
|
### New Transition Phrases Identified
|
||||||
|
- **"עינינו הרואות"** — introducing a document-based finding ("our eyes see that...")
|
||||||
|
- **"הנה כי כן"** — therefore/accordingly (more formal than "לפיכך")
|
||||||
|
- **"נשוב כאן ונבחין"** — returning to distinguish a case
|
||||||
|
- **"נוסיף ונבהיר"** — adding clarification
|
||||||
|
- **"מסקנת הדברים"** — concluding a subsection
|
||||||
|
- **"משכבר קבענו"** — since we already established
|
||||||
|
|
||||||
|
### Applied To
|
||||||
|
- [x] Update legal-decision-lessons.md with lessons 1-7
|
||||||
|
- [x] Update daphna-voice-fingerprint.md with structural and style findings
|
||||||
|
- [ ] Update block-schema.md: block order for 1xxx cases (ט before ז)
|
||||||
|
- [ ] Update daphna-architecture-by-outcome.md: add "bridge planning" analysis for rejections
|
||||||
|
- [ ] Update writer system prompt: mandatory "להלן מתוך" pattern
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
|||||||
return await cases.case_delete(case_number, remove_files)
|
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)
|
# Precedent attachments (user-supplied legal support for the compose phase)
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def precedent_attach(
|
async def precedent_attach(
|
||||||
@@ -209,6 +216,22 @@ async def precedent_library_delete(case_law_id: str) -> str:
|
|||||||
return await plib.precedent_library_delete(case_law_id)
|
return await plib.precedent_library_delete(case_law_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def precedent_link_cases(
|
||||||
|
case_law_id_a: str,
|
||||||
|
case_law_id_b: str,
|
||||||
|
relation_type: str = "same_case_chain",
|
||||||
|
) -> str:
|
||||||
|
"""קישור שתי פסיקות כקשורות (דו-כיווני, idempotent). relation_type: same_case_chain | overruled_by | distinguished."""
|
||||||
|
return await plib.precedent_link_cases(case_law_id_a, case_law_id_b, relation_type)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
|
||||||
|
"""הסרת קישור בין שתי פסיקות (דו-כיווני)."""
|
||||||
|
return await plib.precedent_unlink_cases(case_law_id_a, case_law_id_b)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def precedent_extract_halachot(case_law_id: str) -> str:
|
async def precedent_extract_halachot(case_law_id: str) -> str:
|
||||||
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
|
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
|
||||||
@@ -390,6 +413,35 @@ 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,
|
||||||
|
) -> str:
|
||||||
|
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
|
||||||
|
|
||||||
|
מחזיר החלטות מהקורפוס הפנימי של ועדות הערר — נפרד מפסיקת בתי המשפט.
|
||||||
|
השתמש בו במקביל ל-search_precedent_library להצגת שתי שכבות נפרדות.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: שאילתת חיפוש בעברית
|
||||||
|
practice_area: rishuy_uvniya / betterment_levy / compensation_197
|
||||||
|
appeal_subtype: סינון לפי תת-סוג ערר
|
||||||
|
district: מחוז — ירושלים / מרכז / תל אביב / צפון / דרום / ארצי. ריק = כל המחוזות
|
||||||
|
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
|
||||||
|
limit: מספר תוצאות מקסימלי
|
||||||
|
include_halachot: האם לכלול הלכות שחולצו
|
||||||
|
"""
|
||||||
|
return await search.search_internal_decisions(
|
||||||
|
query, practice_area, appeal_subtype, district, chair_name, limit, include_halachot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Drafting
|
# Drafting
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def get_style_guide() -> str:
|
async def get_style_guide() -> str:
|
||||||
@@ -573,6 +625,43 @@ async def ingest_final_version(
|
|||||||
return await workflow.ingest_final_version(case_number, file_path, final_text)
|
return await workflow.ingest_final_version(case_number, file_path, final_text)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def 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()
|
@mcp.tool()
|
||||||
async def record_chair_feedback(
|
async def record_chair_feedback(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
|
|||||||
@@ -360,13 +360,9 @@ async def write_block(
|
|||||||
post_hearing_context=post_hearing_context,
|
post_hearing_context=post_hearing_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Restructure: sources first, then instructions
|
# source_context is already embedded inside formatted_prompt via {source_context} in the
|
||||||
prompt = (
|
# template. Do NOT prepend it again — doing so doubles the prompt size (was 465K chars).
|
||||||
f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n"
|
prompt = formatted_prompt
|
||||||
f"{source_context}\n\n"
|
|
||||||
f"---\n\n"
|
|
||||||
f"{formatted_prompt}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if instructions:
|
if instructions:
|
||||||
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||||
@@ -377,6 +373,19 @@ async def write_block(
|
|||||||
if not dir_doc.get("approved"):
|
if not dir_doc.get("approved"):
|
||||||
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
||||||
|
|
||||||
|
# Guard against context overflow before calling claude -p.
|
||||||
|
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
|
||||||
|
# In practice the CLI has crashed on prompts above ~400K chars, so use
|
||||||
|
# that as a conservative ceiling (well below the token limit).
|
||||||
|
_MAX_PROMPT_CHARS = 400_000
|
||||||
|
if len(prompt) > _MAX_PROMPT_CHARS:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Prompt too large for {block_id}: {len(prompt):,} chars "
|
||||||
|
f"(limit {_MAX_PROMPT_CHARS:,}). "
|
||||||
|
f"source_context: {len(source_context):,} chars. "
|
||||||
|
f"Reduce documents or call extract_appraiser_facts first."
|
||||||
|
)
|
||||||
|
|
||||||
# Call Claude via Claude Code session (no API)
|
# Call Claude via Claude Code session (no API)
|
||||||
model_key = block_cfg["model"]
|
model_key = block_cfg["model"]
|
||||||
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
||||||
@@ -414,16 +423,35 @@ def _build_case_context(case: dict, decision: dict | None) -> str:
|
|||||||
- תוצאה: {outcome_heb}"""
|
- תוצאה: {outcome_heb}"""
|
||||||
|
|
||||||
|
|
||||||
|
# Which doc_types are relevant per block.
|
||||||
|
# None → skip source docs entirely (block uses other context, e.g. claims_context)
|
||||||
|
# [] → include all doc types (default for unspecified blocks)
|
||||||
|
# [..] → include only the listed doc_type values
|
||||||
|
_BLOCK_DOC_TYPES: dict[str, list[str] | None] = {
|
||||||
|
"block-he": None, # only case_context needed; no full docs
|
||||||
|
"block-vav": ["appeal", "protocol"], # כתב ערר + פרוטוקול ועדה
|
||||||
|
"block-zayin": None, # claims_context is sufficient
|
||||||
|
"block-chet": ["protocol"], # פרוטוקול + השלמות טיעון
|
||||||
|
"block-tet": ["appraisal"], # שומות בלבד
|
||||||
|
# block-yod, block-yod-alef, block-he etc. default → all docs
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _build_source_context(case_id: UUID, block_id: str) -> str:
|
async def _build_source_context(case_id: UUID, block_id: str) -> str:
|
||||||
"""Get full document texts for the block.
|
"""Get document texts for the block, filtered by relevance.
|
||||||
|
|
||||||
Per Anthropic best practices: send full source documents, not truncated excerpts.
|
Per Anthropic best practices: send full source documents, not truncated excerpts.
|
||||||
Place documents at the TOP of the prompt (before instructions) for 30% better recall.
|
Per-block filtering prevents context overflow on large cases (9+ docs).
|
||||||
For grounding: instruct Claude to cite word-for-word from these documents.
|
|
||||||
"""
|
"""
|
||||||
|
allowed = _BLOCK_DOC_TYPES.get(block_id, []) # [] sentinel = not in map → all docs
|
||||||
|
if allowed is None:
|
||||||
|
return "" # this block doesn't need raw source docs
|
||||||
|
|
||||||
docs = await db.list_documents(case_id)
|
docs = await db.list_documents(case_id)
|
||||||
context_parts = []
|
context_parts = []
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
|
if allowed and doc["doc_type"] not in allowed:
|
||||||
|
continue
|
||||||
text = await db.get_document_text(UUID(doc["id"]))
|
text = await db.get_document_text(UUID(doc["id"]))
|
||||||
if text:
|
if text:
|
||||||
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")
|
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ async def query(
|
|||||||
"""
|
"""
|
||||||
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||||
|
|
||||||
|
if len(full_prompt) > 150_000:
|
||||||
|
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"claude", "-p",
|
"claude", "-p",
|
||||||
"--output-format", "json",
|
"--output-format", "json",
|
||||||
@@ -110,7 +113,8 @@ async def query(
|
|||||||
|
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
||||||
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}")
|
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
|
||||||
|
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}{size_info}")
|
||||||
|
|
||||||
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
||||||
if not stdout:
|
if not stdout:
|
||||||
|
|||||||
@@ -691,6 +691,30 @@ CREATE INDEX IF NOT EXISTS idx_prec_img_emb_case_law
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_V10_SQL = """
|
||||||
|
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS chair_name TEXT DEFAULT '';
|
||||||
|
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS district TEXT DEFAULT '';
|
||||||
|
ALTER TABLE cases ADD COLUMN IF NOT EXISTS chair_name TEXT DEFAULT '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_case_law_source_kind ON case_law(source_kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_case_law_chair ON case_law(chair_name) WHERE chair_name <> '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_case_law_district ON case_law(district) WHERE district <> '';
|
||||||
|
"""
|
||||||
|
|
||||||
|
SCHEMA_V11_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS case_law_relations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
case_law_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
|
||||||
|
related_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
|
||||||
|
relation_type TEXT NOT NULL DEFAULT 'same_case_chain',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
UNIQUE(case_law_id, related_id),
|
||||||
|
CHECK (case_law_id <> related_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clr_a ON case_law_relations(case_law_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clr_b ON case_law_relations(related_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute(SCHEMA_SQL)
|
await conn.execute(SCHEMA_SQL)
|
||||||
@@ -703,7 +727,9 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
|||||||
await conn.execute(SCHEMA_V7_SQL)
|
await conn.execute(SCHEMA_V7_SQL)
|
||||||
await conn.execute(SCHEMA_V8_SQL)
|
await conn.execute(SCHEMA_V8_SQL)
|
||||||
await conn.execute(SCHEMA_V9_SQL)
|
await conn.execute(SCHEMA_V9_SQL)
|
||||||
logger.info("Database schema initialized (v1-v9)")
|
await conn.execute(SCHEMA_V10_SQL)
|
||||||
|
await conn.execute(SCHEMA_V11_SQL)
|
||||||
|
logger.info("Database schema initialized (v1-v11)")
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -1724,6 +1750,59 @@ async def get_case_law(case_law_id: UUID) -> dict | None:
|
|||||||
return _row_to_case_law(row) if row else None
|
return _row_to_case_law(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def add_case_law_relation(
|
||||||
|
a_id: UUID, b_id: UUID, relation_type: str = "same_case_chain"
|
||||||
|
) -> None:
|
||||||
|
"""Link two case_law records bidirectionally. Idempotent (ON CONFLICT DO NOTHING)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO case_law_relations(case_law_id, related_id, relation_type)
|
||||||
|
VALUES($1, $2, $3)
|
||||||
|
ON CONFLICT (case_law_id, related_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
[(a_id, b_id, relation_type), (b_id, a_id, relation_type)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_case_law_relation(a_id: UUID, b_id: UUID) -> None:
|
||||||
|
"""Remove a bidirectional link between two case_law records."""
|
||||||
|
pool = await get_pool()
|
||||||
|
await pool.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM case_law_relations
|
||||||
|
WHERE (case_law_id = $1 AND related_id = $2)
|
||||||
|
OR (case_law_id = $2 AND related_id = $1)
|
||||||
|
""",
|
||||||
|
a_id,
|
||||||
|
b_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_case_law_relations(case_law_id: UUID) -> list[dict]:
|
||||||
|
"""Return all case_law records linked to case_law_id, ordered by date asc."""
|
||||||
|
pool = await get_pool()
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"""
|
||||||
|
SELECT cl.*, r.relation_type
|
||||||
|
FROM case_law_relations r
|
||||||
|
JOIN case_law cl ON cl.id = r.related_id
|
||||||
|
WHERE r.case_law_id = $1
|
||||||
|
ORDER BY cl.date ASC NULLS LAST
|
||||||
|
""",
|
||||||
|
case_law_id,
|
||||||
|
)
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
d = dict(row)
|
||||||
|
relation_type = d.pop("relation_type")
|
||||||
|
normalized = _row_to_case_law(d)
|
||||||
|
normalized["relation_type"] = relation_type
|
||||||
|
results.append(normalized)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
async def get_case_law_by_citation(case_number: str) -> dict | None:
|
async def get_case_law_by_citation(case_number: str) -> dict | None:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
row = await pool.fetchrow(
|
row = await pool.fetchrow(
|
||||||
@@ -1817,6 +1896,85 @@ async def create_external_case_law(
|
|||||||
return _row_to_case_law(row)
|
return _row_to_case_law(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_internal_committee_decision(
|
||||||
|
case_number: str,
|
||||||
|
case_name: str,
|
||||||
|
full_text: str,
|
||||||
|
court: str = "",
|
||||||
|
decision_date: date | None = None,
|
||||||
|
chair_name: str = "",
|
||||||
|
district: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
summary: str = "",
|
||||||
|
is_binding: bool = True,
|
||||||
|
document_id: UUID | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Upsert an appeals-committee decision as source_kind='internal_committee'.
|
||||||
|
|
||||||
|
If a row with this case_number already exists as cited_only, promotes it.
|
||||||
|
Idempotent: calling again updates metadata in-place.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
"SELECT id FROM case_law WHERE case_number = $1",
|
||||||
|
case_number,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
UPDATE case_law SET
|
||||||
|
case_name = $2,
|
||||||
|
court = COALESCE(NULLIF($3, ''), court),
|
||||||
|
date = COALESCE($4, date),
|
||||||
|
chair_name = COALESCE(NULLIF($5, ''), chair_name),
|
||||||
|
district = COALESCE(NULLIF($6, ''), district),
|
||||||
|
practice_area = $7,
|
||||||
|
appeal_subtype = $8,
|
||||||
|
subject_tags = $9,
|
||||||
|
summary = COALESCE(NULLIF($10, ''), summary),
|
||||||
|
full_text = $11,
|
||||||
|
source_type = 'appeals_committee',
|
||||||
|
source_kind = 'internal_committee',
|
||||||
|
is_binding = $12,
|
||||||
|
document_id = COALESCE($13, document_id),
|
||||||
|
extraction_status = 'processing',
|
||||||
|
halacha_extraction_status = 'pending'
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
existing["id"], case_name, court, decision_date,
|
||||||
|
chair_name, district, practice_area, appeal_subtype,
|
||||||
|
tags_json, summary, full_text, is_binding, document_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO case_law (
|
||||||
|
case_number, case_name, court, date, chair_name, district,
|
||||||
|
subject_tags, summary, full_text,
|
||||||
|
source_kind, source_type, document_id,
|
||||||
|
extraction_status, halacha_extraction_status,
|
||||||
|
practice_area, appeal_subtype, is_binding
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6,
|
||||||
|
$7, $8, $9,
|
||||||
|
'internal_committee', 'appeals_committee', $10,
|
||||||
|
'processing', 'pending',
|
||||||
|
$11, $12, $13
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
case_number, case_name, court, decision_date, chair_name, district,
|
||||||
|
tags_json, summary, full_text,
|
||||||
|
document_id, practice_area, appeal_subtype, is_binding,
|
||||||
|
)
|
||||||
|
return _row_to_case_law(row)
|
||||||
|
|
||||||
|
|
||||||
async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
|
async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
|
||||||
"""Patch metadata fields on a case_law row.
|
"""Patch metadata fields on a case_law row.
|
||||||
|
|
||||||
@@ -1825,7 +1983,7 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
|
|||||||
precedent_level, is_binding.
|
precedent_level, is_binding.
|
||||||
"""
|
"""
|
||||||
allowed = {
|
allowed = {
|
||||||
"case_name", "court", "date", "practice_area", "appeal_subtype",
|
"case_number", "case_name", "court", "date", "practice_area", "appeal_subtype",
|
||||||
"subject_tags", "summary", "headnote", "key_quote", "source_url",
|
"subject_tags", "summary", "headnote", "key_quote", "source_url",
|
||||||
"source_type", "precedent_level", "is_binding",
|
"source_type", "precedent_level", "is_binding",
|
||||||
}
|
}
|
||||||
@@ -1892,10 +2050,21 @@ async def list_external_case_law(
|
|||||||
search: str = "",
|
search: str = "",
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
|
source_kind: str = "external_upload",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""List chair-uploaded precedents, with simple filters."""
|
"""List chair-uploaded precedents, with simple filters.
|
||||||
|
|
||||||
|
source_kind="all_committees" expands to: source_kind='internal_committee'
|
||||||
|
OR (source_kind='external_upload' AND source_type='appeals_committee').
|
||||||
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
conditions = ["source_kind = 'external_upload'"]
|
if source_kind == "all_committees":
|
||||||
|
conditions = [
|
||||||
|
"(source_kind = 'internal_committee' OR "
|
||||||
|
"(source_kind = 'external_upload' AND source_type = 'appeals_committee'))"
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
conditions = [f"source_kind = '{source_kind}'"]
|
||||||
params: list = []
|
params: list = []
|
||||||
idx = 1
|
idx = 1
|
||||||
if practice_area:
|
if practice_area:
|
||||||
@@ -1927,6 +2096,7 @@ async def list_external_case_law(
|
|||||||
SELECT id, case_number, case_name, court, date, practice_area,
|
SELECT id, case_number, case_name, court, date, practice_area,
|
||||||
appeal_subtype, source_type, precedent_level, is_binding,
|
appeal_subtype, source_type, precedent_level, is_binding,
|
||||||
summary, headnote, subject_tags, source_kind,
|
summary, headnote, subject_tags, source_kind,
|
||||||
|
chair_name, district,
|
||||||
extraction_status, halacha_extraction_status,
|
extraction_status, halacha_extraction_status,
|
||||||
metadata_extraction_requested_at,
|
metadata_extraction_requested_at,
|
||||||
halacha_extraction_requested_at,
|
halacha_extraction_requested_at,
|
||||||
@@ -2194,8 +2364,14 @@ async def search_precedent_library_semantic(
|
|||||||
subject_tag: str = "",
|
subject_tag: str = "",
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
include_halachot: bool = True,
|
include_halachot: bool = True,
|
||||||
|
source_kind: str = "external_upload",
|
||||||
|
district: str = "",
|
||||||
|
chair_name: str = "",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Semantic search over chair-uploaded precedents.
|
"""Semantic search over precedents filtered by source_kind.
|
||||||
|
|
||||||
|
source_kind='external_upload' → court rulings (default)
|
||||||
|
source_kind='internal_committee' → appeals-committee decisions
|
||||||
|
|
||||||
Returns merged halachot + chunks. Halachot are pre-distilled rules, so
|
Returns merged halachot + chunks. Halachot are pre-distilled rules, so
|
||||||
they get a small score boost. Only ``approved`` / ``published`` halachot
|
they get a small score boost. Only ``approved`` / ``published`` halachot
|
||||||
@@ -2204,7 +2380,7 @@ async def search_precedent_library_semantic(
|
|||||||
"""
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
halacha_filters = ["h.review_status IN ('approved', 'published')"]
|
halacha_filters = ["h.review_status IN ('approved', 'published')"]
|
||||||
chunk_filters = ["cl.source_kind = 'external_upload'"]
|
chunk_filters = [f"cl.source_kind = '{source_kind}'"]
|
||||||
h_params: list = [query_embedding, limit]
|
h_params: list = [query_embedding, limit]
|
||||||
c_params: list = [query_embedding, limit]
|
c_params: list = [query_embedding, limit]
|
||||||
h_idx = 3
|
h_idx = 3
|
||||||
@@ -2249,13 +2425,27 @@ async def search_precedent_library_semantic(
|
|||||||
halacha_filters.append(f"${h_idx} = ANY(h.subject_tags)")
|
halacha_filters.append(f"${h_idx} = ANY(h.subject_tags)")
|
||||||
h_params.append(subject_tag)
|
h_params.append(subject_tag)
|
||||||
h_idx += 1
|
h_idx += 1
|
||||||
|
if district:
|
||||||
|
halacha_filters.append(f"cl.district = ${h_idx}")
|
||||||
|
h_params.append(district)
|
||||||
|
h_idx += 1
|
||||||
|
chunk_filters.append(f"cl.district = ${c_idx}")
|
||||||
|
c_params.append(district)
|
||||||
|
c_idx += 1
|
||||||
|
if chair_name:
|
||||||
|
halacha_filters.append(f"cl.chair_name = ${h_idx}")
|
||||||
|
h_params.append(chair_name)
|
||||||
|
h_idx += 1
|
||||||
|
chunk_filters.append(f"cl.chair_name = ${c_idx}")
|
||||||
|
c_params.append(chair_name)
|
||||||
|
c_idx += 1
|
||||||
|
|
||||||
halacha_sql = f"""
|
halacha_sql = f"""
|
||||||
SELECT h.id AS halacha_id, h.case_law_id, h.rule_statement,
|
SELECT h.id AS halacha_id, h.case_law_id, h.rule_statement,
|
||||||
h.reasoning_summary, h.supporting_quote, h.page_reference,
|
h.reasoning_summary, h.supporting_quote, h.page_reference,
|
||||||
h.practice_areas, h.subject_tags, h.confidence, h.rule_type,
|
h.practice_areas, h.subject_tags, h.confidence, h.rule_type,
|
||||||
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
|
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
|
||||||
cl.precedent_level,
|
cl.precedent_level, cl.chair_name, cl.district,
|
||||||
1 - (h.embedding <=> $1) AS score
|
1 - (h.embedding <=> $1) AS score
|
||||||
FROM halachot h
|
FROM halachot h
|
||||||
JOIN case_law cl ON cl.id = h.case_law_id
|
JOIN case_law cl ON cl.id = h.case_law_id
|
||||||
@@ -2269,7 +2459,7 @@ async def search_precedent_library_semantic(
|
|||||||
SELECT pc.id AS chunk_id, pc.case_law_id, pc.content,
|
SELECT pc.id AS chunk_id, pc.case_law_id, pc.content,
|
||||||
pc.section_type, pc.page_number,
|
pc.section_type, pc.page_number,
|
||||||
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
|
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
|
||||||
cl.precedent_level, cl.practice_area,
|
cl.precedent_level, cl.practice_area, cl.chair_name, cl.district,
|
||||||
1 - (pc.embedding <=> $1) AS score
|
1 - (pc.embedding <=> $1) AS score
|
||||||
FROM precedent_chunks pc
|
FROM precedent_chunks pc
|
||||||
JOIN case_law cl ON cl.id = pc.case_law_id
|
JOIN case_law cl ON cl.id = pc.case_law_id
|
||||||
@@ -2308,19 +2498,17 @@ async def precedent_library_stats() -> dict:
|
|||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
total = await conn.fetchval(
|
total = await conn.fetchval(
|
||||||
"SELECT COUNT(*) FROM case_law WHERE source_kind = 'external_upload'"
|
"SELECT COUNT(*) FROM case_law"
|
||||||
)
|
)
|
||||||
by_practice = await conn.fetch(
|
by_practice = await conn.fetch(
|
||||||
"""SELECT practice_area, COUNT(*) AS n
|
"""SELECT practice_area, COUNT(*) AS n
|
||||||
FROM case_law
|
FROM case_law
|
||||||
WHERE source_kind = 'external_upload'
|
|
||||||
GROUP BY practice_area
|
GROUP BY practice_area
|
||||||
ORDER BY n DESC"""
|
ORDER BY n DESC"""
|
||||||
)
|
)
|
||||||
by_level = await conn.fetch(
|
by_level = await conn.fetch(
|
||||||
"""SELECT precedent_level, COUNT(*) AS n
|
"""SELECT precedent_level, COUNT(*) AS n
|
||||||
FROM case_law
|
FROM case_law
|
||||||
WHERE source_kind = 'external_upload'
|
|
||||||
GROUP BY precedent_level
|
GROUP BY precedent_level
|
||||||
ORDER BY n DESC"""
|
ORDER BY n DESC"""
|
||||||
)
|
)
|
||||||
@@ -2354,22 +2542,31 @@ async def precedent_library_stats() -> dict:
|
|||||||
|
|
||||||
async def request_metadata_extraction(case_law_id: UUID) -> bool:
|
async def request_metadata_extraction(case_law_id: UUID) -> bool:
|
||||||
"""Stamp ``metadata_extraction_requested_at`` for the local MCP worker
|
"""Stamp ``metadata_extraction_requested_at`` for the local MCP worker
|
||||||
to pick up. Returns False if the row is missing."""
|
to pick up. Returns False if the row is missing.
|
||||||
|
|
||||||
|
Originally restricted to ``source_kind='external_upload'`` (see git
|
||||||
|
blame). Opened to all source kinds 2026-05-06 — internal_committee
|
||||||
|
rows can also need re-extraction (e.g. corrupted subject_tags from
|
||||||
|
an early ingest pipeline). The extractor itself preserves user
|
||||||
|
values (``precedent_metadata_extractor.extract_and_apply`` only
|
||||||
|
fills empty fields), so this is safe.
|
||||||
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
result = await pool.execute(
|
result = await pool.execute(
|
||||||
"UPDATE case_law SET metadata_extraction_requested_at = now() "
|
"UPDATE case_law SET metadata_extraction_requested_at = now() "
|
||||||
"WHERE id = $1 AND source_kind = 'external_upload'",
|
"WHERE id = $1",
|
||||||
case_law_id,
|
case_law_id,
|
||||||
)
|
)
|
||||||
return result == "UPDATE 1"
|
return result == "UPDATE 1"
|
||||||
|
|
||||||
|
|
||||||
async def request_halacha_extraction(case_law_id: UUID) -> bool:
|
async def request_halacha_extraction(case_law_id: UUID) -> bool:
|
||||||
"""Same but for halacha extraction."""
|
"""Same but for halacha extraction. See note on
|
||||||
|
:func:`request_metadata_extraction` re: opening to all source kinds."""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
result = await pool.execute(
|
result = await pool.execute(
|
||||||
"UPDATE case_law SET halacha_extraction_requested_at = now() "
|
"UPDATE case_law SET halacha_extraction_requested_at = now() "
|
||||||
"WHERE id = $1 AND source_kind = 'external_upload'",
|
"WHERE id = $1",
|
||||||
case_law_id,
|
case_law_id,
|
||||||
)
|
)
|
||||||
return result == "UPDATE 1"
|
return result == "UPDATE 1"
|
||||||
@@ -2389,12 +2586,15 @@ async def list_pending_extraction_requests(
|
|||||||
else "halacha_extraction_requested_at"
|
else "halacha_extraction_requested_at"
|
||||||
)
|
)
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
|
# Drop the legacy ``source_kind = 'external_upload'`` filter — without it
|
||||||
|
# internal_committee rows could be stamped (we opened that gate in
|
||||||
|
# request_metadata_extraction / request_halacha_extraction) but stayed
|
||||||
|
# invisible to the worker forever.
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
f"""SELECT id, case_number, case_name, court, date,
|
f"""SELECT id, case_number, case_name, court, date,
|
||||||
practice_area, is_binding, {col} AS requested_at
|
practice_area, is_binding, {col} AS requested_at
|
||||||
FROM case_law
|
FROM case_law
|
||||||
WHERE {col} IS NOT NULL
|
WHERE {col} IS NOT NULL
|
||||||
AND source_kind = 'external_upload'
|
|
||||||
ORDER BY {col} ASC
|
ORDER BY {col} ASC
|
||||||
LIMIT $1""",
|
LIMIT $1""",
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ _INTERIM_BLOCK_ORDER = [
|
|||||||
"block-bet", # panel (skipped if empty)
|
"block-bet", # panel (skipped if empty)
|
||||||
"block-gimel", # parties (skipped if empty)
|
"block-gimel", # parties (skipped if empty)
|
||||||
"block-dalet", # "החלטה" title (skipped if empty)
|
"block-dalet", # "החלטה" title (skipped if empty)
|
||||||
|
"block-he", # פתיחה ניטרלית (skipped if empty — opt-in for pre-ruling drafts)
|
||||||
"block-vav", # רקע עובדתי
|
"block-vav", # רקע עובדתי
|
||||||
"block-tet", # תכניות + היתרים (extended)
|
"block-tet", # תכניות + היתרים (extended)
|
||||||
"block-zayin", # טענות הצדדים
|
"block-zayin", # טענות הצדדים
|
||||||
|
|||||||
@@ -88,8 +88,15 @@ async def search_precedent_library_hybrid(
|
|||||||
is_binding: bool | None = None,
|
is_binding: bool | None = None,
|
||||||
subject_tag: str = "",
|
subject_tag: str = "",
|
||||||
include_halachot: bool = True,
|
include_halachot: bool = True,
|
||||||
|
source_kind: str = "external_upload",
|
||||||
|
district: str = "",
|
||||||
|
chair_name: str = "",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Hybrid wrapper for precedent-library search."""
|
"""Hybrid wrapper for precedent-library search.
|
||||||
|
|
||||||
|
source_kind='external_upload' → court rulings (default)
|
||||||
|
source_kind='internal_committee' → appeals-committee decisions
|
||||||
|
"""
|
||||||
fetch_k = max(limit, config.VOYAGE_RERANK_FETCH_K) if config.MULTIMODAL_ENABLED else limit
|
fetch_k = max(limit, config.VOYAGE_RERANK_FETCH_K) if config.MULTIMODAL_ENABLED else limit
|
||||||
|
|
||||||
async def _base(limit: int) -> list[dict]:
|
async def _base(limit: int) -> list[dict]:
|
||||||
@@ -103,6 +110,9 @@ async def search_precedent_library_hybrid(
|
|||||||
subject_tag=subject_tag,
|
subject_tag=subject_tag,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
include_halachot=include_halachot,
|
include_halachot=include_halachot,
|
||||||
|
source_kind=source_kind,
|
||||||
|
district=district,
|
||||||
|
chair_name=chair_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
text_results = await rerank.maybe_rerank(
|
text_results = await rerank.maybe_rerank(
|
||||||
|
|||||||
376
mcp-server/src/legal_mcp/services/internal_decisions.py
Normal file
376
mcp-server/src/legal_mcp/services/internal_decisions.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> 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.
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
case_law_id = UUID(str(record["id"]))
|
||||||
|
|
||||||
|
try:
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -257,11 +257,19 @@ async def reextract_halachot(
|
|||||||
case_law_id = UUID(case_law_id)
|
case_law_id = UUID(case_law_id)
|
||||||
|
|
||||||
record = await db.get_case_law(case_law_id)
|
record = await db.get_case_law(case_law_id)
|
||||||
if not record or record.get("source_kind") != "external_upload":
|
if not record:
|
||||||
raise ValueError("precedent not found or not chair-uploaded")
|
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, "מחלץ הלכות מחדש")
|
await progress("extracting_halachot", 50, "מחלץ הלכות מחדש")
|
||||||
result = await halacha_extractor.extract(case_law_id)
|
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(
|
await progress(
|
||||||
"completed",
|
"completed",
|
||||||
100,
|
100,
|
||||||
@@ -402,11 +410,16 @@ async def reextract_metadata(
|
|||||||
case_law_id = UUID(case_law_id)
|
case_law_id = UUID(case_law_id)
|
||||||
|
|
||||||
record = await db.get_case_law(case_law_id)
|
record = await db.get_case_law(case_law_id)
|
||||||
if not record or record.get("source_kind") != "external_upload":
|
if not record:
|
||||||
raise ValueError("precedent not found or not chair-uploaded")
|
raise ValueError("precedent not found")
|
||||||
|
# See note in db.request_metadata_extraction — opened to all source kinds.
|
||||||
|
|
||||||
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)")
|
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)")
|
||||||
result = await precedent_metadata_extractor.extract_and_apply(case_law_id)
|
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 []
|
fields = result.get("fields") or []
|
||||||
msg = (
|
msg = (
|
||||||
f"מולאו {len(fields)} שדות: {', '.join(fields)}"
|
f"מולאו {len(fields)} שדות: {', '.join(fields)}"
|
||||||
@@ -425,13 +438,14 @@ async def delete_precedent(case_law_id: UUID | str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def get_precedent(case_law_id: UUID | str) -> dict | None:
|
async def get_precedent(case_law_id: UUID | str) -> dict | None:
|
||||||
"""Get a precedent with its halachot attached."""
|
"""Get a precedent with its halachot and related cases attached."""
|
||||||
if isinstance(case_law_id, str):
|
if isinstance(case_law_id, str):
|
||||||
case_law_id = UUID(case_law_id)
|
case_law_id = UUID(case_law_id)
|
||||||
record = await db.get_case_law(case_law_id)
|
record = await db.get_case_law(case_law_id)
|
||||||
if not record:
|
if not record:
|
||||||
return None
|
return None
|
||||||
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
|
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
|
||||||
|
record["related_cases"] = await db.get_case_law_relations(case_law_id)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
|
|||||||
"decision_date_iso": "YYYY-MM-DD — תאריך מתן ההחלטה כפי שמופיע בטקסט (בכותרת או בחתימה הסופית). אם לא ניתן לזהות במדויק — מחרוזת ריקה.",
|
"decision_date_iso": "YYYY-MM-DD — תאריך מתן ההחלטה כפי שמופיע בטקסט (בכותרת או בחתימה הסופית). אם לא ניתן לזהות במדויק — מחרוזת ריקה.",
|
||||||
"precedent_level": "אחד מ-4: 'עליון' / 'מנהלי' / 'ועדת_ערר_ארצית' / 'ועדת_ערר_מחוזית'. בחר לפי הערכאה שמסומנת בכותרת הפסק. אם לא ברור — מחרוזת ריקה.",
|
"precedent_level": "אחד מ-4: 'עליון' / 'מנהלי' / 'ועדת_ערר_ארצית' / 'ועדת_ערר_מחוזית'. בחר לפי הערכאה שמסומנת בכותרת הפסק. אם לא ברור — מחרוזת ריקה.",
|
||||||
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
|
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
|
||||||
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות."
|
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
|
||||||
|
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות."
|
||||||
}
|
}
|
||||||
|
|
||||||
## כללי איכות
|
## כללי איכות
|
||||||
@@ -161,12 +162,15 @@ async def extract_metadata(case_law_id: UUID | str) -> dict:
|
|||||||
out["source_type"] = st
|
out["source_type"] = st
|
||||||
if isinstance(result.get("court"), str):
|
if isinstance(result.get("court"), str):
|
||||||
out["court"] = result["court"].strip()
|
out["court"] = result["court"].strip()
|
||||||
|
if isinstance(result.get("case_number_clean"), str):
|
||||||
|
out["case_number_clean"] = result["case_number_clean"].strip()
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def apply_to_record(
|
async def apply_to_record(
|
||||||
case_law_id: UUID | str,
|
case_law_id: UUID | str,
|
||||||
suggested: dict,
|
suggested: dict,
|
||||||
|
overwrite_case_number: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Merge suggested metadata into the case_law row, filling ONLY empty fields.
|
"""Merge suggested metadata into the case_law row, filling ONLY empty fields.
|
||||||
|
|
||||||
@@ -178,6 +182,9 @@ async def apply_to_record(
|
|||||||
case_name has special handling: if the current case_name equals the
|
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
|
case_number (a tell-tale sign of the upload form sending the long
|
||||||
citation into both fields), treat it as empty and overwrite.
|
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):
|
if isinstance(case_law_id, str):
|
||||||
case_law_id = UUID(case_law_id)
|
case_law_id = UUID(case_law_id)
|
||||||
@@ -216,7 +223,17 @@ async def apply_to_record(
|
|||||||
fields_to_update["key_quote"] = s
|
fields_to_update["key_quote"] = s
|
||||||
|
|
||||||
cur_tags = record.get("subject_tags") or []
|
cur_tags = record.get("subject_tags") or []
|
||||||
if not cur_tags:
|
# 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 []
|
sug_tags = suggested.get("subject_tags") or []
|
||||||
if sug_tags:
|
if sug_tags:
|
||||||
fields_to_update["subject_tags"] = sug_tags
|
fields_to_update["subject_tags"] = sug_tags
|
||||||
@@ -250,6 +267,11 @@ async def apply_to_record(
|
|||||||
if c:
|
if c:
|
||||||
fields_to_update["court"] = c
|
fields_to_update["court"] = c
|
||||||
|
|
||||||
|
if overwrite_case_number:
|
||||||
|
cn = (suggested.get("case_number_clean") or "").strip()
|
||||||
|
if cn:
|
||||||
|
fields_to_update["case_number"] = cn
|
||||||
|
|
||||||
if not fields_to_update:
|
if not fields_to_update:
|
||||||
return {"updated": False, "fields": []}
|
return {"updated": False, "fields": []}
|
||||||
|
|
||||||
@@ -257,12 +279,15 @@ async def apply_to_record(
|
|||||||
return {"updated": True, "fields": list(fields_to_update.keys())}
|
return {"updated": True, "fields": list(fields_to_update.keys())}
|
||||||
|
|
||||||
|
|
||||||
async def extract_and_apply(case_law_id: UUID | str) -> dict:
|
async def extract_and_apply(
|
||||||
|
case_law_id: UUID | str,
|
||||||
|
overwrite_case_number: bool = False,
|
||||||
|
) -> dict:
|
||||||
"""Convenience wrapper: extract → merge into row → return summary."""
|
"""Convenience wrapper: extract → merge into row → return summary."""
|
||||||
suggested = await extract_metadata(case_law_id)
|
suggested = await extract_metadata(case_law_id)
|
||||||
if not suggested:
|
if not suggested:
|
||||||
return {"status": "no_metadata", "fields": []}
|
return {"status": "no_metadata", "fields": []}
|
||||||
result = await apply_to_record(case_law_id, suggested)
|
result = await apply_to_record(case_law_id, suggested, overwrite_case_number=overwrite_case_number)
|
||||||
return {
|
return {
|
||||||
"status": "completed" if result["updated"] else "no_changes",
|
"status": "completed" if result["updated"] else "no_changes",
|
||||||
"fields": result["fields"],
|
"fields": result["fields"],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from uuid import UUID
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from legal_mcp import config
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -370,3 +370,66 @@ async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
|||||||
result["removed_files"] = True
|
result["removed_files"] = True
|
||||||
|
|
||||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
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)
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
|
|||||||
# Blocks written for the interim draft, in display order.
|
# Blocks written for the interim draft, in display order.
|
||||||
# This is the same content the chair sees in the final decision (same template,
|
# This is the same content the chair sees in the final decision (same template,
|
||||||
# same skill, same prompts) — minus opening, ruling, summary, signatures.
|
# 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:
|
async def extract_appraiser_facts(case_number: str) -> str:
|
||||||
|
|||||||
@@ -116,6 +116,54 @@ async def precedent_library_get(case_law_id: str) -> str:
|
|||||||
return _ok(record)
|
return _ok(record)
|
||||||
|
|
||||||
|
|
||||||
|
async def precedent_link_cases(
|
||||||
|
case_law_id_a: str,
|
||||||
|
case_law_id_b: str,
|
||||||
|
relation_type: str = "same_case_chain",
|
||||||
|
) -> str:
|
||||||
|
"""קישור שתי פסיקות כקשורות זו לזו (דו-כיווני). idempotent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_law_id_a: UUID של פסיקה ראשונה.
|
||||||
|
case_law_id_b: UUID של פסיקה שנייה.
|
||||||
|
relation_type: same_case_chain | overruled_by | distinguished
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
a = UUID(case_law_id_a)
|
||||||
|
b = UUID(case_law_id_b)
|
||||||
|
except ValueError:
|
||||||
|
return _err("case_law_id לא תקין")
|
||||||
|
rec_a = await db.get_case_law(a)
|
||||||
|
rec_b = await db.get_case_law(b)
|
||||||
|
if not rec_a:
|
||||||
|
return _err(f"פסיקה {case_law_id_a} לא נמצאה")
|
||||||
|
if not rec_b:
|
||||||
|
return _err(f"פסיקה {case_law_id_b} לא נמצאה")
|
||||||
|
await db.add_case_law_relation(a, b, relation_type)
|
||||||
|
return _ok({
|
||||||
|
"linked": True,
|
||||||
|
"relation_type": relation_type,
|
||||||
|
"a": {"id": case_law_id_a, "case_number": rec_a.get("case_number"), "court": rec_a.get("court")},
|
||||||
|
"b": {"id": case_law_id_b, "case_number": rec_b.get("case_number"), "court": rec_b.get("court")},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
|
||||||
|
"""הסרת קישור בין שתי פסיקות (דו-כיווני).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_law_id_a: UUID של פסיקה ראשונה.
|
||||||
|
case_law_id_b: UUID של פסיקה שנייה.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
a = UUID(case_law_id_a)
|
||||||
|
b = UUID(case_law_id_b)
|
||||||
|
except ValueError:
|
||||||
|
return _err("case_law_id לא תקין")
|
||||||
|
await db.remove_case_law_relation(a, b)
|
||||||
|
return _ok({"unlinked": True, "a": case_law_id_a, "b": case_law_id_b})
|
||||||
|
|
||||||
|
|
||||||
async def precedent_library_delete(case_law_id: str) -> str:
|
async def precedent_library_delete(case_law_id: str) -> str:
|
||||||
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
|
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -179,3 +179,63 @@ async def find_similar_cases(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
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,
|
||||||
|
) -> str:
|
||||||
|
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: שאילתת חיפוש בעברית
|
||||||
|
practice_area: rishuy_uvniya / betterment_levy / compensation_197
|
||||||
|
appeal_subtype: סינון לפי תת-סוג ערר
|
||||||
|
district: מחוז — ירושלים / מרכז / תל אביב / צפון / דרום / ארצי. ריק = כל המחוזות
|
||||||
|
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
|
||||||
|
limit: מספר תוצאות מקסימלי
|
||||||
|
include_halachot: האם לכלול הלכות שחולצו
|
||||||
|
"""
|
||||||
|
from legal_mcp.services import internal_decisions as int_svc
|
||||||
|
|
||||||
|
results = await int_svc.search_internal(
|
||||||
|
query,
|
||||||
|
practice_area=practice_area,
|
||||||
|
appeal_subtype=appeal_subtype,
|
||||||
|
district=district,
|
||||||
|
chair_name=chair_name,
|
||||||
|
limit=limit,
|
||||||
|
include_halachot=include_halachot,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return "לא נמצאו החלטות ועדת ערר רלוונטיות."
|
||||||
|
|
||||||
|
formatted = []
|
||||||
|
for r in results:
|
||||||
|
entry = {
|
||||||
|
"score": round(float(r["score"]), 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"),
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
formatted.append(entry)
|
||||||
|
|
||||||
|
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import db
|
from legal_mcp.services import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def workflow_status(case_number: str) -> str:
|
async def workflow_status(case_number: str) -> str:
|
||||||
"""סטטוס תהליך עבודה מלא לתיק - מסמכים, עיבוד, טיוטות.
|
"""סטטוס תהליך עבודה מלא לתיק - מסמכים, עיבוד, טיוטות.
|
||||||
@@ -315,10 +318,29 @@ async def ingest_final_version(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = await learning_loop.process_final_version(case_id, final_text)
|
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:
|
except ValueError as e:
|
||||||
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
|
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# 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 ──────────────────────────────────────────
|
# ── Chair feedback tools ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
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,10 @@
|
|||||||
|
|
||||||
| Script | Type | Purpose | Scheduled |
|
| 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 חוזר. | חד-פעמי (בוצע) |
|
||||||
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
||||||
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
||||||
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
||||||
@@ -50,6 +54,9 @@
|
|||||||
| `seed-appeals.py` | seeding תיקי ערר ראשוניים ל-DB | MCP: `case_create()` |
|
| `seed-appeals.py` | seeding תיקי ערר ראשוניים ל-DB | MCP: `case_create()` |
|
||||||
| `seed-knowledge.py` | seeding לקחים, ביטויי מעבר, פסיקה | MCP: `record_chair_feedback()`, `precedent_attach()` |
|
| `seed-knowledge.py` | seeding לקחים, ביטויי מעבר, פסיקה | MCP: `record_chair_feedback()`, `precedent_attach()` |
|
||||||
| `validate-decision.py` | ולידציה מול block-schema | MCP: `validate_decision()` + `qa_validator.py` |
|
| `validate-decision.py` | ולידציה מול block-schema | MCP: `validate_decision()` + `qa_validator.py` |
|
||||||
|
| `run_curator_deepseek_test.sh` | A/B test #1 (2026-05-05) — Hermes Curator על CMP-78 דרך DeepSeek V4-Pro ב-`provider:custom`, ללא interaction. תוצאה: 6:33 דק׳, 5 ממצאי סגנון/לקסיקון, פי 3 מהיר מ-Sonnet baseline (CMP-80) ופי ~20 זול. **הסקריפט נקודתי לתיק 1130-25 — לא להריץ שוב** | החלפת Curator לאדפטר DeepSeek מקומי (בתהליך) |
|
||||||
|
| `run_curator_deepseek_test_v2.sh` | A/B test #2 (2026-05-05) — אותו run אבל עם interaction. תוצאה: 9:08 דק׳, 5 ממצאים, היחיד מ-4 הריצות שזיהה תוצאה עובדתית נכונה (קבלה חלקית). interaction נכשל ב-API ("Agent run id required" בריצה ידנית). | החלפת Curator לאדפטר DeepSeek מקומי |
|
||||||
|
| `run_curator_sonnet_rerun.sh` | A/B test #3 (2026-05-05) — ריצה חוזרת של Sonnet 4.5 על אותו CMP-78. תוצאה: 12:52 דק׳ (לעומת 20:13 בריצה המקורית — כי בלי לולאת interaction.json). זיהה תוצאה שגויה ("דחייה") **בעקביות עם הריצה המקורית** — Sonnet עקבי-בטעות, DeepSeek אקראי. | בדיקה חד-פעמית — לא להריץ שוב |
|
||||||
|
|
||||||
## סקריפטים שנמחקו (git history בלבד)
|
## סקריפטים שנמחקו (git history בלבד)
|
||||||
|
|
||||||
|
|||||||
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())
|
||||||
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
|
||||||
382
scripts/sync_agents_across_companies.py
Normal file
382
scripts/sync_agents_across_companies.py
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""sync_agents_across_companies.py — Mirror agent configs from CMP (1xxx) to CMPA (8xxx).
|
||||||
|
|
||||||
|
Gap #25: Paperclip enforces ``agents.company_id NOT NULL``, so we have 14
|
||||||
|
agents (7 × 2 companies). Without sync, settings drift between the master
|
||||||
|
(CMP, 1xxx) and the mirror (CMPA, 8xxx). This script copies the relevant
|
||||||
|
fields one-way: CMP → CMPA.
|
||||||
|
|
||||||
|
Design: "אל-כשל" — backup before apply, idempotent, dry-run by default,
|
||||||
|
clear field-level diff, rollback path printed on failure.
|
||||||
|
|
||||||
|
Synced fields:
|
||||||
|
- adapter_config.{model, effort, timeoutSec, maxTurnsPerRun,
|
||||||
|
instructionsBundleMode, instructionsRootPath,
|
||||||
|
instructionsEntryFile, instructionsFilePath,
|
||||||
|
dangerouslySkipPermissions, extraArgs, cwd}
|
||||||
|
- adapter_config.paperclipSkillSync.desiredSkills (filtered for skills
|
||||||
|
that exist in the mirror company — local skills like
|
||||||
|
``local/eba6210d5a/legal-decision`` only exist in CMP)
|
||||||
|
- runtime_config (full replace — heartbeat config)
|
||||||
|
- budget_monthly_cents
|
||||||
|
- metadata, icon, title, role
|
||||||
|
|
||||||
|
Not synced (intentionally per-company):
|
||||||
|
- id, company_id, name, reports_to, default_environment_id
|
||||||
|
- adapter_type, agent_api_keys
|
||||||
|
- status, pause_reason, paused_at, last_heartbeat_at
|
||||||
|
- spent_monthly_cents (separate usage)
|
||||||
|
- permissions (per-company access policies)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python sync_agents_across_companies.py --verify # show drift only
|
||||||
|
python sync_agents_across_companies.py --dry-run # show plan
|
||||||
|
python sync_agents_across_companies.py --apply # backup + apply
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
PAPERCLIP_BOARD_API_KEY (Infisical: /paperclip @ nautilus)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
PAPERCLIP_DB_URL = os.environ.get(
|
||||||
|
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
|
||||||
|
)
|
||||||
|
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
|
||||||
|
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
|
||||||
|
|
||||||
|
BACKUP_DIR = Path("/home/chaim/.paperclip/instances/default/data/backups/manual")
|
||||||
|
|
||||||
|
CMP_COMPANY_ID = "42a7acd0-30c5-4cbd-ac97-7424f65df294" # MASTER (1xxx)
|
||||||
|
CMPA_COMPANY_ID = "8639e837-4c9d-47fa-a76b-95788d651896" # MIRROR (8xxx)
|
||||||
|
|
||||||
|
# adapter_config keys to sync (top-level only; paperclipSkillSync handled separately)
|
||||||
|
ADAPTER_CONFIG_SYNC_KEYS = [
|
||||||
|
"model", "effort", "timeoutSec", "maxTurnsPerRun",
|
||||||
|
"instructionsBundleMode", "instructionsRootPath", "instructionsEntryFile", "instructionsFilePath",
|
||||||
|
"dangerouslySkipPermissions", "extraArgs", "cwd",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Top-level agent fields to sync
|
||||||
|
TOP_LEVEL_SYNC_FIELDS = [
|
||||||
|
"budget_monthly_cents", "metadata", "icon", "title", "role",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg: str) -> None:
|
||||||
|
print(f"❌ {msg}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_agents(conn: asyncpg.Connection, company_id: str) -> list[dict[str, Any]]:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT id::text, name, role, title, icon,
|
||||||
|
adapter_type, adapter_config, runtime_config, metadata,
|
||||||
|
budget_monthly_cents
|
||||||
|
FROM agents
|
||||||
|
WHERE company_id = $1::uuid
|
||||||
|
ORDER BY name
|
||||||
|
""",
|
||||||
|
company_id,
|
||||||
|
)
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
# asyncpg returns jsonb as str; parse
|
||||||
|
for k in ("adapter_config", "runtime_config", "metadata"):
|
||||||
|
if isinstance(d.get(k), str):
|
||||||
|
d[k] = json.loads(d[k]) if d[k] else None
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_company_skills(conn: asyncpg.Connection, company_id: str) -> set[str]:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT key FROM company_skills WHERE company_id = $1::uuid",
|
||||||
|
company_id,
|
||||||
|
)
|
||||||
|
return {r["key"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _get(d: dict | None, key: str, default=None):
|
||||||
|
return d.get(key, default) if isinstance(d, dict) else default
|
||||||
|
|
||||||
|
|
||||||
|
def compute_diff(master: dict, mirror: dict, mirror_skills: set[str]) -> dict[str, Any]:
|
||||||
|
"""Return a dict describing what would change in mirror to match master.
|
||||||
|
Empty dict = in sync."""
|
||||||
|
diff: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Top-level fields
|
||||||
|
for field in TOP_LEVEL_SYNC_FIELDS:
|
||||||
|
if master.get(field) != mirror.get(field):
|
||||||
|
diff[field] = {"from": mirror.get(field), "to": master.get(field)}
|
||||||
|
|
||||||
|
# adapter_config (per key)
|
||||||
|
m_ac = master.get("adapter_config") or {}
|
||||||
|
r_ac = mirror.get("adapter_config") or {}
|
||||||
|
ac_changes = {}
|
||||||
|
for key in ADAPTER_CONFIG_SYNC_KEYS:
|
||||||
|
if _get(m_ac, key) != _get(r_ac, key):
|
||||||
|
ac_changes[key] = {"from": _get(r_ac, key), "to": _get(m_ac, key)}
|
||||||
|
if ac_changes:
|
||||||
|
diff["adapter_config"] = ac_changes
|
||||||
|
|
||||||
|
# paperclipSkillSync.desiredSkills — compare as a SUBSET check.
|
||||||
|
# The Paperclip API auto-adds company-level required runtime skills
|
||||||
|
# (e.g. paperclip-dev) to the desiredSkills list, so the mirror can
|
||||||
|
# legitimately have MORE skills than master. We only need master's
|
||||||
|
# filtered skills to be a subset of mirror's actual list.
|
||||||
|
master_desired = list((_get(m_ac, "paperclipSkillSync") or {}).get("desiredSkills") or [])
|
||||||
|
mirror_desired = list((_get(r_ac, "paperclipSkillSync") or {}).get("desiredSkills") or [])
|
||||||
|
master_filtered = [s for s in master_desired if s in mirror_skills]
|
||||||
|
skipped = [s for s in master_desired if s not in mirror_skills]
|
||||||
|
missing_in_mirror = set(master_filtered) - set(mirror_desired)
|
||||||
|
if missing_in_mirror:
|
||||||
|
diff["paperclipSkillSync.desiredSkills"] = {
|
||||||
|
"from": mirror_desired,
|
||||||
|
"to": master_filtered,
|
||||||
|
"missing_in_mirror": sorted(missing_in_mirror),
|
||||||
|
"skipped_unavailable_in_mirror": skipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
# runtime_config (full replace)
|
||||||
|
if (master.get("runtime_config") or {}) != (mirror.get("runtime_config") or {}):
|
||||||
|
diff["runtime_config"] = {"from": mirror.get("runtime_config"), "to": master.get("runtime_config")}
|
||||||
|
|
||||||
|
return diff
|
||||||
|
|
||||||
|
|
||||||
|
def backup_agents_table() -> Path:
|
||||||
|
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
|
out = BACKUP_DIR / f"agents-pre-cross-company-sync-{stamp}.sql"
|
||||||
|
env = {**os.environ, "PGPASSWORD": "paperclip"}
|
||||||
|
subprocess.run(
|
||||||
|
["pg_dump", "-h", "127.0.0.1", "-p", "54329", "-U", "paperclip",
|
||||||
|
"-d", "paperclip", "-t", "agents", "--data-only", "-f", str(out)],
|
||||||
|
check=True, env=env,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _short(value, max_len=80) -> str:
|
||||||
|
s = json.dumps(value, ensure_ascii=False, default=str) if not isinstance(value, str) else value
|
||||||
|
if len(s) > max_len:
|
||||||
|
return s[:max_len] + "..."
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def print_diff(agent_name: str, diff: dict, master_id: str, mirror_id: str) -> None:
|
||||||
|
if not diff:
|
||||||
|
print(f" ✓ {agent_name:14s} — in sync (no changes)")
|
||||||
|
return
|
||||||
|
print(f" ⚠ {agent_name:14s} — {len(diff)} change(s): master={master_id[:8]}… → mirror={mirror_id[:8]}…")
|
||||||
|
for key, change in diff.items():
|
||||||
|
if key == "adapter_config":
|
||||||
|
for ac_key, ac_change in change.items():
|
||||||
|
print(f" adapter_config.{ac_key}: {_short(ac_change['from'])} → {_short(ac_change['to'])}")
|
||||||
|
elif key == "paperclipSkillSync.desiredSkills":
|
||||||
|
print(f" paperclipSkillSync.desiredSkills: {len(change['from'])} → {len(change['to'])} skills")
|
||||||
|
for s in change.get("skipped_unavailable_in_mirror", []):
|
||||||
|
print(f" (skipped, not in mirror company: {s})")
|
||||||
|
elif key == "runtime_config":
|
||||||
|
print(f" runtime_config: full replace")
|
||||||
|
print(f" from: {_short(change['from'], 100)}")
|
||||||
|
print(f" to: {_short(change['to'], 100)}")
|
||||||
|
else:
|
||||||
|
print(f" {key}: {_short(change['from'])} → {_short(change['to'])}")
|
||||||
|
|
||||||
|
|
||||||
|
async def call_patch(agent_id: str, body: dict) -> tuple[int, dict]:
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
fail("PAPERCLIP_BOARD_API_KEY not set")
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
|
||||||
|
"X-Paperclip-Run-Id": "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
url = f"{PAPERCLIP_API_URL}/api/agents/{agent_id}"
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.patch(url, headers=headers, json=body)
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
data = {"raw": resp.text[:500]}
|
||||||
|
return resp.status_code, data
|
||||||
|
|
||||||
|
|
||||||
|
async def call_skill_sync(agent_id: str, desired_skills: list[str]) -> tuple[int, dict]:
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
fail("PAPERCLIP_BOARD_API_KEY not set")
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
|
||||||
|
"X-Paperclip-Run-Id": "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
url = f"{PAPERCLIP_API_URL}/api/agents/{agent_id}/skills/sync"
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.post(url, headers=headers, json={"desiredSkills": desired_skills})
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
data = {"raw": resp.text[:500]}
|
||||||
|
return resp.status_code, data
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
|
||||||
|
"""Apply the computed diff to the mirror agent. Returns list of error strings."""
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# Build PATCH body for top-level + adapter_config (skills handled separately)
|
||||||
|
patch_body: dict[str, Any] = {}
|
||||||
|
for field in TOP_LEVEL_SYNC_FIELDS:
|
||||||
|
if field in diff:
|
||||||
|
# snake_case → camelCase for the API
|
||||||
|
api_key = {
|
||||||
|
"budget_monthly_cents": "budgetMonthlyCents",
|
||||||
|
"metadata": "metadata",
|
||||||
|
"icon": "icon",
|
||||||
|
"title": "title",
|
||||||
|
"role": "role",
|
||||||
|
}[field]
|
||||||
|
patch_body[api_key] = diff[field]["to"]
|
||||||
|
if "adapter_config" in diff:
|
||||||
|
patch_body["adapterConfig"] = {k: v["to"] for k, v in diff["adapter_config"].items()}
|
||||||
|
if "runtime_config" in diff:
|
||||||
|
patch_body["runtimeConfig"] = diff["runtime_config"]["to"]
|
||||||
|
|
||||||
|
if patch_body:
|
||||||
|
status, data = await call_patch(mirror_id, patch_body)
|
||||||
|
if status >= 400:
|
||||||
|
errors.append(f"PATCH HTTP {status}: {json.dumps(data)[:300]}")
|
||||||
|
else:
|
||||||
|
print(f" ✓ PATCH applied ({len(patch_body)} top-level keys)")
|
||||||
|
|
||||||
|
# Skills via dedicated endpoint (creates 'skill-sync' revision)
|
||||||
|
if "paperclipSkillSync.desiredSkills" in diff:
|
||||||
|
desired = diff["paperclipSkillSync.desiredSkills"]["to"]
|
||||||
|
status, data = await call_skill_sync(mirror_id, desired)
|
||||||
|
if status >= 400:
|
||||||
|
errors.append(f"skills/sync HTTP {status}: {json.dumps(data)[:300]}")
|
||||||
|
else:
|
||||||
|
print(f" ✓ skills/sync applied ({len(desired)} skills)")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
g = p.add_mutually_exclusive_group(required=True)
|
||||||
|
g.add_argument("--verify", action="store_true", help="Show current drift, no changes")
|
||||||
|
g.add_argument("--dry-run", action="store_true", help="Show what would change")
|
||||||
|
g.add_argument("--apply", action="store_true", help="Backup + apply changes")
|
||||||
|
p.add_argument("--only", help="Sync only the named agent (e.g., 'עוזר משפטי')")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
master_agents = await fetch_agents(conn, CMP_COMPANY_ID)
|
||||||
|
mirror_agents = await fetch_agents(conn, CMPA_COMPANY_ID)
|
||||||
|
mirror_skills = await fetch_company_skills(conn, CMPA_COMPANY_ID)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
mirror_by_name = {a["name"]: a for a in mirror_agents}
|
||||||
|
|
||||||
|
print(f"\n=== Master (CMP, 1xxx): {len(master_agents)} agents ===")
|
||||||
|
print(f"=== Mirror (CMPA, 8xxx): {len(mirror_agents)} agents ===")
|
||||||
|
print(f"=== Mirror has {len(mirror_skills)} local skills available ===\n")
|
||||||
|
|
||||||
|
print(f"=== Drift report ===")
|
||||||
|
plan: list[tuple[dict, dict, dict]] = [] # (master, mirror, diff)
|
||||||
|
for m in master_agents:
|
||||||
|
if args.only and m["name"] != args.only:
|
||||||
|
continue
|
||||||
|
mirror = mirror_by_name.get(m["name"])
|
||||||
|
if not mirror:
|
||||||
|
print(f" ⚠ {m['name']:14s} — NOT FOUND in mirror (skipping; we never auto-create)")
|
||||||
|
continue
|
||||||
|
if m["adapter_type"] != mirror["adapter_type"]:
|
||||||
|
print(f" ⚠ {m['name']:14s} — adapter_type mismatch ({m['adapter_type']} vs {mirror['adapter_type']}) — SKIPPING")
|
||||||
|
continue
|
||||||
|
diff = compute_diff(m, mirror, mirror_skills)
|
||||||
|
print_diff(m["name"], diff, m["id"], mirror["id"])
|
||||||
|
if diff:
|
||||||
|
plan.append((m, mirror, diff))
|
||||||
|
|
||||||
|
if args.verify:
|
||||||
|
print(f"\n(verify mode — exiting without changes)")
|
||||||
|
print(f"\nSummary: {len(plan)} agent(s) need sync, {len(master_agents) - len(plan)} in sync")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not plan:
|
||||||
|
print(f"\n✓ All agents in sync — nothing to do.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"\n(dry-run mode — exiting without changes)\nRe-run with --apply to execute.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# APPLY
|
||||||
|
print(f"\n=== Backup ===")
|
||||||
|
backup_path = backup_agents_table()
|
||||||
|
print(f" ✓ {backup_path}")
|
||||||
|
|
||||||
|
print(f"\n=== Applying ({len(plan)} agents) ===")
|
||||||
|
all_errors: list[str] = []
|
||||||
|
for master, mirror, diff in plan:
|
||||||
|
print(f"\n → {master['name']} ({mirror['id']})")
|
||||||
|
errors = await apply_diff(mirror["id"], master["name"], diff)
|
||||||
|
if errors:
|
||||||
|
for e in errors:
|
||||||
|
print(f" ❌ {e}")
|
||||||
|
all_errors.extend([f"{master['name']}: {e}" for e in errors])
|
||||||
|
|
||||||
|
if all_errors:
|
||||||
|
print(f"\n=== ⚠️ {len(all_errors)} error(s) ===")
|
||||||
|
print(f"Rollback option: psql ... -f {backup_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\n=== ✓ Sync complete — re-running --verify to confirm ===\n")
|
||||||
|
# Re-verify
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
master_agents = await fetch_agents(conn, CMP_COMPANY_ID)
|
||||||
|
mirror_agents = await fetch_agents(conn, CMPA_COMPANY_ID)
|
||||||
|
mirror_skills = await fetch_company_skills(conn, CMPA_COMPANY_ID)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
mirror_by_name = {a["name"]: a for a in mirror_agents}
|
||||||
|
|
||||||
|
still_drifting = 0
|
||||||
|
for m in master_agents:
|
||||||
|
mirror = mirror_by_name.get(m["name"])
|
||||||
|
if not mirror or m["adapter_type"] != mirror["adapter_type"]:
|
||||||
|
continue
|
||||||
|
diff = compute_diff(m, mirror, mirror_skills)
|
||||||
|
if diff:
|
||||||
|
still_drifting += 1
|
||||||
|
print(f" ⚠ {m['name']:14s} — STILL has {len(diff)} change(s) after apply (review!)")
|
||||||
|
|
||||||
|
if still_drifting == 0:
|
||||||
|
print(f" ✓ All {len(master_agents)} agents in sync.")
|
||||||
|
else:
|
||||||
|
print(f"\n⚠️ {still_drifting} agents still drifting — investigate.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
191
scripts/sync_missing_agent_skills.py
Normal file
191
scripts/sync_missing_agent_skills.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""sync_missing_agent_skills.py — One-shot fix for Gap #28.
|
||||||
|
|
||||||
|
Adds the missing paperclipSkillSync to הגהת מסמכים and מנתח משפטי
|
||||||
|
in both companies (1xxx CMP, 8xxx CMPA). Idempotent: safe to re-run.
|
||||||
|
|
||||||
|
Design: "אל-כשל" — backup, dry-run mode, idempotent, clear errors.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python sync_missing_agent_skills.py --dry-run # show plan only
|
||||||
|
python sync_missing_agent_skills.py --apply # actually do it
|
||||||
|
python sync_missing_agent_skills.py --verify # check current state
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
PAPERCLIP_DB_URL = os.environ.get(
|
||||||
|
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
|
||||||
|
)
|
||||||
|
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
|
||||||
|
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
|
||||||
|
|
||||||
|
BACKUP_DIR = Path("/home/chaim/.paperclip/instances/default/data/backups/manual")
|
||||||
|
|
||||||
|
PAPERCLIP_BASE_SKILLS = [
|
||||||
|
"paperclipai/paperclip/paperclip",
|
||||||
|
"paperclipai/paperclip/paperclip-create-agent",
|
||||||
|
"paperclipai/paperclip/paperclip-create-plugin",
|
||||||
|
"paperclipai/paperclip/para-memory-files",
|
||||||
|
]
|
||||||
|
|
||||||
|
CMP_COMPANY_ID = "42a7acd0-30c5-4cbd-ac97-7424f65df294" # 1xxx — רישוי ובניה
|
||||||
|
CMPA_COMPANY_ID = "8639e837-4c9d-47fa-a76b-95788d651896" # 8xxx — היטלי השבחה
|
||||||
|
|
||||||
|
# Per-agent + per-company desired skills
|
||||||
|
PLAN: dict[tuple[str, str], list[str]] = {
|
||||||
|
# (agent_name, company_id) -> desired skills
|
||||||
|
("מנתח משפטי", CMP_COMPANY_ID): PAPERCLIP_BASE_SKILLS + ["local/eba6210d5a/legal-decision"],
|
||||||
|
("מנתח משפטי", CMPA_COMPANY_ID): PAPERCLIP_BASE_SKILLS, # CMPA has no local skills
|
||||||
|
("הגהת מסמכים", CMP_COMPANY_ID): PAPERCLIP_BASE_SKILLS,
|
||||||
|
("הגהת מסמכים", CMPA_COMPANY_ID): PAPERCLIP_BASE_SKILLS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg: str) -> None:
|
||||||
|
print(f"❌ {msg}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_targets() -> list[dict[str, Any]]:
|
||||||
|
"""Return rows for the agents we plan to update."""
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT a.id, a.name, a.company_id::text as company_id,
|
||||||
|
COALESCE(
|
||||||
|
jsonb_array_length(a.adapter_config->'paperclipSkillSync'->'desiredSkills'),
|
||||||
|
0
|
||||||
|
) as current_skill_count
|
||||||
|
FROM agents a
|
||||||
|
WHERE a.name IN ('מנתח משפטי', 'הגהת מסמכים')
|
||||||
|
ORDER BY a.name, a.company_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def backup_agents_table() -> Path:
|
||||||
|
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
|
out = BACKUP_DIR / f"agents-pre-skill-sync-{stamp}.sql"
|
||||||
|
env = {**os.environ, "PGPASSWORD": "paperclip"}
|
||||||
|
subprocess.run(
|
||||||
|
["pg_dump", "-h", "127.0.0.1", "-p", "54329", "-U", "paperclip",
|
||||||
|
"-d", "paperclip", "-t", "agents", "--data-only", "-f", str(out)],
|
||||||
|
check=True, env=env,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def call_skill_sync(agent_id: str, desired_skills: list[str]) -> tuple[int, dict[str, Any]]:
|
||||||
|
"""Call POST /api/agents/{id}/skills/sync with the desired skills list."""
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
fail("PAPERCLIP_BOARD_API_KEY not set — needed for /api/agents/.../skills/sync")
|
||||||
|
url = f"{PAPERCLIP_API_URL}/api/agents/{agent_id}/skills/sync"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
|
||||||
|
"X-Paperclip-Run-Id": "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
body = {"desiredSkills": desired_skills}
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.post(url, headers=headers, json=body)
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
data = {"raw": resp.text[:500]}
|
||||||
|
return resp.status_code, data
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
g = p.add_mutually_exclusive_group(required=True)
|
||||||
|
g.add_argument("--dry-run", action="store_true", help="Show plan, do not apply")
|
||||||
|
g.add_argument("--apply", action="store_true", help="Actually call the skill-sync API")
|
||||||
|
g.add_argument("--verify", action="store_true", help="Show current state only")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
targets = await fetch_targets()
|
||||||
|
if len(targets) != 4:
|
||||||
|
fail(f"Expected 4 target rows (2 agents × 2 companies), got {len(targets)}")
|
||||||
|
|
||||||
|
# Build a map for plan
|
||||||
|
by_key = {(r["name"], r["company_id"]): r for r in targets}
|
||||||
|
|
||||||
|
print(f"\n=== Targets in DB ({len(targets)} rows) ===")
|
||||||
|
for r in targets:
|
||||||
|
company_label = "1xxx CMP" if r["company_id"] == CMP_COMPANY_ID else "8xxx CMPA"
|
||||||
|
print(f" {r['name']:14s} | {company_label} | id={r['id']} | currently {r['current_skill_count']} skills")
|
||||||
|
|
||||||
|
print(f"\n=== Plan ===")
|
||||||
|
for (agent_name, company_id), desired in PLAN.items():
|
||||||
|
company_label = "1xxx CMP" if company_id == CMP_COMPANY_ID else "8xxx CMPA"
|
||||||
|
target = by_key.get((agent_name, company_id))
|
||||||
|
if not target:
|
||||||
|
print(f" ❌ {agent_name} in {company_label}: NOT FOUND in DB")
|
||||||
|
continue
|
||||||
|
print(f" {agent_name:14s} | {company_label} | will set {len(desired)} skills:")
|
||||||
|
for s in desired:
|
||||||
|
print(f" - {s}")
|
||||||
|
|
||||||
|
if args.verify:
|
||||||
|
print("\n(verify mode — exiting without changes)")
|
||||||
|
return
|
||||||
|
if args.dry_run:
|
||||||
|
print("\n(dry-run mode — exiting without changes)\nRe-run with --apply to execute.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# APPLY mode
|
||||||
|
print(f"\n=== Backup ===")
|
||||||
|
backup_path = backup_agents_table()
|
||||||
|
print(f" ✓ Backed up agents table → {backup_path}")
|
||||||
|
|
||||||
|
print(f"\n=== Applying skill-sync via API ===")
|
||||||
|
failures = []
|
||||||
|
for (agent_name, company_id), desired in PLAN.items():
|
||||||
|
target = by_key.get((agent_name, company_id))
|
||||||
|
if not target:
|
||||||
|
failures.append(f"{agent_name} in {company_id}: not found")
|
||||||
|
continue
|
||||||
|
status, data = await call_skill_sync(target["id"], desired)
|
||||||
|
if status >= 400:
|
||||||
|
failures.append(f"{agent_name} ({company_id[:8]}...): HTTP {status} — {json.dumps(data)[:200]}")
|
||||||
|
print(f" ❌ {agent_name} ({target['id']}): HTTP {status}")
|
||||||
|
else:
|
||||||
|
new_count = len(data.get("desiredSkills") or data.get("skills") or [])
|
||||||
|
print(f" ✓ {agent_name} ({target['id']}): HTTP {status} (now {new_count or len(desired)} skills)")
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print(f"\n=== ⚠️ {len(failures)} failures ===")
|
||||||
|
for f in failures:
|
||||||
|
print(f" - {f}")
|
||||||
|
print(f"\nRollback: psql ... -f {backup_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
print(f"\n=== Post-apply verification ===")
|
||||||
|
final = await fetch_targets()
|
||||||
|
for r in final:
|
||||||
|
company_label = "1xxx CMP" if r["company_id"] == CMP_COMPANY_ID else "8xxx CMPA"
|
||||||
|
emoji = "✓" if r["current_skill_count"] >= 4 else "❌"
|
||||||
|
print(f" {emoji} {r['name']:14s} | {company_label} | now {r['current_skill_count']} skills")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -293,18 +293,25 @@ description: This skill should be used when writing legal decisions (החלטו
|
|||||||
|
|
||||||
### 7.5 שלושה מקורות פסיקה — אל תבלבל
|
### 7.5 שלושה מקורות פסיקה — אל תבלבל
|
||||||
|
|
||||||
המערכת מפרידה בין שלושה קורפוסי פסיקה. כל אחד מהם משמש למטרה אחרת ויש כלי MCP נפרד לחיפוש בו:
|
המערכת מפרידה בין **ארבעה** קורפוסי פסיקה. כל אחד מהם משמש למטרה אחרת ויש כלי MCP נפרד לחיפוש בו:
|
||||||
|
|
||||||
| קורפוס | טבלה | כלי חיפוש | תפקיד |
|
| קורפוס | טבלה | כלי חיפוש | תפקיד |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| תקדימי דפנה (סגנון) | `style_corpus` + `paragraph_embeddings` | `search_decisions` | החלטות שדפנה עצמה כתבה. מקור לסגנון, ניסוחים, ג'וריספרודנציה אישית. |
|
| לימוד סגנון | `style_corpus` | (לא לחיפוש תוכן) | ממשק /training — ניתוח "הקול" של היו"ר: טון, ביטויי מעבר, מבנה פסקאות. **אין לחפש כאן תוכן משפטי.** |
|
||||||
| ספריית הפסיקה הסמכותית | `case_law` (`source_kind='external_upload'`) + `halachot` | `search_precedent_library` | פסיקה חיצונית מחייבת — עליון, מנהלי, ועדות ערר אחרות — עם הלכות שאושרו ע"י דפנה. **המקור היחיד לציטוטים בבלוק י לפי CREAC.** |
|
| החלטות ועדות ערר | `case_law` (`source_kind='internal_committee'`) + `halachot` | `search_internal_decisions` | **כל** ועדות הערר לתכנון ובנייה (כל המחוזות). מסונן לפי `district` ו-`chair_name`. מקור לעקביות פנימית ופרקטיקה ארצית. |
|
||||||
| ציטוטים שצורפו ידנית | `case_precedents` | `precedent_search_library` | quotes שדפנה צירפה לתיק ספציפי בעבר. דומה לקורפוס סמכותי אך פר-תיק, ידני, לא עוברת חילוץ הלכות. |
|
| פסיקת בתי משפט | `case_law` (`source_kind='external_upload'`) + `halachot` | `search_precedent_library` | בתי משפט: עליון, מנהלי, בג"ץ. **המקור היחיד לציטוטים מחייבים בבלוק י לפי CREAC.** |
|
||||||
|
| ציטוטים ידניים | `case_precedents` | `precedent_search_library` | quotes שצורפו לתיק ספציפי בעבר. פר-תיק, ידני. |
|
||||||
|
|
||||||
**הזרימה הסטנדרטית בבלוק י:**
|
**הזרימה הסטנדרטית בבלוק י — חפש במקביל:**
|
||||||
1. `search_decisions` קודם — בדוק אם דפנה כבר הכריעה בסוגיה דומה (חיסכון דוקטרינרי / הבחנה).
|
1. `search_internal_decisions(district="ירושלים")` — האם ועדת ערר ירושלים הכריעה בסוגיה? (עקביות פנימית)
|
||||||
2. `search_precedent_library` — חפש את הכלל המחייב והציטוט התומך לפסקת CREAC.
|
- אם יש תוצאה רלוונטית: הצג תחת **"החלטות ועדת ערר ירושלים"** והתייחס לה בניתוח.
|
||||||
3. אם הצדדים הפנו לפסיקה שלא בקורפוס — דפנה מעלה אותה דרך `/precedents` ב-UI; חילוץ ההלכות אוטומטי וההלכות מחכות לאישורה.
|
2. `search_internal_decisions()` (ריק = כל המחוזות) — פרקטיקה ארצית של ועדות אחרות.
|
||||||
|
- הצג תחת **"החלטות ועדות ערר אחרות"** — כמשל/השוואה, לא כמחייב.
|
||||||
|
3. `search_precedent_library` — כלל מחייב מבית משפט לפסקת CREAC.
|
||||||
|
- הצג תחת **"פסיקת בתי משפט"** — זה המקור לציטוט מחייב.
|
||||||
|
4. אם הצדדים הפנו לפסיקה שלא בקורפוס — דפנה מעלה אותה דרך `/precedents` ב-UI.
|
||||||
|
|
||||||
|
**חשוב:** החלטות ועדת ערר הן פרקטיקה, לא מחייב. ציטוט מחייב בבלוק י מגיע רק מ-`search_precedent_library`.
|
||||||
|
|
||||||
**איסור על המצאת ציטוטים** — ציטוט פסיקה חייב להגיע מאחד מהקורפוסים. אם אין הלכה מאושרת תומכת בנקודה — אל תמציא; ציין שהנושא דורש הוספת פסיקה לקורפוס.
|
**איסור על המצאת ציטוטים** — ציטוט פסיקה חייב להגיע מאחד מהקורפוסים. אם אין הלכה מאושרת תומכת בנקודה — אל תמציא; ציין שהנושא דורש הוספת פסיקה לקורפוס.
|
||||||
|
|
||||||
|
|||||||
316
skills/new-company-setup/SKILL.md
Normal file
316
skills/new-company-setup/SKILL.md
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
---
|
||||||
|
name: new-company-setup
|
||||||
|
description: מדריך מלא להוספת חברה (board) חדשה במערכת legal-ai + Paperclip — יוצר את כל הרכיבים הנדרשים: companies row ב-Paperclip, 7 סוכנים (CEO + 6 specialists), runtime/adapter config, paperclipSkillSync, instructionsBundleMode, budget, plugin_state mappings, ועדכון קוד legal-ai. השתמש ב-skill זה כאשר המשתמש מבקש להוסיף סוג ערר חדש (למשל 5xxx, 7xxx) או להפריד תחום קיים לחברה משלו. ה-skill מכיל את כל ההגדרות שנקבעו ב-Gaps #16, #17, #19, #21, #22, #24, #25, #28 — אסור להחסיר שלב.
|
||||||
|
---
|
||||||
|
|
||||||
|
# הקמת חברה חדשה — Blueprint מלא
|
||||||
|
|
||||||
|
> **קונטקסט**: עד 2026-05-04 יש לנו 2 חברות (CMP=1xxx רישוי, CMPA=8xxx היטל השבחה + 9xxx פיצויים). הוספת חברה שלישית (לדוגמה 5xxx, 7xxx) דורשת 11 שלבים בסדר מסוים. ה-skill הזה מכיל את כל הלקחים מ-Gap analysis ועדכוני 2026-04 → 2026-05.
|
||||||
|
|
||||||
|
## רקע — ארכיטקטורה דו-חברתית
|
||||||
|
מקור החברות: Paperclip מחייב `agents.company_id NOT NULL` — אין shared agents. לכן כל סוג ערר מקבל company משלו ב-Paperclip, עם סט מלא של 7 סוכנים. החברה הראשונה (CMP) היא **master** — שינויים בה מסונכרנים אוטומטית ל-mirrors דרך `scripts/sync_agents_across_companies.py`.
|
||||||
|
|
||||||
|
**מודל מומלץ לחברה חדשה**: להפוך אותה ל-mirror של CMP במבנה — כל הסוכנים זהים, רק `company_id`, `id`, `reports_to` שונים. ככה הסקריפט הקיים יסנכרן אוטומטית.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ לפני שמתחילים — checklist הבנה
|
||||||
|
|
||||||
|
לפני שמרצים אף פקודה, ודא שאתה יודע:
|
||||||
|
|
||||||
|
- [ ] **מספרי תיקים** של החברה החדשה (לדוגמה: 5xxx, 7xxx) — חייב להיות disjoint מ-1xxx/8xxx/9xxx
|
||||||
|
- [ ] **שם בעברית** של הוועדה (לדוגמה: "ועדת ערר לתכנון ובניה צפון")
|
||||||
|
- [ ] **prefix לidentifiers** של issues (לדוגמה: `CMPN`)
|
||||||
|
- [ ] **`appeal_type` tag** — מחרוזת קצרה לניתוב (לדוגמה: `licensing_north`)
|
||||||
|
- [ ] **המודלים והעלויות** — האם זהה ל-CMP (Opus opus-4-6 ל-CEO+writer, Sonnet sonnet-4-6 לאחרים)?
|
||||||
|
- [ ] **גישה ל-Infisical** ל-`PAPERCLIP_BOARD_API_KEY` (`/paperclip` ב-nautilus env)
|
||||||
|
- [ ] **PostgreSQL access** ל-Paperclip DB (`localhost:54329`, user `paperclip`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שלב 1 — יצירת `companies` row ב-Paperclip DB
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO companies (
|
||||||
|
id, name, issue_prefix, status,
|
||||||
|
attachment_max_bytes,
|
||||||
|
require_board_approval_for_new_agents,
|
||||||
|
hire_approval_required
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(),
|
||||||
|
'ועדת ערר {שם}', -- בעברית
|
||||||
|
'CMPN', -- 4 תווים אנגלית, ייחודי
|
||||||
|
'active',
|
||||||
|
10485760, -- 10MB default
|
||||||
|
false, -- ברירת מחדל מ-2026.428.0 (Gap docs)
|
||||||
|
false
|
||||||
|
)
|
||||||
|
RETURNING id;
|
||||||
|
```
|
||||||
|
|
||||||
|
**שמור את ה-UUID** — תצטרך אותו ב-כל השלבים הבאים. נקרא לו `$NEW_COMPANY_ID`.
|
||||||
|
|
||||||
|
⚠️ אל תיצור project ראשוני ידנית — Paperclip יוצר אוטומטית כשהחברה נשמרת.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שלב 2 — יצירת 7 סוכנים
|
||||||
|
|
||||||
|
צור את הסוכנים בסדר הבא (ה-CEO ראשון, כי בכל הסוכנים `reports_to = CEO_id`):
|
||||||
|
|
||||||
|
| # | name (עברית) | role | model | budget_cents |
|
||||||
|
|---|---------------|------|-------|---------------|
|
||||||
|
| 1 | עוזר משפטי | `ceo` | claude-opus-4-6 | 1500 |
|
||||||
|
| 2 | מנתח משפטי | `researcher` | claude-opus-4-6 | 1500 |
|
||||||
|
| 3 | חוקר תקדימים | `researcher` | claude-sonnet-4-6 | 1500 |
|
||||||
|
| 4 | כותב החלטה | `engineer` | claude-opus-4-6 | 1500 |
|
||||||
|
| 5 | בודק איכות | `qa` | claude-sonnet-4-6 | 1500 |
|
||||||
|
| 6 | מייצא טיוטה | `engineer` | claude-sonnet-4-6 | 1500 |
|
||||||
|
| 7 | הגהת מסמכים | `engineer` | claude-opus-4-6 | 1500 |
|
||||||
|
|
||||||
|
### דרך 1 — sync from master (מומלץ)
|
||||||
|
|
||||||
|
הדרך הקלה ביותר: צור 7 סוכנים ב-CMPN עם **שמות זהים** ל-CMP, ואז הרץ `sync_agents_across_companies.py` שיעתיק את כל ההגדרות.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- לכל אחד מ-7 הסוכנים (שנה את name ו-role בכל פעם):
|
||||||
|
INSERT INTO agents (
|
||||||
|
id, company_id, name, role, adapter_type,
|
||||||
|
adapter_config, runtime_config, budget_monthly_cents,
|
||||||
|
permissions, status
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(),
|
||||||
|
'{NEW_COMPANY_ID}'::uuid,
|
||||||
|
'עוזר משפטי', -- שנה ב-7 שורות
|
||||||
|
'ceo', -- שנה לפי הטבלה למעלה
|
||||||
|
'claude_local',
|
||||||
|
'{}'::jsonb, -- ייטען בשלב 4
|
||||||
|
'{}'::jsonb, -- ייטען בשלב 4
|
||||||
|
1500,
|
||||||
|
'{}'::jsonb,
|
||||||
|
'idle'
|
||||||
|
)
|
||||||
|
RETURNING id, name;
|
||||||
|
```
|
||||||
|
|
||||||
|
שמור את 7 ה-UUIDs לטבלה לעיון מהיר.
|
||||||
|
|
||||||
|
### עדכון `reports_to` (אחרי שיש לך CEO_id)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE agents
|
||||||
|
SET reports_to = '{CEO_id}'::uuid
|
||||||
|
WHERE company_id = '{NEW_COMPANY_ID}'::uuid
|
||||||
|
AND name <> 'עוזר משפטי';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שלב 3 — סנכרון מ-CMP (master) דרך הסקריפט
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PAPERCLIP_BOARD_API_KEY=$(mcp__infisical__get-secret \
|
||||||
|
projectId=9a77b161-f70c-4dd3-9d67-b7ab850cef51 \
|
||||||
|
environmentSlug=nautilus secretPath=/ \
|
||||||
|
secretName=PAPERCLIP_BOARD_API_KEY) \
|
||||||
|
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify
|
||||||
|
```
|
||||||
|
|
||||||
|
**אם הסקריפט לא תומך ב-mirror החדש**, יש לעדכן אותו:
|
||||||
|
1. פתח `scripts/sync_agents_across_companies.py`
|
||||||
|
2. השתמש בdict structure: master_company → list of mirrors. או הוסף flag `--target-company`
|
||||||
|
3. הרץ `--apply` להעתיק את כל ההגדרות מ-CMP ל-CMPN
|
||||||
|
|
||||||
|
**מה הסקריפט מסנכרן** (אוטומטית):
|
||||||
|
- `adapter_config`: model, effort, timeoutSec=3600, maxTurnsPerRun=500, instructionsBundleMode=external, instructionsRootPath/EntryFile, dangerouslySkipPermissions, extraArgs (`--agent legal-{role}`), cwd
|
||||||
|
- `runtime_config.heartbeat`: graceSec=60, cooldownSec=10, wakeOnDemand=true, maxConcurrentRuns (CEO=2, others=1)
|
||||||
|
- `budget_monthly_cents` (1500)
|
||||||
|
- `metadata`, `icon`, `title`
|
||||||
|
|
||||||
|
**מה לא מסונכרן** (חייב לעשות ידנית בהמשך):
|
||||||
|
- `paperclipSkillSync.desiredSkills` — ראה שלב 4
|
||||||
|
- `permissions` — לפי policy של החברה
|
||||||
|
- local skills (אם החברה החדשה צריכה custom skills)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שלב 4 — Paperclip Skills
|
||||||
|
|
||||||
|
הסקריפט מ-שלב 3 כולל כבר את ה-`paperclipSkillSync.desiredSkills` (מסונן לפי skills זמינים ב-mirror). אבל ה-mirror החדש **לא יקבל local skills** של CMP אם הם לא קיימים גם בו.
|
||||||
|
|
||||||
|
### 4א. יצירת company_skills ל-CMPN
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- העתק את 6 ה-paperclip skills הסטנדרטיים מ-CMP ל-CMPN
|
||||||
|
INSERT INTO company_skills (company_id, key, slug, name, description, markdown, source_type, trust_level, compatibility, file_inventory)
|
||||||
|
SELECT
|
||||||
|
'{NEW_COMPANY_ID}'::uuid,
|
||||||
|
key, slug, name, description, markdown, source_type, trust_level, compatibility, file_inventory
|
||||||
|
FROM company_skills
|
||||||
|
WHERE company_id = '42a7acd0-30c5-4cbd-ac97-7424f65df294' -- CMP
|
||||||
|
AND key LIKE 'paperclipai/paperclip/%';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4ב. אם החברה צריכה local skills
|
||||||
|
|
||||||
|
החלט אילו local skills (`local/.../legal-decision`, `local/.../attach-precedents`) רלוונטיות — תלוי בסוג הערר.
|
||||||
|
לדוגמה, חברה ל-"היטלי השבחה צפון" כנראה לא תצריך `attach-precedents` של CMP אלא local skill משלה.
|
||||||
|
|
||||||
|
### 4ג. הפעלת skills/sync לכל סוכן
|
||||||
|
|
||||||
|
הרץ `scripts/sync_missing_agent_skills.py` עם adaptation לחברה החדשה (העתק את הקובץ ושנה את `CMPA_COMPANY_ID` ל-NEW_COMPANY_ID + רשימת ה-skills הרצויה).
|
||||||
|
|
||||||
|
⚠️ **חובה דרך API** (`POST /api/agents/{id}/skills/sync`) — לא דרך SQL ישיר! ה-API יוצר revision מסוג `skill-sync` שנדרש לlogging. SQL ישיר לא יוצר revision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שלב 5 — Symlinks ל-instructions (managed by Paperclip)
|
||||||
|
|
||||||
|
לכל סוכן Paperclip צופה לקבצי הוראות בנתיב:
|
||||||
|
`~/.paperclip/instances/default/companies/{COMPANY_ID}/agents/{AGENT_ID}/instructions/`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEW_COMPANY_ID="..."
|
||||||
|
LEGAL_AI_AGENTS=/home/chaim/legal-ai/.claude/agents
|
||||||
|
|
||||||
|
for ROW in \
|
||||||
|
"ceo:legal-ceo.md" \
|
||||||
|
"analyst:legal-analyst.md" \
|
||||||
|
"researcher:legal-researcher.md" \
|
||||||
|
"writer:legal-writer.md" \
|
||||||
|
"qa:legal-qa.md" \
|
||||||
|
"exporter:legal-exporter.md" \
|
||||||
|
"proofreader:legal-proofreader.md"; do
|
||||||
|
ROLE="${ROW%%:*}"
|
||||||
|
FILE="${ROW##*:}"
|
||||||
|
AGENT_ID=$(PGPASSWORD=paperclip psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -tAc \
|
||||||
|
"SELECT id FROM agents WHERE company_id='$NEW_COMPANY_ID'::uuid AND adapter_config->>'extraArgs' LIKE '%legal-$ROLE%' LIMIT 1")
|
||||||
|
DEST=~/.paperclip/instances/default/companies/$NEW_COMPANY_ID/agents/$AGENT_ID/instructions/
|
||||||
|
mkdir -p $DEST
|
||||||
|
ln -sf "$LEGAL_AI_AGENTS/$FILE" "$DEST/AGENTS.md"
|
||||||
|
ln -sf "$LEGAL_AI_AGENTS/HEARTBEAT.md" "$DEST/HEARTBEAT.md"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
**אימות:** `ls -la ~/.paperclip/instances/default/companies/$NEW_COMPANY_ID/agents/*/instructions/` — צריך לראות 14 symlinks (7 agents × 2 קבצים).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שלב 6 — עדכון `web/paperclip_client.py`
|
||||||
|
|
||||||
|
הקובץ מכיל 3 dicts שצריכים את החברה החדשה:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# COMPANIES dict
|
||||||
|
COMPANIES = {
|
||||||
|
"licensing": "42a7acd0-30c5-4cbd-ac97-7424f65df294",
|
||||||
|
"betterment": "8639e837-4c9d-47fa-a76b-95788d651896",
|
||||||
|
"{appeal_type_new}": "{NEW_COMPANY_ID}", # ← חדש
|
||||||
|
}
|
||||||
|
|
||||||
|
# CEO_AGENTS dict — נדרש ל-wakeup routing
|
||||||
|
CEO_AGENTS = {
|
||||||
|
COMPANIES["licensing"]: "752cebdd-...",
|
||||||
|
COMPANIES["betterment"]: "cdbfa8bc-...",
|
||||||
|
COMPANIES["{appeal_type_new}"]: "{CEO_ID_NEW}", # ← חדש
|
||||||
|
}
|
||||||
|
|
||||||
|
# _FALLBACK_APPEAL_TYPE_TO_COMPANY — ניתוב לפי tag עברי/אנגלי
|
||||||
|
_FALLBACK_APPEAL_TYPE_TO_COMPANY = {
|
||||||
|
# קיימים...
|
||||||
|
"{שם בעברית}": COMPANIES["{appeal_type_new}"],
|
||||||
|
"{english_tag}": COMPANIES["{appeal_type_new}"],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ אחרי השינוי — **deploy** ל-Coolify (FastAPI container חי במכולה — שינוי קוד דורש rebuild). ראה `legal-ai/CLAUDE.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שלב 7 — `tag_company_mappings` ב-legal-ai DB
|
||||||
|
|
||||||
|
ה-FastAPI ראשית מנסה לקרוא ניתוב מ-DB, רק אז fallback ל-dict הקבוע (שלב 6). הוסף mapping:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- ב-legal-ai DB (port 5433)
|
||||||
|
INSERT INTO tag_company_mappings (tag, company_id) VALUES
|
||||||
|
('{שם עברי}', '{NEW_COMPANY_ID}'),
|
||||||
|
('{english_tag}', '{NEW_COMPANY_ID}');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שלב 8 — עדכון `HEARTBEAT.md` §1
|
||||||
|
|
||||||
|
הסעיף §1 מכיל טבלה של חברות + CEO IDs. הוסף שורה חדשה:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| ועדת ערר {שם} (CMPN) | `{NEW_COMPANY_ID}` | {סוג} | **{Nxxx}** | `{CEO_ID_NEW}` |
|
||||||
|
```
|
||||||
|
|
||||||
|
ובסעיף §4ג (CEO wakeup), עדכן את ה-`if` להוסיף אופציה שלישית לחברה החדשה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שלב 9 — עדכון `legal-ai/CLAUDE.md`
|
||||||
|
|
||||||
|
הקובץ מכיל את אותה טבלה. עדכן בקטעים:
|
||||||
|
- "סוגי עררים" (אם קיים)
|
||||||
|
- "Paperclip — כללי אינטגרציה קריטיים" → "ניתוב comments דרך CEO"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שלב 10 — Hebrew translation (אם נדרש)
|
||||||
|
|
||||||
|
אם שם החברה מופיע ב-UI, ייתכן שצריך תרגום ב-`~/.paperclip/hebrew/translate-he.js`. בד"כ לא נדרש — שמות בעברית כבר.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# אחרי שינויים בHebrew file:
|
||||||
|
~/.paperclip/hebrew/apply-hebrew.sh
|
||||||
|
# ⚠️ לא דורש pm2 restart — UI client-side fix.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שלב 11 — בדיקה end-to-end
|
||||||
|
|
||||||
|
1. **CEO מתעורר על comment**: צור issue test בחברה החדשה, פרסם comment, ודא ש-CEO רץ.
|
||||||
|
2. **plugin marcusgroup.legal-ai רואה את החברה**: ב-Paperclip UI → Settings → Plugins → marcusgroup.legal-ai → ודא שהחברה החדשה ב-installed companies.
|
||||||
|
3. **MCP tools פועלים**: דרך Claude Code, הרץ `mcp__legal-ai__case_create` עם appeal_type של החברה החדשה.
|
||||||
|
4. **Sync script עובד**: `python scripts/sync_agents_across_companies.py --verify` — לא צריך drift.
|
||||||
|
5. **Budget enforcement**: צור cost_event מבחן, ודא ש-spent_monthly_cents מתעדכן.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ מלכודות מתועדות (מ-Gap analysis 2026-04 → 2026-05)
|
||||||
|
|
||||||
|
מבחן בכל שלב מאפשר תפיסת issues שתועדו בעבר:
|
||||||
|
|
||||||
|
| # | מלכודת | פתרון |
|
||||||
|
|---|---------|--------|
|
||||||
|
| 1 | סוכנים בלי `paperclipSkillSync` | ראה שלב 4ג (POST /api/agents/{id}/skills/sync, לא SQL) |
|
||||||
|
| 2 | `runtime_config = '{}'` (default → graceSec=1ms!) | ראה שלב 3 (סקריפט מסנכרן `heartbeat.graceSec=60`) |
|
||||||
|
| 3 | `budget_monthly_cents = 0` | ראה שלב 2 (insert עם 1500) |
|
||||||
|
| 4 | `instructionsBundleMode` חסר | ראה שלב 3 (סקריפט מסנכרן `external` + Root + EntryFile) |
|
||||||
|
| 5 | `bootstrapPromptTemplate` deprecated | אין אצלנו — דלג |
|
||||||
|
| 6 | drift בין חברות | ראה שלב 3 — סנכרון אוטומטי כל שינוי הגדרות |
|
||||||
|
| 7 | CEO לא מתעורר על comment | ודא ש-`reports_to` עודכן ושיש symlinks ל-AGENTS.md (שלב 5) |
|
||||||
|
| 8 | `psql` ישיר ל-`issue_attachments` | אסור — ראה `HEARTBEAT.md §2` (heartbeat-context API) |
|
||||||
|
| 9 | curl ישיר ל-Paperclip API | אסור — תמיד `pc.sh` (`HEARTBEAT.md §0`) |
|
||||||
|
| 10 | "@chaim — ענה 1/2/3 בcomment" | אסור — interactions API (`legal-ceo.md §B/§C/§D`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## רפרנסים
|
||||||
|
|
||||||
|
- [`docs/new-company-setup-guide.md`](../../docs/new-company-setup-guide.md) — היסטוריית הקמת CMPA (חברה שנייה, 2026-04)
|
||||||
|
- [`scripts/sync_agents_across_companies.py`](../../scripts/sync_agents_across_companies.py) — אוטומציה לסנכרון
|
||||||
|
- [`scripts/sync_missing_agent_skills.py`](../../scripts/sync_missing_agent_skills.py) — תבנית להפעלת skills/sync
|
||||||
|
- [`~/.paperclip/CUSTOMIZATIONS.md`](../../../.paperclip/CUSTOMIZATIONS.md) — כל ההתאמות הפעילות (סעיפים: agents runtime, instructions, budgets, interactions, skill-sync)
|
||||||
|
- [`HEARTBEAT.md`](../../.claude/agents/HEARTBEAT.md) — §1 טבלת חברות (לעדכן בשלב 8)
|
||||||
|
- [`legal-ai/CLAUDE.md`](../../CLAUDE.md) — Paperclip integration rules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## גרסה
|
||||||
|
- 2026-05-04 — גרסה ראשונה (אחרי Gap #16-#28)
|
||||||
@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { usePrecedent } from "@/lib/api/precedent-library";
|
import { usePrecedent } from "@/lib/api/precedent-library";
|
||||||
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
|
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
|
||||||
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
||||||
|
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
|
||||||
|
|
||||||
const PRACTICE_AREA_LABELS: Record<string, string> = {
|
const PRACTICE_AREA_LABELS: Record<string, string> = {
|
||||||
rishuy_uvniya: "רישוי ובנייה",
|
rishuy_uvniya: "רישוי ובנייה",
|
||||||
@@ -152,6 +153,15 @@ export default function PrecedentDetailPage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<RelatedCasesSection
|
||||||
|
caseId={id}
|
||||||
|
related={data.related_cases ?? []}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5">
|
||||||
<ExtractedHalachotSection halachot={data.halachot ?? []} />
|
<ExtractedHalachotSection halachot={data.halachot ?? []} />
|
||||||
|
|||||||
372
web-ui/src/app/settings/_components/agents-tab.tsx
Normal file
372
web-ui/src/app/settings/_components/agents-tab.tsx
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Bot,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
PauseCircle,
|
||||||
|
PlayCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
usePaperclipAgents,
|
||||||
|
type AgentPair,
|
||||||
|
type DriftEntry,
|
||||||
|
type PaperclipAgent,
|
||||||
|
} from "@/lib/api/paperclip-agents";
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
|
ceo: "CEO",
|
||||||
|
researcher: "מחקר",
|
||||||
|
engineer: "כתיבה",
|
||||||
|
qa: "בקרה",
|
||||||
|
general: "כללי",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIELD_LABEL: Record<string, string> = {
|
||||||
|
model: "מודל",
|
||||||
|
effort: "effort",
|
||||||
|
timeoutSec: "timeout (שניות)",
|
||||||
|
maxTurnsPerRun: "max turns",
|
||||||
|
desiredSkills: "skills",
|
||||||
|
instructionsBundleMode: "bundle mode",
|
||||||
|
instructionsEntryFile: "entry file",
|
||||||
|
graceSec: "grace (שניות)",
|
||||||
|
cooldownSec: "cooldown (שניות)",
|
||||||
|
wakeOnDemand: "wake on demand",
|
||||||
|
maxConcurrentRuns: "max concurrent",
|
||||||
|
budget_monthly_cents: "תקציב חודשי",
|
||||||
|
status: "סטטוס",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCents(cents: number | null): string {
|
||||||
|
if (cents == null) return "—";
|
||||||
|
return `$${(cents / 100).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ agent }: { agent: PaperclipAgent }) {
|
||||||
|
const status = agent.status ?? "unknown";
|
||||||
|
if (status === "paused" || status === "terminated") {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">
|
||||||
|
<PauseCircle className="w-3 h-3 me-1" />
|
||||||
|
{status === "paused" ? "מושהה" : "סיים"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="bg-success-bg text-success border-success/40">
|
||||||
|
<PlayCircle className="w-3 h-3 me-1" />
|
||||||
|
פעיל
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldRow({
|
||||||
|
label,
|
||||||
|
master,
|
||||||
|
mirror,
|
||||||
|
drifted,
|
||||||
|
mono,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
master: React.ReactNode;
|
||||||
|
mirror: React.ReactNode;
|
||||||
|
drifted: boolean;
|
||||||
|
mono?: boolean;
|
||||||
|
}) {
|
||||||
|
const cellBase = `tabular-nums text-[0.82rem] ${mono ? "font-mono" : ""}`;
|
||||||
|
const cellCls = (val: React.ReactNode) =>
|
||||||
|
`${cellBase} px-2 py-1 rounded ${
|
||||||
|
drifted ? "bg-warn-bg text-warn border border-warn/40" : "text-ink"
|
||||||
|
} ${val == null || val === "—" ? "text-ink-light" : ""}`;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[7rem_1fr_1fr] gap-2 items-center">
|
||||||
|
<div className="text-[0.75rem] text-ink-muted">{label}</div>
|
||||||
|
<div className={cellCls(master)} dir="ltr">{master ?? "—"}</div>
|
||||||
|
<div className={cellCls(mirror)} dir="ltr">{mirror ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PairCard({ pair }: { pair: AgentPair }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const driftFields = new Set(pair.drift.map((d) => d.field));
|
||||||
|
const driftCount = pair.drift.length;
|
||||||
|
const pairMissing = driftFields.has("_pair_missing");
|
||||||
|
const a = pair.master ?? pair.mirror;
|
||||||
|
if (!a) return null;
|
||||||
|
|
||||||
|
const fieldVal = (
|
||||||
|
side: "master" | "mirror",
|
||||||
|
key: keyof PaperclipAgent,
|
||||||
|
): React.ReactNode => {
|
||||||
|
const agent = pair[side];
|
||||||
|
if (!agent) return <span className="text-ink-light">—</span>;
|
||||||
|
const v = agent[key];
|
||||||
|
if (v == null) return "—";
|
||||||
|
if (typeof v === "boolean") return v ? "✓" : "✗";
|
||||||
|
if (Array.isArray(v)) return `${v.length}`;
|
||||||
|
return String(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const skillsList = (agent: PaperclipAgent | null) =>
|
||||||
|
agent?.desiredSkills?.length ? agent.desiredSkills : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4 space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Bot className="w-5 h-5 text-gold-deep shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-navy font-semibold text-base mb-0">{pair.name}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<Badge variant="outline" className="text-[0.7rem]">
|
||||||
|
{ROLE_LABEL[pair.role ?? ""] ?? pair.role ?? "—"}
|
||||||
|
</Badge>
|
||||||
|
{pair.master && <StatusBadge agent={pair.master} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pairMissing ? (
|
||||||
|
<Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">
|
||||||
|
<AlertCircle className="w-3 h-3 me-1" />
|
||||||
|
{pair.master ? "חסר ב-CMPA" : "חסר ב-CMP"}
|
||||||
|
</Badge>
|
||||||
|
) : driftCount > 0 ? (
|
||||||
|
<Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">
|
||||||
|
<AlertCircle className="w-3 h-3 me-1" />
|
||||||
|
{driftCount} פערים
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="bg-success-bg text-success border-success/40">
|
||||||
|
מסונכרן
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[7rem_1fr_1fr] gap-2 text-[0.7rem] uppercase tracking-wide text-ink-muted border-b border-rule pb-1">
|
||||||
|
<div></div>
|
||||||
|
<div>CMP (1xxx)</div>
|
||||||
|
<div>CMPA (8xxx)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<FieldRow label={FIELD_LABEL.model} master={fieldVal("master", "model")} mirror={fieldVal("mirror", "model")} drifted={driftFields.has("model")} mono />
|
||||||
|
<FieldRow label={FIELD_LABEL.effort} master={fieldVal("master", "effort")} mirror={fieldVal("mirror", "effort")} drifted={driftFields.has("effort")} />
|
||||||
|
<FieldRow label={FIELD_LABEL.timeoutSec} master={fieldVal("master", "timeoutSec")} mirror={fieldVal("mirror", "timeoutSec")} drifted={driftFields.has("timeoutSec")} />
|
||||||
|
<FieldRow label={FIELD_LABEL.maxTurnsPerRun} master={fieldVal("master", "maxTurnsPerRun")} mirror={fieldVal("mirror", "maxTurnsPerRun")} drifted={driftFields.has("maxTurnsPerRun")} />
|
||||||
|
<FieldRow
|
||||||
|
label={FIELD_LABEL.desiredSkills}
|
||||||
|
master={pair.master ? `${pair.master.desiredSkills.length}` : "—"}
|
||||||
|
mirror={pair.mirror ? `${pair.mirror.desiredSkills.length}` : "—"}
|
||||||
|
drifted={driftFields.has("desiredSkills")}
|
||||||
|
/>
|
||||||
|
<FieldRow label={FIELD_LABEL.graceSec} master={fieldVal("master", "graceSec")} mirror={fieldVal("mirror", "graceSec")} drifted={driftFields.has("graceSec")} />
|
||||||
|
<FieldRow label={FIELD_LABEL.cooldownSec} master={fieldVal("master", "cooldownSec")} mirror={fieldVal("mirror", "cooldownSec")} drifted={driftFields.has("cooldownSec")} />
|
||||||
|
<FieldRow label={FIELD_LABEL.wakeOnDemand} master={fieldVal("master", "wakeOnDemand")} mirror={fieldVal("mirror", "wakeOnDemand")} drifted={driftFields.has("wakeOnDemand")} />
|
||||||
|
<FieldRow label={FIELD_LABEL.maxConcurrentRuns} master={fieldVal("master", "maxConcurrentRuns")} mirror={fieldVal("mirror", "maxConcurrentRuns")} drifted={driftFields.has("maxConcurrentRuns")} />
|
||||||
|
<FieldRow
|
||||||
|
label={FIELD_LABEL.budget_monthly_cents}
|
||||||
|
master={
|
||||||
|
pair.master
|
||||||
|
? `${formatCents(pair.master.spent_monthly_cents)} / ${formatCents(pair.master.budget_monthly_cents)}`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
mirror={
|
||||||
|
pair.mirror
|
||||||
|
? `${formatCents(pair.mirror.spent_monthly_cents)} / ${formatCents(pair.mirror.budget_monthly_cents)}`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
drifted={driftFields.has("budget_monthly_cents")}
|
||||||
|
/>
|
||||||
|
<FieldRow label={FIELD_LABEL.instructionsBundleMode} master={fieldVal("master", "instructionsBundleMode")} mirror={fieldVal("mirror", "instructionsBundleMode")} drifted={driftFields.has("instructionsBundleMode")} mono />
|
||||||
|
<FieldRow label={FIELD_LABEL.instructionsEntryFile} master={fieldVal("master", "instructionsEntryFile")} mirror={fieldVal("mirror", "instructionsEntryFile")} drifted={driftFields.has("instructionsEntryFile")} mono />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-1 border-t border-rule">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-[0.78rem] text-ink-muted"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="w-3 h-3 me-1" />
|
||||||
|
כיווץ
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-3 h-3 me-1" />
|
||||||
|
פרטים מלאים
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{pair.master?.updated_at && (
|
||||||
|
<span className="text-[0.7rem] text-ink-light">
|
||||||
|
עודכן: {new Date(pair.master.updated_at).toLocaleDateString("he-IL")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="pt-2 border-t border-rule space-y-3">
|
||||||
|
{pair.drift.length > 0 && !pairMissing && (
|
||||||
|
<div className="rounded-md bg-warn-bg/40 border border-warn/30 p-3">
|
||||||
|
<div className="text-[0.78rem] text-warn font-medium mb-2">פערי סנכרון</div>
|
||||||
|
<ul className="space-y-1 text-[0.78rem]">
|
||||||
|
{pair.drift.map((d: DriftEntry) => (
|
||||||
|
<li key={d.field} className="flex items-center gap-2 flex-wrap">
|
||||||
|
<code dir="ltr" className="text-[0.72rem]">{FIELD_LABEL[d.field] ?? d.field}</code>
|
||||||
|
<span className="text-ink-muted">CMP:</span>
|
||||||
|
<code dir="ltr" className="text-[0.72rem] text-ink">{JSON.stringify(d.master)}</code>
|
||||||
|
<span className="text-ink-muted">CMPA:</span>
|
||||||
|
<code dir="ltr" className="text-[0.72rem] text-ink">{JSON.stringify(d.mirror)}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{(["master", "mirror"] as const).map((side) => {
|
||||||
|
const agent = pair[side];
|
||||||
|
const skills = skillsList(agent);
|
||||||
|
return (
|
||||||
|
<div key={side} className="rounded-md border border-rule p-3 space-y-2">
|
||||||
|
<div className="text-[0.75rem] text-ink-muted">
|
||||||
|
{side === "master" ? "CMP" : "CMPA"}
|
||||||
|
</div>
|
||||||
|
{agent ? (
|
||||||
|
<>
|
||||||
|
<div className="text-[0.72rem] font-mono text-ink-muted" dir="ltr">
|
||||||
|
id: {agent.id}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted mb-1">
|
||||||
|
skills ({skills.length})
|
||||||
|
</div>
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<span className="text-[0.78rem] text-ink-light">—</span>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{skills.map((s) => (
|
||||||
|
<li key={s} className="text-[0.72rem] font-mono" dir="ltr">
|
||||||
|
{s}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{agent.instructionsFilePath && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted">instructions path</div>
|
||||||
|
<code className="text-[0.72rem] font-mono break-all" dir="ltr">
|
||||||
|
{agent.instructionsFilePath}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{agent.pause_reason && (
|
||||||
|
<div className="text-[0.78rem] text-warn">
|
||||||
|
סיבת השהיה: {agent.pause_reason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-[0.78rem] text-ink-light">חסר</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentsTab() {
|
||||||
|
const { data, isPending, error, refetch, isFetching } = usePaperclipAgents();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-danger/40">
|
||||||
|
<CardContent className="p-6 flex items-center gap-3 text-danger">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
<span>שגיאה: {error.message}</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(7)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-48 w-full rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.pairs.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule">
|
||||||
|
<CardContent className="px-6 py-12 text-center text-ink-muted">
|
||||||
|
לא נמצאו סוכנים
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDrift = data.pairs.reduce(
|
||||||
|
(sum, p) => sum + p.drift.filter((d) => d.field !== "_pair_missing").length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const missingCount = data.pairs.filter((p) => !p.master || !p.mirror).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[0.85rem] text-ink-muted">
|
||||||
|
{data.pairs.length} סוכנים × 2 חברות (CMP master / CMPA mirror)
|
||||||
|
{totalDrift > 0 && (
|
||||||
|
<span className="text-warn ms-2">
|
||||||
|
· {totalDrift} פערי סנכרון
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{missingCount > 0 && (
|
||||||
|
<span className="text-danger ms-2">· {missingCount} זוגות לא שלמים</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.7rem] text-ink-light">
|
||||||
|
פערי skills מחושבים על paperclipai/* בלבד. local/* ו-company/* מסוננים — שם שונה בין החברות הוא צפוי.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
רענון
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.pairs.map((pair) => (
|
||||||
|
<PairCard key={pair.name} pair={pair} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Server, Wrench, Plug, Building2, Layers } from "lucide-react";
|
import { Server, Wrench, Plug, Building2, Layers, Bot } from "lucide-react";
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { PaperclipTab } from "./_components/paperclip-tab";
|
import { PaperclipTab } from "./_components/paperclip-tab";
|
||||||
@@ -9,6 +9,7 @@ import { EnvironmentTab } from "./_components/environment-tab";
|
|||||||
import { ToolsTab } from "./_components/tools-tab";
|
import { ToolsTab } from "./_components/tools-tab";
|
||||||
import { RegistrationsTab } from "./_components/registrations-tab";
|
import { RegistrationsTab } from "./_components/registrations-tab";
|
||||||
import { BlocksTab } from "./_components/blocks-tab";
|
import { BlocksTab } from "./_components/blocks-tab";
|
||||||
|
import { AgentsTab } from "./_components/agents-tab";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -30,31 +31,36 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|
||||||
<Tabs defaultValue="paperclip" className="space-y-4">
|
<Tabs dir="rtl" defaultValue="paperclip" className="space-y-4">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="paperclip">
|
<TabsTrigger value="paperclip">
|
||||||
<Building2 className="w-4 h-4" data-icon="inline-start" />
|
<Building2 className="w-4 h-4" data-icon="inline-start" />
|
||||||
Paperclip
|
Paperclip
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="agents">
|
||||||
|
<Bot className="w-4 h-4" data-icon="inline-start" />
|
||||||
|
סוכנים
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="environment">
|
<TabsTrigger value="environment">
|
||||||
<Server className="w-4 h-4" data-icon="inline-start" />
|
<Server className="w-4 h-4" data-icon="inline-start" />
|
||||||
Environment
|
סביבה
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="tools">
|
<TabsTrigger value="tools">
|
||||||
<Wrench className="w-4 h-4" data-icon="inline-start" />
|
<Wrench className="w-4 h-4" data-icon="inline-start" />
|
||||||
Tools
|
כלים
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="blocks">
|
<TabsTrigger value="blocks">
|
||||||
<Layers className="w-4 h-4" data-icon="inline-start" />
|
<Layers className="w-4 h-4" data-icon="inline-start" />
|
||||||
Blocks
|
בלוקים
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="registrations">
|
<TabsTrigger value="registrations">
|
||||||
<Plug className="w-4 h-4" data-icon="inline-start" />
|
<Plug className="w-4 h-4" data-icon="inline-start" />
|
||||||
Registrations
|
רישומים
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="paperclip"><PaperclipTab /></TabsContent>
|
<TabsContent value="paperclip"><PaperclipTab /></TabsContent>
|
||||||
|
<TabsContent value="agents"><AgentsTab /></TabsContent>
|
||||||
<TabsContent value="environment"><EnvironmentTab /></TabsContent>
|
<TabsContent value="environment"><EnvironmentTab /></TabsContent>
|
||||||
<TabsContent value="tools"><ToolsTab /></TabsContent>
|
<TabsContent value="tools"><ToolsTab /></TabsContent>
|
||||||
<TabsContent value="blocks"><BlocksTab /></TabsContent>
|
<TabsContent value="blocks"><BlocksTab /></TabsContent>
|
||||||
|
|||||||
@@ -5,8 +5,18 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Markdown } from "@/components/ui/markdown";
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
import { useAgentActivity, useSendComment } from "@/lib/api/agents";
|
import {
|
||||||
import type { PaperclipComment } from "@/lib/api/agents";
|
useAgentActivity,
|
||||||
|
useSendComment,
|
||||||
|
useSubmitInteraction,
|
||||||
|
} from "@/lib/api/agents";
|
||||||
|
import type {
|
||||||
|
Interaction,
|
||||||
|
InteractionPayload,
|
||||||
|
InteractionQuestion,
|
||||||
|
InteractionTask,
|
||||||
|
PaperclipComment,
|
||||||
|
} from "@/lib/api/agents";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
@@ -15,6 +25,9 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Clock,
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
HelpCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/* ── Role → color mapping ────────────────────────────────────── */
|
/* ── Role → color mapping ────────────────────────────────────── */
|
||||||
@@ -153,6 +166,463 @@ function CommentCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Interaction card ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const RESOLVED_LABELS: Record<string, { text: string; tone: string; Icon: typeof CheckCircle2 }> = {
|
||||||
|
answered: { text: "נענה", tone: "text-emerald-700 bg-emerald-50 border-emerald-200", Icon: CheckCircle2 },
|
||||||
|
accepted: { text: "התקבל", tone: "text-emerald-700 bg-emerald-50 border-emerald-200", Icon: CheckCircle2 },
|
||||||
|
rejected: { text: "נדחה", tone: "text-rose-700 bg-rose-50 border-rose-200", Icon: XCircle },
|
||||||
|
expired: { text: "פג תוקף", tone: "text-ink-faint bg-gray-50 border-gray-200", Icon: XCircle },
|
||||||
|
failed: { text: "כשל", tone: "text-rose-700 bg-rose-50 border-rose-200", Icon: XCircle },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ResolvedBadge({ status }: { status: string }) {
|
||||||
|
const meta = RESOLVED_LABELS[status];
|
||||||
|
if (!meta) return null;
|
||||||
|
const { text, tone, Icon } = meta;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border ${tone}`}>
|
||||||
|
<Icon className="w-3 h-3" />
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryAnswer(interaction: Interaction): string | null {
|
||||||
|
const result = interaction.result;
|
||||||
|
if (!result) return null;
|
||||||
|
if (typeof result.summaryMarkdown === "string" && result.summaryMarkdown.trim()) {
|
||||||
|
return result.summaryMarkdown;
|
||||||
|
}
|
||||||
|
if (interaction.kind === "ask_user_questions" && Array.isArray(result.answers)) {
|
||||||
|
const optionLabel = (qid: string, oid: string): string => {
|
||||||
|
const q = interaction.payload.questions?.find((qq) => qq.id === qid);
|
||||||
|
return q?.options.find((o) => o.id === oid)?.label ?? oid;
|
||||||
|
};
|
||||||
|
return (result.answers as Array<{ questionId: string; optionIds: string[] }>)
|
||||||
|
.map((a) =>
|
||||||
|
`**${interaction.payload.questions?.find((q) => q.id === a.questionId)?.prompt ?? a.questionId}** — ${a.optionIds
|
||||||
|
.map((oid) => optionLabel(a.questionId, oid))
|
||||||
|
.join(", ")}`,
|
||||||
|
)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
if (interaction.kind === "request_confirmation" && typeof result.reason === "string" && result.reason) {
|
||||||
|
return `נימוק: ${result.reason}`;
|
||||||
|
}
|
||||||
|
if (interaction.kind === "suggest_tasks") {
|
||||||
|
const created = Array.isArray(result.createdTasks) ? result.createdTasks.length : 0;
|
||||||
|
const skipped = Array.isArray(result.skippedClientKeys) ? result.skippedClientKeys.length : 0;
|
||||||
|
if (created || skipped) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (created) parts.push(`נוצרו ${created} משימות`);
|
||||||
|
if (skipped) parts.push(`דילוג על ${skipped}`);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AskUserQuestionsForm({
|
||||||
|
interaction,
|
||||||
|
onSubmit,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
interaction: Interaction;
|
||||||
|
onSubmit: (answers: Array<{ questionId: string; optionIds: string[] }>) => void;
|
||||||
|
pending: boolean;
|
||||||
|
}) {
|
||||||
|
const questions: InteractionQuestion[] = interaction.payload.questions ?? [];
|
||||||
|
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
const setSingle = (qid: string, oid: string) =>
|
||||||
|
setSelections((prev) => ({ ...prev, [qid]: [oid] }));
|
||||||
|
|
||||||
|
const toggleMulti = (qid: string, oid: string) =>
|
||||||
|
setSelections((prev) => {
|
||||||
|
const cur = prev[qid] ?? [];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[qid]: cur.includes(oid) ? cur.filter((x) => x !== oid) : [...cur, oid],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const missingRequired = questions.some(
|
||||||
|
(q) => (q.required ?? true) && !(selections[q.id]?.length),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
const answers = questions
|
||||||
|
.map((q) => ({ questionId: q.id, optionIds: selections[q.id] ?? [] }))
|
||||||
|
.filter((a) => a.optionIds.length > 0);
|
||||||
|
onSubmit(answers);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{questions.map((q) => {
|
||||||
|
const isSingle = (q.selectionMode ?? "single") === "single";
|
||||||
|
const chosen = selections[q.id] ?? [];
|
||||||
|
return (
|
||||||
|
<fieldset key={q.id} className="space-y-2">
|
||||||
|
<legend className="text-sm font-semibold text-navy mb-1">
|
||||||
|
{q.prompt}
|
||||||
|
{(q.required ?? true) && <span className="text-rose-600 mr-1">*</span>}
|
||||||
|
</legend>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{q.options.map((opt) => {
|
||||||
|
const checked = chosen.includes(opt.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={opt.id}
|
||||||
|
className={`flex items-start gap-2 cursor-pointer rounded-md border p-2 transition-colors ${
|
||||||
|
checked
|
||||||
|
? "border-navy bg-navy/5"
|
||||||
|
: "border-rule hover:bg-sand-soft/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type={isSingle ? "radio" : "checkbox"}
|
||||||
|
name={q.id}
|
||||||
|
value={opt.id}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() =>
|
||||||
|
isSingle ? setSingle(q.id, opt.id) : toggleMulti(q.id, opt.id)
|
||||||
|
}
|
||||||
|
className="mt-1 accent-navy"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-sm">
|
||||||
|
<span className="font-medium text-navy">{opt.label}</span>
|
||||||
|
{opt.description && (
|
||||||
|
<span className="block text-xs text-ink-faint mt-0.5">
|
||||||
|
{opt.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={pending || missingRequired}
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4 h-4 ml-1" />
|
||||||
|
)}
|
||||||
|
{interaction.payload.submitLabel || "שלח תשובה"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestConfirmationForm({
|
||||||
|
interaction,
|
||||||
|
onAccept,
|
||||||
|
onReject,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
interaction: Interaction;
|
||||||
|
onAccept: () => void;
|
||||||
|
onReject: (reason: string) => void;
|
||||||
|
pending: boolean;
|
||||||
|
}) {
|
||||||
|
const payload = interaction.payload;
|
||||||
|
const allowReason = payload.allowDeclineReason !== false;
|
||||||
|
const requireReason = payload.rejectRequiresReason === true;
|
||||||
|
const [showReason, setShowReason] = useState(requireReason);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
const acceptLabel = (payload.acceptLabel as string) || "אישור";
|
||||||
|
const rejectLabel = (payload.rejectLabel as string) || "דחייה";
|
||||||
|
const reasonLabel =
|
||||||
|
(payload.rejectReasonLabel as string) || "נימוק (לא חובה)";
|
||||||
|
const reasonPlaceholder =
|
||||||
|
(payload.declineReasonPlaceholder as string) || "סיבת הדחייה...";
|
||||||
|
|
||||||
|
const handleReject = () => {
|
||||||
|
if (requireReason && !reason.trim()) {
|
||||||
|
setShowReason(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onReject(reason.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{typeof payload.prompt === "string" && (
|
||||||
|
<div className="text-sm text-navy whitespace-pre-line">{payload.prompt}</div>
|
||||||
|
)}
|
||||||
|
{typeof payload.detailsMarkdown === "string" && payload.detailsMarkdown && (
|
||||||
|
<div className="text-sm bg-sand-soft/40 rounded-md p-2">
|
||||||
|
<Markdown content={payload.detailsMarkdown} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showReason && allowReason && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-ink-faint">{reasonLabel}</label>
|
||||||
|
<Textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder={reasonPlaceholder}
|
||||||
|
className="min-h-[60px] text-sm"
|
||||||
|
dir="rtl"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2 justify-end">
|
||||||
|
{allowReason && !showReason && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowReason(true)}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
הוסף נימוק
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={pending || (requireReason && !reason.trim())}
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 ml-1" />
|
||||||
|
{rejectLabel}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={onAccept} disabled={pending}>
|
||||||
|
{pending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="w-4 h-4 ml-1" />
|
||||||
|
)}
|
||||||
|
{acceptLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuggestTasksForm({
|
||||||
|
interaction,
|
||||||
|
onAccept,
|
||||||
|
onReject,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
interaction: Interaction;
|
||||||
|
onAccept: (selectedClientKeys: string[]) => void;
|
||||||
|
onReject: (reason: string) => void;
|
||||||
|
pending: boolean;
|
||||||
|
}) {
|
||||||
|
const tasks: InteractionTask[] = (interaction.payload.tasks as InteractionTask[]) ?? [];
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(
|
||||||
|
() => new Set(tasks.map((t) => t.clientKey)),
|
||||||
|
);
|
||||||
|
const [showReason, setShowReason] = useState(false);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
const toggle = (key: string) =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5 max-h-[260px] overflow-y-auto">
|
||||||
|
{tasks.map((t) => {
|
||||||
|
const checked = selected.has(t.clientKey);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={t.clientKey}
|
||||||
|
className={`flex items-start gap-2 cursor-pointer rounded-md border p-2 ${
|
||||||
|
checked
|
||||||
|
? "border-navy bg-navy/5"
|
||||||
|
: "border-rule hover:bg-sand-soft/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggle(t.clientKey)}
|
||||||
|
className="mt-1 accent-navy"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-sm">
|
||||||
|
<span className="font-medium text-navy">{t.title}</span>
|
||||||
|
{t.description && (
|
||||||
|
<span className="block text-xs text-ink-faint mt-0.5 whitespace-pre-line">
|
||||||
|
{t.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{showReason && (
|
||||||
|
<Textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="סיבת הדחייה (לא חובה)..."
|
||||||
|
className="min-h-[60px] text-sm"
|
||||||
|
dir="rtl"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => (showReason ? onReject(reason.trim()) : setShowReason(true))}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 ml-1" />
|
||||||
|
{showReason ? "אישור דחייה" : "דחייה"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAccept(Array.from(selected))}
|
||||||
|
disabled={pending || selected.size === 0}
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="w-4 h-4 ml-1" />
|
||||||
|
)}
|
||||||
|
אישור משימות נבחרות ({selected.size})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InteractionCard({
|
||||||
|
interaction,
|
||||||
|
caseNumber,
|
||||||
|
issueMap,
|
||||||
|
}: {
|
||||||
|
interaction: Interaction;
|
||||||
|
caseNumber: string;
|
||||||
|
issueMap: Map<string, string>;
|
||||||
|
}) {
|
||||||
|
const submit = useSubmitInteraction(caseNumber);
|
||||||
|
const identifier = issueMap.get(interaction.issue_id) ?? "";
|
||||||
|
const isPending = interaction.status === "pending";
|
||||||
|
const summary = summaryAnswer(interaction);
|
||||||
|
|
||||||
|
const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
|
||||||
|
submit.mutate(
|
||||||
|
{
|
||||||
|
issue_id: interaction.issue_id,
|
||||||
|
interaction_id: interaction.id,
|
||||||
|
action,
|
||||||
|
payload: payload as Record<string, unknown>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success("התשובה נשלחה"),
|
||||||
|
onError: () => toast.error("שגיאה בשליחת התשובה"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
|
||||||
|
isPending
|
||||||
|
? "border-amber-300 bg-amber-50/40"
|
||||||
|
: "border-rule bg-sand-soft/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 pt-0.5">
|
||||||
|
<div
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
|
isPending
|
||||||
|
? "bg-amber-100 text-amber-800 border border-amber-300"
|
||||||
|
: "bg-emerald-100 text-emerald-800 border border-emerald-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span className="text-sm font-semibold text-navy">
|
||||||
|
{interaction.title || "שאלה לסוכן"}
|
||||||
|
</span>
|
||||||
|
{isPending ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
|
||||||
|
ממתין לתשובה
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ResolvedBadge status={interaction.status} />
|
||||||
|
)}
|
||||||
|
{identifier && (
|
||||||
|
<Badge variant="outline" className="text-[10px] font-mono">
|
||||||
|
{identifier}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] text-ink-faint mr-auto flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{interaction.summary && (
|
||||||
|
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPending ? (
|
||||||
|
interaction.kind === "ask_user_questions" ? (
|
||||||
|
<AskUserQuestionsForm
|
||||||
|
interaction={interaction}
|
||||||
|
onSubmit={(answers) => send("respond", { answers })}
|
||||||
|
pending={submit.isPending}
|
||||||
|
/>
|
||||||
|
) : interaction.kind === "request_confirmation" ? (
|
||||||
|
<RequestConfirmationForm
|
||||||
|
interaction={interaction}
|
||||||
|
onAccept={() => send("accept", {})}
|
||||||
|
onReject={(reason) =>
|
||||||
|
send("reject", reason ? { reason } : {})
|
||||||
|
}
|
||||||
|
pending={submit.isPending}
|
||||||
|
/>
|
||||||
|
) : interaction.kind === "suggest_tasks" ? (
|
||||||
|
<SuggestTasksForm
|
||||||
|
interaction={interaction}
|
||||||
|
onAccept={(keys) =>
|
||||||
|
send("accept", keys.length ? { selectedClientKeys: keys } : {})
|
||||||
|
}
|
||||||
|
onReject={(reason) =>
|
||||||
|
send("reject", reason ? { reason } : {})
|
||||||
|
}
|
||||||
|
pending={submit.isPending}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
) : summary ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
<Markdown content={summary} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main Feed ───────────────────────────────────────────────── */
|
/* ── Main Feed ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function AgentActivityFeed({
|
export function AgentActivityFeed({
|
||||||
@@ -173,11 +643,12 @@ export function AgentActivityFeed({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll on new comments
|
// Auto-scroll on new comments or interactions
|
||||||
const commentCount = data?.comments?.length ?? 0;
|
const commentCount = data?.comments?.length ?? 0;
|
||||||
|
const interactionCount = data?.interactions?.length ?? 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [commentCount]);
|
}, [commentCount, interactionCount]);
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (!body.trim()) return;
|
if (!body.trim()) return;
|
||||||
@@ -224,6 +695,25 @@ export function AgentActivityFeed({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const comments = data.comments ?? [];
|
const comments = data.comments ?? [];
|
||||||
|
const interactions = data.interactions ?? [];
|
||||||
|
|
||||||
|
// Unified, time-sorted feed: comments + interactions interleaved.
|
||||||
|
type FeedItem =
|
||||||
|
| { kind: "comment"; at: number; comment: PaperclipComment }
|
||||||
|
| { kind: "interaction"; at: number; interaction: Interaction };
|
||||||
|
|
||||||
|
const feed: FeedItem[] = [
|
||||||
|
...comments.map<FeedItem>((c) => ({
|
||||||
|
kind: "comment",
|
||||||
|
at: c.created_at ? new Date(c.created_at).getTime() : 0,
|
||||||
|
comment: c,
|
||||||
|
})),
|
||||||
|
...interactions.map<FeedItem>((i) => ({
|
||||||
|
kind: "interaction",
|
||||||
|
at: i.created_at ? new Date(i.created_at).getTime() : 0,
|
||||||
|
interaction: i,
|
||||||
|
})),
|
||||||
|
].sort((a, b) => a.at - b.at);
|
||||||
|
|
||||||
// An issue is "active" if it's not done/cancelled. When everything is closed
|
// An issue is "active" if it's not done/cancelled. When everything is closed
|
||||||
// we should NOT show the "agents are working, waiting for report" spinner.
|
// we should NOT show the "agents are working, waiting for report" spinner.
|
||||||
@@ -246,9 +736,9 @@ export function AgentActivityFeed({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comments stream */}
|
{/* Comments + interactions stream */}
|
||||||
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
||||||
{comments.length === 0 ? (
|
{feed.length === 0 ? (
|
||||||
hasActiveIssue ? (
|
hasActiveIssue ? (
|
||||||
<div className="text-center py-8 text-ink-faint text-sm">
|
<div className="text-center py-8 text-ink-faint text-sm">
|
||||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||||
@@ -261,9 +751,22 @@ export function AgentActivityFeed({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
comments.map((c) => (
|
feed.map((item) =>
|
||||||
<CommentCard key={c.id} comment={c} issueMap={issueMap} />
|
item.kind === "comment" ? (
|
||||||
))
|
<CommentCard
|
||||||
|
key={`c-${item.comment.id}`}
|
||||||
|
comment={item.comment}
|
||||||
|
issueMap={issueMap}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<InteractionCard
|
||||||
|
key={`i-${item.interaction.id}`}
|
||||||
|
interaction={item.interaction}
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
issueMap={issueMap}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<div ref={endRef} />
|
<div ref={endRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Trash2, Plus, Pencil } from "lucide-react";
|
import Link from "next/link";
|
||||||
|
import { Trash2, Plus, Pencil, Wand2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
@@ -16,11 +17,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
usePrecedents,
|
usePrecedents,
|
||||||
useDeletePrecedent,
|
useDeletePrecedent,
|
||||||
|
useRequestHalachotExtraction,
|
||||||
isPrecedentActive,
|
isPrecedentActive,
|
||||||
type Precedent,
|
type Precedent,
|
||||||
type PracticeArea,
|
type PracticeArea,
|
||||||
} from "@/lib/api/precedent-library";
|
} from "@/lib/api/precedent-library";
|
||||||
import { PRACTICE_AREAS, PRECEDENT_LEVELS, practiceAreaShort } from "./practice-area";
|
import { PRACTICE_AREAS, practiceAreaShort } from "./practice-area";
|
||||||
import { PrecedentUploadSheet } from "./precedent-upload-sheet";
|
import { PrecedentUploadSheet } from "./precedent-upload-sheet";
|
||||||
import { PrecedentEditSheet } from "./precedent-edit-sheet";
|
import { PrecedentEditSheet } from "./precedent-edit-sheet";
|
||||||
|
|
||||||
@@ -33,17 +35,25 @@ function formatDate(iso: string | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The upload form (and Nevo PDFs) embed Unicode bidi marks (RTL/LTR/embedding/
|
|
||||||
* isolate) inside the citation. They render as zero-width but visually push
|
|
||||||
* the text away from the cell edge. Strip them for display only — DB still
|
|
||||||
* has the original. */
|
|
||||||
function cleanCitation(s: string | null | undefined): string {
|
function cleanCitation(s: string | null | undefined): string {
|
||||||
if (!s) return "—";
|
if (!s) return "—";
|
||||||
return s.replace(/[--]/g, "").trim();
|
return s.replace(/[--]/g, "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shimmering pill — used while extraction is actively running.
|
// Show the "extract halachot" button only when the precedent hasn't had a
|
||||||
* Visually distinct from the static "queued" / "completed" pills. */
|
// successful (or even attempted) extraction yet. Hide while processing or
|
||||||
|
// after completion to avoid duplicate requests.
|
||||||
|
function needsHalachaExtraction(p: Precedent): boolean {
|
||||||
|
if (p.extraction_status !== "completed") return false; // text not ready
|
||||||
|
if (p.halacha_extraction_status === "processing") return false;
|
||||||
|
if (p.halacha_extraction_status === "completed") return false;
|
||||||
|
if (p.halacha_extraction_status === "pending" && p.halacha_extraction_requested_at) {
|
||||||
|
return false; // already queued
|
||||||
|
}
|
||||||
|
// Remaining cases: pending+no-requested_at (never tried) or failed (retry).
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function ActivePill({ label }: { label: string }) {
|
function ActivePill({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -55,12 +65,6 @@ function ActivePill({ label }: { label: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Five distinct states. The "queued" state is what the user actually
|
|
||||||
* sees most of the time (after upload, both extractions are auto-queued
|
|
||||||
* but the local MCP worker hasn't drained them yet); "מחלץ" / "מעבד"
|
|
||||||
* shimmers and only appears while the extractor is actively running.
|
|
||||||
*/
|
|
||||||
function StatusPill({ p }: { p: Precedent }) {
|
function StatusPill({ p }: { p: Precedent }) {
|
||||||
if (p.extraction_status === "failed") {
|
if (p.extraction_status === "failed") {
|
||||||
return (
|
return (
|
||||||
@@ -103,37 +107,28 @@ function StatusPill({ p }: { p: Precedent }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// halacha_extraction_status === "completed"
|
|
||||||
if (p.halachot_count === 0) {
|
if (p.halachot_count === 0) {
|
||||||
return <Badge variant="outline">ללא הלכות</Badge>;
|
return <Badge variant="outline">ללא הלכות</Badge>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40">
|
||||||
variant="outline"
|
|
||||||
className="bg-gold-wash text-gold-deep border-gold/40"
|
|
||||||
>
|
|
||||||
{p.approved_count}/{p.halachot_count} מאושרות
|
{p.approved_count}/{p.halachot_count} מאושרות
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrecedentRow({
|
function CourtRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void }) {
|
||||||
p, onEdit,
|
|
||||||
}: {
|
|
||||||
p: Precedent;
|
|
||||||
onEdit: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const del = useDeletePrecedent();
|
const del = useDeletePrecedent();
|
||||||
|
const reqHalachot = useRequestHalachotExtraction();
|
||||||
const active = isPrecedentActive(p);
|
const active = isPrecedentActive(p);
|
||||||
|
const showExtractHalachot = needsHalachaExtraction(p);
|
||||||
|
|
||||||
const onDelete = async () => {
|
const onDelete = async () => {
|
||||||
if (active) {
|
if (active) {
|
||||||
toast.error(
|
toast.error("מתבצע עיבוד — לא ניתן למחוק עכשיו.");
|
||||||
"מתבצע עיבוד — לא ניתן למחוק עכשיו. המתיני לסיום או רעני את הדף.",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!window.confirm(`למחוק את ${p.case_number}? cascade ימחק את ה-chunks וההלכות.`)) return;
|
if (!window.confirm(`למחוק את ${p.case_number}?`)) return;
|
||||||
try {
|
try {
|
||||||
await del.mutateAsync(p.id);
|
await del.mutateAsync(p.id);
|
||||||
toast.success("נמחק");
|
toast.success("נמחק");
|
||||||
@@ -142,65 +137,62 @@ function PrecedentRow({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onExtractHalachot = async () => {
|
||||||
|
try {
|
||||||
|
await reqHalachot.mutateAsync(p.id);
|
||||||
|
toast.success("סומן לחילוץ הלכות. הריצי מ-Claude Code: precedent_process_pending_halachot");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow className="border-rule hover:bg-gold-wash/30 align-top">
|
<TableRow className="border-rule hover:bg-gold-wash/30 align-top">
|
||||||
<TableCell
|
<TableCell
|
||||||
/* shadcn TableCell defaults to whitespace-nowrap which forces the
|
|
||||||
* row wider than the container; for this column we want the long
|
|
||||||
* citation to wrap onto a second line instead of triggering the
|
|
||||||
* horizontal scroll on the table wrapper. min-w/max-w keeps the
|
|
||||||
* column wide enough to avoid awkward 2-word lines while leaving
|
|
||||||
* room for the other columns. */
|
|
||||||
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3"
|
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
>
|
>
|
||||||
<span dir="auto">{cleanCitation(p.case_number)}</span>
|
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
|
||||||
|
{cleanCitation(p.case_number)}
|
||||||
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3">
|
<TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3">
|
||||||
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
||||||
{p.court ? (
|
{p.court ? <div className="text-[0.72rem] text-ink-muted">{p.court}</div> : null}
|
||||||
<div className="text-[0.72rem] text-ink-muted">{p.court}</div>
|
|
||||||
) : null}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-ink-muted">
|
|
||||||
{p.date ? formatDate(p.date) : <span className="text-ink-light">—</span>}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-ink-muted">{p.date ? formatDate(p.date) : <span className="text-ink-light">—</span>}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{p.practice_area ? (
|
{p.practice_area ? (
|
||||||
<Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30">
|
<Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30">
|
||||||
{practiceAreaShort(p.practice_area)}
|
{practiceAreaShort(p.practice_area)}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : <span className="text-ink-light">—</span>}
|
||||||
<span className="text-ink-light">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-ink-muted text-[0.78rem]">
|
<TableCell className="text-ink-muted text-[0.78rem]">
|
||||||
{p.precedent_level ? (
|
{p.precedent_level || <span className="text-ink-light">—</span>}
|
||||||
p.precedent_level
|
|
||||||
) : (
|
|
||||||
<span className="text-ink-light">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusPill p={p} />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell><StatusPill p={p} /></TableCell>
|
||||||
<TableCell className="text-end">
|
<TableCell className="text-end">
|
||||||
<div className="flex items-center gap-1 justify-end">
|
<div className="flex items-center gap-1 justify-end">
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => onEdit(p.id)}
|
||||||
variant="ghost" size="sm" onClick={() => onEdit(p.id)}
|
aria-label={`ערוך את ${p.case_number}`} title="ערוך"
|
||||||
aria-label={`ערוך את ${p.case_number}`}
|
className="text-ink-muted hover:text-navy">
|
||||||
title="ערוך פרטים"
|
|
||||||
className="text-ink-muted hover:text-navy"
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{showExtractHalachot && (
|
||||||
variant="ghost" size="sm" onClick={onDelete}
|
<Button variant="ghost" size="sm" onClick={onExtractHalachot}
|
||||||
|
disabled={reqHalachot.isPending}
|
||||||
|
aria-label={`חלץ הלכות מ-${p.case_number}`}
|
||||||
|
title="חלץ הלכות"
|
||||||
|
className="text-gold-deep hover:text-gold-deep hover:bg-gold-wash disabled:opacity-30">
|
||||||
|
<Wand2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={onDelete}
|
||||||
disabled={del.isPending || active}
|
disabled={del.isPending || active}
|
||||||
aria-label={`מחק את ${p.case_number}`}
|
aria-label={`מחק את ${p.case_number}`}
|
||||||
title={active ? "מתבצע עיבוד — לא ניתן למחוק" : "מחק"}
|
title={active ? "מתבצע עיבוד — לא ניתן למחוק" : "מחק"}
|
||||||
className="text-danger hover:text-danger hover:bg-danger-bg disabled:opacity-30"
|
className="text-danger hover:text-danger hover:bg-danger-bg disabled:opacity-30">
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,33 +201,134 @@ function PrecedentRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CommitteeRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void }) {
|
||||||
|
const del = useDeletePrecedent();
|
||||||
|
const reqHalachot = useRequestHalachotExtraction();
|
||||||
|
const active = isPrecedentActive(p);
|
||||||
|
const showExtractHalachot = needsHalachaExtraction(p);
|
||||||
|
|
||||||
|
const onDelete = async () => {
|
||||||
|
if (active) {
|
||||||
|
toast.error("מתבצע עיבוד — לא ניתן למחוק עכשיו.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`למחוק את ${p.case_number}?`)) return;
|
||||||
|
try {
|
||||||
|
await del.mutateAsync(p.id);
|
||||||
|
toast.success("נמחק");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onExtractHalachot = async () => {
|
||||||
|
try {
|
||||||
|
await reqHalachot.mutateAsync(p.id);
|
||||||
|
toast.success("סומן לחילוץ הלכות. הריצי מ-Claude Code: precedent_process_pending_halachot");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow className="border-rule hover:bg-gold-wash/30 align-top">
|
||||||
|
<TableCell
|
||||||
|
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
|
||||||
|
{cleanCitation(p.case_number)}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-ink whitespace-normal break-words max-w-[220px] py-3">
|
||||||
|
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-ink-muted text-[0.78rem]">
|
||||||
|
{p.district || <span className="text-ink-light">—</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-ink-muted text-[0.78rem]">
|
||||||
|
{p.chair_name || <span className="text-ink-light">—</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-ink-muted">{p.date ? formatDate(p.date) : <span className="text-ink-light">—</span>}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{p.practice_area ? (
|
||||||
|
<Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30">
|
||||||
|
{practiceAreaShort(p.practice_area)}
|
||||||
|
</Badge>
|
||||||
|
) : <span className="text-ink-light">—</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><StatusPill p={p} /></TableCell>
|
||||||
|
<TableCell className="text-end">
|
||||||
|
<div className="flex items-center gap-1 justify-end">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onEdit(p.id)}
|
||||||
|
aria-label={`ערוך את ${p.case_number}`} title="ערוך"
|
||||||
|
className="text-ink-muted hover:text-navy">
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{showExtractHalachot && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onExtractHalachot}
|
||||||
|
disabled={reqHalachot.isPending}
|
||||||
|
aria-label={`חלץ הלכות מ-${p.case_number}`}
|
||||||
|
title="חלץ הלכות"
|
||||||
|
className="text-gold-deep hover:text-gold-deep hover:bg-gold-wash disabled:opacity-30">
|
||||||
|
<Wand2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={onDelete}
|
||||||
|
disabled={del.isPending || active}
|
||||||
|
aria-label={`מחק את ${p.case_number}`}
|
||||||
|
title={active ? "מתבצע עיבוד — לא ניתן למחוק" : "מחק"}
|
||||||
|
className="text-danger hover:text-danger hover:bg-danger-bg disabled:opacity-30">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton({ cols }: { cols: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<TableRow key={i} className="border-rule">
|
||||||
|
{[...Array(cols)].map((_, j) => (
|
||||||
|
<TableCell key={j}><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LibraryListPanel() {
|
export function LibraryListPanel() {
|
||||||
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
|
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
|
||||||
const [precedentLevel, setPrecedentLevel] = useState("");
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [uploadOpen, setUploadOpen] = useState(false);
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data, isPending, error } = usePrecedents({
|
const sharedFilters = {
|
||||||
practiceArea: practiceArea || undefined,
|
practiceArea: practiceArea || undefined,
|
||||||
precedentLevel: precedentLevel || undefined,
|
|
||||||
search: search.trim() || undefined,
|
search: search.trim() || undefined,
|
||||||
limit: 200,
|
limit: 200,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload", sourceType: "court_ruling" });
|
||||||
|
const committee = usePrecedents({ ...sharedFilters, sourceKind: "all_committees" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-8">
|
||||||
|
{/* Shared filters */}
|
||||||
<div className="flex items-end gap-3 flex-wrap">
|
<div className="flex items-end gap-3 flex-wrap">
|
||||||
<div className="flex-1 min-w-[200px]">
|
<div className="flex-1 min-w-[200px]">
|
||||||
<label className="text-[0.78rem] text-ink-muted">חיפוש (מספר תיק / שם / תקציר)</label>
|
<label className="text-[0.78rem] text-ink-muted">חיפוש (מספר תיק / שם)</label>
|
||||||
<Input
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="עע"מ 3975/22"
|
placeholder="עע״מ 3975/22 / 1200-25"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-[180px]">
|
<div className="min-w-[180px]">
|
||||||
<label className="text-[0.78rem] text-ink-muted">תחום</label>
|
<label className="text-[0.78rem] text-ink-muted">תחום</label>
|
||||||
<Select value={practiceArea || "_all"} onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}>
|
<Select value={practiceArea || "_all"} onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}>
|
||||||
@@ -248,70 +341,108 @@ export function LibraryListPanel() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-[170px]">
|
|
||||||
<label className="text-[0.78rem] text-ink-muted">רמת תקדים</label>
|
|
||||||
<Select value={precedentLevel || "_all"} onValueChange={(v) => setPrecedentLevel(v === "_all" ? "" : v)}>
|
|
||||||
<SelectTrigger><SelectValue placeholder="הכל" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="_all">הכל</SelectItem>
|
|
||||||
{PRECEDENT_LEVELS.map((l) => (
|
|
||||||
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={() => setUploadOpen(true)} className="bg-navy text-parchment hover:bg-navy-soft">
|
<Button onClick={() => setUploadOpen(true)} className="bg-navy text-parchment hover:bg-navy-soft">
|
||||||
<Plus className="w-4 h-4 me-1" />
|
<Plus className="w-4 h-4 me-1" />
|
||||||
העלאת פסיקה
|
העלאת פסיקה
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{/* Table 1 — Court rulings */}
|
||||||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
|
<section>
|
||||||
{error.message}
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<h3 className="text-base font-semibold text-navy">פסיקת בתי משפט</h3>
|
||||||
|
{courts.data && (
|
||||||
|
<Badge variant="outline" className="text-ink-muted text-[0.72rem]">
|
||||||
|
{courts.data.count}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{courts.error ? (
|
||||||
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
|
||||||
<Table>
|
{courts.error.message}
|
||||||
<TableHeader className="bg-rule-soft/60">
|
</div>
|
||||||
<TableRow className="border-rule">
|
) : (
|
||||||
<TableHead className="text-navy text-right">מס׳ / מראה מקום</TableHead>
|
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||||
<TableHead className="text-navy text-right">שם / ערכאה</TableHead>
|
<Table>
|
||||||
<TableHead className="text-navy text-right">תאריך</TableHead>
|
<TableHeader className="bg-rule-soft/60">
|
||||||
<TableHead className="text-navy text-right">תחום</TableHead>
|
|
||||||
<TableHead className="text-navy text-right">רמה</TableHead>
|
|
||||||
<TableHead className="text-navy text-right">הלכות</TableHead>
|
|
||||||
<TableHead className="text-navy" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{isPending ? (
|
|
||||||
[...Array(5)].map((_, i) => (
|
|
||||||
<TableRow key={i} className="border-rule">
|
|
||||||
{[...Array(7)].map((_, j) => (
|
|
||||||
<TableCell key={j}>
|
|
||||||
<Skeleton className="h-5 w-full" />
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : !data?.items.length ? (
|
|
||||||
<TableRow className="border-rule">
|
<TableRow className="border-rule">
|
||||||
<TableCell colSpan={7} className="text-center text-ink-muted py-10">
|
<TableHead className="text-navy text-right">מס׳ / מראה מקום</TableHead>
|
||||||
אין פסיקה בקורפוס. העלה את פסק הדין הראשון.
|
<TableHead className="text-navy text-right">שם / ערכאה</TableHead>
|
||||||
</TableCell>
|
<TableHead className="text-navy text-right">תאריך</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">תחום</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">רמה</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">הלכות</TableHead>
|
||||||
|
<TableHead className="text-navy" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
</TableHeader>
|
||||||
data.items.map((p) => (
|
<TableBody>
|
||||||
<PrecedentRow key={p.id} p={p} onEdit={setEditingId} />
|
{courts.isPending ? (
|
||||||
))
|
<TableSkeleton cols={7} />
|
||||||
)}
|
) : !courts.data?.items.length ? (
|
||||||
</TableBody>
|
<TableRow className="border-rule">
|
||||||
</Table>
|
<TableCell colSpan={7} className="text-center text-ink-muted py-8">
|
||||||
|
אין פסיקת בתי משפט בקורפוס.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
courts.data.items.map((p) => (
|
||||||
|
<CourtRow key={p.id} p={p} onEdit={setEditingId} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Table 2 — Appeals committee decisions */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<h3 className="text-base font-semibold text-navy">החלטות ועדות ערר</h3>
|
||||||
|
{committee.data && (
|
||||||
|
<Badge variant="outline" className="text-ink-muted text-[0.72rem]">
|
||||||
|
{committee.data.count}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{committee.error ? (
|
||||||
|
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
|
||||||
|
{committee.error.message}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-rule-soft/60">
|
||||||
|
<TableRow className="border-rule">
|
||||||
|
<TableHead className="text-navy text-right">מספר ערר</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">שם</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">מחוז</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">יו״ר</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">תאריך</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">תחום</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">הלכות</TableHead>
|
||||||
|
<TableHead className="text-navy" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{committee.isPending ? (
|
||||||
|
<TableSkeleton cols={8} />
|
||||||
|
) : !committee.data?.items.length ? (
|
||||||
|
<TableRow className="border-rule">
|
||||||
|
<TableCell colSpan={8} className="text-center text-ink-muted py-8">
|
||||||
|
אין החלטות ועדת ערר בקורפוס.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
committee.data.items.map((p) => (
|
||||||
|
<CommitteeRow key={p.id} p={p} onEdit={setEditingId} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<PrecedentUploadSheet open={uploadOpen} onOpenChange={setUploadOpen} />
|
<PrecedentUploadSheet open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||||
<PrecedentEditSheet
|
<PrecedentEditSheet
|
||||||
|
|||||||
222
web-ui/src/components/precedents/link-related-dialog.tsx
Normal file
222
web-ui/src/components/precedents/link-related-dialog.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link2, Loader2, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
usePrecedents,
|
||||||
|
useLinkRelatedCase,
|
||||||
|
useUnlinkRelatedCase,
|
||||||
|
RelatedCase,
|
||||||
|
} from "@/lib/api/precedent-library";
|
||||||
|
|
||||||
|
const LEVEL_LABELS: Record<string, string> = {
|
||||||
|
"עליון": "עליון",
|
||||||
|
"מנהלי": "מנהלי",
|
||||||
|
"ועדת_ערר_ארצית": "ארצי",
|
||||||
|
"ועדת_ערר_מחוזית": "מחוזי",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_COLORS: Record<string, string> = {
|
||||||
|
"עליון": "bg-red-50 text-red-700 border-red-200",
|
||||||
|
"מנהלי": "bg-orange-50 text-orange-700 border-orange-200",
|
||||||
|
"ועדת_ערר_ארצית": "bg-blue-50 text-blue-700 border-blue-200",
|
||||||
|
"ועדת_ערר_מחוזית": "bg-green-50 text-green-700 border-green-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Search Dialog ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type DialogProps = {
|
||||||
|
caseId: string;
|
||||||
|
currentRelated: RelatedCase[];
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const { mutateAsync: linkCase, isPending } = useLinkRelatedCase(caseId);
|
||||||
|
const alreadyLinkedIds = new Set([...currentRelated.map((r) => r.id), caseId]);
|
||||||
|
|
||||||
|
const { data, isPending: searching } = usePrecedents(
|
||||||
|
query.length >= 2 ? { search: query, limit: 10 } : {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidates = (data?.items ?? []).filter((p) => !alreadyLinkedIds.has(p.id));
|
||||||
|
|
||||||
|
async function handleLink(relatedId: string) {
|
||||||
|
try {
|
||||||
|
await linkCase({ relatedId });
|
||||||
|
toast.success("הפסיקות קושרו");
|
||||||
|
setQuery("");
|
||||||
|
} catch {
|
||||||
|
toast.error("שגיאה בקישור");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg" dir="rtl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-navy">קשר החלטה קשורה</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
placeholder="חפש לפי מספר תיק, שם, ערכאה..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{query.length >= 2 && (
|
||||||
|
<div className="space-y-1 max-h-72 overflow-y-auto">
|
||||||
|
{searching ? (
|
||||||
|
<div className="flex items-center gap-2 text-ink-muted text-sm py-3">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" /> מחפש...
|
||||||
|
</div>
|
||||||
|
) : candidates.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm py-3 text-center">לא נמצאו תוצאות</p>
|
||||||
|
) : (
|
||||||
|
candidates.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => handleLink(p.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full text-right flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule hover:bg-surface/60 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-navy truncate">
|
||||||
|
{p.case_name || p.case_number}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted font-mono" dir="ltr">
|
||||||
|
{p.case_number}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{p.precedent_level && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[0.62rem] shrink-0 ${LEVEL_COLORS[p.precedent_level] ?? ""}`}
|
||||||
|
>
|
||||||
|
{LEVEL_LABELS[p.precedent_level] ?? p.precedent_level}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{query.length > 0 && query.length < 2 && (
|
||||||
|
<p className="text-ink-muted text-xs">הקלד לפחות 2 תווים</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Related Case Card ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RelatedCaseCard({ caseId, related }: { caseId: string; related: RelatedCase }) {
|
||||||
|
const { mutateAsync: unlinkCase, isPending } = useUnlinkRelatedCase(caseId);
|
||||||
|
|
||||||
|
async function handleUnlink() {
|
||||||
|
try {
|
||||||
|
await unlinkCase(related.id);
|
||||||
|
toast.success("הקישור הוסר");
|
||||||
|
} catch {
|
||||||
|
toast.error("שגיאה בהסרת הקישור");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule bg-surface">
|
||||||
|
<a
|
||||||
|
href={`/precedents/${related.id}`}
|
||||||
|
className="min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-navy truncate">
|
||||||
|
{related.case_name || related.case_number}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||||
|
{related.precedent_level && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[0.62rem] ${LEVEL_COLORS[related.precedent_level] ?? ""}`}
|
||||||
|
>
|
||||||
|
{LEVEL_LABELS[related.precedent_level] ?? related.precedent_level}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{related.court && (
|
||||||
|
<span className="text-[0.7rem] text-ink-muted truncate">{related.court}</span>
|
||||||
|
)}
|
||||||
|
{related.date && (
|
||||||
|
<span className="text-[0.7rem] text-ink-muted tabular-nums" dir="ltr">
|
||||||
|
{related.date.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={handleUnlink}
|
||||||
|
disabled={isPending}
|
||||||
|
className="p-1 rounded hover:bg-danger-bg hover:text-danger transition-colors text-ink-muted disabled:opacity-40 shrink-0"
|
||||||
|
title="הסר קישור"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public section component ─────────────────────────────────────────
|
||||||
|
|
||||||
|
type SectionProps = {
|
||||||
|
caseId: string;
|
||||||
|
related: RelatedCase[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RelatedCasesSection({ caseId, related }: SectionProps) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-navy text-sm font-semibold">
|
||||||
|
החלטות קשורות{related.length > 0 ? ` (${related.length})` : ""}
|
||||||
|
</h3>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setDialogOpen(true)}>
|
||||||
|
<Link2 className="w-3.5 h-3.5 me-1" /> קשר החלטה
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{related.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm">אין החלטות קשורות עדיין</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{related.map((r) => (
|
||||||
|
<RelatedCaseCard key={r.id} caseId={caseId} related={r} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LinkDialog
|
||||||
|
caseId={caseId}
|
||||||
|
currentRelated={related}
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,3 +35,17 @@ export function practiceAreaShort(value: string | null | undefined): string {
|
|||||||
const match = PRACTICE_AREAS.find((p) => p.value === value);
|
const match = PRACTICE_AREAS.find((p) => p.value === value);
|
||||||
return match ? match.short : value;
|
return match ? match.short : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display label for ``appeal_subtype``. The field is free text — the chair
|
||||||
|
* usually types specific subjects like "תכנית רחביה" or "סופיות ההחלטה" —
|
||||||
|
* but the LLM extractor sometimes mistakenly stuffs the practice_area
|
||||||
|
* enum value (``betterment_levy``, ``rishuy_uvniya``, ``compensation_197``)
|
||||||
|
* in there. When that happens, translate to Hebrew so the UI doesn't
|
||||||
|
* leak English. Otherwise return the value unchanged.
|
||||||
|
*/
|
||||||
|
export function appealSubtypeLabel(value: string | null | undefined): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const enumMatch = PRACTICE_AREAS.find((p) => p.value === value);
|
||||||
|
return enumMatch ? enumMatch.label : value;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Save, Sparkles } from "lucide-react";
|
import { Save, Sparkles } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
type SourceType,
|
type SourceType,
|
||||||
} from "@/lib/api/precedent-library";
|
} from "@/lib/api/precedent-library";
|
||||||
import {
|
import {
|
||||||
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES,
|
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, appealSubtypeLabel,
|
||||||
} from "./practice-area";
|
} from "./practice-area";
|
||||||
import { ExtractedHalachotSection } from "./extracted-halachot";
|
import { ExtractedHalachotSection } from "./extracted-halachot";
|
||||||
|
|
||||||
@@ -65,17 +65,19 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
|
|
||||||
const [form, setForm] = useState<FormState>(EMPTY);
|
const [form, setForm] = useState<FormState>(EMPTY);
|
||||||
|
|
||||||
// Hydrate form when the record loads.
|
// React-approved derived-state pattern: sync form whenever a different
|
||||||
useEffect(() => {
|
// record arrives (including after save+refetch). Using setState during
|
||||||
if (!record) return;
|
// render avoids the one-frame flash that useEffect would produce.
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
const [syncedRecordId, setSyncedRecordId] = useState<string | null>(null);
|
||||||
|
if (record && record.id !== syncedRecordId) {
|
||||||
|
setSyncedRecordId(record.id as string);
|
||||||
setForm({
|
setForm({
|
||||||
citation: record.case_number || "",
|
citation: record.case_number || "",
|
||||||
case_name: record.case_name || "",
|
case_name: record.case_name || "",
|
||||||
court: record.court || "",
|
court: record.court || "",
|
||||||
decision_date: record.date ? record.date.slice(0, 10) : "",
|
decision_date: record.date ? record.date.slice(0, 10) : "",
|
||||||
practice_area: (record.practice_area || "") as PracticeArea,
|
practice_area: (record.practice_area || "") as PracticeArea,
|
||||||
appeal_subtype: record.appeal_subtype || "",
|
appeal_subtype: appealSubtypeLabel(record.appeal_subtype),
|
||||||
source_type: (record.source_type || "") as SourceType,
|
source_type: (record.source_type || "") as SourceType,
|
||||||
precedent_level: record.precedent_level || "",
|
precedent_level: record.precedent_level || "",
|
||||||
is_binding: record.is_binding ?? true,
|
is_binding: record.is_binding ?? true,
|
||||||
@@ -84,7 +86,7 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
headnote: record.headnote || "",
|
headnote: record.headnote || "",
|
||||||
key_quote: (record as { key_quote?: string }).key_quote || "",
|
key_quote: (record as { key_quote?: string }).key_quote || "",
|
||||||
});
|
});
|
||||||
}, [record]);
|
}
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent) => {
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -42,10 +42,80 @@ export type PaperclipAgent = {
|
|||||||
last_heartbeat_at: string | null;
|
last_heartbeat_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InteractionKind =
|
||||||
|
| "ask_user_questions"
|
||||||
|
| "request_confirmation"
|
||||||
|
| "suggest_tasks";
|
||||||
|
|
||||||
|
export type InteractionStatus =
|
||||||
|
| "pending"
|
||||||
|
| "answered"
|
||||||
|
| "accepted"
|
||||||
|
| "rejected"
|
||||||
|
| "expired"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export type InteractionOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InteractionQuestion = {
|
||||||
|
id: string;
|
||||||
|
prompt: string;
|
||||||
|
selectionMode?: "single" | "multi";
|
||||||
|
required?: boolean;
|
||||||
|
options: InteractionOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InteractionTask = {
|
||||||
|
clientKey: string;
|
||||||
|
parentClientKey?: string | null;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Free-form payload — shape depends on `kind`. Common fields surfaced for the
|
||||||
|
* UI; everything else is preserved on the wire. */
|
||||||
|
export type InteractionPayload = {
|
||||||
|
version?: number;
|
||||||
|
submitLabel?: string;
|
||||||
|
acceptLabel?: string;
|
||||||
|
rejectLabel?: string;
|
||||||
|
questions?: InteractionQuestion[];
|
||||||
|
tasks?: InteractionTask[];
|
||||||
|
body?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Interaction = {
|
||||||
|
id: string;
|
||||||
|
issue_id: string;
|
||||||
|
kind: InteractionKind;
|
||||||
|
status: InteractionStatus;
|
||||||
|
title: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
payload: InteractionPayload;
|
||||||
|
result: Record<string, unknown> | null;
|
||||||
|
created_at: string | null;
|
||||||
|
resolved_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type AgentActivityResponse = {
|
export type AgentActivityResponse = {
|
||||||
issues: PaperclipIssue[];
|
issues: PaperclipIssue[];
|
||||||
comments: PaperclipComment[];
|
comments: PaperclipComment[];
|
||||||
agents: PaperclipAgent[];
|
agents: PaperclipAgent[];
|
||||||
|
interactions: Interaction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InteractionAction = "respond" | "accept" | "reject";
|
||||||
|
|
||||||
|
export type InteractionSubmitVars = {
|
||||||
|
issue_id: string;
|
||||||
|
interaction_id: string;
|
||||||
|
action: InteractionAction;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Query Keys ───────────────────────────────────────────────────
|
// ── Query Keys ───────────────────────────────────────────────────
|
||||||
@@ -85,3 +155,19 @@ export function useSendComment(caseNumber: string | undefined) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSubmitInteraction(caseNumber: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: InteractionSubmitVars) =>
|
||||||
|
apiRequest<Interaction>(
|
||||||
|
`/api/cases/${caseNumber}/agents/interaction-response`,
|
||||||
|
{ method: "POST", body: vars },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (caseNumber) {
|
||||||
|
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
71
web-ui/src/lib/api/paperclip-agents.ts
Normal file
71
web-ui/src/lib/api/paperclip-agents.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Paperclip agents — read-only admin view (Task #29).
|
||||||
|
*
|
||||||
|
* Backend: `GET /api/admin/paperclip-agents` returns master+mirror pairs
|
||||||
|
* (CMP / CMPA) for all 7 agent roles, with drift detection between the pair.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
|
||||||
|
export type PaperclipAgent = {
|
||||||
|
id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name: string;
|
||||||
|
name: string;
|
||||||
|
role: string | null;
|
||||||
|
status: string | null;
|
||||||
|
pause_reason: string | null;
|
||||||
|
adapter_type: string | null;
|
||||||
|
model: string | null;
|
||||||
|
effort: string | null;
|
||||||
|
timeoutSec: number | null;
|
||||||
|
maxTurnsPerRun: number | null;
|
||||||
|
desiredSkills: string[];
|
||||||
|
instructionsBundleMode: string | null;
|
||||||
|
instructionsRootPath: string | null;
|
||||||
|
instructionsEntryFile: string | null;
|
||||||
|
instructionsFilePath: string | null;
|
||||||
|
graceSec: number | null;
|
||||||
|
cooldownSec: number | null;
|
||||||
|
wakeOnDemand: boolean | null;
|
||||||
|
maxConcurrentRuns: number | null;
|
||||||
|
intervalSec: number | null;
|
||||||
|
enabled: boolean | null;
|
||||||
|
budget_monthly_cents: number | null;
|
||||||
|
spent_monthly_cents: number | null;
|
||||||
|
last_heartbeat_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriftEntry = {
|
||||||
|
field: string;
|
||||||
|
master: unknown;
|
||||||
|
mirror: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentPair = {
|
||||||
|
name: string;
|
||||||
|
role: string | null;
|
||||||
|
master: PaperclipAgent | null;
|
||||||
|
mirror: PaperclipAgent | null;
|
||||||
|
drift: DriftEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaperclipAgentsResponse = {
|
||||||
|
pairs: AgentPair[];
|
||||||
|
companies: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
slot: "master" | "mirror";
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePaperclipAgents() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["settings", "paperclip-agents"] as const,
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<PaperclipAgentsResponse>("/api/admin/paperclip-agents", { signal }),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ export type Precedent = {
|
|||||||
headnote: string;
|
headnote: string;
|
||||||
subject_tags: string[];
|
subject_tags: string[];
|
||||||
source_kind: string;
|
source_kind: string;
|
||||||
|
chair_name: string | null;
|
||||||
|
district: string | null;
|
||||||
extraction_status: string;
|
extraction_status: string;
|
||||||
halacha_extraction_status: string;
|
halacha_extraction_status: string;
|
||||||
metadata_extraction_requested_at: string | null;
|
metadata_extraction_requested_at: string | null;
|
||||||
@@ -82,9 +84,20 @@ export type Halacha = {
|
|||||||
precedent_level?: string;
|
precedent_level?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RelatedCase = {
|
||||||
|
id: string;
|
||||||
|
case_number: string;
|
||||||
|
case_name: string;
|
||||||
|
court: string;
|
||||||
|
precedent_level: string;
|
||||||
|
date: string | null;
|
||||||
|
relation_type: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PrecedentDetail = Precedent & {
|
export type PrecedentDetail = Precedent & {
|
||||||
full_text: string;
|
full_text: string;
|
||||||
halachot: Halacha[];
|
halachot: Halacha[];
|
||||||
|
related_cases: RelatedCase[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SearchHit =
|
export type SearchHit =
|
||||||
@@ -137,6 +150,7 @@ export type ListFilters = {
|
|||||||
court?: string;
|
court?: string;
|
||||||
precedentLevel?: string;
|
precedentLevel?: string;
|
||||||
sourceType?: SourceType;
|
sourceType?: SourceType;
|
||||||
|
sourceKind?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
@@ -164,6 +178,7 @@ export function usePrecedents(filters: ListFilters = {}) {
|
|||||||
if (filters.court) p.set("court", filters.court);
|
if (filters.court) p.set("court", filters.court);
|
||||||
if (filters.precedentLevel) p.set("precedent_level", filters.precedentLevel);
|
if (filters.precedentLevel) p.set("precedent_level", filters.precedentLevel);
|
||||||
if (filters.sourceType) p.set("source_type", filters.sourceType);
|
if (filters.sourceType) p.set("source_type", filters.sourceType);
|
||||||
|
if (filters.sourceKind) p.set("source_kind", filters.sourceKind);
|
||||||
if (filters.search) p.set("search", filters.search);
|
if (filters.search) p.set("search", filters.search);
|
||||||
if (filters.limit) p.set("limit", String(filters.limit));
|
if (filters.limit) p.set("limit", String(filters.limit));
|
||||||
if (filters.offset) p.set("offset", String(filters.offset));
|
if (filters.offset) p.set("offset", String(filters.offset));
|
||||||
@@ -353,6 +368,40 @@ export function useDeletePrecedent() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useLinkRelatedCase(caseId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: { relatedId: string; relationType?: string }) =>
|
||||||
|
apiRequest<{ linked: boolean }>(
|
||||||
|
`/api/precedent-library/${encodeURIComponent(caseId)}/relations`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
related_id: vars.relatedId,
|
||||||
|
relation_type: vars.relationType ?? "same_case_chain",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnlinkRelatedCase(caseId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (relatedId: string) =>
|
||||||
|
apiRequest<{ unlinked: boolean }>(
|
||||||
|
`/api/precedent-library/${encodeURIComponent(caseId)}/relations/${encodeURIComponent(relatedId)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type PrecedentPatch = Partial<{
|
export type PrecedentPatch = Partial<{
|
||||||
case_name: string;
|
case_name: string;
|
||||||
court: string;
|
court: string;
|
||||||
|
|||||||
@@ -1355,7 +1355,7 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Api Case Agents
|
* Api Case Agents
|
||||||
* @description Get all Paperclip agent activity for a case: issues, comments, agent status.
|
* @description Get all Paperclip agent activity for a case: issues, comments, interactions, agent status.
|
||||||
*/
|
*/
|
||||||
get: operations["api_case_agents_api_cases__case_number__agents_get"];
|
get: operations["api_case_agents_api_cases__case_number__agents_get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
@@ -1388,6 +1388,149 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/cases/{case_number}/agents/interaction-response": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Api Post Interaction Response
|
||||||
|
* @description Submit a user's answer to a Paperclip issue-thread interaction.
|
||||||
|
*
|
||||||
|
* Routes to /respond | /accept | /reject based on `action`. Paperclip
|
||||||
|
* auto-wakes the issue assignee after a successful submission.
|
||||||
|
*/
|
||||||
|
post: operations["api_post_interaction_response_api_cases__case_number__agents_interaction_response_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/settings/mcp/env": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Api Mcp Env
|
||||||
|
* @description List all catalog env vars with Coolify (authoritative) + container values.
|
||||||
|
*/
|
||||||
|
get: operations["api_mcp_env_api_settings_mcp_env_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/settings/mcp/env/{key}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/**
|
||||||
|
* Api Mcp Env Update
|
||||||
|
* @description Update a non-secret env var in Coolify. Requires redeploy to take effect.
|
||||||
|
*/
|
||||||
|
patch: operations["api_mcp_env_update_api_settings_mcp_env__key__patch"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/settings/mcp/env/redeploy": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Api Mcp Env Redeploy
|
||||||
|
* @description Trigger Coolify redeploy of the legal-ai app.
|
||||||
|
*/
|
||||||
|
post: operations["api_mcp_env_redeploy_api_settings_mcp_env_redeploy_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/settings/mcp/tools": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Api Mcp Tools
|
||||||
|
* @description List all MCP tools registered in legal_mcp.
|
||||||
|
*/
|
||||||
|
get: operations["api_mcp_tools_api_settings_mcp_tools_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/settings/mcp/registrations": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Api Mcp Registrations
|
||||||
|
* @description List MCP server registrations from host config files.
|
||||||
|
*/
|
||||||
|
get: operations["api_mcp_registrations_api_settings_mcp_registrations_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/settings/mcp/blocks": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Api Mcp Blocks
|
||||||
|
* @description List the 12-block decision schema (read-only reference).
|
||||||
|
*/
|
||||||
|
get: operations["api_mcp_blocks_api_settings_mcp_blocks_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/settings/paperclip-companies": {
|
"/api/settings/paperclip-companies": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1516,6 +1659,29 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/paperclip-agents": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Api List Paperclip Agents
|
||||||
|
* @description List all Paperclip agents grouped into master+mirror pairs with drift detection.
|
||||||
|
*
|
||||||
|
* Read-only. Source of truth: Paperclip ``GET /api/companies/{id}/agents`` API
|
||||||
|
* (not direct DB) — keeps us decoupled from Paperclip's schema changes.
|
||||||
|
*/
|
||||||
|
get: operations["api_list_paperclip_agents_api_admin_paperclip_agents_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/admin/skills/install": {
|
"/api/admin/skills/install": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2295,6 +2461,27 @@ export interface components {
|
|||||||
/** Practice Areas */
|
/** Practice Areas */
|
||||||
practice_areas?: string[] | null;
|
practice_areas?: string[] | null;
|
||||||
};
|
};
|
||||||
|
/** InteractionResponseRequest */
|
||||||
|
InteractionResponseRequest: {
|
||||||
|
/** Issue Id */
|
||||||
|
issue_id: string;
|
||||||
|
/** Interaction Id */
|
||||||
|
interaction_id: string;
|
||||||
|
/**
|
||||||
|
* Action
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
action: "respond" | "accept" | "reject";
|
||||||
|
/** Payload */
|
||||||
|
payload: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** McpEnvUpdateRequest */
|
||||||
|
McpEnvUpdateRequest: {
|
||||||
|
/** Value */
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
/** MethodologyUpdateRequest */
|
/** MethodologyUpdateRequest */
|
||||||
MethodologyUpdateRequest: {
|
MethodologyUpdateRequest: {
|
||||||
/** Value */
|
/** Value */
|
||||||
@@ -4491,6 +4678,176 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
api_post_interaction_response_api_cases__case_number__agents_interaction_response_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
case_number: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["InteractionResponseRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_mcp_env_api_settings_mcp_env_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_mcp_env_update_api_settings_mcp_env__key__patch: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["McpEnvUpdateRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_mcp_env_redeploy_api_settings_mcp_env_redeploy_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_mcp_tools_api_settings_mcp_tools_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_mcp_registrations_api_settings_mcp_registrations_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_mcp_blocks_api_settings_mcp_blocks_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
api_paperclip_companies_api_settings_paperclip_companies_get: {
|
api_paperclip_companies_api_settings_paperclip_companies_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4714,6 +5071,26 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
api_list_paperclip_agents_api_admin_paperclip_agents_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
api_install_skill_api_admin_skills_install_post: {
|
api_install_skill_api_admin_skills_install_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
456
web/app.py
456
web/app.py
@@ -22,7 +22,7 @@ import zipfile
|
|||||||
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
@@ -44,7 +44,10 @@ from web.mcp_env_catalog import (
|
|||||||
normalize_for_compare,
|
normalize_for_compare,
|
||||||
)
|
)
|
||||||
from web.progress_store import ProgressStore
|
from web.progress_store import ProgressStore
|
||||||
|
from web.paperclip_api import pc_request
|
||||||
from web.paperclip_client import (
|
from web.paperclip_client import (
|
||||||
|
COMPANIES as PAPERCLIP_COMPANIES,
|
||||||
|
accept_interaction as pc_accept_interaction,
|
||||||
archive_project as pc_archive_project,
|
archive_project as pc_archive_project,
|
||||||
create_project as pc_create_project,
|
create_project as pc_create_project,
|
||||||
create_workflow_issue as pc_create_workflow_issue,
|
create_workflow_issue as pc_create_workflow_issue,
|
||||||
@@ -52,10 +55,14 @@ from web.paperclip_client import (
|
|||||||
get_agents_for_company as pc_get_agents,
|
get_agents_for_company as pc_get_agents,
|
||||||
get_case_issues as pc_get_case_issues,
|
get_case_issues as pc_get_case_issues,
|
||||||
get_issue_comments as pc_get_issue_comments,
|
get_issue_comments as pc_get_issue_comments,
|
||||||
|
get_issue_interactions as pc_get_issue_interactions,
|
||||||
get_project_url,
|
get_project_url,
|
||||||
post_comment as pc_post_comment,
|
post_comment as pc_post_comment,
|
||||||
|
reject_interaction as pc_reject_interaction,
|
||||||
|
respond_to_interaction as pc_respond_to_interaction,
|
||||||
restore_project as pc_restore_project,
|
restore_project as pc_restore_project,
|
||||||
wake_ceo_agent as pc_wake_ceo,
|
wake_ceo_agent as pc_wake_ceo,
|
||||||
|
wake_curator_for_final as pc_wake_curator_for_final,
|
||||||
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
|
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2341,10 +2348,38 @@ async def api_mark_final(case_number: str, filename: str):
|
|||||||
if case_dir.exists():
|
if case_dir.exists():
|
||||||
commit_and_push(case_dir, f"גרסה סופית: {final_name}")
|
commit_and_push(case_dir, f"גרסה סופית: {final_name}")
|
||||||
|
|
||||||
|
# ingest_final_version into the case_law corpus is NOT called here:
|
||||||
|
# it uses claude_session under the hood, which only works when invoked
|
||||||
|
# from the local MCP server (Claude CLI present), not from this
|
||||||
|
# FastAPI container. Run it manually via Claude Code / MCP when needed.
|
||||||
|
# See: ~/.claude/projects/-home-chaim-legal-ai/memory/feedback_claude_session_local_only.md
|
||||||
|
ingest_status: dict = {"status": "skipped", "reason": "container_no_claude_cli"}
|
||||||
|
|
||||||
|
# Best-effort: wake the Knowledge Curator (Hermes) to analyze the
|
||||||
|
# signed final and propose updates to skills/lessons. Non-fatal on
|
||||||
|
# failure so marking final never breaks for the user.
|
||||||
|
curator_status: dict = {"status": "skipped"}
|
||||||
|
try:
|
||||||
|
# Company by case-number prefix: 1xxx=CMP (licensing), 8/9xxx=CMPA (betterment)
|
||||||
|
prefix = case_number[:1]
|
||||||
|
company_id = (
|
||||||
|
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
|
||||||
|
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
curator_status = await pc_wake_curator_for_final(
|
||||||
|
case_number, final_name, company_id=company_id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("curator wakeup failed for %s: %s", case_number, e)
|
||||||
|
curator_status = {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"final_filename": final_name,
|
"final_filename": final_name,
|
||||||
"training_copy": str(training_dest),
|
"training_copy": str(training_dest),
|
||||||
"status": "final",
|
"status": "final",
|
||||||
|
"ingest_final": ingest_status,
|
||||||
|
"curator": curator_status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2507,17 +2542,26 @@ async def api_start_workflow(case_number: str):
|
|||||||
|
|
||||||
@app.get("/api/cases/{case_number}/agents")
|
@app.get("/api/cases/{case_number}/agents")
|
||||||
async def api_case_agents(case_number: str):
|
async def api_case_agents(case_number: str):
|
||||||
"""Get all Paperclip agent activity for a case: issues, comments, agent status."""
|
"""Get all Paperclip agent activity for a case: issues, comments, interactions, agent status."""
|
||||||
issues = await pc_get_case_issues(case_number)
|
issues = await pc_get_case_issues(case_number)
|
||||||
if not issues:
|
if not issues:
|
||||||
return {"issues": [], "comments": [], "agents": []}
|
return {"issues": [], "comments": [], "agents": [], "interactions": []}
|
||||||
|
|
||||||
issue_ids = [i["id"] for i in issues]
|
issue_ids = [i["id"] for i in issues]
|
||||||
company_id = issues[0]["company_id"]
|
company_id = issues[0]["company_id"]
|
||||||
|
|
||||||
comments, agents = await pc_get_issue_comments(issue_ids), await pc_get_agents_for_case(company_id, issue_ids)
|
comments, agents, interactions = await asyncio.gather(
|
||||||
|
pc_get_issue_comments(issue_ids),
|
||||||
|
pc_get_agents_for_case(company_id, issue_ids),
|
||||||
|
pc_get_issue_interactions(issue_ids),
|
||||||
|
)
|
||||||
|
|
||||||
return {"issues": issues, "comments": comments, "agents": agents}
|
return {
|
||||||
|
"issues": issues,
|
||||||
|
"comments": comments,
|
||||||
|
"agents": agents,
|
||||||
|
"interactions": interactions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AgentCommentRequest(BaseModel):
|
class AgentCommentRequest(BaseModel):
|
||||||
@@ -2551,6 +2595,42 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionResponseRequest(BaseModel):
|
||||||
|
issue_id: str
|
||||||
|
interaction_id: str
|
||||||
|
action: Literal["respond", "accept", "reject"]
|
||||||
|
payload: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/agents/interaction-response")
|
||||||
|
async def api_post_interaction_response(
|
||||||
|
case_number: str, req: InteractionResponseRequest,
|
||||||
|
):
|
||||||
|
"""Submit a user's answer to a Paperclip issue-thread interaction.
|
||||||
|
|
||||||
|
Routes to /respond | /accept | /reject based on `action`. Paperclip
|
||||||
|
auto-wakes the issue assignee after a successful submission.
|
||||||
|
"""
|
||||||
|
issues = await pc_get_case_issues(case_number)
|
||||||
|
if not any(i["id"] == req.issue_id for i in issues):
|
||||||
|
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||||
|
|
||||||
|
handlers = {
|
||||||
|
"respond": pc_respond_to_interaction,
|
||||||
|
"accept": pc_accept_interaction,
|
||||||
|
"reject": pc_reject_interaction,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return await handlers[req.action](
|
||||||
|
req.issue_id, req.interaction_id, req.payload,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
body = e.response.text or ""
|
||||||
|
raise HTTPException(e.response.status_code, body[:500])
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
||||||
|
|
||||||
|
|
||||||
# ── Settings: MCP Server Configuration ────────────────────────────
|
# ── Settings: MCP Server Configuration ────────────────────────────
|
||||||
#
|
#
|
||||||
# Source of truth for legal-ai env vars is Coolify (see memory:
|
# Source of truth for legal-ai env vars is Coolify (see memory:
|
||||||
@@ -3123,6 +3203,159 @@ async def api_list_skills():
|
|||||||
return skills
|
return skills
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paperclip agents — read-only admin view (Task #29)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Display order for the 7 agent roles (master+mirror pairs grouped by name).
|
||||||
|
# Matches the legal pipeline: CEO → analysis → research → writing → QA → export → proof.
|
||||||
|
_AGENT_NAME_ORDER = {
|
||||||
|
"עוזר משפטי": 1,
|
||||||
|
"מנתח משפטי": 2,
|
||||||
|
"חוקר תקדימים": 3,
|
||||||
|
"כותב החלטה": 4,
|
||||||
|
"בודק איכות": 5,
|
||||||
|
"מייצא טיוטה": 6,
|
||||||
|
"הגהת מסמכים": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fields that should match between master (CMP) and mirror (CMPA). Drift = bug.
|
||||||
|
# `status` is intentionally excluded — it's runtime state (running/idle/paused),
|
||||||
|
# not config, and changes constantly.
|
||||||
|
_DRIFT_FIELDS = (
|
||||||
|
"model",
|
||||||
|
"effort",
|
||||||
|
"timeoutSec",
|
||||||
|
"maxTurnsPerRun",
|
||||||
|
"desiredSkills",
|
||||||
|
"instructionsBundleMode",
|
||||||
|
"instructionsEntryFile",
|
||||||
|
"graceSec",
|
||||||
|
"cooldownSec",
|
||||||
|
"wakeOnDemand",
|
||||||
|
"maxConcurrentRuns",
|
||||||
|
"budget_monthly_cents",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _portable_skills(skills: list[str]) -> list[str]:
|
||||||
|
"""Return only the skills whose drift across companies is meaningful.
|
||||||
|
|
||||||
|
`local/*` skills carry per-install hashes (different IDs per company even
|
||||||
|
when the underlying skill is identical); `company/{cid}/*` skills are
|
||||||
|
scoped to a single company by construction. Both are expected to differ
|
||||||
|
between master and mirror — comparing them produces noise. Only
|
||||||
|
`paperclipai/*` (vendor-shipped) skills should match exactly.
|
||||||
|
"""
|
||||||
|
return sorted(s for s in skills if s.startswith("paperclipai/"))
|
||||||
|
|
||||||
|
|
||||||
|
def _shape_paperclip_agent(raw: dict, company_id: str, company_name: str) -> dict:
|
||||||
|
"""Flatten a Paperclip agent row into the shape the UI consumes."""
|
||||||
|
ac = raw.get("adapterConfig") or {}
|
||||||
|
rc = raw.get("runtimeConfig") or {}
|
||||||
|
hb = rc.get("heartbeat") or {}
|
||||||
|
skill_sync = ac.get("paperclipSkillSync") or {}
|
||||||
|
return {
|
||||||
|
"id": raw.get("id"),
|
||||||
|
"company_id": company_id,
|
||||||
|
"company_name": company_name,
|
||||||
|
"name": raw.get("name"),
|
||||||
|
"role": raw.get("role"),
|
||||||
|
"status": raw.get("status"),
|
||||||
|
"pause_reason": raw.get("pauseReason"),
|
||||||
|
"adapter_type": raw.get("adapterType"),
|
||||||
|
"model": ac.get("model"),
|
||||||
|
"effort": ac.get("effort"),
|
||||||
|
"timeoutSec": ac.get("timeoutSec"),
|
||||||
|
"maxTurnsPerRun": ac.get("maxTurnsPerRun"),
|
||||||
|
"desiredSkills": sorted(skill_sync.get("desiredSkills") or []),
|
||||||
|
"instructionsBundleMode": ac.get("instructionsBundleMode"),
|
||||||
|
"instructionsRootPath": ac.get("instructionsRootPath"),
|
||||||
|
"instructionsEntryFile": ac.get("instructionsEntryFile"),
|
||||||
|
"instructionsFilePath": ac.get("instructionsFilePath"),
|
||||||
|
"graceSec": hb.get("graceSec"),
|
||||||
|
"cooldownSec": hb.get("cooldownSec"),
|
||||||
|
"wakeOnDemand": hb.get("wakeOnDemand"),
|
||||||
|
"maxConcurrentRuns": hb.get("maxConcurrentRuns"),
|
||||||
|
"intervalSec": hb.get("intervalSec"),
|
||||||
|
"enabled": hb.get("enabled"),
|
||||||
|
"budget_monthly_cents": raw.get("budgetMonthlyCents"),
|
||||||
|
"spent_monthly_cents": raw.get("spentMonthlyCents"),
|
||||||
|
"last_heartbeat_at": raw.get("lastHeartbeatAt"),
|
||||||
|
"updated_at": raw.get("updatedAt"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_drift(master: dict | None, mirror: dict | None) -> list[dict]:
|
||||||
|
if master is None or mirror is None:
|
||||||
|
return [{"field": "_pair_missing", "master": master is not None, "mirror": mirror is not None}]
|
||||||
|
drift = []
|
||||||
|
for field in _DRIFT_FIELDS:
|
||||||
|
m_val = master.get(field)
|
||||||
|
i_val = mirror.get(field)
|
||||||
|
if field == "desiredSkills":
|
||||||
|
m_val = _portable_skills(m_val or [])
|
||||||
|
i_val = _portable_skills(i_val or [])
|
||||||
|
if m_val != i_val:
|
||||||
|
drift.append({"field": field, "master": m_val, "mirror": i_val})
|
||||||
|
return drift
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/paperclip-agents")
|
||||||
|
async def api_list_paperclip_agents():
|
||||||
|
"""List all Paperclip agents grouped into master+mirror pairs with drift detection.
|
||||||
|
|
||||||
|
Read-only. Source of truth: Paperclip ``GET /api/companies/{id}/agents`` API
|
||||||
|
(not direct DB) — keeps us decoupled from Paperclip's schema changes.
|
||||||
|
"""
|
||||||
|
company_labels = {
|
||||||
|
PAPERCLIP_COMPANIES["licensing"]: "CMP — רישוי ובניה",
|
||||||
|
PAPERCLIP_COMPANIES["betterment"]: "CMPA — היטלי השבחה",
|
||||||
|
}
|
||||||
|
|
||||||
|
by_name: dict[str, dict[str, dict]] = {}
|
||||||
|
for cid, cname in company_labels.items():
|
||||||
|
try:
|
||||||
|
resp = await pc_request("GET", f"/api/companies/{cid}/agents", raise_on_error=True)
|
||||||
|
except (httpx.HTTPError, RuntimeError) as e:
|
||||||
|
logger.exception("Paperclip API failed for company %s", cid)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=f"Paperclip API error for company {cname}: {type(e).__name__}: {e}",
|
||||||
|
) from e
|
||||||
|
rows = resp.json()
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
raise HTTPException(status_code=502, detail=f"Unexpected Paperclip response for {cname}")
|
||||||
|
for raw in rows:
|
||||||
|
shaped = _shape_paperclip_agent(raw, cid, cname)
|
||||||
|
slot = "master" if cid == PAPERCLIP_COMPANIES["licensing"] else "mirror"
|
||||||
|
by_name.setdefault(shaped["name"], {})[slot] = shaped
|
||||||
|
|
||||||
|
pairs = []
|
||||||
|
for name, group in by_name.items():
|
||||||
|
master = group.get("master")
|
||||||
|
mirror = group.get("mirror")
|
||||||
|
primary = master or mirror
|
||||||
|
pairs.append({
|
||||||
|
"name": name,
|
||||||
|
"role": primary.get("role") if primary else None,
|
||||||
|
"master": master,
|
||||||
|
"mirror": mirror,
|
||||||
|
"drift": _compute_drift(master, mirror),
|
||||||
|
})
|
||||||
|
|
||||||
|
pairs.sort(key=lambda p: (_AGENT_NAME_ORDER.get(p["name"], 99), p["name"]))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"pairs": pairs,
|
||||||
|
"companies": [
|
||||||
|
{"id": cid, "label": label, "slot": "master" if cid == PAPERCLIP_COMPANIES["licensing"] else "mirror"}
|
||||||
|
for cid, label in company_labels.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/skills/install")
|
@app.post("/api/admin/skills/install")
|
||||||
async def api_install_skill(file: UploadFile = File(...)):
|
async def api_install_skill(file: UploadFile = File(...)):
|
||||||
"""Install or update a Paperclip skill from a ZIP file.
|
"""Install or update a Paperclip skill from a ZIP file.
|
||||||
@@ -4058,13 +4291,15 @@ async def precedent_library_list(
|
|||||||
precedent_level: str = "",
|
precedent_level: str = "",
|
||||||
source_type: str = "",
|
source_type: str = "",
|
||||||
search: str = "",
|
search: str = "",
|
||||||
|
source_kind: str = "external_upload",
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
):
|
):
|
||||||
rows = await db.list_external_case_law(
|
rows = await db.list_external_case_law(
|
||||||
practice_area=practice_area, court=court,
|
practice_area=practice_area, court=court,
|
||||||
precedent_level=precedent_level, source_type=source_type,
|
precedent_level=precedent_level, source_type=source_type,
|
||||||
search=search, limit=limit, offset=offset,
|
search=search, source_kind=source_kind,
|
||||||
|
limit=limit, offset=offset,
|
||||||
)
|
)
|
||||||
return {"items": rows, "count": len(rows)}
|
return {"items": rows, "count": len(rows)}
|
||||||
|
|
||||||
@@ -4145,6 +4380,37 @@ async def precedent_library_delete(case_law_id: str):
|
|||||||
return {"deleted": True, "case_law_id": case_law_id}
|
return {"deleted": True, "case_law_id": case_law_id}
|
||||||
|
|
||||||
|
|
||||||
|
class PrecedentRelationRequest(BaseModel):
|
||||||
|
related_id: str
|
||||||
|
relation_type: str = "same_case_chain"
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/precedent-library/{case_law_id}/relations")
|
||||||
|
async def precedent_add_relation(case_law_id: str, req: PrecedentRelationRequest):
|
||||||
|
try:
|
||||||
|
a = UUID(case_law_id)
|
||||||
|
b = UUID(req.related_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "case_law_id לא תקין")
|
||||||
|
if not await db.get_case_law(a):
|
||||||
|
raise HTTPException(404, "פסיקה לא נמצאה")
|
||||||
|
if not await db.get_case_law(b):
|
||||||
|
raise HTTPException(404, f"פסיקה קשורה {req.related_id} לא נמצאה")
|
||||||
|
await db.add_case_law_relation(a, b, req.relation_type)
|
||||||
|
return {"linked": True, "case_law_id": case_law_id, "related_id": req.related_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/precedent-library/{case_law_id}/relations/{related_id}")
|
||||||
|
async def precedent_remove_relation(case_law_id: str, related_id: str):
|
||||||
|
try:
|
||||||
|
a = UUID(case_law_id)
|
||||||
|
b = UUID(related_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "case_law_id לא תקין")
|
||||||
|
await db.remove_case_law_relation(a, b)
|
||||||
|
return {"unlinked": True, "case_law_id": case_law_id, "related_id": related_id}
|
||||||
|
|
||||||
|
|
||||||
# Halacha and metadata extraction are LLM-driven and rely on the local
|
# Halacha and metadata extraction are LLM-driven and rely on the local
|
||||||
# `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run
|
# `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run
|
||||||
# from this container (no CLI, no claude.ai session). The endpoints below
|
# from this container (no CLI, no claude.ai session). The endpoints below
|
||||||
@@ -4154,18 +4420,41 @@ async def precedent_library_delete(case_law_id: str):
|
|||||||
# drain the queue.
|
# drain the queue.
|
||||||
|
|
||||||
|
|
||||||
|
async def _wake_ceo_for_precedent(case_law_id: UUID, kind: str) -> dict:
|
||||||
|
"""Trigger Paperclip CEO to drain the precedent extraction queue, mirroring
|
||||||
|
the upload flow (see ``precedent_library_upload`` → ``pc_wake_for_precedent_extraction``).
|
||||||
|
|
||||||
|
Best-effort — any failure is logged but doesn't surface to the user, who
|
||||||
|
can still invoke ``mcp__legal-ai__precedent_process_pending`` manually.
|
||||||
|
Returns a dict with the wakeup outcome for inclusion in the API response.
|
||||||
|
"""
|
||||||
|
record = await db.get_case_law(case_law_id)
|
||||||
|
if not record:
|
||||||
|
return {"ok": False, "skipped": "record_missing"}
|
||||||
|
try:
|
||||||
|
return await pc_wake_for_precedent_extraction(
|
||||||
|
case_law_id=str(case_law_id),
|
||||||
|
citation=str(record.get("case_number") or ""),
|
||||||
|
practice_area=str(record.get("practice_area") or ""),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("precedent-extraction wakeup failed (non-fatal, kind=%s)", kind)
|
||||||
|
return {"ok": False, "error": "wakeup_failed"}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/precedent-library/{case_law_id}/request-metadata")
|
@app.post("/api/precedent-library/{case_law_id}/request-metadata")
|
||||||
async def precedent_request_metadata(case_law_id: str):
|
async def precedent_request_metadata(case_law_id: str):
|
||||||
"""Stamp the case_law row as needing metadata extraction. The local
|
"""Stamp the case_law row as needing metadata extraction AND wake the
|
||||||
MCP worker (`precedent_process_pending_metadata`) will pick it up."""
|
Paperclip CEO so extraction runs automatically — same flow as upload."""
|
||||||
try:
|
try:
|
||||||
cid = UUID(case_law_id)
|
cid = UUID(case_law_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(400, "case_law_id לא תקין")
|
raise HTTPException(400, "case_law_id לא תקין")
|
||||||
ok = await db.request_metadata_extraction(cid)
|
ok = await db.request_metadata_extraction(cid)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(404, "פסיקה לא נמצאה (או לא מסוג external_upload)")
|
raise HTTPException(404, "פסיקה לא נמצאה")
|
||||||
return {"queued": True, "case_law_id": case_law_id, "kind": "metadata"}
|
wakeup = await _wake_ceo_for_precedent(cid, kind="metadata")
|
||||||
|
return {"queued": True, "case_law_id": case_law_id, "kind": "metadata", "wakeup": wakeup}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/precedent-library/{case_law_id}/request-halachot")
|
@app.post("/api/precedent-library/{case_law_id}/request-halachot")
|
||||||
@@ -4177,8 +4466,9 @@ async def precedent_request_halachot(case_law_id: str):
|
|||||||
raise HTTPException(400, "case_law_id לא תקין")
|
raise HTTPException(400, "case_law_id לא תקין")
|
||||||
ok = await db.request_halacha_extraction(cid)
|
ok = await db.request_halacha_extraction(cid)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(404, "פסיקה לא נמצאה (או לא מסוג external_upload)")
|
raise HTTPException(404, "פסיקה לא נמצאה")
|
||||||
return {"queued": True, "case_law_id": case_law_id, "kind": "halacha"}
|
wakeup = await _wake_ceo_for_precedent(cid, kind="halacha")
|
||||||
|
return {"queued": True, "case_law_id": case_law_id, "kind": "halacha", "wakeup": wakeup}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/precedent-library/queue/pending")
|
@app.get("/api/precedent-library/queue/pending")
|
||||||
@@ -4191,6 +4481,148 @@ async def precedent_queue_pending(kind: str = "metadata", limit: int = 20):
|
|||||||
return {"items": items, "count": len(items)}
|
return {"items": items, "count": len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
from legal_mcp.services import internal_decisions as int_decisions_service # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/internal-decisions/upload")
|
||||||
|
async def internal_decisions_upload(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
case_number: str = Form(...),
|
||||||
|
case_name: str = Form(""),
|
||||||
|
court: str = Form(""),
|
||||||
|
decision_date: str = Form(""),
|
||||||
|
chair_name: str = Form(""),
|
||||||
|
district: str = Form(""),
|
||||||
|
practice_area: str = Form(""),
|
||||||
|
appeal_subtype: str = Form(""),
|
||||||
|
subject_tags: str = Form("[]"),
|
||||||
|
is_binding: bool = Form(True),
|
||||||
|
summary: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Upload a planning appeals-committee decision to the internal corpus."""
|
||||||
|
if practice_area and practice_area not in _PRACTICE_AREAS:
|
||||||
|
raise HTTPException(400, "practice_area לא תקין")
|
||||||
|
if not case_number.strip():
|
||||||
|
raise HTTPException(400, "case_number חובה")
|
||||||
|
|
||||||
|
suffix = Path(file.filename or "").suffix.lower()
|
||||||
|
if suffix not in ALLOWED_EXTENSIONS:
|
||||||
|
raise HTTPException(400, f"סוג קובץ לא נתמך: {suffix}")
|
||||||
|
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
staged = UPLOAD_DIR / f"int_{uuid4().hex[:8]}_{file.filename}"
|
||||||
|
size = 0
|
||||||
|
with staged.open("wb") as out:
|
||||||
|
while chunk := await file.read(1024 * 1024):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_FILE_SIZE:
|
||||||
|
staged.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(413, "קובץ גדול מדי")
|
||||||
|
out.write(chunk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tags = json.loads(subject_tags) if subject_tags else []
|
||||||
|
if not isinstance(tags, list):
|
||||||
|
tags = []
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
tags = []
|
||||||
|
|
||||||
|
task_id = str(uuid4())
|
||||||
|
await _progress.set(task_id, {
|
||||||
|
"status": "queued", "filename": file.filename or "",
|
||||||
|
"stage": "queued", "percent": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
try:
|
||||||
|
await int_decisions_service.ingest_internal_decision(
|
||||||
|
case_number=case_number.strip(),
|
||||||
|
case_name=case_name.strip(),
|
||||||
|
court=court.strip(),
|
||||||
|
decision_date=decision_date or None,
|
||||||
|
chair_name=chair_name.strip(),
|
||||||
|
district=district.strip(),
|
||||||
|
practice_area=practice_area,
|
||||||
|
appeal_subtype=appeal_subtype.strip(),
|
||||||
|
subject_tags=tags,
|
||||||
|
is_binding=is_binding,
|
||||||
|
summary=summary.strip(),
|
||||||
|
file_path=staged,
|
||||||
|
)
|
||||||
|
await _progress.set(task_id, {"status": "completed", "percent": 100})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("internal-decisions upload failed")
|
||||||
|
await _progress.set(task_id, {"status": "failed", "error": str(e)})
|
||||||
|
finally:
|
||||||
|
staged.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
asyncio.create_task(_run())
|
||||||
|
return {"task_id": task_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/internal-decisions/migrate")
|
||||||
|
async def internal_decisions_migrate(
|
||||||
|
source: str = "both",
|
||||||
|
dry_run: bool = True,
|
||||||
|
):
|
||||||
|
"""Migrate existing data to the internal committee corpus.
|
||||||
|
|
||||||
|
source: 'style_corpus' | 'external_corpus' | 'both'
|
||||||
|
dry_run: if true, only report what would be done (no writes)
|
||||||
|
"""
|
||||||
|
if source not in {"style_corpus", "external_corpus", "both"}:
|
||||||
|
raise HTTPException(400, "source חייב להיות style_corpus / external_corpus / both")
|
||||||
|
|
||||||
|
results: dict = {}
|
||||||
|
if source in {"style_corpus", "both"}:
|
||||||
|
results["style_corpus"] = await int_decisions_service.migrate_from_style_corpus(dry_run=dry_run)
|
||||||
|
if source in {"external_corpus", "both"}:
|
||||||
|
results["external_corpus"] = await int_decisions_service.migrate_from_external_corpus(dry_run=dry_run)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/internal-decisions")
|
||||||
|
async def internal_decisions_list(
|
||||||
|
district: str = "",
|
||||||
|
chair_name: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
):
|
||||||
|
"""List internal committee decisions with optional filters."""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
conditions = ["source_kind = 'internal_committee'"]
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
if district:
|
||||||
|
conditions.append(f"district = ${idx}")
|
||||||
|
params.append(district)
|
||||||
|
idx += 1
|
||||||
|
if chair_name:
|
||||||
|
conditions.append(f"chair_name = ${idx}")
|
||||||
|
params.append(chair_name)
|
||||||
|
idx += 1
|
||||||
|
if practice_area:
|
||||||
|
conditions.append(f"practice_area = ${idx}")
|
||||||
|
params.append(practice_area)
|
||||||
|
idx += 1
|
||||||
|
params.append(limit)
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
rows = await conn.fetch(
|
||||||
|
f"SELECT id, case_number, case_name, court, district, chair_name, "
|
||||||
|
f"date, practice_area, appeal_subtype, extraction_status, halacha_extraction_status "
|
||||||
|
f"FROM case_law WHERE {where} ORDER BY date DESC NULLS LAST LIMIT ${idx}",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
total = await conn.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM case_law WHERE source_kind = 'internal_committee'"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"items": [dict(r) for r in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/halachot")
|
@app.get("/api/halachot")
|
||||||
async def halachot_list(
|
async def halachot_list(
|
||||||
case_law_id: str = "",
|
case_law_id: str = "",
|
||||||
|
|||||||
83
web/paperclip_api.py
Normal file
83
web/paperclip_api.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Paperclip REST API helper.
|
||||||
|
|
||||||
|
All HTTP calls from legal-ai backend (FastAPI) to Paperclip should go through
|
||||||
|
``pc_request`` so that auth + audit headers are applied consistently.
|
||||||
|
|
||||||
|
The bash counterpart for agents lives at ``scripts/pc.sh``.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
* Uses ``PAPERCLIP_BOARD_API_KEY`` (long-lived) — these are *board* actions
|
||||||
|
(wakeups, comments-as-user) initiated from outside a heartbeat, not agent
|
||||||
|
actions. Board API keys are not JWTs, so they do **not** carry a ``run_id``
|
||||||
|
claim; pass ``run_id=`` explicitly when you have one (rare for board flows).
|
||||||
|
* For agent actions inside a heartbeat run, agents use the bash helper with
|
||||||
|
the auto-injected ``PAPERCLIP_API_KEY`` JWT — those carry ``run_id`` in
|
||||||
|
claims, so the header is informational/future-proofing.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
|
||||||
|
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def _build_headers(run_id: str | None, has_body: bool) -> dict[str, str]:
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
raise RuntimeError("PAPERCLIP_BOARD_API_KEY not set — cannot call Paperclip API")
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
|
||||||
|
"X-Paperclip-Run-Id": run_id or "",
|
||||||
|
}
|
||||||
|
if has_body:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
async def pc_request(
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
raise_on_error: bool = False,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""Make a Paperclip REST request.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
method : str
|
||||||
|
HTTP method (GET, POST, PATCH, DELETE).
|
||||||
|
path : str
|
||||||
|
Path relative to PAPERCLIP_API_URL (must start with ``/``).
|
||||||
|
json : dict, optional
|
||||||
|
Request body — sent as JSON.
|
||||||
|
run_id : str, optional
|
||||||
|
Heartbeat run ID for audit trail (X-Paperclip-Run-Id header).
|
||||||
|
Rare for board actions; provide when initiating from inside a run.
|
||||||
|
timeout : float
|
||||||
|
httpx timeout (default 15s).
|
||||||
|
raise_on_error : bool
|
||||||
|
If True, calls ``response.raise_for_status()`` on 4xx/5xx.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
httpx.Response
|
||||||
|
"""
|
||||||
|
headers = _build_headers(run_id, has_body=json is not None)
|
||||||
|
url = f"{PAPERCLIP_API_URL}{path}"
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.request(method, url, headers=headers, json=json)
|
||||||
|
if raise_on_error:
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp
|
||||||
@@ -12,7 +12,8 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
import httpx
|
|
||||||
|
from web.paperclip_api import pc_request
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -21,7 +22,8 @@ PAPERCLIP_DB_URL = os.environ.get(
|
|||||||
)
|
)
|
||||||
|
|
||||||
PLUGIN_ID = "53461b5a-7f58-411a-9952-72f9c8d4a328" # marcusgroup.legal-ai
|
PLUGIN_ID = "53461b5a-7f58-411a-9952-72f9c8d4a328" # marcusgroup.legal-ai
|
||||||
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
|
# PAPERCLIP_API_URL — moved to web.paperclip_api (used only by pc_request now).
|
||||||
|
# Direct DB calls below use PAPERCLIP_DB_URL instead.
|
||||||
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
|
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
|
||||||
|
|
||||||
# Default workspace attached to every new Paperclip project — points agents at
|
# Default workspace attached to every new Paperclip project — points agents at
|
||||||
@@ -43,6 +45,14 @@ CEO_AGENTS = {
|
|||||||
# Default for backwards compat
|
# Default for backwards compat
|
||||||
CEO_AGENT_ID = CEO_AGENTS[COMPANIES["licensing"]]
|
CEO_AGENT_ID = CEO_AGENTS[COMPANIES["licensing"]]
|
||||||
|
|
||||||
|
# Knowledge Curator (Hermes) agent per company — woken after a case is
|
||||||
|
# marked final, to analyze the signed decision and propose updates to
|
||||||
|
# the style guide / lessons. POC stage 1.
|
||||||
|
CURATOR_AGENTS = {
|
||||||
|
COMPANIES["licensing"]: "60dce831-5c5b-4bae-bda9-5282d506f0dc", # CMP curator
|
||||||
|
COMPANIES["betterment"]: "d6f7c55d-570a-46b8-8d72-1286d07da0d8", # CMPA curator
|
||||||
|
}
|
||||||
|
|
||||||
# Fallback mapping — used only when DB lookup returns no results
|
# Fallback mapping — used only when DB lookup returns no results
|
||||||
_FALLBACK_APPEAL_TYPE_TO_COMPANY = {
|
_FALLBACK_APPEAL_TYPE_TO_COMPANY = {
|
||||||
"רישוי": COMPANIES["licensing"],
|
"רישוי": COMPANIES["licensing"],
|
||||||
@@ -584,16 +594,15 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
|||||||
# Try Board API first — this triggers the event bus
|
# Try Board API first — this triggers the event bus
|
||||||
if PAPERCLIP_BOARD_API_KEY:
|
if PAPERCLIP_BOARD_API_KEY:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
resp = await pc_request(
|
||||||
resp = await client.post(
|
"POST",
|
||||||
f"{PAPERCLIP_API_URL}/api/board/issues/{issue_id}/comments",
|
f"/api/board/issues/{issue_id}/comments",
|
||||||
json={"body": body},
|
json={"body": body},
|
||||||
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
)
|
||||||
)
|
if resp.status_code < 400:
|
||||||
if resp.status_code < 400:
|
result = resp.json()
|
||||||
result = resp.json()
|
logger.info("Posted comment via Board API on issue %s", issue_id)
|
||||||
logger.info("Posted comment via Board API on issue %s", issue_id)
|
return {"comment_id": result.get("id", ""), "issue_id": issue_id, "method": "api"}
|
||||||
return {"comment_id": result.get("id", ""), "issue_id": issue_id, "method": "api"}
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id)
|
logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id)
|
||||||
|
|
||||||
@@ -613,25 +622,118 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
|||||||
# Wake the correct CEO for this company
|
# Wake the correct CEO for this company
|
||||||
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||||
try:
|
try:
|
||||||
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
|
await pc_request(
|
||||||
payload = {
|
"POST",
|
||||||
"source": "on_demand",
|
f"/api/agents/{ceo_id}/wakeup",
|
||||||
"triggerDetail": "manual",
|
json={
|
||||||
"reason": f"user_comment_{issue_id}",
|
"source": "on_demand",
|
||||||
"payload": {"issueId": issue_id, "mutation": "comment"},
|
"triggerDetail": "manual",
|
||||||
}
|
"reason": f"user_comment_{issue_id}",
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
"payload": {"issueId": issue_id, "mutation": "comment"},
|
||||||
resp = await client.post(
|
},
|
||||||
url, json=payload,
|
raise_on_error=True,
|
||||||
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
)
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to wake CEO after DB comment on issue %s", issue_id)
|
logger.warning("Failed to wake CEO after DB comment on issue %s", issue_id)
|
||||||
|
|
||||||
return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"}
|
return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_issue_interactions(issue_ids: list[str]) -> list[dict]:
|
||||||
|
"""Fetch issue-thread interactions (agent → user button prompts).
|
||||||
|
|
||||||
|
Returns all `pending` interactions plus any resolved within the last 24h
|
||||||
|
so the user sees a brief tail of recent answers without flooding the feed.
|
||||||
|
Ordered by ``created_at`` so callers can interleave with comments.
|
||||||
|
"""
|
||||||
|
if not issue_ids:
|
||||||
|
return []
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT id, issue_id, kind, status, title, summary,
|
||||||
|
payload, result, created_at, resolved_at
|
||||||
|
FROM issue_thread_interactions
|
||||||
|
WHERE issue_id = ANY($1::uuid[])
|
||||||
|
AND (status = 'pending'
|
||||||
|
OR resolved_at > now() - interval '24 hours')
|
||||||
|
ORDER BY created_at""",
|
||||||
|
issue_ids,
|
||||||
|
)
|
||||||
|
out: list[dict] = []
|
||||||
|
for r in rows:
|
||||||
|
payload = r["payload"]
|
||||||
|
result = r["result"]
|
||||||
|
if isinstance(payload, str):
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload)
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
if isinstance(result, str):
|
||||||
|
try:
|
||||||
|
result = json.loads(result)
|
||||||
|
except Exception:
|
||||||
|
result = None
|
||||||
|
out.append({
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"issue_id": str(r["issue_id"]),
|
||||||
|
"kind": r["kind"],
|
||||||
|
"status": r["status"],
|
||||||
|
"title": r["title"],
|
||||||
|
"summary": r["summary"],
|
||||||
|
"payload": payload or {},
|
||||||
|
"result": result,
|
||||||
|
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||||
|
"resolved_at": r["resolved_at"].isoformat() if r["resolved_at"] else None,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def respond_to_interaction(
|
||||||
|
issue_id: str, interaction_id: str, payload: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""Submit a user response to an `ask_user_questions` interaction.
|
||||||
|
|
||||||
|
Paperclip auto-wakes the issue assignee on success
|
||||||
|
(`queueResolvedInteractionContinuationWakeup`).
|
||||||
|
"""
|
||||||
|
resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/issues/{issue_id}/interactions/{interaction_id}/respond",
|
||||||
|
json=payload,
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def accept_interaction(
|
||||||
|
issue_id: str, interaction_id: str, payload: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""Accept a `request_confirmation` or `suggest_tasks` interaction."""
|
||||||
|
resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/issues/{issue_id}/interactions/{interaction_id}/accept",
|
||||||
|
json=payload,
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def reject_interaction(
|
||||||
|
issue_id: str, interaction_id: str, payload: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""Reject a `request_confirmation` or `suggest_tasks` interaction."""
|
||||||
|
resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/issues/{issue_id}/interactions/{interaction_id}/reject",
|
||||||
|
json=payload,
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
# Singleton project for the precedent-library extraction queue. One issue per
|
# Singleton project for the precedent-library extraction queue. One issue per
|
||||||
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
|
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
|
||||||
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
|
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
|
||||||
@@ -747,7 +849,6 @@ async def wake_for_precedent_extraction(
|
|||||||
return {"ok": False, "error": f"db: {e}"}
|
return {"ok": False, "error": f"db: {e}"}
|
||||||
|
|
||||||
# Wake the CEO. Per Paperclip rules: must use API + carry issueId in payload.
|
# Wake the CEO. Per Paperclip rules: must use API + carry issueId in payload.
|
||||||
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
|
|
||||||
payload = {
|
payload = {
|
||||||
"source": "automation",
|
"source": "automation",
|
||||||
"triggerDetail": "precedent_library_upload",
|
"triggerDetail": "precedent_library_upload",
|
||||||
@@ -759,14 +860,13 @@ async def wake_for_precedent_extraction(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
resp = await pc_request(
|
||||||
resp = await client.post(
|
"POST",
|
||||||
url,
|
f"/api/agents/{ceo_id}/wakeup",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
raise_on_error=True,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
result = resp.json()
|
||||||
result = resp.json()
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Precedent-extraction wakeup queued: issue=%s case_law_id=%s",
|
"Precedent-extraction wakeup queued: issue=%s case_law_id=%s",
|
||||||
identifier, case_law_id,
|
identifier, case_law_id,
|
||||||
@@ -787,7 +887,6 @@ async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "")
|
|||||||
raise RuntimeError("PAPERCLIP_BOARD_API_KEY not set — cannot wake CEO agent")
|
raise RuntimeError("PAPERCLIP_BOARD_API_KEY not set — cannot wake CEO agent")
|
||||||
|
|
||||||
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||||
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
|
|
||||||
payload = {
|
payload = {
|
||||||
"source": "on_demand",
|
"source": "on_demand",
|
||||||
"triggerDetail": "manual",
|
"triggerDetail": "manual",
|
||||||
@@ -798,13 +897,101 @@ async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "")
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
resp = await pc_request(
|
||||||
resp = await client.post(
|
"POST",
|
||||||
url,
|
f"/api/agents/{ceo_id}/wakeup",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
raise_on_error=True,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
result = resp.json()
|
||||||
result = resp.json()
|
logger.info("CEO agent wakeup for case %s: %s", case_number, result)
|
||||||
logger.info("CEO agent wakeup for case %s: %s", case_number, result)
|
return result
|
||||||
return result
|
|
||||||
|
|
||||||
|
async def wake_curator_for_final(
|
||||||
|
case_number: str,
|
||||||
|
final_filename: str,
|
||||||
|
company_id: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Wake the Knowledge Curator (Hermes) when a case is marked final.
|
||||||
|
|
||||||
|
Creates a child issue under the main case issue, assigns it to the
|
||||||
|
curator, and triggers wakeup. Best-effort — silently skips if no
|
||||||
|
curator is configured for the company or no main issue is found.
|
||||||
|
|
||||||
|
Returns ``{"status": "ok"|"skipped", ...}``.
|
||||||
|
"""
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping curator wakeup")
|
||||||
|
return {"status": "skipped", "reason": "no_api_key"}
|
||||||
|
|
||||||
|
curator_id = CURATOR_AGENTS.get(company_id)
|
||||||
|
if not curator_id:
|
||||||
|
logger.info("No curator configured for company %s — skipping", company_id)
|
||||||
|
return {"status": "skipped", "reason": "no_curator", "company_id": company_id}
|
||||||
|
|
||||||
|
issues = await get_case_issues(case_number)
|
||||||
|
if not issues:
|
||||||
|
logger.warning("No Paperclip issues found for case %s — skipping curator", case_number)
|
||||||
|
return {"status": "skipped", "reason": "no_issue"}
|
||||||
|
|
||||||
|
main_issue = next((i for i in issues if i.get("status") == "in_progress"), None) or issues[0]
|
||||||
|
main_issue_id = main_issue["id"]
|
||||||
|
|
||||||
|
description = (
|
||||||
|
f"דפנה סימנה את ההחלטה הסופית של תיק {case_number} כסופית.\n"
|
||||||
|
f"קובץ סופי: `{final_filename}`\n\n"
|
||||||
|
f"סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.\n"
|
||||||
|
f"חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, "
|
||||||
|
f"ממוספר. עדכן את MEMORY.md שלך. סגור את ה-issue (status=done)."
|
||||||
|
)
|
||||||
|
child_resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/issues/{main_issue_id}/children",
|
||||||
|
json={
|
||||||
|
"title": f"[ערר {case_number}] סקירת ידע — Knowledge Curator",
|
||||||
|
"description": description,
|
||||||
|
"status": "in_progress",
|
||||||
|
"priority": "low",
|
||||||
|
"assigneeAgentId": curator_id,
|
||||||
|
},
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
sub_issue = child_resp.json()
|
||||||
|
sub_issue_id = sub_issue["id"]
|
||||||
|
|
||||||
|
# Tag plugin_state for case-number visibility on the case page
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
await _link_case_to_issue(conn, sub_issue_id, case_number)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("plugin_state link failed for sub_issue=%s: %s", sub_issue_id, e)
|
||||||
|
|
||||||
|
# Trigger wakeup (use API per Paperclip rule — never DB insert)
|
||||||
|
wake_resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/agents/{curator_id}/wakeup",
|
||||||
|
json={
|
||||||
|
"source": "on_demand",
|
||||||
|
"triggerDetail": "manual",
|
||||||
|
"reason": f"final_marked_{case_number}",
|
||||||
|
"payload": {
|
||||||
|
"issueId": sub_issue_id,
|
||||||
|
"mutation": "assignment",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Curator wakeup for case %s: sub_issue=%s curator=%s wake=%s",
|
||||||
|
case_number, sub_issue_id, curator_id, wake_resp.status_code,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"sub_issue_id": sub_issue_id,
|
||||||
|
"curator_id": curator_id,
|
||||||
|
"main_issue_id": main_issue_id,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user