Compare commits
58 Commits
feat/mcp-s
...
a2fc36d65f
| Author | SHA1 | Date | |
|---|---|---|---|
| a2fc36d65f | |||
| 653f441e99 | |||
| c3ce0e7e1f | |||
| 1608ea5ed0 | |||
| 35423eafc1 | |||
| a584dc3602 | |||
| d37d03f478 | |||
| 011555fb78 | |||
| ea0532b7ba | |||
| cddc7c8d24 | |||
| 83b6ff51b7 | |||
| 8dc7a40fa2 | |||
| a3468d5b2f | |||
| 5f43659b5a | |||
| 86734da210 | |||
| 82ded005a4 | |||
| c7ed1110f8 | |||
| 015e553d06 | |||
| 6bdf9786ac | |||
| d87f9c5a5f | |||
| a0fab1f6de | |||
| d5043100a7 | |||
| 932cc7191c | |||
| d983cfdd3b | |||
| 50649baeed | |||
| a9cd8aeb12 | |||
| 10a63fb9e0 | |||
| f94201c577 | |||
| 026457dac4 | |||
| 75493ce233 | |||
| 3e14cd6798 | |||
| 13a8d9e58f | |||
| 45341a0bc8 | |||
| d81c3c37ab | |||
| fff2d1c859 | |||
| 36b78ea404 | |||
| c7132ba0d2 | |||
| 171da84680 | |||
| afcc4818a4 | |||
| bd4b0ca766 | |||
| 7c9582ed04 | |||
| ea29778197 | |||
| 3be676e062 | |||
| 799b950961 | |||
| 77e5996497 | |||
| 69d4827f33 | |||
| c0f67ab841 | |||
| 92a2763b86 | |||
| 1b14e04373 | |||
| 69e153b3db | |||
| 702c01d678 | |||
| bd6a66e80d | |||
| af2dc0df2a | |||
| eab0ca906c | |||
| cf5f6fe274 | |||
| 6f713042b5 | |||
| d0994704cf | |||
| 82b29510f2 |
@@ -1,197 +1,165 @@
|
||||
# HEARTBEAT.md — רשימת ביצוע לכל ריצה
|
||||
# HEARTBEAT.md — רשימת ביצוע לכל ריצה (Project-Specific)
|
||||
|
||||
## שפה — כלל עליון
|
||||
|
||||
**כל הפלט שלך חייב להיות בעברית בלבד.** זה כולל:
|
||||
- Comments ב-Paperclip
|
||||
- הודעות סטטוס
|
||||
- תיאורי שגיאות
|
||||
- סיכומים ודיווחים
|
||||
- חשיבה פנימית (thinking)
|
||||
|
||||
אין יוצאים מן הכלל. גם שמות tools, פקודות, ונתיבי קבצים — ההסבר סביבם בעברית.
|
||||
> **🎯 קובץ זה — Project-specific only.** ה-skill הרשמי `paperclipai/paperclip/paperclip` (טעון אוטומטית בכל heartbeat דרך `paperclipSkillSync`) מכיל את כל ה-API patterns הגנריים: identity (`/api/agents/me`), `PAPERCLIP_WAKE_PAYLOAD_JSON`, `APPROVAL_ID`, inbox, comments, checkout, status updates, וכו'. **קובץ זה מתעד רק התאמות שלנו** — סינון חברה, helpers, workarounds, ו-quirks.
|
||||
>
|
||||
> **בקונפליקט:** קובץ זה גובר על ה-skill (project-specific מנצח default).
|
||||
|
||||
---
|
||||
|
||||
הרץ את הרשימה הזו בכל heartbeat.
|
||||
## שפה — כלל עליון
|
||||
|
||||
## 1. זיהוי וסינון חברה
|
||||
**כל הפלט שלך חייב להיות בעברית בלבד.** כולל: comments, סטטוס, שגיאות, סיכומים, ו-thinking פנימי. אין יוצאים מן הכלל. גם שמות tools, פקודות, ונתיבי קבצים — ההסבר סביבם בעברית. ה-skill הרשמי באנגלית — תרגם אם נדרש.
|
||||
|
||||
- וודא שאתה יודע מי אתה: `$PAPERCLIP_AGENT_ID`
|
||||
- בדוק הקשר: `$PAPERCLIP_TASK_ID`, `$PAPERCLIP_WAKE_REASON`
|
||||
- **זהה את החברה שלך**: `$PAPERCLIP_COMPANY_ID`
|
||||
---
|
||||
|
||||
### ⚠️ סינון תיקים לפי חברה — כלל ברזל
|
||||
## §0. כל קריאה ל-Paperclip API — דרך `pc.sh` בלבד
|
||||
|
||||
**אתה אחראי רק על תיקים ששייכים לחברה שלך.** הספרה הראשונה של מספר התיק קובעת:
|
||||
|
||||
| חברה | COMPANY_ID | סוגי תיקים | טווח מספרים |
|
||||
|------|------------|-------------|-------------|
|
||||
| ועדת ערר רישוי ובניה | `42a7acd0-30c5-4cbd-ac97-7424f65df294` | רישוי ובניה | **1xxx** |
|
||||
| ועדת ערר היטלי השבחה | `8639e837-4c9d-47fa-a76b-95788d651896` | היטל השבחה + פיצויים ס' 197 | **8xxx, 9xxx** |
|
||||
|
||||
- אם `$PAPERCLIP_COMPANY_ID` = `42a7acd0...` → עבוד רק על תיקים שמתחילים ב-**1**
|
||||
- אם `$PAPERCLIP_COMPANY_ID` = `8639e837...` → עבוד רק על תיקים שמתחילים ב-**8** או **9**
|
||||
- **לעולם אל תיצור פרויקט, issue, או תוכן לתיק שלא בטווח שלך**
|
||||
- אם issue שהוקצה לך מכוון לתיק שלא בטווח שלך — סרב בנימוס ודווח ב-comment
|
||||
|
||||
## 2. בדוק תיבת דואר
|
||||
**ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "$PAPERCLIP_API_URL/api/agents/me/inbox-lite"
|
||||
~/legal-ai/scripts/pc.sh <METHOD> <PATH> [BODY_JSON] [extra curl args...]
|
||||
```
|
||||
|
||||
- תעדוף: `in_progress` קודם, אחר כך `todo`
|
||||
- אם `PAPERCLIP_TASK_ID` מוגדר — תעדף אותו
|
||||
מוסיף אוטומטית: `Authorization`, `X-Paperclip-Run-Id` (audit), `Content-Type`, base URL.
|
||||
|
||||
## 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
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]'
|
||||
CONTEXT=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/heartbeat-context?wakeCommentId=$LATEST_COMMENT_ID")
|
||||
ATTACHMENTS=$(echo "$CONTEXT" | jq '.attachments')
|
||||
```
|
||||
|
||||
- אם יש comment מחיים (authorUserId, לא authorAgentId) שנכתב **אחרי** ה-comment האחרון שלך — **קרא אותו בתשומת לב**
|
||||
- אם ה-comment מכיל הוראות עבודה — **עקוב אחריהן**
|
||||
- אם ה-comment מזכיר קובץ שהועלה — בדוק attachments (ראה 2c)
|
||||
- אם ה-comment מבקש להעביר לסוכן אחר — **עצור**, פרסם comment שמאשר, והעֵר את ה-CEO
|
||||
**1.5ג. APPROVAL_ID flow** — אם חיים ענה על interaction (ראה `legal-ceo.md` §B/§C/§D), קרא תשובה דרך:
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID/interactions/$PAPERCLIP_APPROVAL_ID" | jq '{status, kind, response}'
|
||||
```
|
||||
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
|
||||
|
||||
## 2c. בדוק קבצים מצורפים
|
||||
---
|
||||
|
||||
אם comment מחיים מזכיר קובץ או טיוטה:
|
||||
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
|
||||
|
||||
ה-attachments זמינים ב-`$CONTEXT.attachments` (מ-§1.5ב):
|
||||
|
||||
```bash
|
||||
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
|
||||
SELECT a.original_filename, a.content_type, a.object_key, a.byte_size
|
||||
FROM issue_attachments ia
|
||||
JOIN assets a ON a.id = ia.asset_id
|
||||
WHERE ia.issue_id = '{issue-id}'
|
||||
ORDER BY ia.created_at DESC LIMIT 5;"
|
||||
echo "$CONTEXT" | jq '.attachments[] | {filename, contentPath, contentType, byteSize}'
|
||||
|
||||
# נתיב מלא לקובץ:
|
||||
CONTENT_PATH=$(echo "$CONTEXT" | jq -r '.attachments[0].contentPath')
|
||||
FULL_PATH="/home/chaim/.paperclip/instances/default/data/storage/$CONTENT_PATH"
|
||||
```
|
||||
|
||||
- נתיב מלא לקובץ: `/home/chaim/.paperclip/instances/default/data/storage/{object_key}`
|
||||
- קבצי DOCX — קרא אותם עם `Read`
|
||||
- השתמש בתוכן הקובץ כקלט לעבודתך
|
||||
קבצי DOCX/PDF — קרא עם `Read` tool ב-`$FULL_PATH`.
|
||||
|
||||
## 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
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/checkout"
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" '{"body": "סיכום..."}'
|
||||
```
|
||||
|
||||
- עבוד על המשימה לפי ההוראות ב-AGENTS.md שלך
|
||||
- השתמש בכלים המשפטיים (legal-ai MCP)
|
||||
**ל-body ארוך עם markdown/backticks/נתיבים — חובה שתי פעולות נפרדות:**
|
||||
|
||||
### ⚠️ self-recovery — issue ב-`todo` עם תוצרים קיימים
|
||||
|
||||
ל-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):
|
||||
1. כתוב את ה-JSON לקובץ זמני דרך **Write tool** (לא bash heredoc):
|
||||
```
|
||||
Write(file_path="/tmp/comment-{issue-id}.json",
|
||||
content=json.dumps({"body": markdown_body}, ensure_ascii=False))
|
||||
```
|
||||
|
||||
2. אז `curl -d @file` שקורא את הקובץ ישירות — בלי shell expansion:
|
||||
2. אז `pc.sh` עם `-d @file` שקורא את הקובץ ישירות:
|
||||
```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 @/tmp/comment-{issue-id}.json
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" "" \
|
||||
-H "Content-Type: application/json" -d @/tmp/comment-{issue-id}.json
|
||||
```
|
||||
|
||||
**⚠️ למה לא bash heredoc / `python3 -c`:** backticks ב-markdown (`` `path/to/file` ``) ייפרשו על ידי bash כ-command substitution גם כשהם בתוך מחרוזת Python. תקבל שגיאת `Permission denied` מטעה (`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
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "done"}'
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}' # הצליח
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}' # נכשל / חסום
|
||||
```
|
||||
|
||||
**אם המשימה נכשלה או חסומה** (מסמך לא חולץ, timeout, חוסר מידע, שגיאה שלא ניתנת לפתרון):
|
||||
```bash
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "blocked"}'
|
||||
```
|
||||
**אסור** לסיים issue כ-"done" אם יש כשל שלא טופל. "done" = הכל הושלם בהצלחה. אם משהו נכשל — "blocked".
|
||||
**אסור** `done` עם כשל שלא טופל. אם משהו נכשל → `blocked` + comment עם פירוט.
|
||||
|
||||
### 4ג. העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
אחרי כל סיום משימה (done או blocked), **העֵר את העוזר המשפטי של החברה שלך** כדי שיבדוק תוצאות ויחליט על הצעד הבא:
|
||||
### §4ג. wake CEO לפי חברה
|
||||
|
||||
**⚠️ בחר CEO לפי חברה:**
|
||||
| חברה | COMPANY_ID | CEO Agent ID |
|
||||
|------|------------|-------------|
|
||||
| רישוי ובניה (CMP) | `42a7acd0-...` | `752cebdd-6748-4a04-aacd-c7ab0294ef33` |
|
||||
| היטלי השבחה (CMPA) | `8639e837-...` | `cdbfa8bc-3d61-41a4-a2e7-677ec7d34562` |
|
||||
**⚠️ CEO שונה לכל חברה** (ראה §1). UUID hardcoded **אסור** — תמיד דרך `$PAPERCLIP_COMPANY_ID`:
|
||||
|
||||
```bash
|
||||
# קבע CEO_ID לפי חברה:
|
||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562"
|
||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA
|
||||
else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33"
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP
|
||||
fi
|
||||
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/$CEO_ID/wakeup" \
|
||||
-d '{"source":"automation","triggerDetail":"system","reason":"סוכן [שמך] סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
|
||||
'{"source":"automation","triggerDetail":"system","reason":"סוכן [שם] סיים [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
||||
```
|
||||
|
||||
**⚠️ כללי ברזל — Paperclip API:**
|
||||
1. **אסור** `INSERT INTO agent_wakeup_requests` — לא יוצר heartbeat_run, הסוכן לא יתעורר לעולם
|
||||
2. **חובה** `payload.issueId` בכל wakeup — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי cwd)
|
||||
3. **agent JWT לא יכול להעיר סוכנים אחרים** — רק את עצמו. כדי להעיר סוכן אחר → צור issue + הקצה אליו (Paperclip מפעיל wakeup אוטומטי)
|
||||
⚠️ **חובה `payload.issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי cwd).
|
||||
⚠️ **wakeup לחברה אחרת נדחה** — `Agent key cannot access another company`.
|
||||
⚠️ **אסור** `INSERT INTO agent_wakeup_requests` ישיר — לא יוצר heartbeat_run, הסוכן לא מתעורר.
|
||||
|
||||
**נתיבי API:**
|
||||
| פעולה | נתיב |
|
||||
|-------|-------|
|
||||
| פרסום comment | `POST /api/issues/{issue-id}/comments` |
|
||||
| יצירת issue | `POST /api/companies/{company-id}/issues` |
|
||||
| עדכון issue | `PATCH /api/issues/{issue-id}` |
|
||||
| wakeup עצמי/CEO | `POST /api/agents/{agent-id}/wakeup` (עם payload!) |
|
||||
---
|
||||
|
||||
## 5. התראת מייל — כשנדרשת תשובה אנושית
|
||||
|
||||
**כשהתוצאה דורשת החלטה או תשובה של חיים**, שלח מייל:
|
||||
## §5. התראת מייל — כשנדרשת תשובה אנושית
|
||||
|
||||
```bash
|
||||
python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
@@ -199,22 +167,29 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
"תוכן ההודעה עם סיכום מה נדרש"
|
||||
```
|
||||
|
||||
**מתי לשלוח — תמיד:**
|
||||
- **סיום כל משימה** — עם סיכום קצר של מה בוצע
|
||||
- בקשה לקביעת תוצאה (דחייה/קבלה/חלקית)
|
||||
- בקשה לאישור כיוון נימוק
|
||||
- דוח QA שנכשל (צריך החלטה על תיקונים)
|
||||
- החלטה מוכנה לביקורת דפנה
|
||||
- כל מצב שדורש פעולה אנושית ולא יכול להתקדם לבד
|
||||
- שגיאה שלא ניתן לפתור ללא התערבות
|
||||
**מתי לשלוח (תמיד):** סיום כל משימה (סיכום קצר), בקשת תוצאה/כיוון, QA fail, החלטה מוכנה לדפנה, מצב שדורש פעולה אנושית, שגיאה לא פתירה.
|
||||
|
||||
**מתי לא לשלוח:**
|
||||
- עדכוני סטטוס ביניים (רק בסיום)
|
||||
- שגיאות טכניות שאפשר לפתור לבד
|
||||
**מתי לא:** עדכוני סטטוס ביניים, שגיאות טכניות שאפשר לפתור לבד.
|
||||
|
||||
## 6. Release
|
||||
---
|
||||
|
||||
## §6. Release
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/release"
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/release"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## נתיבי 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)
|
||||
|
||||
## שלב 5: חיפוש פנימי בקורפוס
|
||||
חפש תקדימים רלוונטיים בקורפוס הפנימי:
|
||||
- `search_decisions` — בהחלטות קודמות של דפנה
|
||||
- `find_similar_cases` — תיקים דומים
|
||||
הוסף תוצאות רלוונטיות תחת כל סוגיה כ-"תקדימים מהקורפוס הפנימי".
|
||||
## שלב 5: חיפוש בשלושת הקורפוסים — חובה, עם תיעוד queries
|
||||
|
||||
**חובה לבצע** — לא הצעה. בלי השלב הזה הניתוח חסר תקדימי-עליון רלוונטיים, וה-writer לא יוכל לכתוב CREAC מלא. נבחן ב-QA.
|
||||
|
||||
### 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: בדיקת שלמות — לפני שמסיימים!
|
||||
|
||||
@@ -224,19 +288,11 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
||||
|
||||
**אם הכל עבר בהצלחה (בדיקות שלב 6 + טענות + עובדות שמאי):**
|
||||
```bash
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "done"}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "done"}'```
|
||||
|
||||
**אם בדיקות שלב 6 נכשלו או חילוץ נכשל:**
|
||||
```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"}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "blocked"}'```
|
||||
**אסור** לסיים `done` עם פלט חסר — אם ניסיון חוזר נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||
|
||||
5. **שלח מייל**:
|
||||
@@ -248,20 +304,19 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
```bash
|
||||
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
|
||||
# $PAPERCLIP_TASK_ID הוא UUID המלא שPaperclip מספק בסביבת הריצה — לעולם לא CMP-XX
|
||||
# אסור להחליף ידנית: משתמשים ב-$PAPERCLIP_TASK_ID ישירות
|
||||
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
|
||||
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
|
||||
else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
fi
|
||||
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/$CEO_ID/wakeup" \
|
||||
-d '{"source":"automation","triggerDetail":"system","reason":"מנתח משפטי סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
|
||||
"{\"source\":\"automation\",\"triggerDetail\":\"system\",\"reason\":\"מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_ID\",\"mutation\":\"agent_completion\"}}"```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** המשתנה מוגדר אוטומטית ע"י Paperclip בסביבת הריצה. אם משתמשים בו ב-double-quotes (`"..."`), bash מרחיב אותו לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
|
||||
|
||||
## מבנה הפלט המלא — analysis-and-research.md
|
||||
|
||||
@@ -337,11 +392,15 @@ X שאלות עומדות להכרעה:
|
||||
- סעיף X לחוק...
|
||||
(הערה: התחל מלשון הטקסט הנורמטיבי. תקדים נדרש רק כשהטקסט עמום.)
|
||||
|
||||
**תקדימים מהקורפוס הפנימי:**
|
||||
- [אם נמצאו]
|
||||
**תקדימים מהקורפוס הסמכותי (search_precedent_library):**
|
||||
- [תקדים שנבחר עם citation, headnote, רלוונטיות]
|
||||
- (חובה לפחות שאילתה אחת ב-Q1 בסעיף 7א — גם אם 0 תוצאות, יש לתעד שם)
|
||||
|
||||
**תקדימים מהקאנון של דפנה (search_decisions):**
|
||||
- [אם נמצאו — חיסכון או הבחנה?]
|
||||
|
||||
**עמדת ועדת הערר:**
|
||||
[ימולא ע"י יו"ר הוועדה — עמדה/הנחיה לגבי סוגיה זו שתשמש את סוכן הכתיבה]
|
||||
[ימולא ע"י יו"ר הוועדה]
|
||||
|
||||
---
|
||||
|
||||
@@ -362,6 +421,9 @@ X שאלות עומדות להכרעה:
|
||||
- **סדר דיון מומלץ**: הסדר המומלץ לדיון בסוגיות בהחלטה
|
||||
- **תלויות**: סוגיות שהכרעתן תלויה בהכרעה בסוגיה אחרת
|
||||
- **הערכה כללית**: לאן נוטה הניתוח ומהם הסיכויים הכלליים של הערר
|
||||
|
||||
## 7א. שאילתות לקורפוסים — log מלא
|
||||
[סעיף חובה לפי שלב 5ד — log כל קריאה ל-search_precedent_library, search_decisions, find_similar_cases. גם 0 results.]
|
||||
```
|
||||
|
||||
## שלב 8: העמקת ניתוח (pass 2) — אחרי אישור כיוון
|
||||
@@ -373,10 +435,14 @@ X שאלות עומדות להכרעה:
|
||||
### 8א. אימות פסיקה
|
||||
סרוק את עמדות היו"ר וזהה כל אזכור פסיקה (בג"ץ, עע"מ, עת"מ, ע"א, ערר וכו').
|
||||
לכל פסק דין שמוזכר:
|
||||
1. חפש בקורפוס הפנימי (`search_decisions`, `find_similar_cases`)
|
||||
2. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות
|
||||
3. **אם נמצא** — חלץ ציטוט מדויק, הקשר, רלוונטיות
|
||||
4. **אם לא נמצא** — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש
|
||||
1. חפש ב**קורפוס הסמכותי** (`search_precedent_library`) — חובה ראשונה. שם נמצאות הלכות מאושרות עם supporting_quote מוכן לציטוט.
|
||||
2. חפש בקאנון דפנה (`search_decisions`, `find_similar_cases`)
|
||||
3. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות
|
||||
4. **אם נמצא ב-precedent_library** — צטט citation+supporting_quote מדויקים מהקורפוס.
|
||||
5. **אם נמצא רק במסמכי התיק** — סמן: "מקור: כתבי טענות, דורש אימות מול הקורפוס".
|
||||
6. **אם לא נמצא בכלל** — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש.
|
||||
|
||||
הוסף לסעיף "7א. שאילתות לקורפוסים" כל query נוסף שהורצה ב-pass 2.
|
||||
|
||||
הוסף לכל סוגיה תת-סעיף:
|
||||
|
||||
@@ -419,11 +485,8 @@ X שאלות עומדות להכרעה:
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
fi
|
||||
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/$CEO_ID/wakeup" \
|
||||
-d '{"source":"automation","triggerDetail":"system","reason":"מנתח משפטי סיים העמקת ניתוח (pass 2) [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
|
||||
"{\"source\":\"automation\",\"triggerDetail\":\"system\",\"reason\":\"מנתח משפטי סיים העמקת ניתוח (pass 2) $PAPERCLIP_TASK_ID\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_ID\",\"mutation\":\"agent_completion\"}}"```
|
||||
**⚠️ אם ה-API מחזיר שגיאה — אל תיגע ב-DB.** `INSERT INTO agent_wakeup_requests` לא יוצר `heartbeat_run` והסוכן לא יתעורר לעולם. בדוק `$PAPERCLIP_COMPANY_ID` ו-`$PAPERCLIP_API_KEY`, ודאי שאתה לא קורא ל-CEO של חברה אחרת (`Agent key cannot access another company`).
|
||||
|
||||
## כללים קריטיים
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: "legal-ceo"
|
||||
description: "עוזר משפטי — מנהל תהליך כתיבת החלטות, מתזמר סוכנים, מפקח על התקדמות"
|
||||
model: "claude-sonnet-4-6"
|
||||
model: "claude-opus-4-7"
|
||||
tools:
|
||||
- Read
|
||||
- Bash
|
||||
@@ -36,6 +36,9 @@ tools:
|
||||
- mcp__legal-ai__precedent_library_list
|
||||
- mcp__legal-ai__halacha_review
|
||||
- mcp__legal-ai__halachot_pending
|
||||
- mcp__legal-ai__extract_appraiser_facts
|
||||
- mcp__legal-ai__write_interim_draft
|
||||
- mcp__legal-ai__export_interim_draft
|
||||
---
|
||||
|
||||
# עוזר משפטי — מנהל תהליך כתיבת החלטות
|
||||
@@ -79,11 +82,12 @@ tools:
|
||||
| סוכן | Agent ID | תפקיד |
|
||||
|-------|----------|--------|
|
||||
| מגיה מסמכים | 410c0167-27dc-485c-a51b-7aa8b9ff2217 | הגהת OCR — תיקון ראשי תיבות ושגיאות חילוץ |
|
||||
| מנתח משפטי | c26e9439-a88a-49dc-9e67-2262c95db65c | חילוץ טענות, תשובות, תגובות |
|
||||
| מנתח משפטי | c26e9439-a88a-49dc-9e67-2262c95db65c | ניתוח משפטי מלא — חילוץ טענות, ניתוח עמוק, מחקר בקורפוסים, כתיבת analysis-and-research.md |
|
||||
| חוקר תקדימים | 35022af0-0498-4c3d-90ca-b0ab9e987198 | ניתוח פסיקה, תכניות, פרוטוקולים |
|
||||
| כותב החלטה | 7ed8686f-24bc-49a3-bc02-67ca15b895a9 | כתיבת בלוקים ה-יב (Opus) |
|
||||
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
||||
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
||||
| מנהל ידע (Hermes) | CMP: 60dce831-5c5b-4bae-bda9-5282d506f0dc · CMPA: d6f7c55d-570a-46b8-8d72-1286d07da0d8 | סקירת החלטות סופיות, הצעות לעדכון style guide / lessons. **לא קורא ישירות מ-CEO** — מופעל אוטומטית מ-`web/app.py:api_mark_final` כשדפנה לוחצת "סמן כסופי" ב-UI. |
|
||||
|
||||
## כלל: כל issue חדש = תת-משימה
|
||||
|
||||
@@ -92,10 +96,7 @@ tools:
|
||||
|
||||
```bash
|
||||
# שלב 1: יצירת issue
|
||||
ISSUE_ID=$(curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
|
||||
-d '{"title": "[ערר CASE_NUMBER] ....", "description": "...", "parentId": "'$PAPERCLIP_TASK_ID'", "assigneeAgentId": "..."}' \
|
||||
ISSUE_ID=$(~/legal-ai/scripts/pc.sh POST "/api/companies/$PAPERCLIP_COMPANY_ID/issues" '{"title": "[ערר CASE_NUMBER] ....", "description": "...", "parentId": "'$PAPERCLIP_TASK_ID'", "assigneeAgentId": "..."}' \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
|
||||
# שלב 2 (חובה!): קישור ל-case number בעוזר המשפטי
|
||||
@@ -112,8 +113,7 @@ PGPASSWORD=paperclip psql -h localhost -p 54329 -U paperclip -d paperclip -c \
|
||||
|
||||
**אם** ה-issue שלך הוא בעצמו תת-משימה (יש לו parent), השתמש ב-parent של ה-parent — כלומר ה-issue הראשי של התיק. לקבלת ה-parent:
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('parentId') or d['id'])"
|
||||
~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('parentId') or d['id'])"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -160,6 +160,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
- אם ה-reason מכיל `user_commented` → **דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.**
|
||||
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
||||
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
||||
- אם ה-reason מכיל `weekly-feedback-job` → **דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
|
||||
- אחרת → המשך לשלב A (heartbeat רגיל)
|
||||
|
||||
### חילוץ פסיקה אוטומטי
|
||||
@@ -186,6 +187,26 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
|
||||
**אל**: אל תיצור issues של ביצוע בתיקי ערר, אל תיכנס לתהליך כתיבת החלטה — זו רק עבודת תחזוקה של ספריית הפסיקה.
|
||||
|
||||
### ניתוח פידבק שבועי (weekly-feedback-job)
|
||||
|
||||
**מתי:** `$PAPERCLIP_WAKE_REASON` מכיל `weekly-feedback-job`
|
||||
|
||||
ה-prompt שתקבל מכיל סיכום של כל הפידבק מיו"ר מהשבוע האחרון, בפורמט:
|
||||
```
|
||||
- תיק X (קטגוריה): טקסט הפידבק
|
||||
- תיק Y (קטגוריה): ...
|
||||
```
|
||||
|
||||
**מה לעשות:**
|
||||
1. **קרא את `docs/legal-decision-lessons.md`** — הבן מה כבר מתועד שם.
|
||||
2. **נתח את הפידבק** — אילו דפוסים חוזרים? מה חדש שלא מופיע בלקחים?
|
||||
3. **עדכן את `docs/legal-decision-lessons.md`** — הוסף רק לקחים חדשים ומהותיים (לא כפל). כל לקח = משפט אחד ברור.
|
||||
4. **רשום ל-stdout** (לא ל-issue): `echo "weekly feedback done: N lessons added"` — החלף N במספר הלקחים שנוספו.
|
||||
|
||||
⚠️ **אין issue ב-Paperclip עבור job זה** — `$PAPERCLIP_TASK_ID` ריק. אל תנסה לפרסם comment ואל תנסה לסגור issue. הפעולה מסתיימת לאחר כתיבת הקובץ.
|
||||
|
||||
**כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים, אל תבצע heartbeat רגיל — זו משימת תחזוקה בלבד.
|
||||
|
||||
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
||||
|
||||
בכל heartbeat **רגיל** (לא comment routing):
|
||||
@@ -206,6 +227,12 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
- **מסמך ריק**: האם יש מסמך appeal/response עם טקסט שלא ייצר טענות ולא דווח ככשל?
|
||||
|
||||
#### A3. אימות תאימות מתודולוגיה
|
||||
**תנאי קדם — קודם וודא שהמסמך קיים:**
|
||||
```bash
|
||||
ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
```
|
||||
אם הקובץ **לא קיים** — עצור. המנתח לא ביצע את הניתוח המלא. בדוק את issue המנתח: אם הוא `done` אבל הקובץ חסר — צור issue מנתח חדש עם הנחיה לבצע שלבים 2-7 מ-`legal-analyst.md` (לא לחלץ טענות מחדש — `get_claims` להצגה).
|
||||
|
||||
קרא את `analysis-and-research.md` ובדוק:
|
||||
- [ ] סוגיות מנוסחות כסילוגיזם (כלל + עובדות + שאלה)?
|
||||
- [ ] ממצאים עובדתיים מופרדים ממסקנות משפטיות?
|
||||
@@ -221,9 +248,11 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
|
||||
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
|
||||
|
||||
**מתי:** כשיש טענות מחולצות + מחקר תקדימים, אבל אין תוצאה עדיין
|
||||
**מתי:** כשיש `analysis-and-research.md` מלא (מנתח סיים שלבים 1-7) וסטטוס `analyst_verified`, אבל אין תוצאה עדיין
|
||||
|
||||
פרסם comment ב-Paperclip:
|
||||
**שיטה — dual dispatch:** קודם פרסם comment עם הסיכום המלא (לתיעוד), ואז צור interaction עם כפתורים (לחיים).
|
||||
|
||||
#### B.1 פרסם comment עם הסיכום
|
||||
|
||||
```
|
||||
## סיכום תיק {case_number} — מוכן להחלטה
|
||||
@@ -259,135 +288,151 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
- כלל: ...
|
||||
- עובדות: ...
|
||||
- שאלה: ...
|
||||
|
||||
---
|
||||
|
||||
**מה התוצאה הצפויה?**
|
||||
1. 🔴 **דחייה** — הערר נדחה
|
||||
2. 🟡 **קבלה חלקית** — מתקבל עם תנאים
|
||||
3. 🟢 **קבלה מלאה** — הערר מתקבל
|
||||
|
||||
@chaim — הגב עם מספר (1/2/3) + הערות אם יש
|
||||
```
|
||||
|
||||
**אחרי פרסום ה-comment:** עדכן את ה-issue הראשי ל-`status=in_review` (ראה "כלל קריטי: ניהול סטטוס issue" בראש הסעיף).
|
||||
#### B.2 צור interaction לבחירת תוצאה + טיפול בטענות
|
||||
|
||||
לאחר שחיים בחר תוצאה, שאל אותו לסמן טיפול בכל טענה:
|
||||
|
||||
```
|
||||
## טיפול בטענות — {case_number}
|
||||
|
||||
סמן לכל טענה את סוג הטיפול:
|
||||
|
||||
| # | טענה | טיפול |
|
||||
|---|------|-------|
|
||||
| 1 | {טענה 1} | דיון מלא / קיבוץ / דילוג |
|
||||
| 2 | {טענה 2} | דיון מלא / קיבוץ / דילוג |
|
||||
| 3 | {טענה 3} | דיון מלא / קיבוץ / דילוג |
|
||||
| ... | ... | ... |
|
||||
|
||||
**הסבר:**
|
||||
- **דיון מלא** — ניתוח סילוגיסטי מלא (כלל → עובדות → מסקנה)
|
||||
- **קיבוץ** — טענות שמכוונות לאותה נקודה ייאגדו יחד
|
||||
- **דילוג** — "לא מצאנו ממש" או "אין צורך להכריע נוכח מסקנתנו"
|
||||
|
||||
@chaim — סמן בטבלה והחזר
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/$PAPERCLIP_TASK_ID/interactions" '{
|
||||
"kind": "ask_user_questions",
|
||||
"idempotencyKey": "outcome:'"$PAPERCLIP_TASK_ID"':v1",
|
||||
"title": "תוצאה וטיפול בטענות — {case_number}",
|
||||
"summary": "ראה את הסיכום ב-comment לעיל. שתי שאלות מובנות.",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
"version": 1,
|
||||
"submitLabel": "המשך לכיוונים",
|
||||
"questions": [
|
||||
{
|
||||
"id": "outcome",
|
||||
"prompt": "מה התוצאה?",
|
||||
"selectionMode": "single",
|
||||
"required": true,
|
||||
"options": [
|
||||
{"id":"reject", "label":"דחייה", "description":"הערר נדחה"},
|
||||
{"id":"partial","label":"קבלה חלקית","description":"מתקבל עם תנאים"},
|
||||
{"id":"accept", "label":"קבלה מלאה","description":"הערר מתקבל"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "claims_treatment",
|
||||
"prompt": "אילו טענות לדון בנפרד? (multi)",
|
||||
"selectionMode": "multi",
|
||||
"helpText": "סמן רק טענות שצריכות דיון מלא. השאר → קיבוץ או דילוג.",
|
||||
"options": [
|
||||
{"id":"claim_1","label":"{טענה 1 מקוצר}"},
|
||||
{"id":"claim_2","label":"{טענה 2 מקוצר}"},
|
||||
{"id":"claim_3","label":"{טענה 3 מקוצר}"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**אחרי פרסום ה-comment:** עדכן את ה-issue הראשי ל-`status=in_review`.
|
||||
**אחרי יצירת ה-interaction:** עדכן את ה-issue הראשי ל-`status=in_review` (ראה "כלל קריטי: ניהול סטטוס issue" בראש הסעיף). חיים יקבל UI עם dropdowns וכפתורי radio במקום להקליד מספרים.
|
||||
|
||||
⚠️ **`idempotencyKey`** — חובה. אם תתעורר פעמיים, Paperclip לא יוצר 2 interactions זהים.
|
||||
|
||||
**מתי לחזור אחורה:** אם הסיכום לא מצליח לנסח שאלות כסילוגיזמים מכווצים — ייתכן שחסר מידע עובדתי או נורמטיבי. חזור למנתח/חוקר להשלמה.
|
||||
|
||||
### שלב C: קליטת תוצאה וכיוונים סילוגיסטיים
|
||||
|
||||
**מתי:** חיים הגיב עם מספר תוצאה + טיפול בטענות
|
||||
**מתי:** התעוררת עם `$PAPERCLIP_APPROVAL_ID` שמצביע על interaction מ-§B (תשובת תוצאה+טענות).
|
||||
|
||||
0. **החזר את ה-issue הראשי ל-`status=in_progress`** (קיבלת קלט והמשכת לעבוד).
|
||||
1. קרא את ה-comment של חיים
|
||||
2. זהה את הבחירה (1=rejected, 2=partial, 3=accepted)
|
||||
3. הרץ `set_outcome(case_number, outcome, reasoning)`
|
||||
4. **חשוב סילוגיסטית** על 2-3 כיוונים לנימוק — אתה כבר Claude, אתה יודע את הטענות והתקדימים. בנה כל כיוון כסילוגיזם מלא.
|
||||
1. **קרא את תשובת חיים מה-API** (לא מ-comment חופשי):
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID/interactions/$PAPERCLIP_APPROVAL_ID" \
|
||||
| jq '{status, payload: .response}'
|
||||
```
|
||||
- תשובת `outcome`: `reject` / `partial` / `accept` (זהה ל-1/2/3 הישן)
|
||||
- תשובת `claims_treatment`: array של claim IDs לדיון מלא
|
||||
2. הרץ `set_outcome(case_number, outcome, reasoning)`
|
||||
3. **חשוב סילוגיסטית** על 2-3 כיוונים לנימוק — אתה כבר Claude, אתה יודע את הטענות והתקדימים. בנה כל כיוון כסילוגיזם מלא.
|
||||
|
||||
> **הערה טכנית:** אל תקרא ל-`brainstorm_directions` — זה מפעיל Claude בתוך Claude ולוקח יותר מדי זמן.
|
||||
|
||||
5. פרסם comment עם **סדר סוגיות מוצע**:
|
||||
4. פרסם comment קצר עם **סדר סוגיות מוצע** (לתיעוד thread):
|
||||
|
||||
```
|
||||
## כיוונים אפשריים לנימוק — {outcome_hebrew}
|
||||
## כיוונים לנימוק — {outcome_hebrew}
|
||||
|
||||
### סדר הסוגיות המוצע
|
||||
1. {שאלת סף — אם רלוונטית}
|
||||
2. {הסוגיה המכריעה}
|
||||
3. {סוגיות נוספות לפי חוזק}
|
||||
|
||||
---
|
||||
|
||||
### כיוון 1: {title}
|
||||
|
||||
**כלל (הנחה עליונה):**
|
||||
{הוראת תכנית / סעיף חוק / הלכה פסוקה}
|
||||
|
||||
**עובדות (הנחה תחתונה):**
|
||||
{העובדות הספציפיות של הערר שנבחנות לאור הכלל}
|
||||
|
||||
**מסקנה:**
|
||||
{התוצאה שנובעת מהחלת הכלל על העובדות}
|
||||
|
||||
**תקדימים תומכים:** {precedents}
|
||||
|
||||
---
|
||||
|
||||
### כיוון 2: {title}
|
||||
|
||||
**כלל (הנחה עליונה):**
|
||||
{...}
|
||||
|
||||
**עובדות (הנחה תחתונה):**
|
||||
{...}
|
||||
|
||||
**מסקנה:**
|
||||
{...}
|
||||
|
||||
**תקדימים תומכים:** {precedents}
|
||||
|
||||
---
|
||||
|
||||
### כיוון 3: {title}
|
||||
|
||||
**כלל (הנחה עליונה):**
|
||||
{...}
|
||||
|
||||
**עובדות (הנחה תחתונה):**
|
||||
{...}
|
||||
|
||||
**מסקנה:**
|
||||
{...}
|
||||
|
||||
**תקדימים תומכים:** {precedents}
|
||||
|
||||
---
|
||||
|
||||
@chaim — איזה כיוון מועדף? (1/2/3)
|
||||
אפשר גם לשלב כיוונים או להוסיף הערות.
|
||||
(הכיוונים המלאים — בinteraction למטה)
|
||||
```
|
||||
|
||||
**אחרי פרסום ה-comment:** עדכן את ה-issue הראשי ל-`status=in_review`.
|
||||
5. צור **interaction לבחירת כיוון** עם detailsMarkdown מלא:
|
||||
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/$PAPERCLIP_TASK_ID/interactions" '{
|
||||
"kind": "ask_user_questions",
|
||||
"idempotencyKey": "direction:'"$PAPERCLIP_TASK_ID"':v1",
|
||||
"title": "בחירת כיוון לנימוק — {case_number}",
|
||||
"summary": "3 כיוונים סילוגיסטיים. בחר אחד או שלב.",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
"version": 1,
|
||||
"submitLabel": "אישור כיוון — להעברה לכותב",
|
||||
"questions": [
|
||||
{
|
||||
"id": "direction",
|
||||
"prompt": "איזה כיוון מועדף?",
|
||||
"selectionMode": "single",
|
||||
"required": true,
|
||||
"helpText": "ניתן לשלב כיוונים בהערות ב-comment נפרד אחרי הבחירה.",
|
||||
"options": [
|
||||
{
|
||||
"id": "direction_1",
|
||||
"label": "כיוון 1: {title}",
|
||||
"description": "כלל: {הוראת תכנית/סעיף חוק/הלכה}\nעובדות: {ספציפיות הערר}\nמסקנה: {התוצאה}\nתקדימים: {precedents}"
|
||||
},
|
||||
{
|
||||
"id": "direction_2",
|
||||
"label": "כיוון 2: {title}",
|
||||
"description": "כלל: {...}\nעובדות: {...}\nמסקנה: {...}\nתקדימים: {precedents}"
|
||||
},
|
||||
{
|
||||
"id": "direction_3",
|
||||
"label": "כיוון 3: {title}",
|
||||
"description": "כלל: {...}\nעובדות: {...}\nמסקנה: {...}\nתקדימים: {precedents}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
⚠️ ה-`description` של כל option בעברית. ה-`label` קצר (3-4 מילים), ה-`description` הוא הסילוגיזם המלא — חיים רואה הכל בלי להקליד.
|
||||
|
||||
**אחרי יצירת ה-interaction:** עדכן את ה-issue הראשי ל-`status=in_review`.
|
||||
|
||||
**מתי לחזור אחורה:** אם לא ניתן לבנות סילוגיזם מלא (חסר כלל, חסרות עובדות, או המסקנה לא נובעת) — חזור לחוקר תקדימים או למנתח להשלמת החסר.
|
||||
|
||||
### שלב D: אישור כיוון והפעלת כתיבה
|
||||
|
||||
**מתי:** חיים הגיב עם בחירת כיוון
|
||||
**מתי:** התעוררת עם `$PAPERCLIP_APPROVAL_ID` שמצביע על interaction מ-§C (תשובת כיוון).
|
||||
|
||||
0. **החזר את ה-issue הראשי ל-`status=in_progress`** (קיבלת קלט והמשכת לעבוד).
|
||||
1. קרא את ה-comment של חיים
|
||||
2. זהה כיוון (1/2/3) + הערות נוספות
|
||||
1. **קרא את תשובת חיים מה-API:**
|
||||
```bash
|
||||
~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID/interactions/$PAPERCLIP_APPROVAL_ID" \
|
||||
| jq '{status, response: .response}'
|
||||
```
|
||||
- `response.direction` יחזיר `direction_1` / `direction_2` / `direction_3`
|
||||
- אם יש הערות נוספות — חיים יוסיף ב-comment נפרד; קרא את ה-comments האחרונים
|
||||
2. זהה את הכיוון מהתשובה (1/2/3 → לפי המספר ב-id)
|
||||
3. **אימות שלמות chair_directions** — לפני שליחה לכותב, ודא:
|
||||
- [ ] טיפול בטענות (דיון מלא / קיבוץ / דילוג) מוגדר לכל טענה
|
||||
- [ ] כיוון סילוגיסטי נבחר ומאושר
|
||||
- [ ] טיפול בטענות (דיון מלא / קיבוץ / דילוג) מוגדר לכל טענה (מ-§B)
|
||||
- [ ] כיוון סילוגיסטי נבחר ומאושר (מ-§C — interaction status=`answered`)
|
||||
- [ ] סדר סוגיות מוגדר
|
||||
- [ ] תקן ביקורת מצוין
|
||||
- אם חסר פריט כלשהו — **שאל את חיים** לפני שממשיכים
|
||||
- אם חסר פריט כלשהו — צור interaction חדש (`request_confirmation` או `ask_user_questions`) **לפני** שממשיכים. אסור לקרוא לחיים בcomment חופשי.
|
||||
4. הרץ `approve_direction(case_number, direction_index, additional_notes)`
|
||||
5. עדכן סטטוס: `case_update(status=direction_approved)`
|
||||
6. צור issue חדש ב-Paperclip:
|
||||
@@ -396,7 +441,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
- תיאור: "כיוון אושר. בצע pass 2: אמת פסיקה מעמדות היו"ר, העמק עובדות לאור הכיוון שנבחר."
|
||||
7. פרסם comment: "כיוון אושר. הועבר למנתח להעמקת ניתוח לפני כתיבה."
|
||||
|
||||
**מתי לחזור אחורה:** אם חיים שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב B או C בהתאם.
|
||||
**מתי לחזור אחורה:** אם חיים דחה את ה-interaction (`status=rejected`) או שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב B או C בהתאם וצור interaction חדש עם `idempotencyKey` מעודכן (לדוגמה `:v2`).
|
||||
|
||||
### שלב D2: אחרי העמקת ניתוח (pass 2)
|
||||
|
||||
@@ -474,17 +519,84 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
- השתמש ב-`revise_draft` בלבד במצב ג'.
|
||||
- אם המשתמש ביקש שינוי מאסיבי (שכתוב מלא של בלוק) — עדיף להציע לו לעבוד על זה בעריכה נוספת מצדו ולא לייצר revisions ארוכים.
|
||||
|
||||
### שלב H: טיוטת ביניים (לבקשת חיים, לפני דיון והכרעה)
|
||||
|
||||
**מתי:** חיים מבקש בקומנט "טיוטת ביניים" / "interim draft" / "טיוטה לפני דיון" / "תכין לי את הטיוטה עם טענות הצדדים". בכל שלב לפני שיש תוצאה (בד"כ כשהתיק ב-`research_complete` או `analyst_verified`).
|
||||
|
||||
**מטרה:** ייצור מסמך עבודה לחיים עם פתיחה ניטרלית, רקע, תכניות+היתרים, טענות הצדדים, והליכים — **בלי דיון והכרעה**. חיים יכתוב את בלוק י בעצמו ואז נמשיך לזרימה הרגילה (QA + ייצוא סופי).
|
||||
|
||||
**זה side-quest, לא חלק מהזרימה B-F.** אל תשנה `cases.status`. אל תייצר issues לסוכני משנה. הכלים `write_interim_draft` ו-`export_interim_draft` עושים הכל בעצמם.
|
||||
|
||||
**זרימה (~5-10 דקות):**
|
||||
|
||||
1. פרסם comment קצר: "מתחיל יצירת טיוטת ביניים — אעדכן בסיום." עדכן את ה-issue הראשי ל-`status=in_progress`.
|
||||
|
||||
2. **חילוץ עובדות שמאיות** (אם תיק 8xxx/9xxx ויש מסמכי שומה):
|
||||
```
|
||||
mcp__legal-ai__extract_appraiser_facts(case_number="...")
|
||||
```
|
||||
⚠️ אם מחזיר `status="sides_missing"` → דווח לחיים שאין תיוג `appraiser_side` במסמכי השומה (`document_update` עם `appraiser_side` בערכים `committee`/`appellant`/`deciding`). עצור עד שיתוקן.
|
||||
|
||||
אם הטבלה כבר מלאה — `write_interim_draft` ידלג על ההרצה אוטומטית, אז גם בלי הצעד הזה זה יעבוד.
|
||||
|
||||
3. **כתיבת 5 הבלוקים:**
|
||||
```
|
||||
mcp__legal-ai__write_interim_draft(
|
||||
case_number="...",
|
||||
instructions="לבלוק ה (פתיחה): נוסח ניטרלי לחלוטין — 'לפנינו ערר על שומה מכרעת...' + הגדרות 'להלן' בלבד. אין לרמוז על תוצאת הדיון, אין מילות שיפוט, אין אזכור 'דין הערר להידחות/להתקבל'. רק זיהוי הצדדים, השומה המכרעת, המקרקעין והגורם המחליט."
|
||||
)
|
||||
```
|
||||
הכלי כותב ל-DB את בלוקים ה (פתיחה), ו (רקע), ט (תכניות+היתרים מורחב), ז (טענות), ח (הליכים). מחזיר `word_count` לכל בלוק.
|
||||
|
||||
4. **ייצוא DOCX:**
|
||||
```
|
||||
mcp__legal-ai__export_interim_draft(case_number="...")
|
||||
```
|
||||
מייצר `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`, מעדכן `active_draft_path`.
|
||||
|
||||
5. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
|
||||
```
|
||||
## טיוטת ביניים מוכנה — ערר {case_number}
|
||||
|
||||
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`
|
||||
|
||||
### מה כלול
|
||||
| בלוק | כותרת | מילים |
|
||||
|------|-------|-------|
|
||||
| ה | פתיחה (ניטרלית) | {N} |
|
||||
| ו | רקע עובדתי | {N} |
|
||||
| ט | תכניות + היתרים | {N} |
|
||||
| ז | טענות הצדדים | {N} |
|
||||
| ח | הליכים | {N} |
|
||||
| **סה"כ** | | **{N}** |
|
||||
|
||||
### סתירות שמאיות שזוהו
|
||||
{אם יש — רשימה קצרה: "תכנית X — שמאי A קבע ..., שמאי B קבע ...". אם אין — "לא זוהו סתירות בין שמאים."}
|
||||
|
||||
### מה הלאה
|
||||
הטיוטה מוכנה לעבודה. כשתסיים לכתוב את בלוק י, חזור ב-comment ונמשיך
|
||||
לשלב F (QA + ייצוא סופי).
|
||||
```
|
||||
|
||||
6. **סטטוס issue הראשי:** עדכן ל-`in_review` (ממתין לחיים שיכתוב את בלוק י).
|
||||
|
||||
**אזהרות:**
|
||||
- אל תייצא DOCX סופי (`export_docx`) — זה לא תחליף לטיוטת ביניים.
|
||||
- אל תפעיל את שלב B (סיכום + שאלת תוצאה) במקביל — חיים מחליט מתי לעבור לזרימה הראשית.
|
||||
- אם בלוק ח חסר (אין פרוטוקול דיון/סיור) — ציין זאת בדוח. הכלי כותב מה שיש, אבל המשתמש צריך לדעת אם חסר.
|
||||
|
||||
## מפת סטטוסים
|
||||
|
||||
**סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
|
||||
|
||||
| סטטוס | מי שינה לזה | פעולה הבאה |
|
||||
|--------|-------------|------------|
|
||||
| `processing` | start-workflow (ממשק) | → בדוק אם כבר קיים issue פעיל לסוכן משנה. אם לא → המשך ל-§A כרגיל (בדוק documents + claims) |
|
||||
| `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח |
|
||||
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
||||
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified` |
|
||||
| `analyst_verified` | CEO (אחרי שלב A) | → האם יש מחקר תקדימים? אם לא → צור issue לחוקר (35022af0). אם כן → שלב B |
|
||||
| `research_complete` | חוקר | → שלב B (סיכום + סיווג + שאלת תוצאה לחיים) |
|
||||
| `analyst_verified` | CEO (אחרי שלב A) | → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
||||
| `research_complete` | (מנתח — legacy, או תרחיש מיוחד עם חוקר) | → שלב B (סיכום + שאלת תוצאה לחיים). בזרימה הרגילה המנתח לא מגדיר סטטוס זה — רק `documents_ready`. אם תראה סטטוס זה, בדוק אם `analysis-and-research.md` קיים לפני §B. |
|
||||
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
||||
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
||||
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
||||
@@ -541,15 +653,51 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
---
|
||||
|
||||
**תבנית issue למנתח — חובה בכל תיק:**
|
||||
1. **טבלת מיפוי מסמכים** — לכל מסמך: שם, doc_type, פעולה נדרשת:
|
||||
- `appeal` → `extract_claims` (claim_type=claim, party_role=appellant)
|
||||
- `response` → `extract_claims` (claim_type=response, party_role=respondent/committee)
|
||||
- `reply` → `extract_claims` (claim_type=reply, party_role=permit_applicant/appellant)
|
||||
- **`appraisal` → `extract_appraiser_facts`** (לא extract_claims! שומה אינה כתב טענות. חובה בכל תיק 8xxx/9xxx)
|
||||
- `reference`/`plan`/`protocol`/`permit`/`decision`/`court_decision` → אל תחלץ — חומר רקע בלבד
|
||||
2. **בדיקת השלמה** — לכל doc_type='appraisal' בתיק, וודא שה-issue אומר במפורש להריץ `extract_appraiser_facts`. בלי זה ה-writer יקבל בלוק ז ריק ממספרים.
|
||||
3. **הנחיה לסגור את ה-issue ב-PATCH** — סטטוס `done` בהצלחה, `blocked` בכשל. בלי זה Paperclip יפעיל retry בלולאה (נצפה בפועל ב-CMPA-16 / 30-04-26).
|
||||
4. **הנחיה לשלוח wakeup ל-CEO בסיום** (כך שאתה תידע להמשיך)
|
||||
|
||||
**כותרת:** `[ערר CASE_NUMBER] ניתוח משפטי ומחקר — CASE_NAME`
|
||||
|
||||
**תיאור חובה — כלול את כל הסעיפים הבאים:**
|
||||
|
||||
```
|
||||
בצע ניתוח משפטי מלא לפי legal-analyst.md שלבים 1-7:
|
||||
|
||||
שלב 1: קליטה וזיהוי
|
||||
- חלץ טענות/תשובות/תגובות מכל מסמכי appeal/response/reply (ראה טבלה למטה)
|
||||
- לכל appraisal: הרץ extract_appraiser_facts (לא extract_claims)
|
||||
|
||||
טבלת מסמכים:
|
||||
[לכל מסמך: שם | doc_type | פעולה נדרשת]
|
||||
- appeal → extract_claims(claim_type=claim, party_role=appellant)
|
||||
- response → extract_claims(claim_type=response, party_role=respondent/committee)
|
||||
- reply → extract_claims(claim_type=reply, party_role=permit_applicant/appellant)
|
||||
- appraisal → extract_appraiser_facts (לא extract_claims!)
|
||||
- reference/plan/protocol/permit/decision → אל תחלץ — רקע בלבד
|
||||
|
||||
שלב 2: ניתוח מעמיק — גוף מחליט, רקע דיוני, עובדות מוסכמות, עובדות שנויות
|
||||
|
||||
שלב 3: טענות סף, מפת דרכים, סוגיות להכרעה (כולל CREAC + עמדת ועדת הערר ריקה)
|
||||
|
||||
שלב 4: שאלות מחקר (1-3 לכל סוגיה)
|
||||
|
||||
שלב 5: חיפוש בשלושת הקורפוסים — חובה:
|
||||
- search_precedent_library(practice_area=RELEVANT_AREA)
|
||||
- search_decisions
|
||||
- find_similar_cases
|
||||
|
||||
שלב 6: בדיקת שלמות — get_claims ≥ 1 מכל צד
|
||||
|
||||
שלב 7: שמור analysis-and-research.md ב-data/cases/CASE_NUMBER/documents/research/
|
||||
עדכן case_update(status='documents_ready')
|
||||
סגור issue: PATCH status=done (או blocked אם נכשל)
|
||||
שלח wakeup ל-CEO עם $PAPERCLIP_TASK_ID כ-issueId (ראה HEARTBEAT.md §4ג)
|
||||
|
||||
⚠️ אחרי יצירת task זה — עדכן את ה-issue הראשי ל-status=in_review והמתן ל-wakeup
|
||||
עם mutation=agent_completion מהמנתח. אין לבדוק get_claims לפני ה-wakeup.
|
||||
```
|
||||
|
||||
1. **בדיקת השלמה** — לכל doc_type='appraisal' בתיק, וודא שה-issue אומר במפורש להריץ `extract_appraiser_facts`. בלי זה ה-writer יקבל בלוק ז ריק ממספרים.
|
||||
2. **הנחיה לסגור את ה-issue ב-PATCH** — סטטוס `done` בהצלחה, `blocked` בכשל. בלי זה Paperclip יפעיל retry בלולאה (נצפה בפועל ב-CMPA-16 / 30-04-26).
|
||||
3. **הנחיה לשלוח wakeup ל-CEO בסיום** (כך שאתה תידע להמשיך) — חובה להשתמש ב-`$PAPERCLIP_TASK_ID` (UUID) ולא ב-CMP-XX.
|
||||
|
||||
## סינון תיקים לפי חברה — חובה!
|
||||
|
||||
@@ -592,22 +740,18 @@ case_prefix="${case_number:0:1}"
|
||||
|
||||
0. **החזר את ה-issue הראשי ל-`status=in_progress`** — אם ה-issue ב-`in_review` (כי המתנת לחיים) או ב-`blocked` (כי Paperclip חסם אוטומטית), הראשון דבר: עדכן ל-`in_progress` כדי לסמן שאתה עובד עליו.
|
||||
|
||||
1. **קרא את ה-comments האחרונים** על ה-issue שצוין ב-prompt:
|
||||
1. **קרא את ההקשר המלא** — issue + ancestors + project + goal + comments + attachments בקריאה אחת (ראה `HEARTBEAT.md §1.7`):
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]'
|
||||
CONTEXT=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/heartbeat-context")
|
||||
```
|
||||
|
||||
2. **בדוק attachments** — אם חיים ציין קובץ שהועלה:
|
||||
2. **בדוק attachments** — אם חיים ציין קובץ שהועלה, הוא כבר ב-`$CONTEXT.attachments`:
|
||||
```bash
|
||||
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
|
||||
SELECT a.original_filename, a.content_type, a.object_key
|
||||
FROM issue_attachments ia
|
||||
JOIN assets a ON a.id = ia.asset_id
|
||||
WHERE ia.issue_id = '{issue-id}'
|
||||
ORDER BY ia.created_at DESC LIMIT 5;"
|
||||
echo "$CONTEXT" | jq '.attachments[] | {filename, contentPath, contentType, byteSize}'
|
||||
```
|
||||
נתיב מלא לקובץ: `/home/chaim/.paperclip/instances/default/data/storage/{object_key}`
|
||||
נתיב מלא לקובץ: `/home/chaim/.paperclip/instances/default/data/storage/$(echo $CONTEXT | jq -r '.attachments[0].contentPath')`
|
||||
|
||||
⚠️ **אסור** psql ישיר ל-`issue_attachments` — ה-API הוא ה-source of truth.
|
||||
|
||||
3. **אם יש טיוטה/קובץ — קרא אותו מילה במילה.** חפש בתוכו:
|
||||
- הוראות עריכה (טקסט כמו "צריך לערוך", "להוסיף", "חסר", "הוראות כתיבה")
|
||||
@@ -658,34 +802,37 @@ case_prefix="${case_number:0:1}"
|
||||
## נתיבי API — חובה!
|
||||
|
||||
```bash
|
||||
# קרא comments על issue
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '.[-1].body'
|
||||
# קרא comments על issue (אבל בד"כ עדיף heartbeat-context — ראה HEARTBEAT.md §1.7)
|
||||
~/legal-ai/scripts/pc.sh GET "/api/issues/{issue-id}/comments" | jq '.[-1].body'
|
||||
|
||||
# פרסם comment
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" \
|
||||
-d '{"body": "..."}'
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" '{"body": "..."}'
|
||||
|
||||
# צור issue חדש (עם הקצאה לסוכן → מפעיל wakeup אוטומטי!)
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/companies/42a7acd0-30c5-4cbd-ac97-7424f65df294/issues" \
|
||||
-d '{"title":"...","projectId":"25c1b4a1-2c0e-4a2d-9938-8ae56ccda6f1","assigneeAgentId":"{agent-id}","description":"...","status":"todo"}'
|
||||
# ⚠️ שלוף projectId מה-issue ההורה — אל תקבע UUID ידנית:
|
||||
PROJECT_ID=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID" | jq -r '.projectId')
|
||||
~/legal-ai/scripts/pc.sh POST "/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
|
||||
"{\"title\":\"...\",\"projectId\":\"$PROJECT_ID\",\"assigneeAgentId\":\"{agent-id}\",\"description\":\"...\",\"status\":\"todo\"}"
|
||||
|
||||
# עדכן issue
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "done"}'
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'
|
||||
|
||||
# צור interaction מובנה לחיים (ראה §B/§C למעלה למבנה payload)
|
||||
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/interactions" '{"kind":"...","payload":{...}}'
|
||||
|
||||
# קרא תשובת interaction (כשהתעוררת עם $PAPERCLIP_APPROVAL_ID)
|
||||
~/legal-ai/scripts/pc.sh GET "/api/issues/{issue-id}/interactions/$PAPERCLIP_APPROVAL_ID" | jq '.'
|
||||
```
|
||||
|
||||
**⚠️ agent JWT לא יכול להעיר סוכנים אחרים ישירות.** כדי להעיר סוכן → **צור issue חדש + הקצה אליו** (Paperclip מפעיל wakeup אוטומטי על assignment).
|
||||
|
||||
חפש ב-comment של חיים:
|
||||
- מספר (1/2/3) → בחירה
|
||||
- "כיוון" + מספר → אישור כיוון
|
||||
- טבלת טיפול בטענות → סימון claim_handling
|
||||
- שאלה → ענה
|
||||
- הערה → שלב בתהליך
|
||||
## מתי להשתמש בinteraction לעומת comment
|
||||
|
||||
| מצב | פתרון |
|
||||
|------|--------|
|
||||
| נדרשת בחירה מובנית מחיים (תוצאה, כיוון, אישור) | **interaction** (`ask_user_questions` / `request_confirmation`) — UI עם כפתורים |
|
||||
| הצעת עץ משימות לאישור | **interaction** (`suggest_tasks`) |
|
||||
| עדכון סטטוס/תיעוד מסע (לא דורש פעולה) | **comment** רגיל |
|
||||
| הסבר ארוך + שאלת בחירה | **dual** — comment עם הסבר + interaction עם options (ראה §B) |
|
||||
|
||||
**אסור:** "@chaim — ענה 1/2/3 בcomment". זה anti-pattern. תמיד interaction עם options.
|
||||
|
||||
@@ -19,6 +19,7 @@ tools:
|
||||
- mcp__legal-ai__revise_draft
|
||||
- mcp__legal-ai__get_style_guide
|
||||
- mcp__legal-ai__validate_decision
|
||||
- mcp__legal-ai__case_update
|
||||
---
|
||||
|
||||
# מייצא טיוטה — סוכן ייצוא סופי
|
||||
@@ -40,14 +41,14 @@ tools:
|
||||
## סקייל ייצוא
|
||||
|
||||
**חובה לקרוא לפני כל ייצוא:**
|
||||
- `/home/chaim/.paperclip/instances/default/skills/42a7acd0-30c5-4cbd-ac97-7424f65df294/legal-docx/SKILL.md`
|
||||
- `/home/chaim/.paperclip/instances/default/skills/42a7acd0-30c5-4cbd-ac97-7424f65df294/legal-docx/references/document-types.md`
|
||||
- `/home/chaim/.paperclip/instances/default/skills/$PAPERCLIP_COMPANY_ID/legal-docx/SKILL.md`
|
||||
- `/home/chaim/.paperclip/instances/default/skills/$PAPERCLIP_COMPANY_ID/legal-docx/references/document-types.md`
|
||||
|
||||
**סקריפט ייצוא:**
|
||||
- `/home/chaim/.paperclip/instances/default/skills/42a7acd0-30c5-4cbd-ac97-7424f65df294/legal-docx/scripts/create-legal-doc.js`
|
||||
- `/home/chaim/.paperclip/instances/default/skills/$PAPERCLIP_COMPANY_ID/legal-docx/scripts/create-legal-doc.js`
|
||||
|
||||
**תבנית:**
|
||||
- `/home/chaim/.paperclip/instances/default/skills/42a7acd0-30c5-4cbd-ac97-7424f65df294/legal-docx/references/docx template.docx`
|
||||
- `/home/chaim/.paperclip/instances/default/skills/$PAPERCLIP_COMPANY_ID/legal-docx/references/docx template.docx`
|
||||
|
||||
## תהליך עבודה
|
||||
|
||||
@@ -102,12 +103,13 @@ tools:
|
||||
|
||||
### שלב 4: שמירה מגורסת
|
||||
1. צור תיקייה `~/legal-ai/data/cases/{מספר-ערר}/exports/` (אם לא קיימת)
|
||||
2. בדוק כמה טיוטות כבר קיימות בתיקייה (קבצים שמתחילים ב-`טיוטה-V`)
|
||||
3. שמור כ-`טיוטה-V{N}.docx` כאשר N = המספר הבא בתור
|
||||
- אם אין טיוטות: `טיוטה-V1.docx`
|
||||
- אם יש V1: `טיוטה-V2.docx`
|
||||
2. בדוק כמה טיוטות כבר קיימות בתיקייה (קבצים שמתחילים ב-`טיוטה-v`)
|
||||
3. שמור כ-`טיוטה-v{N}.docx` כאשר N = המספר הבא בתור
|
||||
- אם אין טיוטות: `טיוטה-v1.docx`
|
||||
- אם יש v1: `טיוטה-v2.docx`
|
||||
- וכן הלאה
|
||||
4. ודא שהקובץ נוצר ושגודלו סביר
|
||||
5. עדכן סטטוס תיק ל-`exported` דרך `case_update(case_number, {"status": "exported"})`
|
||||
|
||||
### שלב 5: דיווח
|
||||
דווח למשתמש:
|
||||
@@ -122,19 +124,11 @@ tools:
|
||||
|
||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||
```bash
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "done"}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||
|
||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
||||
```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"}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
@@ -146,17 +140,13 @@ else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
fi
|
||||
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/$CEO_ID/wakeup" \
|
||||
-d '{"source":"automation","triggerDetail":"system","reason":"מייצא טיוטה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"מייצא טיוטה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||
|
||||
## כללים קריטיים
|
||||
|
||||
1. **לעולם אל תייצא בלי בדיקה** — תמיד הרץ validate_decision קודם
|
||||
2. **לא לדרוס טיוטות קודמות** — תמיד גרסה חדשה (V1, V2, V3...)
|
||||
3. **שמות קבצים בעברית** — `טיוטה-V1.docx`, לא `draft-V1.docx`
|
||||
2. **לא לדרוס טיוטות קודמות** — תמיד גרסה חדשה (v1, v2, v3...)
|
||||
3. **שמות קבצים בעברית** — `טיוטה-v1.docx`, לא `draft-v1.docx`
|
||||
4. **קרא את הסקייל** — לפני כל ייצוא, קרא את legal-docx SKILL.md
|
||||
|
||||
@@ -92,19 +92,11 @@ tools:
|
||||
|
||||
**אם הכל עבר בהצלחה:**
|
||||
```bash
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "done"}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "done"}'```
|
||||
|
||||
**אם נכשלו תיקונים קריטיים או יש markers `[?]` רבים:**
|
||||
```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"}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "blocked"}'```
|
||||
**אסור** לסיים `done` עם פלט חסר — אם נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
@@ -117,10 +109,6 @@ else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
fi
|
||||
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/$CEO_ID/wakeup" \
|
||||
-d '{"source":"automation","triggerDetail":"system","reason":"מגיה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"מגיה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||
|
||||
@@ -79,6 +79,31 @@ tools:
|
||||
- סעיפים 1, 2, 3... ללא איפוס בין בלוקים
|
||||
- ללא כפילויות במספור
|
||||
|
||||
### 7א. שלמות חיפוש בקורפוסים (corpus_queries_logged) — critical
|
||||
|
||||
ה-analyst וה-researcher חייבים לתעד queries לקורפוסים שלהם. בלי תיעוד — אין דרך לוודא שתקדימי עליון רלוונטיים לא הוחמצו.
|
||||
|
||||
**שיטת בדיקה:** grep ידני — קרא את קבצי המחקר וחפש בהם את הסעיפים הנ"ל. `validate_decision` **לא** בודק זאת אוטומטית. הצלבה עם MCP (סעיף 4 למטה) היא אופציונלית ומשלימה.
|
||||
|
||||
בדוק:
|
||||
1. **קיום סעיף "שאילתות לקורפוסים"**:
|
||||
- ב-`{case_dir}/documents/research/analysis-and-research.md` — סעיף **7א** (לפי שלב 5ד של ה-analyst)
|
||||
- ב-`{case_dir}/documents/research/precedent-research.md` — סעיף **ז** (לפי שלב 2ב.4 של ה-researcher)
|
||||
- אם חסר באחד מהם — `corpus_queries_logged = fail` (critical, חוסם המשך).
|
||||
|
||||
2. **מספר queries מינימלי לקורפוס הסמכותי (`search_precedent_library`):**
|
||||
- `analyst >= (מספר טענות סף + מספר סוגיות מרכזיות)`
|
||||
- `researcher >= מספר סוגיות מרכזיות`
|
||||
- חישוב: ספור את הסוגיות בסעיף 6 של `analysis-and-research.md`. מתחת לסף → `fail`.
|
||||
|
||||
3. **negative evidence מתועד:** גם 0-result query חייבת להופיע. אם מצאת queries שכולן 0-result — לא fail; פשוט תיעוד שהקורפוס דליל בנושא.
|
||||
|
||||
4. **אצליבה הצלבה (cross-check):**
|
||||
- הרץ `mcp__legal-ai__precedent_library_list(practice_area=X, search="<keyword מרכזי מהתיק>")` עם practice_area של התיק.
|
||||
- אם החזיר תוצאות שלא מופיעות בסעיף "נבחרו" או "נדחו" של ה-analyst/researcher → `corpus_queries_logged = warning` (לא חוסם, אבל דווח לחיים).
|
||||
|
||||
חומרה: **critical** — בלי queries מתועדות אין דרך לאמת שלא הוחמצה הלכה מחייבת.
|
||||
|
||||
### 7. עמידה במתודולוגיה (methodology_compliance)
|
||||
ראה `docs/decision-methodology.md` לעקרונות המלאים. בדוק:
|
||||
- לכל סוגיה בבלוק י — ניתן לזהות מבנה סילוגיסטי: כלל + עובדות + מסקנה?
|
||||
@@ -137,6 +162,7 @@ tools:
|
||||
| משקלות | warning | מדווח, לא חוסם |
|
||||
| כפילות | warning | מדווח, לא חוסם |
|
||||
| מספור | warning | מדווח, לא חוסם |
|
||||
| **שאילתות לקורפוסים** | **critical** | **חוסם ייצוא** |
|
||||
| מתודולוגיה | critical | חוסם ייצוא |
|
||||
| **קול דפנה** | **critical** | **חוסם ייצוא** |
|
||||
|
||||
@@ -173,19 +199,11 @@ tools:
|
||||
|
||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||
```bash
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "done"}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||
|
||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
||||
```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"}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
@@ -197,10 +215,6 @@ else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
fi
|
||||
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/$CEO_ID/wakeup" \
|
||||
-d '{"source":"automation","triggerDetail":"system","reason":"בודק איכות סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"בודק איכות סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||
|
||||
@@ -14,6 +14,7 @@ tools:
|
||||
- mcp__legal-ai__document_get_text
|
||||
- mcp__legal-ai__search_case_documents
|
||||
- mcp__legal-ai__search_decisions
|
||||
- mcp__legal-ai__search_internal_decisions
|
||||
- mcp__legal-ai__find_similar_cases
|
||||
- mcp__legal-ai__extract_references
|
||||
- mcp__legal-ai__precedent_attach
|
||||
@@ -30,6 +31,8 @@ tools:
|
||||
- mcp__legal-ai__workflow_status
|
||||
---
|
||||
|
||||
> ראה גם: [HEARTBEAT.md](HEARTBEAT.md) לכללי הפעלה כלליים — routing, company filtering, wakeup API
|
||||
|
||||
# חוקר תקדימים — סוכן מחקר משפטי
|
||||
|
||||
אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים.
|
||||
@@ -85,22 +88,98 @@ tools:
|
||||
- **האם זה תקדם מהקאנון של דפנה?** (בדוק `docs/daphna-precedent-network.md` — אם כן, ציין שזה התקדם המועדף שלה לסוגיה)
|
||||
4. הפק הפניות (`extract_references`)
|
||||
|
||||
### שלב 2ב: בדיקה מצטלבת מול הקאנון של דפנה
|
||||
אחרי שאספת את הפסיקה הרלוונטית בתיק:
|
||||
1. **לכל סוגיה משפטית** בתיק — בדוק ב-`daphna-precedent-network.md`:
|
||||
- האם יש תקדם מועדף של דפנה לסוגיה?
|
||||
- האם הוא הוצג בכתבי הטענות? אם לא — סמן כתקדם שיש להוסיף
|
||||
2. **תקדמים אישיים**: `search_decisions` בקטגוריה זהה לתיק. אם דפנה כבר הכריעה בסוגיה דומה:
|
||||
- אם תוצאה דומה: תקדם לחיסכון דוקטרינרי ("כפי שקבענו ב-X")
|
||||
- אם תוצאה הפוכה: ציין כי **חובה** הבחנה (distinguishing)
|
||||
3. **קורפוס פסיקה סמכותית**: `search_precedent_library` — חיפוש סמנטי בהלכות שאושרו ע"י דפנה (פסיקת עליון/מנהלי/ועדות ערר אחרות). מחזיר rule_statement + supporting_quote + citation מוכנים לציטוט בבלוק י. אם הצדדים הפנו לפסק דין שלא בקורפוס — הוסף אותו דרך `precedent_attach` (לתיק) או דרך ממשק ההעלאה ב-`/precedents` (לקורפוס הקבוע).
|
||||
4. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||
### שלב 2ב: חיפוש מובנה בשלושת הקורפוסים — חובה, עם תיעוד queries
|
||||
|
||||
**שלושת המקורות — אל תבלבל:**
|
||||
- `search_decisions` = החלטות דפנה (style_corpus).
|
||||
- `search_precedent_library` = פסיקה חיצונית סמכותית עם הלכות מאושרות.
|
||||
**חובה לבצע** — לא הצעה. הניתוח קודם הראה (ערר 1200-25) שאם הקורפוס לא נסרק במפורש, מפספסים תקדימי עליון רלוונטיים שיושבים בו. ה-QA יחזיר `needs_revision` אם סעיף ה-queries חסר.
|
||||
|
||||
**שלושת הקורפוסים — אל תבלבל:**
|
||||
- `search_precedent_library` = פסיקה חיצונית סמכותית עם הלכות מאושרות (עליון/מנהלי/ועדות ערר אחרות) + supporting_quote מוכן.
|
||||
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
||||
- `precedent_search_library` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
||||
|
||||
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
||||
|
||||
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
|
||||
|
||||
| סיווג תיק | practice_area |
|
||||
|------------|---------------|
|
||||
| 1xxx (רישוי ובניה) | `rishuy_uvniya` |
|
||||
| 8xxx (היטל השבחה) | `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ב.2א — ועדות ערר אחרות (`search_internal_decisions`) — לפי שיקול דעת
|
||||
|
||||
**ההבדל מ-`search_decisions`:** `search_decisions` מחפש **רק בהחלטות של דפנה**. `search_internal_decisions` מחפש בהחלטות **כל ועדות הערר** בכל המחוזות (ירושלים, מרכז, תל אביב, צפון, דרום, ארצי).
|
||||
|
||||
**מתי להשתמש:**
|
||||
- כשהסוגיה היא חדשנית ודפנה לא הכריעה בה → בדוק אם ועדת ערר אחרת כבר הכריעה
|
||||
- כשרוצים לבדוק האם יש גישות שונות בין מחוזות (ועדות ערר שונות)
|
||||
- **אל תשתמש** אם `search_decisions` כבר מצא את התשובה — אין צורך לחפש פעמיים
|
||||
|
||||
```
|
||||
search_internal_decisions(
|
||||
query="...",
|
||||
practice_area="histael_hashbacha", # rishuy_uvniya / betterment_levy / compensation_197
|
||||
district="ירושלים", # ריק = כל המחוזות
|
||||
chair_name="", # ריק = כל היו"רים; "דפנה תמיר" = דפנה בלבד (שווה ל-search_decisions)
|
||||
limit=5
|
||||
)
|
||||
```
|
||||
|
||||
⚠️ **שים לב להיררכיה:** החלטת ועדת ערר נמוכה מבית משפט מחוזי. אל תציג ועדת ערר אחרת כ"הלכה מחייבת".
|
||||
|
||||
#### 2ב.3 — בדיקה מצטלבת מול `daphna-precedent-network.md`
|
||||
|
||||
לכל סוגיה — בדוק במסמך:
|
||||
- האם יש תקדם מועדף של דפנה?
|
||||
- האם הוצג בכתבי הטענות? אם לא — סמן כתקדם שיש להוסיף.
|
||||
|
||||
#### 2ב.4 — תיעוד מחייב — סעיף "שאילתות לקורפוסים" ב-`precedent-research.md`
|
||||
|
||||
חובה להופיע סעיף בשם **"ז. שאילתות לקורפוסים — log מלא"** עם:
|
||||
|
||||
```markdown
|
||||
## ז. שאילתות לקורפוסים — log מלא
|
||||
|
||||
### קורפוס סמכותי (search_precedent_library)
|
||||
|
||||
#### Q1 — סוגיה: [שם]
|
||||
- **שאילתה:** "..."
|
||||
- **פילטרים:** practice_area=..., appeal_subtype=...
|
||||
- **תוצאות:** N
|
||||
- **נבחרו:** [case_number] — headnote/למה רלוונטי
|
||||
- **נדחו:** [case_number] — למה לא
|
||||
- **0 results?** ציין מפורש + נמק
|
||||
|
||||
#### Q2 — ...
|
||||
|
||||
### קאנון דפנה (search_decisions)
|
||||
#### Q1 — ...
|
||||
```
|
||||
|
||||
**negative evidence חובה:** גם 0 results נרשם. זה ההבדל בין "נסרק וריק" ל"לא נסרק".
|
||||
|
||||
**מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו.
|
||||
|
||||
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||
|
||||
### שלב 3: מיפוי תכנית
|
||||
1. קרא הוראות התכנית **במלואן** — לא רק את הסעיף הנטען
|
||||
2. זהה סעיפים רלוונטיים למחלוקת
|
||||
@@ -158,19 +237,11 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
|
||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||
```bash
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "done"}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||
|
||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
||||
```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"}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
@@ -182,11 +253,7 @@ else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
fi
|
||||
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/$CEO_ID/wakeup" \
|
||||
-d '{"source":"automation","triggerDetail":"system","reason":"חוקר תקדימים סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"חוקר תקדימים סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||
|
||||
|
||||
@@ -210,19 +210,11 @@ case_update(case_number, status="drafted")
|
||||
|
||||
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
|
||||
```bash
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "done"}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
|
||||
|
||||
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
|
||||
```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"}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
|
||||
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
@@ -234,11 +226,7 @@ else
|
||||
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
|
||||
fi
|
||||
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/$CEO_ID/wakeup" \
|
||||
-d '{"source":"automation","triggerDetail":"system","reason":"כותב החלטה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
||||
```
|
||||
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"כותב החלטה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
**⚠️ אסור לקבע 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/exports/
|
||||
data/backups/
|
||||
data/precedent-library/
|
||||
data/.auto-sync.log
|
||||
data/*.db
|
||||
*.bak-pre-*
|
||||
mcp-server/.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"migrationNoticeShown": true
|
||||
"migrationNoticeShown": true,
|
||||
"currentTag": "legal-ai",
|
||||
"lastSwitched": "2026-05-03T20:31:48.957Z",
|
||||
"branchTagMapping": {}
|
||||
}
|
||||
@@ -1155,13 +1155,229 @@
|
||||
],
|
||||
"priority": "medium",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-05-03T10:19:15.134Z",
|
||||
"taskCount": 13,
|
||||
"completedCount": 12,
|
||||
"lastModified": "2026-05-04T17:29:25.687Z",
|
||||
"taskCount": 29,
|
||||
"completedCount": 24,
|
||||
"tags": [
|
||||
"legal-ai"
|
||||
]
|
||||
|
||||
85
CLAUDE.md
85
CLAUDE.md
@@ -48,6 +48,7 @@
|
||||
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
|
||||
| [`docs/product-specification.md`](docs/product-specification.md) | איפיון מוצר מלא — personas, תהליכים עסקיים, דרישות | להתמצאות עסקית/מוצרית |
|
||||
| [`docs/new-company-setup-guide.md`](docs/new-company-setup-guide.md) | מדריך הקמת חברה חדשה (CMPA) — skills, corpus, style analysis | לפני הוספת חברה/סוג ערר חדש |
|
||||
| [`skills/new-company-setup/SKILL.md`](skills/new-company-setup/SKILL.md) | **Blueprint טכני מלא להוספת חברה** — 11 שלבים מסודרים (companies, agents, runtime/adapter, skills, instructions, code, mappings) + checklist 10 מלכודות מ-Gap analysis #16-#28 | **חובה לפני הוספת חברה** (יותר actionable מ-doc) |
|
||||
| [`docs/audit-report.md`](docs/audit-report.md) | דוח audit של המערכת | רקע כללי |
|
||||
| [`docs/case-migration-tracker.md`](docs/case-migration-tracker.md) | מעקב מיגרציה של תיקים קיימים | לצורך מעקב |
|
||||
| [`docs/case-deletion-runbook.md`](docs/case-deletion-runbook.md) | runbook מלא למחיקת תיק — legal-ai DB + disk + Paperclip + Gitea, FK ordering, fallback ל-SQL ישיר | לפני reset שלם של תיק (מבחן, מחיקה בטעות) |
|
||||
@@ -55,6 +56,8 @@
|
||||
| [`docs/decision-block-mapping.md`](docs/decision-block-mapping.md) | מיפוי בלוקים להחלטות — איך 12 הבלוקים משתקפים ב-DOCX | להתמצאות במבנה |
|
||||
| [`docs/memory.md`](docs/memory.md) | הקשר כללי — skills, פרויקטים שהושלמו, מבנה vault | להתמצאות כללית |
|
||||
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
|
||||
| [`.claude/agents/HEARTBEAT.md`](.claude/agents/HEARTBEAT.md) | checklist הפעלת סוכן — routing, company filtering, quirks, wakeup עם UUID נכון | **לפני כל עבודה על סוכנים** |
|
||||
| [`skills/dafna-decision-template/SKILL.md`](skills/dafna-decision-template/SKILL.md) | export DOCX לפי styles של תבנית Word של דפנה — line classification, dash policy, placeholder handling | לפני export DOCX |
|
||||
|
||||
---
|
||||
|
||||
@@ -105,18 +108,34 @@
|
||||
├── skills/ ← כלי עבודה ומדריכים
|
||||
│ ├── decision/ מדריך סגנון + references + 12 בלוקים
|
||||
│ ├── assistant/ קטלוג מסמכים
|
||||
│ └── docx/ עיצוב DOCX
|
||||
│ ├── docx/ עיצוב DOCX
|
||||
│ ├── dafna-decision-template/ export DOCX לפי תבנית Word של דפנה
|
||||
│ └── new-company-setup/ blueprint הוספת חברה חדשה
|
||||
├── .claude/
|
||||
│ └── agents/ ← הוראות סוכנים + HEARTBEAT.md (symlinks ב-Paperclip)
|
||||
│ ├── HEARTBEAT.md checklist הפעלה משותף לכל הסוכנים
|
||||
│ ├── legal-ceo.md תזמורן + בקרת זרימה
|
||||
│ ├── legal-writer.md כתיבת בלוקים בסגנון דפנה
|
||||
│ ├── legal-analyst.md ניתוח משפטי + חילוץ טענות
|
||||
│ ├── legal-researcher.md חיפוש תקדימים
|
||||
│ ├── legal-qa.md 7 שערי איכות
|
||||
│ ├── legal-proofreader.md תיקון OCR
|
||||
│ ├── legal-exporter.md ייצוא DOCX סופי
|
||||
│ └── hermes-curator.md סוכן Hermes לניתוח סגנון post-export
|
||||
├── data/
|
||||
│ ├── training/ ← 4 החלטות לאימון (DOCX)
|
||||
│ ├── exports/ ← טיוטות DOCX מיוצאות
|
||||
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
|
||||
├── web/ ← FastAPI backend (Python): 75 API endpoints
|
||||
├── web/ ← FastAPI backend (Python): 75+ API endpoints
|
||||
│ ├── app.py ← API ראשי
|
||||
│ ├── paperclip_client.py ← אינטגרציית Paperclip
|
||||
│ ├── paperclip_api.py ← אינטגרציית Paperclip: `pc_request()` + `emit_case_status_webhook()`
|
||||
│ ├── paperclip_client.py ← legacy client (ישן — השתמש ב-paperclip_api.py)
|
||||
│ └── gitea_client.py ← אינטגרציית Gitea
|
||||
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
|
||||
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
|
||||
├── mcp-server/ ← MCP server + services + tools
|
||||
├── adapters/ ← Paperclip external adapters (ראה למטה)
|
||||
│ └── deepseek-paperclip-adapter/ ← `deepseek_local` (Hermes-pinned ל-DeepSeek profile)
|
||||
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
|
||||
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
|
||||
```
|
||||
@@ -160,6 +179,66 @@
|
||||
- ה-CEO קורא את ה-comment, מחליט על ניתוב, ויוצר issue לסוכן המתאים
|
||||
- כל הסוכנים חייבים לקרוא comments אחרונים לפני שהם מתחילים לעבוד (HEARTBEAT שלבים 2b-2c)
|
||||
|
||||
### קריאות API — תמיד דרך helper, לעולם לא `curl` ישיר
|
||||
- **bash (סוכנים):** `~/legal-ai/scripts/pc.sh <METHOD> <PATH> [BODY_JSON]` — מוסיף Authorization, X-Paperclip-Run-Id, Content-Type, base URL. ראה `HEARTBEAT.md §0`.
|
||||
- **Python (FastAPI):** `from web.paperclip_api import pc_request; await pc_request("POST", "/api/...", json={...})` — שימוש ב-board API key.
|
||||
- **אסור** `curl ... $PAPERCLIP_API_URL` ישיר ב-bash; **אסור** `httpx.AsyncClient` ישיר ל-Paperclip ב-Python.
|
||||
- **למה:** ה-skill הרשמי דורש `X-Paperclip-Run-Id` בכל קריאה משנה issue. אצלנו ה-audit trail עבד ממילא דרך JWT claims (`runId: runIdHeader || claims.run_id`), אבל ה-helper מבטיח עקביות + תאימות ל-board API keys (long-lived) שלא נושאות JWT claims.
|
||||
|
||||
### Cross-company agent sync — אחרי כל שינוי הגדרות
|
||||
- יש 14 סוכנים = 7 × 2 חברות (CMP=1xxx, CMPA=8xxx). Paperclip מחייב `agents.company_id NOT NULL` — אין shared agents.
|
||||
- **Master = CMP (1xxx)**, **Mirror = CMPA (8xxx)**.
|
||||
- אחרי כל שינוי ב-`adapter_config`, `runtime_config`, `budget_monthly_cents`, או skills של סוכן ב-master (UI, SQL, או API), **חובה להריץ:**
|
||||
```bash
|
||||
PAPERCLIP_BOARD_API_KEY=$(...infisical...) \
|
||||
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify # לבדיקה
|
||||
PAPERCLIP_BOARD_API_KEY=$(...) \
|
||||
python ~/legal-ai/scripts/sync_agents_across_companies.py --apply # לסנכרן
|
||||
```
|
||||
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
|
||||
- שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`.
|
||||
|
||||
### Webhook יוצא — עדכון סטטוס תיק לפלאגין
|
||||
|
||||
כשסטטוס תיק משתנה דרך `PUT /api/cases/{case_number}`, הבקאנד שולח webhook אסינכרוני לפלאגין:
|
||||
|
||||
```
|
||||
PUT /api/cases/{case_number} → emit_case_status_webhook() [BackgroundTask]
|
||||
→ POST /api/plugins/marcusgroup.legal-ai/webhooks/case-status
|
||||
→ plugin-legal-ai/onWebhook()
|
||||
→ comment בעברית על issue + CEO wakeup (כשסטטוס = qa_failed)
|
||||
```
|
||||
|
||||
- הקוד ב-`web/paperclip_api.py` (`emit_case_status_webhook`), fire-and-forget, timeout 5s
|
||||
- הפלאגין שומר idempotency key ב-state עם TTL 5 דקות למניעת spam על retry
|
||||
- `GET /api/cases/stale?days=N` — תיקים שלא עודכנו N ימים; מוחרגים: `new`, `final`, `exported`
|
||||
- `GET /api/chair-feedback/weekly-summary` — סיכום פידבק YU"R לשבוע האחרון
|
||||
|
||||
### Scheduled Jobs (plugin-legal-ai)
|
||||
|
||||
| Job | לוח זמנים | מה עושה |
|
||||
|-----|-----------|---------|
|
||||
| `stale-case-reminder` | יומי 08:00 | שולח comment אזהרה על תיקים תקועים >3 ימים |
|
||||
| `weekly-feedback-analysis` | ראשון 19:00 | מעיר CEO לניתוח פידבק YU"R ועדכון `docs/legal-decision-lessons.md` |
|
||||
| `sync-case-status` | כל 30 דק' | מסנכרן סטטוסי תיקים בין legal-ai ל-Paperclip |
|
||||
|
||||
CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **אין לו issueId, אל תנסה לפרסם comment או לסגור issue**.
|
||||
|
||||
### External adapters — `deepseek_local`
|
||||
- מיקום ה-package: [adapters/deepseek-paperclip-adapter/](adapters/deepseek-paperclip-adapter/) (לא ב-`node_modules`).
|
||||
- רישום ב-Paperclip: רשומה ב-`~/.paperclip/adapter-plugins.json` (נטען אוטומטית ב-startup דרך `buildExternalAdapters`). אין צורך בעריכת `node_modules`.
|
||||
- **מה ה-adapter עושה**: spawnל-`hermes chat` עם `HERMES_HOME=/home/chaim/.hermes/profiles/deepseek` כך שה-CLI טוען את `config.yaml` (`base_url=https://api.deepseek.com/v1`, `provider=custom`, `key_env=DEEPSEEK_API_KEY`) ואת `.env` (שמכיל את ה-key).
|
||||
- **מודלים זמינים** (lookup ב-DeepSeek `/v1/models`): `deepseek-v4-pro` (default), `deepseek-v4-flash`. יופיעו כדרופ-דאון ב-UI.
|
||||
- **התקנה מחדש / עדכון**: `curl -X POST -H "Authorization: Bearer pcapi_legal_install_key_2026" -H "Content-Type: application/json" -d '{"packageName":"/home/chaim/legal-ai/adapters/deepseek-paperclip-adapter","isLocalPath":true}' http://localhost:3100/api/adapters/install`. לעדכון hot — `POST /api/adapters/deepseek_local/reload`.
|
||||
- **⚠ Cross-company sync**: `sync_agents_across_companies.py` **מדלג** על סוכנים עם `adapter_type` שונה בין CMP ל-CMPA. כשעוברים סוכן ל-`deepseek_local` חובה להחיל ידנית בשתי החברות לפני sync.
|
||||
- **תוספת adapters עתידיים** (OpenAI ישיר, Anthropic ישיר, וכו'): אותו דפוס. ה-package הראשי חייב לייצא `createServerAdapter()` שמחזיר `{ type, label, models, agentConfigurationDoc, execute, testEnvironment, sessionCodec, listSkills, syncSkills, ... }`. ראה את [adapters/deepseek-paperclip-adapter/dist/index.js](adapters/deepseek-paperclip-adapter/dist/index.js) כתבנית.
|
||||
|
||||
### External adapters — Hermes Curator (`curator-cmp` / `curator-cmpa`)
|
||||
- פרופילי Hermes נפרדים לסוכן `hermes-curator` — מנתח החלטות סופיות ומציע עדכוני SKILL.md/lessons.md
|
||||
- מיקום: `~/.hermes/profiles/curator-cmp/` + `~/.hermes/profiles/curator-cmpa/`
|
||||
- מופעל אחרי export סופי; אינו מעדכן קבצים ישירות
|
||||
- **תהליך אישור הצעות:** הצעות ה-curator מגיעות כ-comment ב-Paperclip → חיים בוחן ומאשר ידנית → commits ל-`SKILL.md` ו-`docs/legal-decision-lessons.md`
|
||||
|
||||
---
|
||||
|
||||
## עקרונות כתיבה קריטיים
|
||||
|
||||
99
adapters/deepseek-paperclip-adapter/dist/index.js
vendored
Normal file
99
adapters/deepseek-paperclip-adapter/dist/index.js
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* DeepSeek (via Hermes) — external Paperclip adapter.
|
||||
*
|
||||
* Loaded by Paperclip's plugin-loader. Contract:
|
||||
* The package's main module must export createServerAdapter() returning
|
||||
* a single ServerAdapterModule object with all fields wired in.
|
||||
*
|
||||
* Runtime: spawns the local `hermes` CLI with HERMES_HOME pinned to a
|
||||
* DeepSeek profile that defines model.base_url=https://api.deepseek.com/v1
|
||||
* and model.key_env=DEEPSEEK_API_KEY.
|
||||
*/
|
||||
|
||||
import {
|
||||
ADAPTER_TYPE,
|
||||
ADAPTER_LABEL,
|
||||
DEEPSEEK_MODELS,
|
||||
DEFAULT_PROFILE_HOME,
|
||||
} from "./shared/constants.js";
|
||||
import { execute } from "./server/execute.js";
|
||||
import { testEnvironment } from "./server/test.js";
|
||||
import { sessionCodec } from "./server/session-codec.js";
|
||||
import { listSkills, syncSkills } from "./server/skills.js";
|
||||
|
||||
const AGENT_CONFIGURATION_DOC = `# DeepSeek (via Hermes) — Agent Configuration
|
||||
|
||||
DeepSeek-pinned variant of the Hermes adapter. Runs the local \`hermes\` CLI
|
||||
with \`HERMES_HOME\` pointed at a DeepSeek profile (\`config.yaml\` declares
|
||||
\`base_url=https://api.deepseek.com/v1\` and \`key_env=DEEPSEEK_API_KEY\`).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Hermes Agent installed (\`pip install hermes-agent\`) — \`hermes --version\` works.
|
||||
- DeepSeek profile dir exists (default: \`/home/chaim/.hermes/profiles/deepseek\`)
|
||||
with \`config.yaml\` + \`.env\` (containing \`DEEPSEEK_API_KEY\`).
|
||||
|
||||
## Core Configuration
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| model | string | \`deepseek-v4-pro\` | DeepSeek model id (\`deepseek-v4-pro\` or \`deepseek-v4-flash\`). |
|
||||
| provider | string | \`custom\` | Hermes provider name. The DeepSeek profile defines \`provider: custom\` so \`custom\` is the right value. |
|
||||
| hermesProfileHome | string | \`/home/chaim/.hermes/profiles/deepseek\` | Absolute path to a Hermes profile dir. Set per-agent if you maintain multiple DeepSeek profiles. |
|
||||
| timeoutSec | number | 1800 | Execution timeout in seconds. |
|
||||
| graceSec | number | 30 | SIGTERM grace period in seconds. |
|
||||
|
||||
## Tools / Workspace
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| toolsets | string | (profile default) | Comma-separated toolsets to enable. |
|
||||
| persistSession | boolean | true | Resume sessions across heartbeats via \`--resume\`. |
|
||||
| worktreeMode | boolean | false | Use git worktree for isolated changes. |
|
||||
| checkpoints | boolean | false | Enable filesystem checkpoints. |
|
||||
|
||||
## Advanced
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| hermesCommand | string | \`hermes\` | Path to the hermes binary. |
|
||||
| verbose | boolean | false | Enable verbose Hermes logs. |
|
||||
| extraArgs | string[] | [] | Extra CLI args appended after standard flags. |
|
||||
| env | object | {} | Extra environment variables passed to Hermes. \`HERMES_HOME\` here overrides \`hermesProfileHome\`. |
|
||||
| promptTemplate | string | (default) | Override the default Paperclip wakeup prompt. |
|
||||
| paperclipApiUrl | string | \`http://127.0.0.1:3100/api\` | Paperclip API URL injected into the prompt template. |
|
||||
|
||||
## Available template variables
|
||||
|
||||
\`{{agentId}}\`, \`{{agentName}}\`, \`{{companyId}}\`, \`{{companyName}}\`,
|
||||
\`{{runId}}\`, \`{{taskId}}\`, \`{{taskTitle}}\`, \`{{taskBody}}\`,
|
||||
\`{{commentId}}\`, \`{{wakeReason}}\`, \`{{projectName}}\`, \`{{paperclipApiUrl}}\`.
|
||||
`;
|
||||
|
||||
export function createServerAdapter() {
|
||||
return {
|
||||
type: ADAPTER_TYPE,
|
||||
label: ADAPTER_LABEL,
|
||||
models: DEEPSEEK_MODELS,
|
||||
agentConfigurationDoc: AGENT_CONFIGURATION_DOC,
|
||||
|
||||
execute,
|
||||
testEnvironment,
|
||||
sessionCodec,
|
||||
listSkills,
|
||||
syncSkills,
|
||||
|
||||
// Capability flags
|
||||
supportsLocalAgentJwt: true,
|
||||
supportsInstructionsBundle: false,
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Also export the loose constants for any caller that wants to inspect
|
||||
// the package without invoking createServerAdapter (e.g., test harnesses).
|
||||
export const type = ADAPTER_TYPE;
|
||||
export const label = ADAPTER_LABEL;
|
||||
export const models = DEEPSEEK_MODELS;
|
||||
export const agentConfigurationDoc = AGENT_CONFIGURATION_DOC;
|
||||
export const defaultProfileHome = DEFAULT_PROFILE_HOME;
|
||||
352
adapters/deepseek-paperclip-adapter/dist/server/execute.js
vendored
Normal file
352
adapters/deepseek-paperclip-adapter/dist/server/execute.js
vendored
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Server-side execution for the DeepSeek-via-Hermes adapter.
|
||||
*
|
||||
* Spawns `hermes chat -q "..." -Q -m <model> --provider custom` with
|
||||
* HERMES_HOME pinned to a DeepSeek-configured profile so the same machine
|
||||
* can run other Hermes-based agents on different providers in parallel.
|
||||
*
|
||||
* The Hermes CLI loads model.base_url, model.key_env (DEEPSEEK_API_KEY),
|
||||
* and toolsets from <HERMES_HOME>/config.yaml + <HERMES_HOME>/.env.
|
||||
*/
|
||||
|
||||
import {
|
||||
runChildProcess,
|
||||
buildPaperclipEnv,
|
||||
renderTemplate,
|
||||
ensureAbsoluteDirectory,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
HERMES_CLI,
|
||||
DEFAULT_PROFILE_HOME,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_PROVIDER,
|
||||
DEFAULT_TIMEOUT_SEC,
|
||||
DEFAULT_GRACE_SEC,
|
||||
SESSION_ID_REGEX,
|
||||
SESSION_ID_REGEX_LEGACY,
|
||||
TOKEN_USAGE_REGEX,
|
||||
COST_REGEX,
|
||||
} from "../shared/constants.js";
|
||||
|
||||
function cfgString(v) {
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
}
|
||||
function cfgNumber(v) {
|
||||
return typeof v === "number" ? v : undefined;
|
||||
}
|
||||
function cfgBoolean(v) {
|
||||
return typeof v === "boolean" ? v : undefined;
|
||||
}
|
||||
function cfgStringArray(v) {
|
||||
return Array.isArray(v) && v.every((i) => typeof i === "string") ? v : undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_PROMPT_TEMPLATE = `You are "{{agentName}}", an AI agent employee in a Paperclip-managed company powered by DeepSeek.
|
||||
|
||||
IMPORTANT: Use the \`terminal\` tool with \`curl\` for ALL Paperclip API calls (web_extract and browser cannot access localhost).
|
||||
|
||||
Your Paperclip identity:
|
||||
Agent ID: {{agentId}}
|
||||
Company ID: {{companyId}}
|
||||
API Base: {{paperclipApiUrl}}
|
||||
|
||||
{{#taskId}}
|
||||
## Assigned Task
|
||||
|
||||
Issue ID: {{taskId}}
|
||||
Title: {{taskTitle}}
|
||||
|
||||
{{taskBody}}
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Work on the task using your tools.
|
||||
2. When done, mark the issue completed:
|
||||
\`curl -s -X PATCH "{{paperclipApiUrl}}/issues/{{taskId}}" -H "Content-Type: application/json" -d '{"status":"done"}'\`
|
||||
3. Post a completion comment summarizing what you did:
|
||||
\`curl -s -X POST "{{paperclipApiUrl}}/issues/{{taskId}}/comments" -H "Content-Type: application/json" -d '{"body":"DONE: <your summary here>"}'\`
|
||||
{{/taskId}}
|
||||
|
||||
{{#commentId}}
|
||||
## Comment on This Issue
|
||||
|
||||
Someone commented. Read it:
|
||||
\`curl -s "{{paperclipApiUrl}}/issues/{{taskId}}/comments/{{commentId}}" | python3 -m json.tool\`
|
||||
Address the comment, POST a reply if needed, then continue working.
|
||||
{{/commentId}}
|
||||
|
||||
{{#noTask}}
|
||||
## Heartbeat Wake — Check for Work
|
||||
|
||||
1. List your open issues:
|
||||
\`curl -s "{{paperclipApiUrl}}/companies/{{companyId}}/issues?assigneeAgentId={{agentId}}"\`
|
||||
2. Pick the highest priority and work on it. When done, follow steps 2-3 above.
|
||||
3. If nothing to do, report briefly what you checked.
|
||||
{{/noTask}}`;
|
||||
|
||||
function buildPrompt(ctx, config) {
|
||||
const template = cfgString(config.promptTemplate) || DEFAULT_PROMPT_TEMPLATE;
|
||||
const taskId = cfgString(ctx.context?.taskId);
|
||||
const taskTitle = cfgString(ctx.context?.taskTitle) || "";
|
||||
const taskBody = cfgString(ctx.context?.taskBody) || "";
|
||||
const commentId = cfgString(ctx.context?.commentId) || "";
|
||||
const wakeReason = cfgString(ctx.context?.wakeReason) || "";
|
||||
const agentName = ctx.agent?.name || "DeepSeek Agent";
|
||||
const companyName = cfgString(ctx.context?.companyName) || "";
|
||||
const projectName = cfgString(ctx.context?.projectName) || "";
|
||||
|
||||
let paperclipApiUrl =
|
||||
cfgString(config.paperclipApiUrl) ||
|
||||
process.env.PAPERCLIP_API_URL ||
|
||||
"http://127.0.0.1:3100/api";
|
||||
if (!paperclipApiUrl.endsWith("/api")) {
|
||||
paperclipApiUrl = paperclipApiUrl.replace(/\/+$/, "") + "/api";
|
||||
}
|
||||
|
||||
const vars = {
|
||||
agentId: ctx.agent?.id || "",
|
||||
agentName,
|
||||
companyId: ctx.agent?.companyId || "",
|
||||
companyName,
|
||||
runId: ctx.runId || "",
|
||||
taskId: taskId || "",
|
||||
taskTitle,
|
||||
taskBody,
|
||||
commentId,
|
||||
wakeReason,
|
||||
projectName,
|
||||
paperclipApiUrl,
|
||||
};
|
||||
|
||||
let rendered = template;
|
||||
rendered = rendered.replace(/\{\{#taskId\}\}([\s\S]*?)\{\{\/taskId\}\}/g, taskId ? "$1" : "");
|
||||
rendered = rendered.replace(/\{\{#noTask\}\}([\s\S]*?)\{\{\/noTask\}\}/g, taskId ? "" : "$1");
|
||||
rendered = rendered.replace(/\{\{#commentId\}\}([\s\S]*?)\{\{\/commentId\}\}/g, commentId ? "$1" : "");
|
||||
return renderTemplate(rendered, vars);
|
||||
}
|
||||
|
||||
function cleanResponse(raw) {
|
||||
return raw
|
||||
.split("\n")
|
||||
.filter((line) => {
|
||||
const t = line.trim();
|
||||
if (!t) return true;
|
||||
if (t.startsWith("[tool]") || t.startsWith("[hermes]") || t.startsWith("[paperclip]") || t.startsWith("[deepseek]")) return false;
|
||||
if (t.startsWith("session_id:")) return false;
|
||||
if (/^\[\d{4}-\d{2}-\d{2}T/.test(t)) return false;
|
||||
if (/^\[done\]\s*┊/.test(t)) return false;
|
||||
if (/^┊\s*[\p{Emoji_Presentation}]/u.test(t) && !/^┊\s*💬/.test(t)) return false;
|
||||
if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(t)) return false;
|
||||
return true;
|
||||
})
|
||||
.map((line) => {
|
||||
let t = line.replace(/^[\s]*┊\s*💬\s*/, "").trim();
|
||||
t = t.replace(/^\[done\]\s*/, "").trim();
|
||||
return t;
|
||||
})
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseHermesOutput(stdout, stderr) {
|
||||
const combined = stdout + "\n" + stderr;
|
||||
const result = {};
|
||||
|
||||
const sessionMatch = stdout.match(SESSION_ID_REGEX);
|
||||
if (sessionMatch?.[1]) {
|
||||
result.sessionId = sessionMatch[1];
|
||||
const sessionLineIdx = stdout.lastIndexOf("\nsession_id:");
|
||||
if (sessionLineIdx > 0) {
|
||||
result.response = cleanResponse(stdout.slice(0, sessionLineIdx));
|
||||
}
|
||||
} else {
|
||||
const legacyMatch = combined.match(SESSION_ID_REGEX_LEGACY);
|
||||
if (legacyMatch?.[1]) result.sessionId = legacyMatch[1];
|
||||
const cleaned = cleanResponse(stdout);
|
||||
if (cleaned.length > 0) result.response = cleaned;
|
||||
}
|
||||
|
||||
const usageMatch = combined.match(TOKEN_USAGE_REGEX);
|
||||
if (usageMatch) {
|
||||
result.usage = {
|
||||
inputTokens: parseInt(usageMatch[1], 10) || 0,
|
||||
outputTokens: parseInt(usageMatch[2], 10) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
const costMatch = combined.match(COST_REGEX);
|
||||
if (costMatch?.[1]) result.costUsd = parseFloat(costMatch[1]);
|
||||
|
||||
if (stderr.trim()) {
|
||||
const errorLines = stderr
|
||||
.split("\n")
|
||||
.filter((line) => /error|exception|traceback|failed/i.test(line))
|
||||
.filter((line) => !/INFO|DEBUG|warn/i.test(line));
|
||||
if (errorLines.length > 0) result.errorMessage = errorLines.slice(0, 5).join("\n");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function execute(ctx) {
|
||||
const config = ctx.agent?.adapterConfig ?? {};
|
||||
|
||||
const hermesCmd = cfgString(config.hermesCommand) || HERMES_CLI;
|
||||
const model = cfgString(config.model) || DEFAULT_MODEL;
|
||||
const provider = cfgString(config.provider) || DEFAULT_PROVIDER;
|
||||
const profileHome = cfgString(config.hermesProfileHome) || DEFAULT_PROFILE_HOME;
|
||||
const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC;
|
||||
const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC;
|
||||
const toolsets = cfgString(config.toolsets) || cfgStringArray(config.enabledToolsets)?.join(",");
|
||||
const extraArgs = cfgStringArray(config.extraArgs);
|
||||
const persistSession = cfgBoolean(config.persistSession) !== false;
|
||||
const worktreeMode = cfgBoolean(config.worktreeMode) === true;
|
||||
const checkpoints = cfgBoolean(config.checkpoints) === true;
|
||||
const useQuiet = cfgBoolean(config.quiet) !== false;
|
||||
|
||||
const prompt = buildPrompt(ctx, config);
|
||||
|
||||
const args = ["chat", "-q", prompt];
|
||||
if (useQuiet) args.push("-Q");
|
||||
if (model) args.push("-m", model);
|
||||
args.push("--provider", provider);
|
||||
if (toolsets) args.push("-t", toolsets);
|
||||
if (worktreeMode) args.push("-w");
|
||||
if (checkpoints) args.push("--checkpoints");
|
||||
if (cfgBoolean(config.verbose) === true) args.push("-v");
|
||||
args.push("--source", "tool");
|
||||
args.push("--yolo");
|
||||
|
||||
const prevSessionId = cfgString(ctx.runtime?.sessionParams?.sessionId);
|
||||
if (persistSession && prevSessionId) args.push("--resume", prevSessionId);
|
||||
if (extraArgs?.length) args.push(...extraArgs);
|
||||
|
||||
// Pin Hermes to the DeepSeek profile by default. The agent can override
|
||||
// by setting adapter_config.hermesProfileHome or adapter_config.env.HERMES_HOME.
|
||||
const env = {
|
||||
...process.env,
|
||||
...buildPaperclipEnv(ctx.agent),
|
||||
HERMES_HOME: profileHome,
|
||||
};
|
||||
if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId;
|
||||
const taskId = cfgString(ctx.context?.taskId);
|
||||
if (taskId) env.PAPERCLIP_TASK_ID = taskId;
|
||||
|
||||
// Parity with hermes_local (paperclip-src/server/src/adapters/registry.ts:267):
|
||||
// inject the per-run agent auth token so the agent can call the Paperclip API.
|
||||
// Without this, every Paperclip API write from the running agent fails with 401.
|
||||
//
|
||||
// Resolve env from the runtime-resolved config (ctx.config.env contains plain
|
||||
// strings — Paperclip's secrets service unwraps {type:"plain"|"secret_ref", ...}
|
||||
// bindings before invocation in services/heartbeat.ts:5433-5437).
|
||||
// Fall back to agent.adapterConfig.env with manual unwrapping for older paths.
|
||||
function unwrapEnvValue(v) {
|
||||
if (typeof v === "string") return v;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||
if (v.type === "plain" && typeof v.value === "string") return v.value;
|
||||
}
|
||||
return undefined; // skip secret_ref / unknown types — let resolver handle them
|
||||
}
|
||||
const resolvedUserEnv =
|
||||
ctx.config && typeof ctx.config === "object" && ctx.config.env && typeof ctx.config.env === "object" && !Array.isArray(ctx.config.env)
|
||||
? ctx.config.env
|
||||
: null;
|
||||
const rawUserEnv =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? config.env
|
||||
: {};
|
||||
// Prefer pre-resolved values from ctx.config.env when available; fall back to
|
||||
// unwrapping raw bindings from agent.adapterConfig.env.
|
||||
const flattenedUserEnv = {};
|
||||
for (const [k, v] of Object.entries(rawUserEnv)) {
|
||||
const resolved = resolvedUserEnv && typeof resolvedUserEnv[k] === "string" ? resolvedUserEnv[k] : unwrapEnvValue(v);
|
||||
if (typeof resolved === "string") flattenedUserEnv[k] = resolved;
|
||||
}
|
||||
const userEnvApiKey = flattenedUserEnv.PAPERCLIP_API_KEY;
|
||||
const explicitApiKey =
|
||||
typeof userEnvApiKey === "string" && userEnvApiKey.trim().length > 0;
|
||||
if (ctx.authToken && !explicitApiKey) env.PAPERCLIP_API_KEY = ctx.authToken;
|
||||
|
||||
// Apply unwrapped user env (may override HERMES_HOME, OPENAI_API_KEY, etc.).
|
||||
Object.assign(env, flattenedUserEnv);
|
||||
|
||||
const cwd = cfgString(config.cwd) || cfgString(ctx.config?.workspaceDir) || ".";
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[deepseek] Starting Hermes (model=${model}, provider=${provider}, profileHome=${env.HERMES_HOME}, timeout=${timeoutSec}s)\n`,
|
||||
);
|
||||
if (prevSessionId) {
|
||||
await ctx.onLog("stdout", `[deepseek] Resuming session: ${prevSessionId}\n`);
|
||||
}
|
||||
|
||||
// Reclassify benign Hermes stderr lines as stdout so the UI doesn't paint them red.
|
||||
const wrappedOnLog = async (stream, chunk) => {
|
||||
if (stream === "stderr") {
|
||||
const trimmed = chunk.trimEnd();
|
||||
const isBenign =
|
||||
/^\[?\d{4}[-/]\d{2}[-/]\d{2}T/.test(trimmed) ||
|
||||
/^[A-Z]+:\s+(INFO|DEBUG|WARN|WARNING)\b/.test(trimmed) ||
|
||||
/Successfully registered all tools/.test(trimmed) ||
|
||||
/MCP [Ss]erver/.test(trimmed) ||
|
||||
/tool registered successfully/.test(trimmed) ||
|
||||
/Application initialized/.test(trimmed);
|
||||
if (isBenign) return ctx.onLog("stdout", chunk);
|
||||
}
|
||||
return ctx.onLog(stream, chunk);
|
||||
};
|
||||
|
||||
// Forward ctx.onSpawn so Paperclip persists processPid/processGroupId to the
|
||||
// heartbeat_runs row. Without it, the reaper cannot verify the child is alive
|
||||
// (run.processPid is null) and treats the run as orphaned during long quiet
|
||||
// phases (DeepSeek V4-Pro thinking can be silent for 60-90s per turn).
|
||||
const result = await runChildProcess(ctx.runId, hermesCmd, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog: wrappedOnLog,
|
||||
onSpawn: ctx.onSpawn,
|
||||
});
|
||||
|
||||
const parsed = parseHermesOutput(result.stdout || "", result.stderr || "");
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[deepseek] Exit code: ${result.exitCode ?? "null"}, timed out: ${result.timedOut}\n`,
|
||||
);
|
||||
if (parsed.sessionId) {
|
||||
await ctx.onLog("stdout", `[deepseek] Session: ${parsed.sessionId}\n`);
|
||||
}
|
||||
|
||||
const executionResult = {
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
provider,
|
||||
model,
|
||||
};
|
||||
if (parsed.errorMessage) executionResult.errorMessage = parsed.errorMessage;
|
||||
if (parsed.usage) executionResult.usage = parsed.usage;
|
||||
if (parsed.costUsd !== undefined) executionResult.costUsd = parsed.costUsd;
|
||||
if (parsed.response) executionResult.summary = parsed.response.slice(0, 2000);
|
||||
|
||||
executionResult.resultJson = {
|
||||
result: parsed.response || "",
|
||||
session_id: parsed.sessionId || null,
|
||||
usage: parsed.usage || null,
|
||||
cost_usd: parsed.costUsd ?? null,
|
||||
};
|
||||
|
||||
if (persistSession && parsed.sessionId) {
|
||||
executionResult.sessionParams = { sessionId: parsed.sessionId };
|
||||
executionResult.sessionDisplayId = parsed.sessionId.slice(0, 16);
|
||||
}
|
||||
|
||||
return executionResult;
|
||||
}
|
||||
29
adapters/deepseek-paperclip-adapter/dist/server/session-codec.js
vendored
Normal file
29
adapters/deepseek-paperclip-adapter/dist/server/session-codec.js
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Session codec — Hermes uses a single sessionId for cross-heartbeat continuity
|
||||
* via the --resume CLI flag. Same shape as the Hermes adapter.
|
||||
*/
|
||||
|
||||
function readNonEmptyString(value) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec = {
|
||||
deserialize(raw) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(raw.sessionId) ?? readNonEmptyString(raw.session_id);
|
||||
if (!sessionId) return null;
|
||||
return { sessionId };
|
||||
},
|
||||
serialize(params) {
|
||||
if (!params) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||
if (!sessionId) return null;
|
||||
return { sessionId };
|
||||
},
|
||||
getDisplayId(params) {
|
||||
if (!params) return null;
|
||||
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||
},
|
||||
};
|
||||
171
adapters/deepseek-paperclip-adapter/dist/server/skills.js
vendored
Normal file
171
adapters/deepseek-paperclip-adapter/dist/server/skills.js
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Skill snapshot for the DeepSeek-via-Hermes adapter.
|
||||
*
|
||||
* Hermes manages its own skills under ~/.hermes/skills/ (global; not per-profile).
|
||||
* Paperclip-managed skills declared in adapter config are surfaced as
|
||||
* "company_managed" entries — same behavior as the upstream Hermes adapter.
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { ADAPTER_TYPE } from "../shared/constants.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function asString(value) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function parseSkillFrontmatter(content) {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!match) return {};
|
||||
const fm = {};
|
||||
for (const line of match[1].split("\n")) {
|
||||
const idx = line.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
let val = line.slice(idx + 1).trim();
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
fm[key] = val;
|
||||
}
|
||||
return fm;
|
||||
}
|
||||
|
||||
async function buildSkillEntry(key, skillMdPath, categoryPath) {
|
||||
let description = null;
|
||||
try {
|
||||
const content = await fs.readFile(skillMdPath, "utf8");
|
||||
description = parseSkillFrontmatter(content).description ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {
|
||||
key,
|
||||
runtimeName: key,
|
||||
desired: true,
|
||||
managed: false,
|
||||
state: "installed",
|
||||
origin: "user_installed",
|
||||
originLabel: "Hermes skill",
|
||||
locationLabel: `~/.hermes/skills/${categoryPath}`,
|
||||
readOnly: true,
|
||||
sourcePath: skillMdPath,
|
||||
targetPath: null,
|
||||
detail: description,
|
||||
};
|
||||
}
|
||||
|
||||
async function scanHermesSkills(skillsHome) {
|
||||
const entries = [];
|
||||
try {
|
||||
const cats = await fs.readdir(skillsHome, { withFileTypes: true });
|
||||
for (const cat of cats) {
|
||||
if (!cat.isDirectory()) continue;
|
||||
const catPath = path.join(skillsHome, cat.name);
|
||||
const topSkill = path.join(catPath, "SKILL.md");
|
||||
if (await fs.stat(topSkill).catch(() => null)) {
|
||||
entries.push(await buildSkillEntry(cat.name, topSkill, cat.name));
|
||||
}
|
||||
const items = await fs.readdir(catPath, { withFileTypes: true }).catch(() => []);
|
||||
for (const item of items) {
|
||||
if (!item.isDirectory()) continue;
|
||||
const skillMd = path.join(catPath, item.name, "SKILL.md");
|
||||
if (await fs.stat(skillMd).catch(() => null)) {
|
||||
entries.push(await buildSkillEntry(item.name, skillMd, `${cat.name}/${item.name}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ~/.hermes/skills/ doesn't exist
|
||||
}
|
||||
return entries.sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
async function buildSnapshot(config) {
|
||||
const homedir =
|
||||
asString(config.env?.HOME) ??
|
||||
process.env.HOME ??
|
||||
"/home/chaim";
|
||||
const hermesSkillsHome = path.join(homedir, ".hermes", "skills");
|
||||
|
||||
const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, paperclipEntries);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const availableByKey = new Map(paperclipEntries.map((e) => [e.key, e]));
|
||||
|
||||
const hermesSkillEntries = await scanHermesSkills(hermesSkillsHome);
|
||||
const hermesKeys = new Set(hermesSkillEntries.map((e) => e.key));
|
||||
|
||||
const entries = [];
|
||||
const warnings = [];
|
||||
|
||||
for (const entry of paperclipEntries) {
|
||||
const desired = desiredSet.has(entry.key);
|
||||
entries.push({
|
||||
key: entry.key,
|
||||
runtimeName: entry.runtimeName,
|
||||
desired,
|
||||
managed: true,
|
||||
state: desired ? "configured" : "available",
|
||||
origin: entry.required ? "paperclip_required" : "company_managed",
|
||||
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: entry.source,
|
||||
targetPath: null,
|
||||
detail: desired ? "Will be available on the next run via Hermes skill loading." : null,
|
||||
required: Boolean(entry.required),
|
||||
requiredReason: entry.requiredReason ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of hermesSkillEntries) {
|
||||
if (availableByKey.has(entry.key)) continue;
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
for (const desired of desiredSkills) {
|
||||
if (availableByKey.has(desired) || hermesKeys.has(desired)) continue;
|
||||
warnings.push(`Desired skill "${desired}" is not available in Paperclip or Hermes skills.`);
|
||||
entries.push({
|
||||
key: desired,
|
||||
runtimeName: null,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
origin: "external_unknown",
|
||||
originLabel: "External or unavailable",
|
||||
readOnly: false,
|
||||
sourcePath: null,
|
||||
targetPath: null,
|
||||
detail: "Cannot find this skill in Paperclip or ~/.hermes/skills/.",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ADAPTER_TYPE,
|
||||
supported: true,
|
||||
mode: "persistent",
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listSkills(ctx) {
|
||||
return buildSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncSkills(ctx, _desired) {
|
||||
return buildSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export function resolveDesiredSkillNames(config, availableEntries) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
164
adapters/deepseek-paperclip-adapter/dist/server/test.js
vendored
Normal file
164
adapters/deepseek-paperclip-adapter/dist/server/test.js
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Environment test for the DeepSeek (via Hermes) adapter.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
HERMES_CLI,
|
||||
ADAPTER_TYPE,
|
||||
DEFAULT_PROFILE_HOME,
|
||||
} from "../shared/constants.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function asString(v) {
|
||||
return typeof v === "string" ? v : undefined;
|
||||
}
|
||||
|
||||
async function checkCliInstalled(command) {
|
||||
try {
|
||||
await execFileAsync(command, ["--version"], { timeout: 10_000 });
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err && err.code === "ENOENT") {
|
||||
return {
|
||||
level: "error",
|
||||
message: `Hermes CLI "${command}" not found in PATH`,
|
||||
hint: "Install Hermes Agent: pip install hermes-agent",
|
||||
code: "deepseek_hermes_cli_not_found",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkProfile(profileHome) {
|
||||
try {
|
||||
const stat = await fs.stat(profileHome);
|
||||
if (!stat.isDirectory()) {
|
||||
return {
|
||||
level: "error",
|
||||
message: `Profile path is not a directory: ${profileHome}`,
|
||||
hint: "Create the directory or override hermesProfileHome in adapter config.",
|
||||
code: "deepseek_profile_not_dir",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
level: "error",
|
||||
message: `Hermes profile dir does not exist: ${profileHome}`,
|
||||
hint: "Create the profile dir with config.yaml + .env (DEEPSEEK_API_KEY).",
|
||||
code: "deepseek_profile_missing",
|
||||
};
|
||||
}
|
||||
|
||||
const configPath = path.join(profileHome, "config.yaml");
|
||||
try {
|
||||
await fs.stat(configPath);
|
||||
} catch {
|
||||
return {
|
||||
level: "error",
|
||||
message: `Profile is missing config.yaml: ${configPath}`,
|
||||
hint: "Add config.yaml with model.default + model.base_url + model.key_env.",
|
||||
code: "deepseek_profile_no_config",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
level: "info",
|
||||
message: `Profile resolved: ${profileHome}`,
|
||||
code: "deepseek_profile_ok",
|
||||
};
|
||||
}
|
||||
|
||||
async function checkApiKey(profileHome, configEnv) {
|
||||
// 1. config.env (resolved by Paperclip from secrets)
|
||||
if (configEnv && typeof configEnv === "object" && asString(configEnv.DEEPSEEK_API_KEY)) {
|
||||
return {
|
||||
level: "info",
|
||||
message: "DEEPSEEK_API_KEY found in adapter env config",
|
||||
code: "deepseek_api_key_in_config",
|
||||
};
|
||||
}
|
||||
// 2. Profile-local .env
|
||||
try {
|
||||
const envFile = path.join(profileHome, ".env");
|
||||
const text = await fs.readFile(envFile, "utf-8");
|
||||
if (/^\s*DEEPSEEK_API_KEY=/m.test(text)) {
|
||||
return {
|
||||
level: "info",
|
||||
message: `DEEPSEEK_API_KEY found in ${envFile}`,
|
||||
code: "deepseek_api_key_in_profile",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// 3. Process env
|
||||
if (process.env.DEEPSEEK_API_KEY) {
|
||||
return {
|
||||
level: "info",
|
||||
message: "DEEPSEEK_API_KEY found in Paperclip process env",
|
||||
code: "deepseek_api_key_in_process",
|
||||
};
|
||||
}
|
||||
return {
|
||||
level: "error",
|
||||
message: "DEEPSEEK_API_KEY not found in adapter env, profile .env, or process env",
|
||||
hint: "Add DEEPSEEK_API_KEY to <HERMES_HOME>/.env or to the agent's env secrets.",
|
||||
code: "deepseek_api_key_missing",
|
||||
};
|
||||
}
|
||||
|
||||
export async function testEnvironment(ctx) {
|
||||
const config = ctx.config ?? {};
|
||||
const command = asString(config.hermesCommand) || HERMES_CLI;
|
||||
const profileHome = asString(config.hermesProfileHome) || DEFAULT_PROFILE_HOME;
|
||||
const checks = [];
|
||||
|
||||
const cliCheck = await checkCliInstalled(command);
|
||||
if (cliCheck) {
|
||||
checks.push(cliCheck);
|
||||
if (cliCheck.level === "error") {
|
||||
return {
|
||||
adapterType: ADAPTER_TYPE,
|
||||
status: "fail",
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const profileCheck = await checkProfile(profileHome);
|
||||
checks.push(profileCheck);
|
||||
if (profileCheck.level === "error") {
|
||||
return {
|
||||
adapterType: ADAPTER_TYPE,
|
||||
status: "fail",
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const apiKeyCheck = await checkApiKey(profileHome, config.env);
|
||||
checks.push(apiKeyCheck);
|
||||
|
||||
const model = asString(config.model);
|
||||
checks.push({
|
||||
level: "info",
|
||||
message: model ? `Model: ${model}` : "Using profile default model",
|
||||
code: "deepseek_model",
|
||||
});
|
||||
|
||||
const hasErrors = checks.some((c) => c.level === "error");
|
||||
const hasWarnings = checks.some((c) => c.level === "warn");
|
||||
return {
|
||||
adapterType: ADAPTER_TYPE,
|
||||
status: hasErrors ? "fail" : hasWarnings ? "warn" : "pass",
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
36
adapters/deepseek-paperclip-adapter/dist/shared/constants.js
vendored
Normal file
36
adapters/deepseek-paperclip-adapter/dist/shared/constants.js
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared constants for the DeepSeek (via Hermes) Paperclip adapter.
|
||||
*/
|
||||
|
||||
export const ADAPTER_TYPE = "deepseek_local";
|
||||
export const ADAPTER_LABEL = "DeepSeek (via Hermes)";
|
||||
|
||||
/** Default Hermes CLI binary name. */
|
||||
export const HERMES_CLI = "hermes";
|
||||
|
||||
/** Default profile directory used as HERMES_HOME if the agent does not override it. */
|
||||
export const DEFAULT_PROFILE_HOME = "/home/chaim/.hermes/profiles/deepseek";
|
||||
|
||||
/** Default model — V4-Pro is the strongest DeepSeek model currently exposed. */
|
||||
export const DEFAULT_MODEL = "deepseek-v4-pro";
|
||||
|
||||
/** DeepSeek profiles in this stack use Hermes' "custom" provider (user-defined in profile config.yaml). */
|
||||
export const DEFAULT_PROVIDER = "custom";
|
||||
|
||||
/** Default timeout (seconds) for one CLI invocation. */
|
||||
export const DEFAULT_TIMEOUT_SEC = 1800;
|
||||
|
||||
/** Grace period (seconds) after SIGTERM before SIGKILL. */
|
||||
export const DEFAULT_GRACE_SEC = 30;
|
||||
|
||||
/** Models that DeepSeek's API currently exposes (verified via /v1/models). */
|
||||
export const DEEPSEEK_MODELS = [
|
||||
{ id: "deepseek-v4-pro", label: "DeepSeek V4 Pro" },
|
||||
{ id: "deepseek-v4-flash", label: "DeepSeek V4 Flash" },
|
||||
];
|
||||
|
||||
/** Regex for extracting session_id from quiet-mode Hermes output. */
|
||||
export const SESSION_ID_REGEX = /^session_id:\s*(\S+)/m;
|
||||
export const SESSION_ID_REGEX_LEGACY = /session[_ ](?:id|saved)[:\s]+([a-zA-Z0-9_-]+)/i;
|
||||
export const TOKEN_USAGE_REGEX = /tokens?[:\s]+(\d+)\s*(?:input|in)\b.*?(\d+)\s*(?:output|out)\b/i;
|
||||
export const COST_REGEX = /(?:cost|spent)[:\s]*\$?([\d.]+)/i;
|
||||
25
adapters/deepseek-paperclip-adapter/package-lock.json
generated
Normal file
25
adapters/deepseek-paperclip-adapter/package-lock.json
generated
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "deepseek-paperclip-adapter",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "deepseek-paperclip-adapter",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "^2026.325.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@paperclipai/adapter-utils": {
|
||||
"version": "2026.428.0",
|
||||
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.428.0.tgz",
|
||||
"integrity": "sha512-kGHpE7rhePPCbnG3OwXbNuHZZuI+XyuFgNSiDnrEeiSbkI2c5XHM2WnWDCZ/NGHULfJW3lWhSxGMFoYqiy38vQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
adapters/deepseek-paperclip-adapter/package.json
Normal file
21
adapters/deepseek-paperclip-adapter/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "deepseek-paperclip-adapter",
|
||||
"version": "0.1.0",
|
||||
"description": "Paperclip adapter for DeepSeek (V4-Pro / V4-Flash) — runs Hermes Agent locally pinned to a DeepSeek profile",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "^2026.325.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
414
docs/agent-audit-2026-05-17.md
Normal file
414
docs/agent-audit-2026-05-17.md
Normal file
@@ -0,0 +1,414 @@
|
||||
# דו"ח Audit סוכנים — 2026-05-17
|
||||
|
||||
> נוצר על-ידי 7 sub-agents מקבילים שחקרו כל סוכן בנפרד.
|
||||
> כיסוי: קבצי הנחיות, תצורת DB, skills, MCP tools, freshness, drift CMP↔CMPA.
|
||||
>
|
||||
> **עדכון 2026-05-17:** כל 12 הבעיות טופלו באותו יום. ראה סעיף "סטטוס תיקונים" למטה.
|
||||
|
||||
---
|
||||
|
||||
## סיכום מנהלים
|
||||
|
||||
### טבלת מצב כללית — לאחר תיקונים (2026-05-17)
|
||||
|
||||
| סוכן | מודל (instructions = DB) | Skills CMP | Skills CMPA | סטטוס |
|
||||
|------|--------------------------|-----------|-----------|--------|
|
||||
| עוזר משפטי (CEO) | claude-opus-4-7 ✅ | 9 | 6 | ✅ תקין |
|
||||
| מנתח משפטי | claude-opus-4-7 ✅ | 9 | 6 | ✅ תקין |
|
||||
| חוקר תקדימים | claude-sonnet-4-6 ✅ | 9 | 6 | ✅ תקין |
|
||||
| כותב החלטה | claude-opus-4-7 ✅ | 9 | 6 | ✅ תקין |
|
||||
| בודק איכות (QA) | claude-sonnet-4-6 ✅ | 9 | 6 | ✅ תקין |
|
||||
| מייצא טיוטה | claude-sonnet-4-6 ✅ | 9 | 6 | ✅ תקין |
|
||||
| מגיה מסמכים | claude-opus-4-7 ✅ | 9 | 6 | ✅ תקין |
|
||||
| מנהל ידע (Curator) | deepseek-v4-pro ✅ | 9 | 6 | ✅ תקין |
|
||||
|
||||
> Skills CMPA=6 הוא עיצוב מכוון (6 shared-only skills). verify script מאשר "0 agents need sync".
|
||||
|
||||
### סטטוס תיקונים — כל 12 הבעיות טופלו
|
||||
|
||||
| # | חומרה | סוכן | בעיה | סטטוס | commit |
|
||||
|---|-------|------|------|-------|--------|
|
||||
| 1 | 🔴 | מייצא | `טיוטה-V` → `טיוטה-v` — דורס גרסאות | ✅ תוקן | `a584dc3` |
|
||||
| 2 | 🔴 | מייצא | case.status לא מעודכן ל-`exported` + case_update חסר מ-tools | ✅ תוקן | `a584dc3` |
|
||||
| 3 | 🔴 | חוקר | §ז (query log) חסר בתיק 8174-24 | ✅ תוקן | data (gitignored) |
|
||||
| 4 | 🟠 | כולם | Skills asymmetry CMPA | ✅ לא נדרש — verify: "0 need sync" (עיצוב מכוון) | — |
|
||||
| 5 | 🟠 | חוקר | `search_internal_decisions` לא מתועד | ✅ תוקן — tool + סעיף 2ב.2א | `35423ea` |
|
||||
| 6 | 🟠 | מייצא | נתיב legal-docx hardcoded ל-CMP UUID | ✅ תוקן → `$PAPERCLIP_COMPANY_ID` | `a584dc3` |
|
||||
| 7 | 🟠 | CEO | Project ID + company UUID hardcoded | ✅ תוקן → דינמי מ-$PAPERCLIP_TASK_ID | `35423ea` |
|
||||
| 8 | 🟡 | רוב | Model drift instructions↔DB | ✅ תוקן + שודרג ל-opus-4-7 | `1608ea5`, `c3ce0e7` |
|
||||
| 9 | 🟡 | QA | corpus_queries_logged: ידני או אוטומטי? | ✅ תוקן — הבהרה מפורשת: grep ידני | `1608ea5` |
|
||||
| 10 | 🟡 | CEO | maxConcurrentRuns=NULL | ✅ לא נדרש — DB כבר maxConcurrentRuns=2 | — |
|
||||
| 11 | 🟡 | מגיה | {issue-id} placeholder בקוד | ✅ תוקן → `$PAPERCLIP_TASK_ID` | `1608ea5` |
|
||||
| 12 | 🟢 | מנהל ידע | ownership הצעות curator לא מוגדר | ✅ תוקן — הוסף ל-CLAUDE.md | `1608ea5` |
|
||||
|
||||
### שינויים נוספים שבוצעו באותו סשן
|
||||
|
||||
| שינוי | קובץ | commit |
|
||||
|-------|------|--------|
|
||||
| weekly-feedback-job: כתיבה לקובץ בלבד, לא Paperclip comment | legal-ceo.md | `ea0532b` |
|
||||
| try-catch על agents.invoke בפידבק שבועי | worker.ts | `73e37df` |
|
||||
| try-catch על http.fetch ב-stale-case-reminder | worker.ts | `73e37df` |
|
||||
| HEARTBEAT.md reference בראש legal-researcher.md | legal-researcher.md | `1608ea5` |
|
||||
| search_internal_decisions הוסף ל-legal-researcher tools | legal-researcher.md | `35423ea` |
|
||||
| opus-4-6 → opus-4-7 ב-DB: CEO, מנתח, כותב, מגיה (16 סוכנים) | DB | `c3ce0e7` |
|
||||
|
||||
---
|
||||
|
||||
## ממצאים לפי סוכן
|
||||
|
||||
### 1. עוזר משפטי (CEO)
|
||||
|
||||
**קובץ:** `.claude/agents/legal-ceo.md` — 796 שורות, עודכן 2026-05-17
|
||||
|
||||
**תצורה:**
|
||||
| חברה | ID | Model | Budget |
|
||||
|------|-----|-------|--------|
|
||||
| CMP | `752cebdd-6748-4a04-aacd-c7ab0294ef33` | claude-opus-4-6 | 1500¢ |
|
||||
| CMPA | `cdbfa8bc-3d61-41a4-a2e7-677ec7d34562` | claude-opus-4-6 | 1500¢ |
|
||||
|
||||
**routing conditions:** `user_commented`, `agent_completion`, `precedent_extraction_*`, `weekly-feedback-job`, fallback→heartbeat רגיל
|
||||
|
||||
**MCP tools מוזכרים (41):** case_get/list/update, document_list, get_claims, get_chair_directions, record/list_chair_feedback, approve_direction, brainstorm_directions, search_case_documents, search_precedent_library, workflow_status, processing_status, get_metrics, validate_decision, set_outcome, export_docx, apply_user_edit, list_bookmarks, revise_draft, precedent_process_pending, extract_halachot/metadata, library_get/list, halacha_review, halachot_pending, extract_appraiser_facts, write_interim_draft, export_interim_draft
|
||||
|
||||
**✅ תקין:**
|
||||
- Routing logic מלא ועדכני (כולל weekly-feedback-job שתוקן לאחרונה)
|
||||
- Company filtering ברור (טבלה עם UUIDs וטווחי תיקים)
|
||||
- Wakeup דרך API בלבד (לא DB ישיר) — מוגדר במפורש
|
||||
- HEARTBEAT.md references נכונים (§0, §1, §1.7)
|
||||
- weekly-feedback-job: כתיבה לקובץ בלבד, ללא issueId — נכון
|
||||
|
||||
**⚠️ בעיות:**
|
||||
- 🟠 **Model drift:** instructions = claude-sonnet-4-6, DB = claude-opus-4-6
|
||||
- 🟠 **Hardcoded Project ID:** `25c1b4a1-2c0e-4a2d-9938-8ae56ccda6f1` (תיק 1130-25) — צריך להיות דינמי
|
||||
- 🟡 **maxConcurrentRuns = NULL** ב-DB (שאר הסוכנים = 1)
|
||||
- 🟡 **MCP startup race:** הוראות מדברות על sleep+retry אבל לא כ-code אוטומטי
|
||||
|
||||
---
|
||||
|
||||
### 2. מנתח משפטי
|
||||
|
||||
**קובץ:** `.claude/agents/legal-analyst.md` — 498 שורות, עודכן 2026-05-04
|
||||
|
||||
**תצורה:**
|
||||
| חברה | ID | Model | Budget |
|
||||
|------|-----|-------|--------|
|
||||
| CMP | `c26e9439-a88a-49dc-9e67-2262c95db65c` | claude-opus-4-6 | 1500¢ |
|
||||
| CMPA | `f70fd353-...` | claude-opus-4-6 | 1500¢ |
|
||||
|
||||
**MCP tools (18):** case_get/list/update, document_list/get_text, extract_claims, extract_appraiser_facts, get_claims, search_case_documents, search_decisions, search_precedent_library, precedent_library_get/list, halacha_review, halachot_pending, find_similar_cases, workflow_status, processing_status
|
||||
|
||||
**Output artifacts:** `{case_dir}/documents/research/analysis-and-research.md`
|
||||
|
||||
**Query logging (§5ד/§7א):** לרשום כל `search_precedent_library`, `search_decisions`, `find_similar_cases` כולל ניסיונות עם 0 תוצאות
|
||||
|
||||
**✅ תקין:**
|
||||
- כל 18 כלי MCP מוזכרים ומיושמים
|
||||
- סיווג claim_type ברור (claim/response/reply)
|
||||
- Wakeup CEO בפורמט נכון
|
||||
- reference files קיימים
|
||||
|
||||
**⚠️ בעיות:**
|
||||
- 🟠 **Model drift:** instructions = claude-opus-4-7, DB = claude-opus-4-6
|
||||
- 🟡 **CMPA sync gap:** עדכון אחרון CMPA = 2026-05-04 (13 ימים לפני CMP)
|
||||
|
||||
---
|
||||
|
||||
### 3. חוקר תקדימים
|
||||
|
||||
**קובץ:** `.claude/agents/legal-researcher.md` — 240 שורות, עודכן 2026-05-04
|
||||
|
||||
**תצורה:**
|
||||
| חברה | ID | Model | Budget |
|
||||
|------|-----|-------|--------|
|
||||
| CMP | `35022af0-0498-4c3d-90ca-b0ab9e987198` | claude-sonnet-4-6 | 1500¢ |
|
||||
| CMPA | `5dd06843-...` | claude-sonnet-4-6 | 1500¢ |
|
||||
|
||||
**MCP tools (29):** case_get/update, document_list/get_text, search_case_documents, search_decisions, find_similar_cases, extract_references, precedent_attach, precedent_list, precedent_search_library, search_precedent_library, library_get/list, extract_halachot/metadata, precedent_process_pending, halacha_review, halachot_pending, workflow_status
|
||||
|
||||
**Output artifact:** `{case_dir}/documents/research/precedent-research.md`
|
||||
|
||||
**Query logging (§ז):** חובה — כל query עם פילטרים, תוצאות, בחירה/דחייה, negative evidence
|
||||
|
||||
**✅ תקין:**
|
||||
- שלושת הקורפוסים מוגדרים בבירור (פסיקה חיצונית / קאנון דפנה / ציטוטים ידניים)
|
||||
- precedent_attach עם הוראות מלאות
|
||||
- Wakeup CEO דינמי לפי חברה
|
||||
|
||||
**⚠️ בעיות:**
|
||||
- 🔴 **§ז חסר בתיק 8174-24** — 1 מתוך 3 תיקים בדיסק חסר את תיעוד השאילתות. QA אמור לחסום ייצוא.
|
||||
- 🟠 **`search_internal_decisions` לא מתועד** — הכלי ב-header אבל לא מוסבר בגוף ההנחיות. מתי להשתמש בו?
|
||||
- 🟠 **Skills asymmetry CMPA** — CMPA חסרה: legal-assistant, legal-decision, legal-docx, diagnose-why-work-stopped, appendix-expert-intern, terminal-bench-loop
|
||||
- 🟡 **`daphna-precedent-network.md` עדכון אחרון 27 אפריל** — עשוי להיות לפני תקדימים חדשים
|
||||
- 🟡 **HEARTBEAT.md לא מוזכר בפירוש** — אין link ישיר בתחילת ההנחיות
|
||||
|
||||
---
|
||||
|
||||
### 4. כותב החלטה
|
||||
|
||||
**קובץ:** `.claude/agents/legal-writer.md` — 410 שורות, עודכן 2026-05-04
|
||||
|
||||
**תצורה:**
|
||||
| חברה | ID | Model | Budget |
|
||||
|------|-----|-------|--------|
|
||||
| CMP | `7ed8686f-24bc-49a3-bc02-67ca15b895a9` | claude-opus-4-6 | 1500¢ |
|
||||
| CMPA | `99289cb1-...` | claude-opus-4-6 | 1500¢ |
|
||||
|
||||
**Block range:** ה-יא (5-11), כותב בסדר; א-ד (אוטומטי), יב (אוטומטי)
|
||||
|
||||
**5 style docs לפני בלוק י (כולם קיימים):**
|
||||
- `docs/daphna-voice-fingerprint.md` ✅ (עודכן 10 מאי)
|
||||
- `docs/daphna-precedent-network.md` ✅ (עודכן 27 אפריל)
|
||||
- `docs/daphna-architecture-by-outcome.md` ✅ (עודכן 28 אפריל)
|
||||
- `docs/daphna-acceptance-architecture.md` ✅ (עודכן 28 אפריל)
|
||||
- `docs/voice-1130-25.md` ✅ (עודכן 26 אפריל)
|
||||
|
||||
**MCP tools (18):** case_get/update, document_list/get_text, get_claims, get_chair_directions, get_decision_template, get_block_context, save_block_content, write_block, search_decisions, search_precedent_library, library_get/list, search_case_documents, get_style_guide, halacha_review, workflow_status, apply_user_edit
|
||||
|
||||
**✅ תקין:**
|
||||
- 4 statuses של get_chair_directions מוגדרים (missing/empty/partial/complete)
|
||||
- Revision mode ברור (לא לשמור ב-DB בעריכה)
|
||||
- 10 anti-patterns ברורים
|
||||
- Company filtering נכון (CEO IDs שונים לפי חברה)
|
||||
|
||||
**⚠️ בעיות:**
|
||||
- 🟠 **Model drift:** instructions = claude-opus-4-7, DB = claude-opus-4-6
|
||||
- 🟡 **חסר שלב 0 מפורש:** בדיקת `issue.description` (ההוראה הראשית מה-CEO)
|
||||
|
||||
---
|
||||
|
||||
### 5. בודק איכות (QA)
|
||||
|
||||
**קובץ:** `.claude/agents/legal-qa.md` — 219 שורות, עודכן 2026-05-04
|
||||
|
||||
**תצורה:**
|
||||
| חברה | ID | Model | Budget |
|
||||
|------|-----|-------|--------|
|
||||
| CMP | `1a5b229e-9220-4b13-940c-f8eb7285fc29` | claude-sonnet-4-6 | 1500¢ |
|
||||
| CMPA | `7191ff77-...` | claude-sonnet-4-6 | 1500¢ |
|
||||
|
||||
**9 בדיקות (לא 8 — §7א הוא נפרד):**
|
||||
1. שלמות מבנית — critical
|
||||
2. רקע ניטרלי — critical
|
||||
3. כיסוי טענות — critical
|
||||
4. משקלות — warning
|
||||
5. ללא כפילות — warning
|
||||
6. מספור רציף — warning
|
||||
7א. שאילתות קורפוס (corpus_queries_logged) — **critical blocker**
|
||||
7. תאימות מתודולוגיה — critical
|
||||
8. קול דפנה — critical
|
||||
|
||||
**Reference files (כולם קיימים):**
|
||||
- `docs/daphna-decision-tree.md` ✅ (521 שורות)
|
||||
- `docs/daphna-voice-fingerprint.md` ✅ (471 שורות)
|
||||
- `docs/daphna-architecture-by-outcome.md` ✅ (381 שורות)
|
||||
- `docs/daphna-acceptance-architecture.md` ✅ (640 שורות)
|
||||
- `docs/daphna-block-zayin-claims.md` ✅ (385 שורות)
|
||||
- `docs/daphna-precedent-network.md` ✅ (379 שורות)
|
||||
|
||||
**✅ תקין:**
|
||||
- כל reference files קיימים ונגישים
|
||||
- Company filtering מתועד (CEO IDs נכונים)
|
||||
- Decision logic done/blocked מוגדרת
|
||||
|
||||
**⚠️ בעיות:**
|
||||
- 🟡 **בדיקה 7א לא ברורה** — אוטומטית (validate_decision) או ידנית (grep בקובצי markdown)?
|
||||
- 🟡 **בדיקה 8 (קול דפנה) סובייקטיבית** — חסרות דוגמאות anti-patterns מדידות
|
||||
- 🟡 **get_metrics() — אין ספי קבלה** — מה מספר/אחוז שמוגדר כ-pass?
|
||||
- 🟡 **decision tree:** אם רק בדיקות 4-6 (warning) נכשלו — done או blocked?
|
||||
|
||||
---
|
||||
|
||||
### 6. מייצא טיוטה (Exporter)
|
||||
|
||||
**קובץ:** `.claude/agents/legal-exporter.md` — 151 שורות, עודכן 2026-05-04
|
||||
|
||||
**תצורה:**
|
||||
| חברה | ID | Model | Budget |
|
||||
|------|-----|-------|--------|
|
||||
| CMP | `d0dc703b-ca83-4883-bca7-c9449e8713cd` | claude-sonnet-4-6 | 1500¢ |
|
||||
| CMPA | `ada99a7d-...` | claude-sonnet-4-6 | 1500¢ |
|
||||
|
||||
**MCP tools (8):** export_docx, apply_user_edit, list_bookmarks, revise_draft, validate_decision, get_claims, get_block_context, workflow_status
|
||||
|
||||
**✅ תקין:**
|
||||
- Git integration לכל ייצוא/עדכון
|
||||
- validate_decision לפני export מוגדר
|
||||
- active_draft detection (עריכה-*.docx) מוגדר
|
||||
|
||||
**⚠️ בעיות:**
|
||||
- 🔴 **Naming mismatch קריטי:** הנחיות → `טיוטה-V{N}.docx` (V גדולה); קוד `revise_draft` → `טיוטה-v{N}.docx` (v קטנה); בדיסק בפועל → `טיוטה-v1.docx` (v קטנה). **הסוכן יחפש V גדולה ולא ימצא — יתחיל מ-v1 בכל הפעלה ויחליף קבצים קיימים!**
|
||||
- 🔴 **case.status לא מעודכן ל-`exported`** — אחרי export מצליח, הסטטוס נשאר `drafted`/`reviewed`; הסטטוס `exported` קיים ב-DB schema ומוחרג מ-stale query
|
||||
- 🟠 **legal-docx SKILL.md path hardcoded לCMP UUID** — CMPA ייכשל בקריאת ה-SKILL.md
|
||||
- נכון: `/home/chaim/.paperclip/instances/default/skills/42a7acd0-.../legal-docx/SKILL.md`
|
||||
- חסר: דינמי לפי `$PAPERCLIP_COMPANY_ID`
|
||||
- 🟡 **Heartbeat grace=60s** — אם export DOCX > 60s, שני instances יתעוררו במקביל
|
||||
- 🟡 **File size validation** — מוזכר בהנחיות אך לא מיושם בקוד
|
||||
|
||||
---
|
||||
|
||||
### 7. מגיה מסמכים (Proofreader)
|
||||
|
||||
**קובץ:** `.claude/agents/legal-proofreader.md` — 115 שורות, עודכן 2026-05-04
|
||||
|
||||
**תצורה:**
|
||||
| חברה | ID | Model | Budget |
|
||||
|------|-----|-------|--------|
|
||||
| CMP | `410c0167-27dc-485c-a51b-7aa8b9ff2217` | claude-opus-4-6 | 1500¢ |
|
||||
| CMPA | `17839fc6-...` | claude-opus-4-6 | 1500¢ |
|
||||
|
||||
**OCR workflow — 5 שלבים:** זיהוי → תיקון אוטומטי (abbreviations.json) → הגהה חכמה → שמירה → דיווח+סגירה
|
||||
|
||||
**abbreviations.json:** קיים ב-`/home/chaim/legal-ai/data/abbreviations.json` (2545 bytes, עודכן אפריל)
|
||||
|
||||
**✅ תקין:**
|
||||
- abbreviations.json קיים
|
||||
- Wakeup CEO דינמי לפי חברה
|
||||
- חיוב סגירת issue
|
||||
|
||||
**⚠️ בעיות:**
|
||||
- 🟠 **Model drift:** instructions = claude-opus-4-7, DB = claude-opus-4-6
|
||||
- 🟡 **MCP write support לתיקיות:** לא אומת שה-tools תומכים בכתיבה ל-`documents/proofread/`
|
||||
- 🟡 **Placeholder `{issue-id}` בקוד:** pc.sh calls משתמשות ב-literal `{issue-id}` — האם הסוכן מחליף עם `$PAPERCLIP_TASK_ID`?
|
||||
- 🟡 **`extraction_status = proofread`:** האם השדה קיים ב-MCP document schema?
|
||||
|
||||
---
|
||||
|
||||
### 8. מנהל ידע (Hermes Curator)
|
||||
|
||||
**קובץ:** `.claude/agents/hermes-curator.md` — 147 שורות, עודכן 2026-05-10
|
||||
|
||||
**תצורה:**
|
||||
| חברה | ID | Adapter | Model | Budget |
|
||||
|------|-----|---------|-------|--------|
|
||||
| CMP | `60dce831-5c5b-4bae-bda9-5282d506f0dc` | deepseek_local | deepseek-v4-pro | 1500¢ |
|
||||
| CMPA | `d6f7c55d-570a-46b8-8d72-1286d07da0d8` | deepseek_local | deepseek-v4-pro | 1500¢ |
|
||||
|
||||
**Profiles:** `~/.hermes/profiles/curator-cmp/` ✅ + `curator-cmpa/` ✅ (שניהם קיימים)
|
||||
|
||||
**Trigger:** UI "סמן כסופי" → `web/paperclip_client.py:pc_wake_curator_for_final()` → sub-issue + wakeup
|
||||
|
||||
**MCP tools (6):** case_get, case_get_final_text, document_list, get_style_guide, precedent_library_list, search_internal_decisions, halacha_review
|
||||
|
||||
**✅ תקין:**
|
||||
- deepseek_local מוגדר נכון בשתי החברות
|
||||
- Profiles קיימים ועובדים (MEMORY.md מ-06/05 עם 5 ממצאים)
|
||||
- Read-only design — לא מעדכן קבצים ישירות
|
||||
- env vars נדרשים מתועדים
|
||||
|
||||
**⚠️ בעיות:**
|
||||
- 🟢 **לא מוגדר:** מי מממש הצעות ל-SKILL.md/lessons.md שה-curator מציע ב-comments?
|
||||
- 🟢 **Hermes bias:** DeepSeek V4-Pro עלול לפרש תוצאות בצורה סובייקטיבית — אין oversight layer
|
||||
|
||||
---
|
||||
|
||||
## בעיות חוצות-סוכנים
|
||||
|
||||
### 1. Skills Asymmetry CMP vs CMPA (🟠 גבוה)
|
||||
|
||||
**Skills ב-CMP (9):**
|
||||
- משותפים (6): paperclip, paperclip-converting-plans-to-tasks, paperclip-create-agent, paperclip-create-plugin, paperclip-dev, para-memory-files
|
||||
- ייחודיים CMP (3+): legal-assistant, legal-decision, legal-docx, appendix-expert-intern, diagnose-why-work-stopped, terminal-bench-loop
|
||||
|
||||
**Skills ב-CMPA (6):** משותפים בלבד — **חסרים כל ה-legal-* skills**
|
||||
|
||||
**השפעה:** סוכני CMPA לא יכולים להשתמש ב-legal-decision skill (כתיבה), legal-assistant (ניתוח), legal-docx (DOCX). לא ברור אם זו החלטה מכוונת (CMPA עובד אחרת?) או gap בסנכרון.
|
||||
|
||||
**פעולה:** הרץ `sync_agents_across_companies.py --verify` עם PAPERCLIP_BOARD_API_KEY לבדיקה.
|
||||
|
||||
### 2. Model Version Drift (🟡 בינוני)
|
||||
|
||||
ב-DB כל הסוכנים רצים על claude-opus-4-6 או claude-sonnet-4-6, אבל קבצי הנחיות מציינים גרסאות שונות:
|
||||
|
||||
| סוכן | instructions מציין | DB רץ על |
|
||||
|------|-------------------|---------|
|
||||
| CEO | claude-sonnet-4-6 | claude-opus-4-6 |
|
||||
| מנתח | claude-opus-4-7 | claude-opus-4-6 |
|
||||
| כותב | claude-opus-4-7 | claude-opus-4-6 |
|
||||
| מגיה | claude-opus-4-7 | claude-opus-4-6 |
|
||||
| חוקר, QA, מייצא | claude-sonnet-4-6 | claude-sonnet-4-6 ✅ |
|
||||
| מנהל ידע | deepseek-v4-pro | deepseek-v4-pro ✅ |
|
||||
|
||||
**לא ברור:** האם CEO/מנתח/כותב **אמורים** לרוץ על Opus (בחירה מכוונת לאיכות) ורק קבצי instructions לא עודכנו? או שה-DB צריך להתעדכן?
|
||||
|
||||
### 3. HEARTBEAT.md Reference (🟢 נמוך)
|
||||
|
||||
קובץ `legal-researcher.md` לא מפנה ל-`HEARTBEAT.md` בפירוש בתחילת הקובץ. שאר הסוכנים כן עושים זאת.
|
||||
|
||||
---
|
||||
|
||||
## רשימת תיקונים לפי עדיפות
|
||||
|
||||
### 🔴 קריטי — לתקן לפני תיק הבא
|
||||
|
||||
1. **`legal-exporter.md` + `web/app.py`/`drafting.py`:** אחד הדברים:
|
||||
- תיקן הנחיות: שנה `טיוטה-V` → `טיוטה-v` (v קטנה) בכל המקומות
|
||||
- **ועוד:** הוסף לקובץ הנחיות שלב: "אחרי export מוצלח — עדכן `case.status = 'exported'` דרך MCP או API"
|
||||
|
||||
2. **תיק 8174-24 — §ז חסר:** בדוק אם שלב המחקר הושלם. אם לא — הפעל חוקר מחדש לתיק זה.
|
||||
|
||||
### 🟠 גבוה — לתקן בשבוע הקרוב
|
||||
|
||||
3. **Skills CMPA:** הרץ:
|
||||
```bash
|
||||
PAPERCLIP_BOARD_API_KEY=$(mcp__infisical__get-secret \
|
||||
--projectId 9a77b161-f70c-4dd3-9d67-b7ab850cef51 \
|
||||
--environmentSlug nautilus --secretPath /paperclip --secretName BOARD_API_KEY) \
|
||||
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify
|
||||
```
|
||||
החלט אם להוסיף legal-* skills ל-CMPA ואם כן — הרץ `--apply`.
|
||||
|
||||
4. **`legal-researcher.md`:** הוסף תת-סעיף עם הוראות ל-`search_internal_decisions`:
|
||||
- מתי להשתמש (החלטות פנימיות דפנה שלא בקורפוס הציבורי)
|
||||
- מה ההבדל מ-`search_decisions`
|
||||
|
||||
5. **`legal-exporter.md` — נתיב legal-docx:** שנה מ-hardcoded UUID ל-דינמי:
|
||||
```
|
||||
אם $PAPERCLIP_COMPANY_ID = 42a7acd0... → CMP path
|
||||
אם $PAPERCLIP_COMPANY_ID = 8639e837... → CMPA path
|
||||
```
|
||||
|
||||
6. **`legal-ceo.md` — Project ID:** הסר את ה-hardcoded ID של 1130-25. החלף בהוראה: "השתמש ב-`projects_list` לקבלת project_id הנכון לפי חברה ולתיק".
|
||||
|
||||
### 🟡 בינוני — לתקן בחודש הקרוב
|
||||
|
||||
7. **Model documentation:** החלט על גרסאות מודל לכל סוכן ועדכן גם הנחיות גם DB. עדיף: שמור הנחיות כ-source of truth ועדכן DB דרך `sync_agents_across_companies.py --apply`.
|
||||
|
||||
8. **`legal-qa.md` — הבהרת corpus_queries_logged:** הוסף: "הבדיקה היא קריאת `validate_decision` עם `check_corpus_log=true` / או grep ידני בקובץ `analysis-and-research.md` לסעיף ז".
|
||||
|
||||
9. **`legal-ceo.md` — maxConcurrentRuns:** עדכן DB ל-maxConcurrentRuns=1 (או 2 אם CEO רוצה מקביליות מכוונת).
|
||||
|
||||
10. **`legal-proofreader.md` — {issue-id} placeholder:** שנה ל-`$PAPERCLIP_TASK_ID` באופן מפורש.
|
||||
|
||||
11. **`legal-researcher.md` — HEARTBEAT.md link:** הוסף בשורה 1: `> ראה גם: HEARTBEAT.md לחוקים הכלליים`.
|
||||
|
||||
### 🟢 נמוך — future improvement
|
||||
|
||||
12. **מנהל ידע — ownership:** הוסף ל-CLAUDE.md הנחיה: "Curator proposals ב-comments → חיים מאשר ידנית → commits ל-SKILL.md ו-lessons.md".
|
||||
|
||||
---
|
||||
|
||||
## אימות (לאחר תיקונים)
|
||||
|
||||
```bash
|
||||
# 1. שלוף API key
|
||||
PAPERCLIP_BOARD_API_KEY=$(mcp__infisical__get-secret \
|
||||
--projectId 9a77b161-f70c-4dd3-9d67-b7ab850cef51 \
|
||||
--environmentSlug nautilus --secretPath /paperclip --secretName BOARD_API_KEY)
|
||||
|
||||
# 2. בדוק drift
|
||||
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify
|
||||
|
||||
# 3. בדוק freshness של הנחיות
|
||||
python ~/legal-ai/scripts/sync_agents_across_companies.py --check-instructions
|
||||
|
||||
# 4. בדוק שסוכני CMPA עובדים עם skills נכונים
|
||||
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
|
||||
SELECT a.name, array_agg(s.name ORDER BY s.name) as skills
|
||||
FROM agents a
|
||||
JOIN companies c ON a.company_id = c.id
|
||||
LEFT JOIN agent_skills ask ON ask.agent_id = a.id
|
||||
LEFT JOIN skills s ON ask.skill_id = s.id
|
||||
WHERE c.name LIKE '%השבחה%' AND (a.is_deleted = false OR a.is_deleted IS NULL)
|
||||
GROUP BY a.id ORDER BY a.name;
|
||||
"
|
||||
```
|
||||
@@ -400,6 +400,54 @@
|
||||
- **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md)
|
||||
- **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 6.11 לקחים מערר 1200-25 (קרית ענבים, מאי 2026)
|
||||
|
||||
השוואה בין טיוטת הכותב לעריכת דפנה חשפה 7 דפוסי סגנון שלא היו מתועדים:
|
||||
|
||||
### א. סדר בלוקים — תכניות לפני טענות (1xxx)
|
||||
בתיקי רישוי, דפנה מעדיפה שבלוק ט (תכניות חלות) יופיע **לפני** בלוק ז (טענות). הרציונל: הקורא צריך להכיר את המסגרת הנורמטיבית לפני שהוא קורא את טענות הצדדים.
|
||||
|
||||
**סדר נכון ל-1xxx:** ה → ו → **ט** → ו.ב (רקע מורחב) → ז → ח → י → יא → יב
|
||||
|
||||
### ב. תבנית "להלן מתוך" — חובה
|
||||
כל התייחסות למסמך מקור מלווה ב-"להלן מתוך [שם המסמך]:" כ-placeholder לציטוט/צילום. **12 מופעים** בעריכה, **0** בטיוטה. זהו דפוס סגנוני מרכזי שחייב להיות אוטומטי.
|
||||
|
||||
דוגמאות:
|
||||
- "להלן מתוך הוראות התכנית:"
|
||||
- "להלן מתוך פרוטוקול הדיון בוועדה המקומית:"
|
||||
- "להלן מתוך הבקשה להיתר:"
|
||||
- "להלן מתוך מטרת התכנית:"
|
||||
- "להלן מתוך תשריט מצב מוצע:"
|
||||
|
||||
### ג. רקע עובדתי מורחב — ציר זמן מלא
|
||||
בלוק ו חייב לספר את "הסיפור" של התיק: הגשת בקשה → פרסום → מספר התנגדויות → ישיבות ועדה מקומית (תאריך + תוצאה לכל אחת) → החלטה סופית → הגשת ערר. הטיוטה נתנה שורה אחת (90 מילים); דפנה הרחיבה ל-3 ישיבות מפורטות (~420 מילים).
|
||||
|
||||
### ד. ניתוח "גשר תכנוני"
|
||||
כשמבקש שימוש חורג גם מקדם תכנית — דפנה מנתחת: האם השימוש המבוקש **תואם** את התכנון העתידי (→ גשר לגיטימי, כמו בכוכבה תורן)? או **סותר** (→ סטייה כפולה)? מסגרת ניתוח שלמה (249 מילים) שלא הייתה בטיוטה.
|
||||
|
||||
### ה. עיגון כמותי
|
||||
דפנה מוסיפה נתונים מספריים ספציפיים: "4,404.98 מ"ר לכלל היישוב vs 1,425 מ"ר מבוקש — 32%". המספרים מעגנים את ההחלטה במציאות ומקשים על ערעור.
|
||||
|
||||
### ו. כותרות שטוחות (Heading 2 בלבד)
|
||||
דפנה השתמשה ב-Heading 2 לכל הסעיפים, כולל תת-נושאים בדיון. **אין Heading 3**. כל סעיף עומד בפני עצמו.
|
||||
|
||||
### ז. הבחנת תקדימים inline
|
||||
במקום סעיף נפרד "הבחנה מתקדימי העוררת" — ההבחנות מנוסחות inline: "באשר ל-[שם פסק דין]" → מה ההבדל → סיכום. דוגמה: "באשר לבג"ץ 6525/15 עמק שווה... אולם ההבדל מהותי".
|
||||
|
||||
### ביטויי מעבר חדשים (מעריכה 1200-25)
|
||||
| ביטוי | הקשר |
|
||||
|-------|-------|
|
||||
| "עינינו הרואות" | ממצא מתוך מסמך |
|
||||
| "הנה כי כן" | לפיכך (פורמלי) |
|
||||
| "נשוב כאן ונבחין" | חזרה להבחנת תקדים |
|
||||
| "נוסיף ונבהיר" | הוספת הבהרה |
|
||||
| "מסקנת הדברים" | סיכום סעיף |
|
||||
| "משכבר קבענו" | הפניה לקביעה קודמת |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 7. מה עדיין לא ראינו
|
||||
|
||||
@@ -385,3 +385,64 @@ The draft's biggest structural error was adding the "נבאר" doctrinal paragra
|
||||
- [ ] Update voice-fingerprint: add new transition phrases
|
||||
- [ ] Update architecture-by-outcome: add "clean acceptance" archetype
|
||||
- [ ] Fix agent opening punctuation: "ונפרט;" not "נפרט."
|
||||
|
||||
---
|
||||
|
||||
## Lessons from ערר 1200-25 (קרית ענבים — שימוש חורג, דחייה)
|
||||
|
||||
### Source
|
||||
- Our draft: `data/cases/1200-25/exports/טיוטה-v1.docx` (3,181 words)
|
||||
- Daphna's edit: `data/cases/1200-25/exports/עריכה-v1.docx` (4,313 words, +35%)
|
||||
- Date: May 2026
|
||||
|
||||
### What the Edit Changed
|
||||
|
||||
#### 1. Block Order — Plans Before Claims
|
||||
- **Draft:** ה→ו→ז→ח→ט→י→יא→יב (plans after procedures)
|
||||
- **Edit:** ה→ו→**ט**→ו.ב→ז→ח→י→יא→יב (plans BEFORE claims)
|
||||
- **Lesson:** In licensing cases (1xxx), the reader must understand the normative framework (plans) before reading the parties' arguments about those plans. Block ט should precede Block ז. The new order: opening → brief background → **applicable plans** → expanded background (application + committee proceedings) → claims → procedures → discussion.
|
||||
|
||||
#### 2. "להלן מתוך" Document Insertion Pattern
|
||||
- **Draft:** 0 occurrences
|
||||
- **Edit:** 12 occurrences of "להלן מתוך [document name]:"
|
||||
- **Lesson:** Every reference to a source document must be accompanied by "להלן מתוך [שם המסמך]:" as a placeholder for a direct quote/image. This is a MANDATORY pattern, not optional. Examples: "להלן מתוך הוראות התכנית:", "להלן מתוך פרוטוקול הדיון:", "להלן מתוך הבקשה להיתר:"
|
||||
|
||||
#### 3. Expanded Factual Background (Block ו)
|
||||
- **Draft:** ~90 words (3%), one paragraph
|
||||
- **Edit:** ~420 words (10%), covering: (a) the application details, (b) 3 committee meetings with dates and outcomes, (c) the final decision
|
||||
- **Lesson:** Block ו must tell the full "story" of the case: when the application was filed → when it was published → how many objections → when committee meetings were held → what was decided at each meeting → when the appeal was filed. Each meeting should have date + outcome.
|
||||
|
||||
#### 4. Bridge Planning Analysis ("גשר תכנוני")
|
||||
- **Draft:** Not present
|
||||
- **Edit:** 249 words — new analytical framework
|
||||
- **Lesson:** When an applicant for deviation/variance is also promoting a plan for the same land, the decision must analyze: (a) is the pending plan harmonious with the requested use? If yes → the deviation can serve as a "bridge" until the plan is approved (cite כוכבה תורן). If no → the contradiction STRENGTHENS the rejection. The writer must check `search_case_documents` for pending plans and compare them with the requested use.
|
||||
|
||||
#### 5. Competing Plans Analysis
|
||||
- **Draft:** Not present (1,033 words added)
|
||||
- **Edit:** Detailed comparison of the site-specific plan (151-1382787) vs the comprehensive plan (151-1337534)
|
||||
- **Lesson:** When there's a site-specific plan AND a comprehensive plan, the decision must: (a) describe each plan's scope, (b) compare the permitted uses, (c) show quantitative contradictions (e.g., "the comprehensive plan allocates 4,404 m² for ALL commerce in the settlement, while the request alone is for 1,425 m² — 32%"), (d) conclude whether there's harmony or contradiction. This is often the STRONGEST argument in the decision.
|
||||
|
||||
#### 6. Heading Level — Flat Structure
|
||||
- **Draft:** Mixed Heading 2 + Heading 3 (nested subsections)
|
||||
- **Edit:** All Heading 2 (flat structure)
|
||||
- **Lesson:** Each section stands independently. No nesting. In the discussion, each analytical step is a separate Heading 2 section.
|
||||
|
||||
#### 7. Inline Precedent Distinguishing
|
||||
- **Draft:** Separate section "הבחנה מתקדימי העוררת" (Heading 3)
|
||||
- **Edit:** Each precedent distinguished inline with "באשר ל-[case name]" → what's different → conclusion
|
||||
- **Lesson:** Don't create a separate "distinguishing" section. Address each precedent where it naturally comes up in the discussion, using "באשר ל..." as the opener.
|
||||
|
||||
### New Transition Phrases Identified
|
||||
- **"עינינו הרואות"** — introducing a document-based finding ("our eyes see that...")
|
||||
- **"הנה כי כן"** — therefore/accordingly (more formal than "לפיכך")
|
||||
- **"נשוב כאן ונבחין"** — returning to distinguish a case
|
||||
- **"נוסיף ונבהיר"** — adding clarification
|
||||
- **"מסקנת הדברים"** — concluding a subsection
|
||||
- **"משכבר קבענו"** — since we already established
|
||||
|
||||
### Applied To
|
||||
- [x] Update legal-decision-lessons.md with lessons 1-7
|
||||
- [x] Update daphna-voice-fingerprint.md with structural and style findings
|
||||
- [ ] Update block-schema.md: block order for 1xxx cases (ט before ז)
|
||||
- [ ] Update daphna-architecture-by-outcome.md: add "bridge planning" analysis for rejections
|
||||
- [ ] Update writer system prompt: mandatory "להלן מתוך" pattern
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
|
||||
"""קליטת טקסט ההחלטה הסופית (`סופי-{case}.docx` בתיקיית exports).
|
||||
max_chars: 0=הכל, אחרת חיתוך לאורך הנתון. שימושי ל-Hermes Knowledge Curator."""
|
||||
return await cases.case_get_final_text(case_number, max_chars)
|
||||
|
||||
|
||||
# Precedent attachments (user-supplied legal support for the compose phase)
|
||||
@mcp.tool()
|
||||
async def precedent_attach(
|
||||
@@ -209,6 +216,22 @@ async def precedent_library_delete(case_law_id: str) -> str:
|
||||
return await plib.precedent_library_delete(case_law_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_link_cases(
|
||||
case_law_id_a: str,
|
||||
case_law_id_b: str,
|
||||
relation_type: str = "same_case_chain",
|
||||
) -> str:
|
||||
"""קישור שתי פסיקות כקשורות (דו-כיווני, idempotent). relation_type: same_case_chain | overruled_by | distinguished."""
|
||||
return await plib.precedent_link_cases(case_law_id_a, case_law_id_b, relation_type)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
|
||||
"""הסרת קישור בין שתי פסיקות (דו-כיווני)."""
|
||||
return await plib.precedent_unlink_cases(case_law_id_a, case_law_id_b)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_extract_halachot(case_law_id: str) -> str:
|
||||
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
|
||||
@@ -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
|
||||
@mcp.tool()
|
||||
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)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def internal_decision_migrate(
|
||||
source: str = "both",
|
||||
dry_run: bool = True,
|
||||
) -> str:
|
||||
"""העברת החלטות ועדת ערר קיימות לקורפוס הפנימי (פעולת admin).
|
||||
|
||||
source: 'style_corpus' | 'external_corpus' | 'both'
|
||||
dry_run: אם true — מציג מה יקרה ללא כתיבה
|
||||
"""
|
||||
import json as _json
|
||||
from legal_mcp.services import internal_decisions as int_svc
|
||||
if source not in {"style_corpus", "external_corpus", "both"}:
|
||||
return "source חייב להיות style_corpus / external_corpus / both"
|
||||
results: dict = {}
|
||||
if source in {"style_corpus", "both"}:
|
||||
results["style_corpus"] = await int_svc.migrate_from_style_corpus(dry_run=dry_run)
|
||||
if source in {"external_corpus", "both"}:
|
||||
results["external_corpus"] = await int_svc.migrate_from_external_corpus(dry_run=dry_run)
|
||||
return _json.dumps(results, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def internal_decision_enrich(
|
||||
dry_run: bool = True,
|
||||
) -> str:
|
||||
"""העשרת החלטות שהומגרו (חד-פעמי): תיקון מספר ערר + שם + תאריך + תור להלכות.
|
||||
|
||||
dry_run=True — מציג כמה רשומות יטופלו ללא כתיבה.
|
||||
dry_run=False — מריץ בפועל: metadata extraction (תיקון case_number/case_name/date) ואחר כך תור חילוץ הלכות.
|
||||
"""
|
||||
import json as _json
|
||||
from legal_mcp.services import internal_decisions as int_svc
|
||||
result = await int_svc.enrich_migrated_entries(dry_run=dry_run)
|
||||
return _json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def record_chair_feedback(
|
||||
case_number: str,
|
||||
|
||||
@@ -360,13 +360,9 @@ async def write_block(
|
||||
post_hearing_context=post_hearing_context,
|
||||
)
|
||||
|
||||
# Restructure: sources first, then instructions
|
||||
prompt = (
|
||||
f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n"
|
||||
f"{source_context}\n\n"
|
||||
f"---\n\n"
|
||||
f"{formatted_prompt}"
|
||||
)
|
||||
# source_context is already embedded inside formatted_prompt via {source_context} in the
|
||||
# template. Do NOT prepend it again — doing so doubles the prompt size (was 465K chars).
|
||||
prompt = formatted_prompt
|
||||
|
||||
if instructions:
|
||||
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||
@@ -377,6 +373,19 @@ async def write_block(
|
||||
if not dir_doc.get("approved"):
|
||||
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
||||
|
||||
# Guard against context overflow before calling claude -p.
|
||||
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
|
||||
# In practice the CLI has crashed on prompts above ~400K chars, so use
|
||||
# that as a conservative ceiling (well below the token limit).
|
||||
_MAX_PROMPT_CHARS = 400_000
|
||||
if len(prompt) > _MAX_PROMPT_CHARS:
|
||||
raise RuntimeError(
|
||||
f"Prompt too large for {block_id}: {len(prompt):,} chars "
|
||||
f"(limit {_MAX_PROMPT_CHARS:,}). "
|
||||
f"source_context: {len(source_context):,} chars. "
|
||||
f"Reduce documents or call extract_appraiser_facts first."
|
||||
)
|
||||
|
||||
# Call Claude via Claude Code session (no API)
|
||||
model_key = block_cfg["model"]
|
||||
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
||||
@@ -414,16 +423,35 @@ def _build_case_context(case: dict, decision: dict | None) -> str:
|
||||
- תוצאה: {outcome_heb}"""
|
||||
|
||||
|
||||
# Which doc_types are relevant per block.
|
||||
# None → skip source docs entirely (block uses other context, e.g. claims_context)
|
||||
# [] → include all doc types (default for unspecified blocks)
|
||||
# [..] → include only the listed doc_type values
|
||||
_BLOCK_DOC_TYPES: dict[str, list[str] | None] = {
|
||||
"block-he": None, # only case_context needed; no full docs
|
||||
"block-vav": ["appeal", "protocol"], # כתב ערר + פרוטוקול ועדה
|
||||
"block-zayin": None, # claims_context is sufficient
|
||||
"block-chet": ["protocol"], # פרוטוקול + השלמות טיעון
|
||||
"block-tet": ["appraisal"], # שומות בלבד
|
||||
# block-yod, block-yod-alef, block-he etc. default → all docs
|
||||
}
|
||||
|
||||
|
||||
async def _build_source_context(case_id: UUID, block_id: str) -> str:
|
||||
"""Get full document texts for the block.
|
||||
"""Get document texts for the block, filtered by relevance.
|
||||
|
||||
Per Anthropic best practices: send full source documents, not truncated excerpts.
|
||||
Place documents at the TOP of the prompt (before instructions) for 30% better recall.
|
||||
For grounding: instruct Claude to cite word-for-word from these documents.
|
||||
Per-block filtering prevents context overflow on large cases (9+ docs).
|
||||
"""
|
||||
allowed = _BLOCK_DOC_TYPES.get(block_id, []) # [] sentinel = not in map → all docs
|
||||
if allowed is None:
|
||||
return "" # this block doesn't need raw source docs
|
||||
|
||||
docs = await db.list_documents(case_id)
|
||||
context_parts = []
|
||||
for doc in docs:
|
||||
if allowed and doc["doc_type"] not in allowed:
|
||||
continue
|
||||
text = await db.get_document_text(UUID(doc["id"]))
|
||||
if text:
|
||||
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")
|
||||
|
||||
@@ -72,6 +72,9 @@ async def query(
|
||||
"""
|
||||
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||
|
||||
if len(full_prompt) > 150_000:
|
||||
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
|
||||
|
||||
cmd = [
|
||||
"claude", "-p",
|
||||
"--output-format", "json",
|
||||
@@ -110,7 +113,8 @@ async def query(
|
||||
|
||||
if proc.returncode != 0:
|
||||
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()
|
||||
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 with pool.acquire() as conn:
|
||||
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_V8_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:
|
||||
@@ -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
|
||||
|
||||
|
||||
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:
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
@@ -1817,6 +1896,85 @@ async def create_external_case_law(
|
||||
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:
|
||||
"""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.
|
||||
"""
|
||||
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",
|
||||
"source_type", "precedent_level", "is_binding",
|
||||
}
|
||||
@@ -1892,10 +2050,21 @@ async def list_external_case_law(
|
||||
search: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
source_kind: str = "external_upload",
|
||||
) -> 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()
|
||||
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 = []
|
||||
idx = 1
|
||||
if practice_area:
|
||||
@@ -1927,6 +2096,7 @@ async def list_external_case_law(
|
||||
SELECT id, case_number, case_name, court, date, practice_area,
|
||||
appeal_subtype, source_type, precedent_level, is_binding,
|
||||
summary, headnote, subject_tags, source_kind,
|
||||
chair_name, district,
|
||||
extraction_status, halacha_extraction_status,
|
||||
metadata_extraction_requested_at,
|
||||
halacha_extraction_requested_at,
|
||||
@@ -2194,8 +2364,14 @@ async def search_precedent_library_semantic(
|
||||
subject_tag: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
source_kind: str = "external_upload",
|
||||
district: str = "",
|
||||
chair_name: str = "",
|
||||
) -> 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
|
||||
they get a small score boost. Only ``approved`` / ``published`` halachot
|
||||
@@ -2204,7 +2380,7 @@ async def search_precedent_library_semantic(
|
||||
"""
|
||||
pool = await get_pool()
|
||||
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]
|
||||
c_params: list = [query_embedding, limit]
|
||||
h_idx = 3
|
||||
@@ -2249,13 +2425,27 @@ async def search_precedent_library_semantic(
|
||||
halacha_filters.append(f"${h_idx} = ANY(h.subject_tags)")
|
||||
h_params.append(subject_tag)
|
||||
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"""
|
||||
SELECT h.id AS halacha_id, h.case_law_id, h.rule_statement,
|
||||
h.reasoning_summary, h.supporting_quote, h.page_reference,
|
||||
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.precedent_level,
|
||||
cl.precedent_level, cl.chair_name, cl.district,
|
||||
1 - (h.embedding <=> $1) AS score
|
||||
FROM halachot h
|
||||
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,
|
||||
pc.section_type, pc.page_number,
|
||||
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
|
||||
FROM precedent_chunks pc
|
||||
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()
|
||||
async with pool.acquire() as conn:
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM case_law WHERE source_kind = 'external_upload'"
|
||||
"SELECT COUNT(*) FROM case_law"
|
||||
)
|
||||
by_practice = await conn.fetch(
|
||||
"""SELECT practice_area, COUNT(*) AS n
|
||||
FROM case_law
|
||||
WHERE source_kind = 'external_upload'
|
||||
GROUP BY practice_area
|
||||
ORDER BY n DESC"""
|
||||
)
|
||||
by_level = await conn.fetch(
|
||||
"""SELECT precedent_level, COUNT(*) AS n
|
||||
FROM case_law
|
||||
WHERE source_kind = 'external_upload'
|
||||
GROUP BY precedent_level
|
||||
ORDER BY n DESC"""
|
||||
)
|
||||
@@ -2354,22 +2542,31 @@ async def precedent_library_stats() -> dict:
|
||||
|
||||
async def request_metadata_extraction(case_law_id: UUID) -> bool:
|
||||
"""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()
|
||||
result = await pool.execute(
|
||||
"UPDATE case_law SET metadata_extraction_requested_at = now() "
|
||||
"WHERE id = $1 AND source_kind = 'external_upload'",
|
||||
"WHERE id = $1",
|
||||
case_law_id,
|
||||
)
|
||||
return result == "UPDATE 1"
|
||||
|
||||
|
||||
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()
|
||||
result = await pool.execute(
|
||||
"UPDATE case_law SET halacha_extraction_requested_at = now() "
|
||||
"WHERE id = $1 AND source_kind = 'external_upload'",
|
||||
"WHERE id = $1",
|
||||
case_law_id,
|
||||
)
|
||||
return result == "UPDATE 1"
|
||||
@@ -2389,12 +2586,15 @@ async def list_pending_extraction_requests(
|
||||
else "halacha_extraction_requested_at"
|
||||
)
|
||||
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(
|
||||
f"""SELECT id, case_number, case_name, court, date,
|
||||
practice_area, is_binding, {col} AS requested_at
|
||||
FROM case_law
|
||||
WHERE {col} IS NOT NULL
|
||||
AND source_kind = 'external_upload'
|
||||
ORDER BY {col} ASC
|
||||
LIMIT $1""",
|
||||
limit,
|
||||
|
||||
@@ -291,6 +291,7 @@ _INTERIM_BLOCK_ORDER = [
|
||||
"block-bet", # panel (skipped if empty)
|
||||
"block-gimel", # parties (skipped if empty)
|
||||
"block-dalet", # "החלטה" title (skipped if empty)
|
||||
"block-he", # פתיחה ניטרלית (skipped if empty — opt-in for pre-ruling drafts)
|
||||
"block-vav", # רקע עובדתי
|
||||
"block-tet", # תכניות + היתרים (extended)
|
||||
"block-zayin", # טענות הצדדים
|
||||
|
||||
@@ -88,8 +88,15 @@ async def search_precedent_library_hybrid(
|
||||
is_binding: bool | None = None,
|
||||
subject_tag: str = "",
|
||||
include_halachot: bool = True,
|
||||
source_kind: str = "external_upload",
|
||||
district: str = "",
|
||||
chair_name: str = "",
|
||||
) -> 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
|
||||
|
||||
async def _base(limit: int) -> list[dict]:
|
||||
@@ -103,6 +110,9 @@ async def search_precedent_library_hybrid(
|
||||
subject_tag=subject_tag,
|
||||
limit=limit,
|
||||
include_halachot=include_halachot,
|
||||
source_kind=source_kind,
|
||||
district=district,
|
||||
chair_name=chair_name,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -123,7 +123,7 @@ SUMMARY_STRATEGIES = {
|
||||
|
||||
DISCUSSION_RULES: dict[str, list[str]] = {
|
||||
"universal": [
|
||||
"פרק הדיון = אסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
||||
"פרק הדיון = מאסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
||||
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
|
||||
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
|
||||
],
|
||||
@@ -485,6 +485,7 @@ CONTENT_CHECKLISTS: dict[str, str] = {
|
||||
- שווי מקרקעין — מצב קודם ומצב חדש (שיטת השוואה / יחידות תועלת)
|
||||
- עלויות עודפות (חניה, מטלות ציבוריות, תשתיות)
|
||||
- מקדמי זמינות, שיעורי הפקעה
|
||||
- הכרעה מפוצלת (bifurcation) — כשהוועדה מאשרת חבות אך ממנה שמאי מייעץ: ביטויי גישור ("ניתן יהיה לעלות בפני השמאי המייעץ"), נוסחת מינוי, הפניה לתקנות סדרי דין התשס"ט-2008, הוראות המשך (30 יום להשגות). ללא סיכום — ישירות לחתימה. ראה: 8070/25
|
||||
|
||||
### ד. שאלות משפטיות (לפי רלוונטיות)
|
||||
- פטורים — דירת מגורים (ס' 19(ג)(1)), שטח עד 140 מ"ר, תא משפחתי
|
||||
@@ -493,6 +494,7 @@ CONTENT_CHECKLISTS: dict[str, str] = {
|
||||
- מקרקעי ישראל — הסדרים מיוחדים (ס' 21 לתוספת השלישית)
|
||||
- שומות מוסכמות — תוקף, משמעות, "בלתי נצפה מראש"
|
||||
- פרשנות תכניות — ייעוד, שימושים מותרים, מדיניות ועדה מקומית
|
||||
- טענת "תכנית צל = זכות מוקנית" — ניתוח תלת-שכבתי: (1) נורמטיבית — תכנית צל = המחשה, לא מקור נורמטיבי; (2) פרוצדורלית — הקלה ניתנת פר-מבקש, לא זכות כללית; (3) שמאית — משקל הסתברותי בהערכת ההשבחה, לא במישור המשפטי. ראה: 8070/25
|
||||
|
||||
### ה. ניתוח שמאי (כשיש שומה מכרעת)
|
||||
- האם השומה מבוססת על מסד עובדתי הולם?
|
||||
|
||||
@@ -257,11 +257,19 @@ async def reextract_halachot(
|
||||
case_law_id = UUID(case_law_id)
|
||||
|
||||
record = await db.get_case_law(case_law_id)
|
||||
if not record or record.get("source_kind") != "external_upload":
|
||||
raise ValueError("precedent not found or not chair-uploaded")
|
||||
if not record:
|
||||
raise ValueError("precedent not found")
|
||||
# Was restricted to source_kind='external_upload'; opened 2026-05-06 so
|
||||
# internal_committee rows can also be re-extracted when ingest produced
|
||||
# bad data. See note in db.request_metadata_extraction.
|
||||
|
||||
await progress("extracting_halachot", 50, "מחלץ הלכות מחדש")
|
||||
result = await halacha_extractor.extract(case_law_id)
|
||||
# Clear the queue timestamp on completion so the UI badge / worker queue
|
||||
# don't keep showing this row. The queue worker (process_pending_extractions)
|
||||
# already does this; mirror it here so per-record extraction drains too.
|
||||
if result.get("status") in ("completed", "no_halachot"):
|
||||
await db.clear_extraction_request(case_law_id, kind="halacha")
|
||||
await progress(
|
||||
"completed",
|
||||
100,
|
||||
@@ -402,11 +410,16 @@ async def reextract_metadata(
|
||||
case_law_id = UUID(case_law_id)
|
||||
|
||||
record = await db.get_case_law(case_law_id)
|
||||
if not record or record.get("source_kind") != "external_upload":
|
||||
raise ValueError("precedent not found or not chair-uploaded")
|
||||
if not record:
|
||||
raise ValueError("precedent not found")
|
||||
# See note in db.request_metadata_extraction — opened to all source kinds.
|
||||
|
||||
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)")
|
||||
result = await precedent_metadata_extractor.extract_and_apply(case_law_id)
|
||||
# Clear the queue timestamp so the UI / worker stop showing this row.
|
||||
# See note in reextract_halachot.
|
||||
if result.get("status") in ("completed", "no_changes"):
|
||||
await db.clear_extraction_request(case_law_id, kind="metadata")
|
||||
fields = result.get("fields") or []
|
||||
msg = (
|
||||
f"מולאו {len(fields)} שדות: {', '.join(fields)}"
|
||||
@@ -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:
|
||||
"""Get a precedent with its halachot attached."""
|
||||
"""Get a precedent with its halachot and related cases attached."""
|
||||
if isinstance(case_law_id, str):
|
||||
case_law_id = UUID(case_law_id)
|
||||
record = await db.get_case_law(case_law_id)
|
||||
if not record:
|
||||
return None
|
||||
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
|
||||
record["related_cases"] = await db.get_case_law_relations(case_law_id)
|
||||
return record
|
||||
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
|
||||
"decision_date_iso": "YYYY-MM-DD — תאריך מתן ההחלטה כפי שמופיע בטקסט (בכותרת או בחתימה הסופית). אם לא ניתן לזהות במדויק — מחרוזת ריקה.",
|
||||
"precedent_level": "אחד מ-4: 'עליון' / 'מנהלי' / 'ועדת_ערר_ארצית' / 'ועדת_ערר_מחוזית'. בחר לפי הערכאה שמסומנת בכותרת הפסק. אם לא ברור — מחרוזת ריקה.",
|
||||
"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
|
||||
if isinstance(result.get("court"), str):
|
||||
out["court"] = result["court"].strip()
|
||||
if isinstance(result.get("case_number_clean"), str):
|
||||
out["case_number_clean"] = result["case_number_clean"].strip()
|
||||
return out
|
||||
|
||||
|
||||
async def apply_to_record(
|
||||
case_law_id: UUID | str,
|
||||
suggested: dict,
|
||||
overwrite_case_number: bool = False,
|
||||
) -> dict:
|
||||
"""Merge suggested metadata into the case_law row, filling ONLY empty fields.
|
||||
|
||||
@@ -178,6 +182,9 @@ async def apply_to_record(
|
||||
case_name has special handling: if the current case_name equals the
|
||||
case_number (a tell-tale sign of the upload form sending the long
|
||||
citation into both fields), treat it as empty and overwrite.
|
||||
|
||||
overwrite_case_number: when True, update case_number from case_number_clean
|
||||
even if the field already has a value (used for one-time migration enrichment).
|
||||
"""
|
||||
if isinstance(case_law_id, str):
|
||||
case_law_id = UUID(case_law_id)
|
||||
@@ -216,7 +223,17 @@ async def apply_to_record(
|
||||
fields_to_update["key_quote"] = s
|
||||
|
||||
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 []
|
||||
if sug_tags:
|
||||
fields_to_update["subject_tags"] = sug_tags
|
||||
@@ -250,6 +267,11 @@ async def apply_to_record(
|
||||
if 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:
|
||||
return {"updated": False, "fields": []}
|
||||
|
||||
@@ -257,12 +279,15 @@ async def apply_to_record(
|
||||
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."""
|
||||
suggested = await extract_metadata(case_law_id)
|
||||
if not suggested:
|
||||
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 {
|
||||
"status": "completed" if result["updated"] else "no_changes",
|
||||
"fields": result["fields"],
|
||||
|
||||
@@ -55,6 +55,9 @@ def _is_placeholder(text: str) -> bool:
|
||||
for ph in CHAIR_POSITION_PLACEHOLDERS:
|
||||
if ph in stripped:
|
||||
return True
|
||||
# Extended placeholders: [ימולא ע"י יו"ר הוועדה — extra descriptive text]
|
||||
if re.match(r'^\[ימולא\b', stripped):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from uuid import UUID
|
||||
import httpx
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import audit, db, git_sync, practice_area as pa
|
||||
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -237,7 +237,10 @@ async def case_list(status: str = "", limit: int = 50) -> str:
|
||||
"""רשימת תיקי ערר עם אפשרות סינון לפי סטטוס.
|
||||
|
||||
Args:
|
||||
status: סינון לפי סטטוס (new, in_progress, drafted, reviewed, final). ריק = הכל
|
||||
status: סינון לפי סטטוס (new, processing, proofread, documents_ready, analyst_verified,
|
||||
research_complete, outcome_set, direction_pending, direction_approved,
|
||||
analysis_enriched, ready_for_writing, drafted, qa_passed, qa_failed,
|
||||
exported, done). ריק = הכל
|
||||
limit: מספר תוצאות מקסימלי
|
||||
"""
|
||||
cases = await db.list_cases(status=status or None, limit=limit)
|
||||
@@ -271,6 +274,10 @@ async def case_update(
|
||||
decision_date: str = "",
|
||||
tags: list[str] | None = None,
|
||||
expected_outcome: str = "",
|
||||
appellants: list[str] | None = None,
|
||||
respondents: list[str] | None = None,
|
||||
property_address: str = "",
|
||||
permit_number: str = "",
|
||||
) -> str:
|
||||
"""עדכון פרטי תיק.
|
||||
|
||||
@@ -284,6 +291,10 @@ async def case_update(
|
||||
decision_date: תאריך החלטה (YYYY-MM-DD)
|
||||
tags: תגיות
|
||||
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
|
||||
appellants: רשימת עוררים חדשה
|
||||
respondents: רשימת משיבים חדשה
|
||||
property_address: כתובת נכס חדשה
|
||||
permit_number: מספר תכנית/בקשה חדש
|
||||
"""
|
||||
from datetime import date as date_type
|
||||
|
||||
@@ -322,6 +333,14 @@ async def case_update(
|
||||
fields["tags"] = tags
|
||||
if expected_outcome:
|
||||
fields["expected_outcome"] = expected_outcome
|
||||
if appellants is not None:
|
||||
fields["appellants"] = appellants
|
||||
if respondents is not None:
|
||||
fields["respondents"] = respondents
|
||||
if property_address:
|
||||
fields["property_address"] = property_address
|
||||
if permit_number:
|
||||
fields["permit_number"] = permit_number
|
||||
|
||||
updated = await db.update_case(UUID(case["id"]), **fields)
|
||||
|
||||
@@ -370,3 +389,66 @@ async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
||||
result["removed_files"] = True
|
||||
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
|
||||
"""קליטת טקסט ההחלטה הסופית (`סופי-{case}.docx` בתיקיית exports).
|
||||
|
||||
בניגוד ל-`document_get_text` שעובד על שורות בטבלת `documents`,
|
||||
הקובץ הסופי הוא רק קובץ בתיקייה (נוצר על ידי `api_mark_final`).
|
||||
תומך בכל הפורמטים ש-extractor.extract_text מטפל בהם — מנסה
|
||||
`.docx` תחילה, ואז `.pdf`, `.doc`, `.rtf`, `.txt`, `.md`.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
max_chars: אם >0, חתוך את הטקסט המוחזר לאורך הזה. 0 = הכל.
|
||||
"""
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
exports_dir = case_dir / "exports"
|
||||
final_stem = f"סופי-{case_number}"
|
||||
|
||||
final_path = None
|
||||
for ext in (".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"):
|
||||
candidate = exports_dir / f"{final_stem}{ext}"
|
||||
if candidate.exists():
|
||||
final_path = candidate
|
||||
break
|
||||
|
||||
if final_path is None:
|
||||
return json.dumps({
|
||||
"status": "not_found",
|
||||
"case_number": case_number,
|
||||
"expected_path": str(exports_dir / f"{final_stem}.docx"),
|
||||
"tried_extensions": [".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"],
|
||||
"hint": (
|
||||
"ההחלטה הסופית עדיין לא סומנה כ'סופית' ב-UI. "
|
||||
"דפנה צריכה ללחוץ 'סמן כסופי' על קובץ הטיוטה הנכון."
|
||||
),
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
try:
|
||||
text, page_count, _ = await extractor.extract_text(str(final_path))
|
||||
except Exception as e:
|
||||
logger.exception("case_get_final_text: extraction failed for %s", case_number)
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"case_number": case_number,
|
||||
"file_path": str(final_path),
|
||||
"error": str(e),
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
text = text or ""
|
||||
truncated = False
|
||||
if max_chars > 0 and len(text) > max_chars:
|
||||
text = text[:max_chars]
|
||||
truncated = True
|
||||
|
||||
return json.dumps({
|
||||
"status": "ok",
|
||||
"case_number": case_number,
|
||||
"file_path": str(final_path),
|
||||
"text_length": len(text),
|
||||
"page_count": page_count,
|
||||
"truncated": truncated,
|
||||
"text": text,
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -424,7 +424,7 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
|
||||
# Blocks written for the interim draft, in display order.
|
||||
# This is the same content the chair sees in the final decision (same template,
|
||||
# same skill, same prompts) — minus opening, ruling, summary, signatures.
|
||||
_INTERIM_BLOCKS = ["block-vav", "block-tet", "block-zayin", "block-chet"]
|
||||
_INTERIM_BLOCKS = ["block-he", "block-vav", "block-tet", "block-zayin", "block-chet"]
|
||||
|
||||
|
||||
async def extract_appraiser_facts(case_number: str) -> str:
|
||||
|
||||
@@ -116,6 +116,54 @@ async def precedent_library_get(case_law_id: str) -> str:
|
||||
return _ok(record)
|
||||
|
||||
|
||||
async def precedent_link_cases(
|
||||
case_law_id_a: str,
|
||||
case_law_id_b: str,
|
||||
relation_type: str = "same_case_chain",
|
||||
) -> str:
|
||||
"""קישור שתי פסיקות כקשורות זו לזו (דו-כיווני). idempotent.
|
||||
|
||||
Args:
|
||||
case_law_id_a: UUID של פסיקה ראשונה.
|
||||
case_law_id_b: UUID של פסיקה שנייה.
|
||||
relation_type: same_case_chain | overruled_by | distinguished
|
||||
"""
|
||||
try:
|
||||
a = UUID(case_law_id_a)
|
||||
b = UUID(case_law_id_b)
|
||||
except ValueError:
|
||||
return _err("case_law_id לא תקין")
|
||||
rec_a = await db.get_case_law(a)
|
||||
rec_b = await db.get_case_law(b)
|
||||
if not rec_a:
|
||||
return _err(f"פסיקה {case_law_id_a} לא נמצאה")
|
||||
if not rec_b:
|
||||
return _err(f"פסיקה {case_law_id_b} לא נמצאה")
|
||||
await db.add_case_law_relation(a, b, relation_type)
|
||||
return _ok({
|
||||
"linked": True,
|
||||
"relation_type": relation_type,
|
||||
"a": {"id": case_law_id_a, "case_number": rec_a.get("case_number"), "court": rec_a.get("court")},
|
||||
"b": {"id": case_law_id_b, "case_number": rec_b.get("case_number"), "court": rec_b.get("court")},
|
||||
})
|
||||
|
||||
|
||||
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
|
||||
"""הסרת קישור בין שתי פסיקות (דו-כיווני).
|
||||
|
||||
Args:
|
||||
case_law_id_a: UUID של פסיקה ראשונה.
|
||||
case_law_id_b: UUID של פסיקה שנייה.
|
||||
"""
|
||||
try:
|
||||
a = UUID(case_law_id_a)
|
||||
b = UUID(case_law_id_b)
|
||||
except ValueError:
|
||||
return _err("case_law_id לא תקין")
|
||||
await db.remove_case_law_relation(a, b)
|
||||
return _ok({"unlinked": True, "a": case_law_id_a, "b": case_law_id_b})
|
||||
|
||||
|
||||
async def precedent_library_delete(case_law_id: str) -> str:
|
||||
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
|
||||
try:
|
||||
|
||||
@@ -179,3 +179,63 @@ async def find_similar_cases(
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
import json
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def workflow_status(case_number: str) -> str:
|
||||
"""סטטוס תהליך עבודה מלא לתיק - מסמכים, עיבוד, טיוטות.
|
||||
@@ -315,10 +318,29 @@ async def ingest_final_version(
|
||||
|
||||
try:
|
||||
result = await learning_loop.process_final_version(case_id, final_text)
|
||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
||||
except ValueError as e:
|
||||
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
|
||||
|
||||
# Auto-ingest into internal committee decisions corpus (best-effort).
|
||||
try:
|
||||
from legal_mcp.services import internal_decisions as int_svc
|
||||
await int_svc.ingest_internal_decision(
|
||||
case_number=case_number,
|
||||
case_name=case.get("title", ""),
|
||||
decision_date=case.get("decision_date"),
|
||||
chair_name=case.get("chair_name", ""),
|
||||
district="ירושלים",
|
||||
practice_area=case.get("practice_area", ""),
|
||||
appeal_subtype=case.get("appeal_subtype", ""),
|
||||
text=final_text,
|
||||
)
|
||||
result["internal_corpus_ingested"] = True
|
||||
except Exception as e:
|
||||
logger.warning("ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e)
|
||||
result["internal_corpus_ingested"] = False
|
||||
|
||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ── Chair feedback tools ──────────────────────────────────────────
|
||||
|
||||
|
||||
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 |
|
||||
|--------|------|---------|-----------|
|
||||
| `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) |
|
||||
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
||||
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
||||
@@ -50,6 +54,9 @@
|
||||
| `seed-appeals.py` | seeding תיקי ערר ראשוניים ל-DB | MCP: `case_create()` |
|
||||
| `seed-knowledge.py` | seeding לקחים, ביטויי מעבר, פסיקה | MCP: `record_chair_feedback()`, `precedent_attach()` |
|
||||
| `validate-decision.py` | ולידציה מול block-schema | MCP: `validate_decision()` + `qa_validator.py` |
|
||||
| `run_curator_deepseek_test.sh` | A/B test #1 (2026-05-05) — Hermes Curator על CMP-78 דרך DeepSeek V4-Pro ב-`provider:custom`, ללא interaction. תוצאה: 6:33 דק׳, 5 ממצאי סגנון/לקסיקון, פי 3 מהיר מ-Sonnet baseline (CMP-80) ופי ~20 זול. **הסקריפט נקודתי לתיק 1130-25 — לא להריץ שוב** | החלפת Curator לאדפטר DeepSeek מקומי (בתהליך) |
|
||||
| `run_curator_deepseek_test_v2.sh` | A/B test #2 (2026-05-05) — אותו run אבל עם interaction. תוצאה: 9:08 דק׳, 5 ממצאים, היחיד מ-4 הריצות שזיהה תוצאה עובדתית נכונה (קבלה חלקית). interaction נכשל ב-API ("Agent run id required" בריצה ידנית). | החלפת Curator לאדפטר DeepSeek מקומי |
|
||||
| `run_curator_sonnet_rerun.sh` | A/B test #3 (2026-05-05) — ריצה חוזרת של Sonnet 4.5 על אותו CMP-78. תוצאה: 12:52 דק׳ (לעומת 20:13 בריצה המקורית — כי בלי לולאת interaction.json). זיהה תוצאה שגויה ("דחייה") **בעקביות עם הריצה המקורית** — Sonnet עקבי-בטעות, DeepSeek אקראי. | בדיקה חד-פעמית — לא להריץ שוב |
|
||||
|
||||
## סקריפטים שנמחקו (git history בלבד)
|
||||
|
||||
|
||||
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
|
||||
469
scripts/sync_agents_across_companies.py
Normal file
469
scripts/sync_agents_across_companies.py
Normal file
@@ -0,0 +1,469 @@
|
||||
#!/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"]
|
||||
|
||||
# Stamp claude_md_mtime + last_synced into metadata
|
||||
mtime = diff.get("_claude_md_mtime")
|
||||
if mtime:
|
||||
current_meta = dict(patch_body.get("metadata") or {})
|
||||
current_meta["claude_md_mtime"] = mtime
|
||||
current_meta["claude_md_last_synced"] = datetime.now(timezone.utc).isoformat()
|
||||
patch_body["metadata"] = current_meta
|
||||
|
||||
if patch_body:
|
||||
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
|
||||
|
||||
|
||||
def get_claude_md_mtime(adapter_config: dict) -> str | None:
|
||||
"""Return Unix mtime of the agent's instructionsFilePath, or None if file missing."""
|
||||
path = adapter_config.get("instructionsFilePath", "")
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
return str(int(os.path.getmtime(path)))
|
||||
|
||||
|
||||
async def check_instructions(agents: list[dict]) -> bool:
|
||||
"""Print a report of all agents' instruction files. Returns True if all OK."""
|
||||
from datetime import datetime
|
||||
|
||||
all_ok = True
|
||||
print(f"\n{'Agent':<30} {'File':<55} {'Status':<12} {'Size':>7} {'Modified'}")
|
||||
print("-" * 115)
|
||||
|
||||
for agent in agents:
|
||||
name = (agent.get("name") or agent.get("id") or "?")[:29]
|
||||
|
||||
try:
|
||||
adapter_cfg = agent.get("adapter_config") or {}
|
||||
if isinstance(adapter_cfg, str):
|
||||
adapter_cfg = json.loads(adapter_cfg)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
print(f"{name:<30} {'(malformed adapter_config in DB)':<55} {'⚠ ERROR':<12}")
|
||||
continue
|
||||
|
||||
file_path = adapter_cfg.get("instructionsFilePath", "")
|
||||
|
||||
if not file_path:
|
||||
print(f"{name:<30} {'(none)':<55} {'⚠ NOT SET':<12}")
|
||||
continue
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(f"{name:<30} {file_path[-54:]:<55} {'❌ MISSING':<12}")
|
||||
all_ok = False
|
||||
continue
|
||||
|
||||
stat = os.stat(file_path)
|
||||
size_kb = stat.st_size // 1024
|
||||
mtime = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# Check for drift vs DB metadata
|
||||
try:
|
||||
metadata = agent.get("metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
metadata = json.loads(metadata)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
metadata = {}
|
||||
db_mtime = metadata.get("claude_md_mtime", "")
|
||||
actual_mtime = str(int(stat.st_mtime))
|
||||
drift = " ⚠ DRIFT" if db_mtime and db_mtime != actual_mtime else ""
|
||||
|
||||
print(f"{name:<30} {file_path[-54:]:<55} {'✅ OK':<12} {size_kb:>5}KB {mtime}{drift}")
|
||||
|
||||
print()
|
||||
return all_ok
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
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")
|
||||
g.add_argument("--check-instructions", action="store_true",
|
||||
help="Scan all agents' instructionsFilePath and report missing/outdated files")
|
||||
p.add_argument("--only", help="Sync only the named agent (e.g., 'עוזר משפטי')")
|
||||
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()
|
||||
|
||||
if args.check_instructions:
|
||||
all_agents = master_agents + mirror_agents
|
||||
all_ok = await check_instructions(all_agents)
|
||||
sys.exit(0 if all_ok else 1)
|
||||
|
||||
mirror_by_name = {a["name"]: a for a in mirror_agents}
|
||||
|
||||
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
|
||||
# Pre-flight: abort if any master agent is missing its instructions file
|
||||
print("🔍 Pre-flight: checking instruction files...")
|
||||
all_ok = await check_instructions(master_agents)
|
||||
if not all_ok:
|
||||
print("❌ Abort: one or more instruction files are missing. Fix before --apply.")
|
||||
sys.exit(1)
|
||||
print("✅ Pre-flight passed.\n")
|
||||
|
||||
print(f"\n=== Backup ===")
|
||||
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']})")
|
||||
# Inject mtime into diff so apply_diff can stamp metadata
|
||||
master_ac = master.get("adapter_config") or {}
|
||||
mtime = get_claude_md_mtime(master_ac)
|
||||
if mtime:
|
||||
diff["_claude_md_mtime"] = mtime
|
||||
errors = await apply_diff(mirror["id"], master["name"], diff)
|
||||
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())
|
||||
@@ -283,6 +283,16 @@ description: This skill should be used when writing legal decisions (החלטו
|
||||
**ערר היטל השבחה:**
|
||||
פתיחה ישירה עם מסקנה. ניתוח ישיר - ציטוטי פסיקה מרובים. סיום יבש.
|
||||
|
||||
**ערר היטל השבחה — הכרעה מפוצלת (שמאי מייעץ):**
|
||||
תת-מסלול חוזר: הוועדה מאשרת את עצם החבות אך אינה קובעת את גובה ההיטל — ממנה שמאי מייעץ. פתיחה: זהה למסלול הכללי — "עניינו של ערר זה בדרישת תשלום היטל השבחה...". ללא סיפור תכנוני רחב. דיון — שלב משפטי: הכרעה בשאלת עצם החבות. ניתוח הטענה המרכזית (בד"כ "זכות מוקנית") מול ההקלה שהתבקשה. ציטוטי פסיקה inline — הפניה להחלטות ועדת ערר קודמות ללא בלוק ציטוט מלא. ביטויי מפתח: "אנו סבורים כי...", "בניגוד לעמדת העורר...", "לא ניתן לטעון כי...". מעבר לשלב השמאי — 3 ביטויי גישור: (א) "בכל הנוגע לטענות לגבי מקדמים... וכל טענה בעלת אופי שמאי **ניתן יהיה לעלות בפני השמאי המייעץ**." (ב) "על כן, לאור האמור **אנו ממנים שמאי מייעץ** אשר יערוך שומה להערכת ההשבחה במקרקעין כתוצאה מאישור ההקלה..." (ג) "השמאי המייעץ ינהל את הדיון **בהתאם לתקנות התכנון והבניה (סדרי דין בבקשה להכרעה לפני שמאי מכריע או שמאי מייעץ), התשס"ט-2008**." — נוסחה קבועה. הוראות המשך: "לאחר קבלת השומה המייעצת יהיו רשאים הצדדים להגיש את השגותיהם בתוך 30 יום לוועדת הערר ולאחר מכן תתקבל החלטה באשר לאופן קידום ההליך." סיום: **ללא** כותרת "סיכום" / "סוף דבר" — זורם ישירות מהוראות המינוי לחתימה "ניתנה פה אחד היום...". הוצאות: לא מוזכרות (ההליך טרם הסתיים). ראה: נווה יעקב 8070/25.
|
||||
|
||||
**ערר היטל השבחה — מסגרת תלת-שכבתית לניתוח "תכנית צל":**
|
||||
כשעורר טוען ש"תכנית צל" מאושרת הופכת זכויות להקלה לזכויות מוקנות, הניתוח מתבצע בשלוש שכבות נפרדות:
|
||||
*שכבה 1 — נורמטיבית* (שלילת המעמד המשפטי): "תכנית צל אינה מקור נורמטיבי לאישור זכויות... אינה 'מבטיחה' אישור זכויות עבור השכן." ביטויי מפתח: "תכנית צל הינה תכנית המבקשת... להראות היתכנות בניה על ידי יתר בעלי הזכויות ו/או השלכת הבניה עליהם, על הבניין ועל הסביבה." כלל: תכנית צל = המחשה, לא מקור נורמטיבי.
|
||||
*שכבה 2 — פרוצדורלית* (ההקלה ניתנת פר-מבקש): "גם בהיתר חבקין התבקשה הקלה שאושרה, הקלה אך ורק להיתר שהתבקש ולזכויות שהתבקשו מכוחו. ההקלה לא התבקשה עבור כל דיירי הבניין... בקשה להקלה ופרסומה יש בצידם שיקול דעת... באופן ייחודי לכל בקשה לגופה." כלל: אישור הקלה לדייר א' ≠ זכות מוקנית לדייר ב'.
|
||||
*שכבה 3 — שמאית* (הכרה בערך ראייתי, ניתוב למישור הנכון): "העובדה שאושרה תכנית צל יש בה מידת וודאות גבוהה יותר באשר לסיכוי כי תאושר ההקלה... ולכך **משקל שמאי** בהערכת ההשבחה." כלל: תכנית צל משפיעה על **ההסתברות** (מישור שמאי), לא על **הזכות** (מישור משפטי).
|
||||
סדר הניתוח: תמיד שכבה 1 → 2 → 3. לא לדלג — גם אם שכבה 1 מכריעה, יש ערך בכל שלוש לביסוס ולמניעת ערעור. ראה: נווה יעקב 8070/25.
|
||||
|
||||
**ערר רישוי שמתקבל חלקית — מסלול מיפוי מתחים + ניתוח נושאי:**
|
||||
פתיחה במיפוי מתחים (3-6 סעיפים): הקשר כללי קצר (1-2 פסקאות), רשימת נקודות מתח ספציפיות בתיק (4-6 בולטים), מעבר לניתוח. אין שימוש בשכבות/עיגולים קונצנטריים — ניתוח לפי נושאים: כל נושא מקבל טיפול מלא (הצגה → ציטוט הוראות תכנית → פסיקה → מסקנה). נושא חניה/תשתיות מקבל טיפול מעמיק במיוחד עם ציטוטים ישירים מהוראות תכנית ונספחים. טענות ספציפיות (מטרדים, עצים, בור מים) — 1-2 סעיפים תמציתיים לכל אחת. סיכום מינימלי — רק הוראות אופרטיביות (2-3 סעיפים). ראה: בית הכרם 1126/25.
|
||||
|
||||
@@ -293,18 +303,25 @@ description: This skill should be used when writing legal decisions (החלטו
|
||||
|
||||
### 7.5 שלושה מקורות פסיקה — אל תבלבל
|
||||
|
||||
המערכת מפרידה בין שלושה קורפוסי פסיקה. כל אחד מהם משמש למטרה אחרת ויש כלי MCP נפרד לחיפוש בו:
|
||||
המערכת מפרידה בין **ארבעה** קורפוסי פסיקה. כל אחד מהם משמש למטרה אחרת ויש כלי MCP נפרד לחיפוש בו:
|
||||
|
||||
| קורפוס | טבלה | כלי חיפוש | תפקיד |
|
||||
|---|---|---|---|
|
||||
| תקדימי דפנה (סגנון) | `style_corpus` + `paragraph_embeddings` | `search_decisions` | החלטות שדפנה עצמה כתבה. מקור לסגנון, ניסוחים, ג'וריספרודנציה אישית. |
|
||||
| ספריית הפסיקה הסמכותית | `case_law` (`source_kind='external_upload'`) + `halachot` | `search_precedent_library` | פסיקה חיצונית מחייבת — עליון, מנהלי, ועדות ערר אחרות — עם הלכות שאושרו ע"י דפנה. **המקור היחיד לציטוטים בבלוק י לפי CREAC.** |
|
||||
| ציטוטים שצורפו ידנית | `case_precedents` | `precedent_search_library` | quotes שדפנה צירפה לתיק ספציפי בעבר. דומה לקורפוס סמכותי אך פר-תיק, ידני, לא עוברת חילוץ הלכות. |
|
||||
| לימוד סגנון | `style_corpus` | (לא לחיפוש תוכן) | ממשק /training — ניתוח "הקול" של היו"ר: טון, ביטויי מעבר, מבנה פסקאות. **אין לחפש כאן תוכן משפטי.** |
|
||||
| החלטות ועדות ערר | `case_law` (`source_kind='internal_committee'`) + `halachot` | `search_internal_decisions` | **כל** ועדות הערר לתכנון ובנייה (כל המחוזות). מסונן לפי `district` ו-`chair_name`. מקור לעקביות פנימית ופרקטיקה ארצית. |
|
||||
| פסיקת בתי משפט | `case_law` (`source_kind='external_upload'`) + `halachot` | `search_precedent_library` | בתי משפט: עליון, מנהלי, בג"ץ. **המקור היחיד לציטוטים מחייבים בבלוק י לפי CREAC.** |
|
||||
| ציטוטים ידניים | `case_precedents` | `precedent_search_library` | quotes שצורפו לתיק ספציפי בעבר. פר-תיק, ידני. |
|
||||
|
||||
**הזרימה הסטנדרטית בבלוק י:**
|
||||
1. `search_decisions` קודם — בדוק אם דפנה כבר הכריעה בסוגיה דומה (חיסכון דוקטרינרי / הבחנה).
|
||||
2. `search_precedent_library` — חפש את הכלל המחייב והציטוט התומך לפסקת CREAC.
|
||||
3. אם הצדדים הפנו לפסיקה שלא בקורפוס — דפנה מעלה אותה דרך `/precedents` ב-UI; חילוץ ההלכות אוטומטי וההלכות מחכות לאישורה.
|
||||
**הזרימה הסטנדרטית בבלוק י — חפש במקביל:**
|
||||
1. `search_internal_decisions(district="ירושלים")` — האם ועדת ערר ירושלים הכריעה בסוגיה? (עקביות פנימית)
|
||||
- אם יש תוצאה רלוונטית: הצג תחת **"החלטות ועדת ערר ירושלים"** והתייחס לה בניתוח.
|
||||
2. `search_internal_decisions()` (ריק = כל המחוזות) — פרקטיקה ארצית של ועדות אחרות.
|
||||
- הצג תחת **"החלטות ועדות ערר אחרות"** — כמשל/השוואה, לא כמחייב.
|
||||
3. `search_precedent_library` — כלל מחייב מבית משפט לפסקת CREAC.
|
||||
- הצג תחת **"פסיקת בתי משפט"** — זה המקור לציטוט מחייב.
|
||||
4. אם הצדדים הפנו לפסיקה שלא בקורפוס — דפנה מעלה אותה דרך `/precedents` ב-UI.
|
||||
|
||||
**חשוב:** החלטות ועדת ערר הן פרקטיקה, לא מחייב. ציטוט מחייב בבלוק י מגיע רק מ-`search_precedent_library`.
|
||||
|
||||
**איסור על המצאת ציטוטים** — ציטוט פסיקה חייב להגיע מאחד מהקורפוסים. אם אין הלכה מאושרת תומכת בנקודה — אל תמציא; ציין שהנושא דורש הוספת פסיקה לקורפוס.
|
||||
|
||||
|
||||
@@ -252,82 +252,10 @@ new Table({
|
||||
|
||||
## Tracked Changes — עקוב אחר שינויים
|
||||
|
||||
### שם מחבר בעברית
|
||||
```xml
|
||||
<w:del w:id="10" w:author="עו"ד כהן" w:date="2026-02-06T09:00:00Z">
|
||||
```
|
||||
ראה [`references/tracked-changes.md`](references/tracked-changes.md) — XML patterns לשינוי ערך, מחיקת סעיף, RTL PROPS, קבלה/דחייה.
|
||||
|
||||
### שינוי ערך (סכום, תאריך, תקופה)
|
||||
פצל את הטקסט ועטוף רק את הערך שמשתנה:
|
||||
```xml
|
||||
<w:r><w:rPr>...RTL PROPS...</w:rPr>
|
||||
<w:t xml:space="preserve">שכר הטרחה יעמוד על סך של </w:t></w:r>
|
||||
<w:del w:id="10" w:author="עו"ד כהן" w:date="...">
|
||||
<w:r><w:rPr>...RTL PROPS...</w:rPr><w:delText>750</w:delText></w:r>
|
||||
</w:del>
|
||||
<w:ins w:id="11" w:author="עו"ד כהן" w:date="...">
|
||||
<w:r><w:rPr>...RTL PROPS...</w:rPr><w:t>850</w:t></w:r>
|
||||
</w:ins>
|
||||
<w:r><w:rPr>...RTL PROPS...</w:rPr>
|
||||
<w:t xml:space="preserve"> ש״ח לשעת עבודה</w:t></w:r>
|
||||
```
|
||||
|
||||
### מחיקת סעיף שלם
|
||||
סמן גם את ה-paragraph mark כ-deleted:
|
||||
```xml
|
||||
<w:p>
|
||||
<w:pPr>
|
||||
<w:bidi/>
|
||||
<w:jc w:val="both"/>
|
||||
<w:rPr>
|
||||
<w:del w:id="20" w:author="עו"ד כהן" w:date="..."/>
|
||||
</w:rPr>
|
||||
</w:pPr>
|
||||
<w:del w:id="21" w:author="עו"ד כהן" w:date="...">
|
||||
<w:r><w:rPr>...RTL PROPS...</w:rPr>
|
||||
<w:delText>הסעיף שנמחק</w:delText></w:r>
|
||||
</w:del>
|
||||
</w:p>
|
||||
```
|
||||
|
||||
### RTL PROPS — בלוק rPr מלא לכל run
|
||||
```xml
|
||||
<w:rPr>
|
||||
<w:rFonts w:ascii="David" w:cs="David" w:eastAsia="David" w:hAnsi="David"/>
|
||||
<w:sz w:val="24"/>
|
||||
<w:szCs w:val="24"/>
|
||||
<w:rtl/>
|
||||
</w:rPr>
|
||||
```
|
||||
|
||||
### קבלה/דחייה של שינויים
|
||||
|
||||
**קבלת Insertion:**
|
||||
```
|
||||
לפני: <w:ins w:id="5" w:author="..."><w:r>...<w:t>טקסט חדש</w:t></w:r></w:ins>
|
||||
אחרי: <w:r>...<w:t>טקסט חדש</w:t></w:r>
|
||||
→ הסר את תגית <w:ins> ושמור את התוכן הפנימי.
|
||||
```
|
||||
|
||||
**דחיית Insertion:**
|
||||
```
|
||||
לפני: <w:ins w:id="5" w:author="..."><w:r>...<w:t>טקסט חדש</w:t></w:r></w:ins>
|
||||
אחרי: (הסר לחלוטין)
|
||||
→ מחק את כל בלוק ה-<w:ins> כולל תוכנו.
|
||||
```
|
||||
|
||||
**קבלת מחיקה:**
|
||||
```
|
||||
לפני: <w:del w:id="10" w:author="..."><w:r>...<w:delText>טקסט שנמחק</w:delText></w:r></w:del>
|
||||
אחרי: (הסר לחלוטין)
|
||||
→ מחק את כל בלוק ה-<w:del> כולל תוכנו — המחיקה מתקבלת.
|
||||
```
|
||||
|
||||
**שחזור טקסט מקורי (דחיית מחיקה):**
|
||||
```
|
||||
לפני: <w:del w:id="10" w:author="..."><w:r>...<w:delText>טקסט מקורי</w:delText></w:r></w:del>
|
||||
אחרי: <w:r>...<w:t>טקסט מקורי</w:t></w:r>
|
||||
→ הסר <w:del>, החלף <w:delText> ב-<w:t>, הסר <w:del> מ-rPr אם קיים.
|
||||
```bash
|
||||
python /mnt/skills/public/docx/scripts/comment.py unpacked/ 0 "הערה" --author "עו״ד כהן"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -397,72 +325,6 @@ python /mnt/skills/public/docx/scripts/pack.py unpacked/ output.docx --original
|
||||
|
||||
---
|
||||
|
||||
## הערות שוליים (Footnotes)
|
||||
|
||||
**השימוש המרכזי:** הפניות לחקיקה ופסיקה.
|
||||
|
||||
```javascript
|
||||
const { FootnoteReferenceRun } = require('docx');
|
||||
|
||||
// 1. הגדרה ב-Document:
|
||||
const doc = new Document({
|
||||
footnotes: {
|
||||
1: { children: [new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START, // ✅ START
|
||||
children: [new TextRun({
|
||||
text: "חוק החוזים (חלק כללי), התשל״ג-1973, סעיף 12.",
|
||||
font: "David", size: 20, rightToLeft: true // 10pt להערות שוליים
|
||||
})]
|
||||
})] },
|
||||
2: { children: [new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
children: [new TextRun({
|
||||
text: "ע״א 1234/20 כהן נ׳ לוי, פסקה 15 (פורסם בנבו, 1.1.2024).",
|
||||
font: "David", size: 20, rightToLeft: true
|
||||
})]
|
||||
})] },
|
||||
},
|
||||
// ...sections
|
||||
});
|
||||
|
||||
// 2. הפניה בגוף הטקסט:
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.BOTH,
|
||||
children: [
|
||||
new TextRun({ text: "חובת תום הלב", font: "David", size: 24, rightToLeft: true }),
|
||||
new FootnoteReferenceRun(1),
|
||||
new TextRun({ text: " חלה על כל שלבי המשא ומתן", font: "David", size: 24, rightToLeft: true }),
|
||||
new FootnoteReferenceRun(2),
|
||||
new TextRun({ text: ".", font: "David", size: 24, rightToLeft: true }),
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
### תיקון RTL בהערות שוליים (post-unpack)
|
||||
docx-js לא מגדיר RTL מלא בהערות שוליים. אחרי unpack, צריך לתקן ב-`word/footnotes.xml`:
|
||||
```xml
|
||||
<!-- 1. הוסף pStyle + bidi לכל הערת שוליים: -->
|
||||
<w:footnote w:id="1">
|
||||
<w:p>
|
||||
<w:pPr>
|
||||
<w:pStyle w:val="FootnoteText"/>
|
||||
<w:bidi/>
|
||||
<w:jc w:val="start"/>
|
||||
</w:pPr>
|
||||
...
|
||||
|
||||
<!-- 2. הוסף rtl ל-footnoteRef run: -->
|
||||
<w:r>
|
||||
<w:rPr>
|
||||
<w:rStyle w:val="FootnoteReference"/>
|
||||
<w:rtl/>
|
||||
</w:rPr>
|
||||
<w:footnoteRef/>
|
||||
</w:r>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## מרווח שורות (Line Spacing)
|
||||
|
||||
**דרישת בתי המשפט:** בדרך כלל 1.5 שורות.
|
||||
@@ -482,48 +344,6 @@ spacing: { line: 360, lineRule: LineRuleType.AUTO, before: 120, after: 120 }
|
||||
|
||||
---
|
||||
|
||||
## תוכן עניינים (TOC)
|
||||
|
||||
**⚠️ חובה: TOC ידני (לא TableOfContents).**
|
||||
`TableOfContents` של docx-js מייצר שדה שוורד מעדכן ב-F9 ומאבד הגדרות RTL.
|
||||
|
||||
```javascript
|
||||
const { Tab, TabStopType, LeaderType, PageBreak } = require('docx');
|
||||
|
||||
// שורת TOC ידנית
|
||||
const tocEntry = (text, pageNum, opts = {}) => new Paragraph({
|
||||
bidirectional: true,
|
||||
spacing: { after: 60, line: 276, lineRule: LineRuleType.AUTO },
|
||||
...(opts.indent ? { indent: { right: opts.indent } } : {}),
|
||||
tabStops: [{ type: TabStopType.RIGHT, position: 9026, leader: LeaderType.DOT }],
|
||||
children: [
|
||||
new TextRun({
|
||||
text, font: "David", size: 24, rightToLeft: true,
|
||||
bold: opts.bold || false,
|
||||
}),
|
||||
new TextRun({ children: [new Tab()], font: "David", rightToLeft: true }),
|
||||
new TextRun({
|
||||
text: String(pageNum), font: "David", size: 24, rightToLeft: true,
|
||||
}),
|
||||
]
|
||||
});
|
||||
|
||||
// שימוש:
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 200 },
|
||||
children: [new TextRun({
|
||||
text: "תוכן עניינים", font: "David", size: 32, bold: true, rightToLeft: true
|
||||
})]
|
||||
}),
|
||||
tocEntry("פרק א׳ — הגדרות כלליות", 2, { bold: true }),
|
||||
tocEntry("1. הגדרות יסוד", 2, { indent: 400 }),
|
||||
tocEntry("פרק ב׳ — השירותים", 3, { bold: true }),
|
||||
new Paragraph({ children: [new PageBreak()] }),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## קו תחתי (Underline)
|
||||
|
||||
```javascript
|
||||
@@ -544,337 +364,23 @@ underline: { type: UnderlineType.DOUBLE }
|
||||
|
||||
---
|
||||
|
||||
## מספר סקשנים (Multiple Sections)
|
||||
## פיצ'רים מתקדמים
|
||||
|
||||
**שימוש:** כותרות שונות לנספחים, עמוד לרוחב לטבלאות, שוליים שונים.
|
||||
|
||||
```javascript
|
||||
const doc = new Document({
|
||||
sections: [
|
||||
// סקשן 1 — גוף ההסכם
|
||||
{
|
||||
properties: {
|
||||
page: { size: { width: 11906, height: 16838 },
|
||||
margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } },
|
||||
bidi: true,
|
||||
},
|
||||
headers: {
|
||||
default: new Header({ children: [new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "הסכם שירותים", font: "David", size: 20, bold: true, rightToLeft: true })]
|
||||
})] })
|
||||
},
|
||||
children: [ /* ... */ ]
|
||||
},
|
||||
// סקשן 2 — נספח עם כותרת שונה
|
||||
{
|
||||
properties: {
|
||||
page: { size: { width: 11906, height: 16838 },
|
||||
margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } },
|
||||
bidi: true,
|
||||
},
|
||||
headers: {
|
||||
default: new Header({ children: [new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START, // ✅ START
|
||||
children: [new TextRun({ text: "נספח א׳ — לוח תעריפים", font: "David", size: 20, bold: true, rightToLeft: true })]
|
||||
})] })
|
||||
},
|
||||
children: [ /* ... */ ]
|
||||
}
|
||||
]
|
||||
});
|
||||
```
|
||||
ראה [`references/advanced-features.md`](references/advanced-features.md):
|
||||
- **הערות שוליים** — Footnotes עם RTL + תיקון post-unpack ב-footnotes.xml
|
||||
- **תוכן עניינים** — TOC ידני (אסור `TableOfContents`)
|
||||
- **מספר סקשנים** — כותרות שונות לנספחים
|
||||
- **Letterhead** — לוגו/תמונה בכותרת
|
||||
- **היפרלינקים** — `ExternalHyperlink` עם color+underline ידני (לא `style: "Hyperlink"`)
|
||||
|
||||
---
|
||||
|
||||
## לוגו/תמונה בכותרת (Letterhead)
|
||||
## תבניות מסמכים
|
||||
|
||||
```javascript
|
||||
const { ImageRun } = require('docx');
|
||||
|
||||
const logoBuffer = fs.readFileSync('/path/to/logo.png');
|
||||
|
||||
headers: {
|
||||
default: new Header({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [
|
||||
new ImageRun({
|
||||
data: logoBuffer,
|
||||
transformation: { width: 200, height: 60 }, // pixels
|
||||
type: "png",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({
|
||||
text: "משרד עורכי דין ישראלי ושות׳",
|
||||
font: "David", size: 20, bold: true, rightToLeft: true
|
||||
})],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
**הערה:** תמונה חייבת להיות קובץ אמיתי — לבקש מהמשתמש אם אין.
|
||||
|
||||
---
|
||||
|
||||
## היפרלינקים
|
||||
|
||||
```javascript
|
||||
const { ExternalHyperlink, UnderlineType } = require('docx');
|
||||
|
||||
new Paragraph({
|
||||
bidirectional: true,
|
||||
children: [
|
||||
new TextRun({ text: "ראה: ", font: "David", size: 24, rightToLeft: true }),
|
||||
new ExternalHyperlink({
|
||||
link: "https://www.nevo.co.il/law_html/law01/073_002.htm",
|
||||
children: [new TextRun({
|
||||
text: "חוק החוזים באתר נבו",
|
||||
font: "David", size: 24, rightToLeft: true,
|
||||
color: "0563C1",
|
||||
underline: { type: UnderlineType.SINGLE },
|
||||
})],
|
||||
}),
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
**⚠️ אזהרות:**
|
||||
- **לא להשתמש ב-`style: "Hyperlink"`** — מפריע ל-RTL!
|
||||
- **לא להוסיף `alignment: AlignmentType.RIGHT`** — `bidirectional: true` מספיק
|
||||
|
||||
---
|
||||
|
||||
## תבניות מסמכים — Document Templates
|
||||
|
||||
### תבנית 1: כתב טענות (בקשה, תביעה, הגנה, ערעור)
|
||||
|
||||
```javascript
|
||||
const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
|
||||
AlignmentType, LevelFormat, BorderStyle, WidthType } = require('docx');
|
||||
|
||||
const PAGE_WIDTH = 11906;
|
||||
const MARGINS = { top: 1134, right: 1134, bottom: 1134, left: 1134 };
|
||||
const CONTENT_WIDTH = PAGE_WIDTH - MARGINS.left - MARGINS.right;
|
||||
|
||||
const noBorder = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" };
|
||||
const noBorders = { top: noBorder, bottom: noBorder, left: noBorder, right: noBorder };
|
||||
|
||||
// Header בית משפט — טבלה עם שם בית המשפט (ימין) ומספר תיק (שמאל)
|
||||
function courtHeader(courtName, caseNumber) {
|
||||
return new Table({
|
||||
width: { size: CONTENT_WIDTH, type: WidthType.DXA },
|
||||
columnWidths: [CONTENT_WIDTH / 2, CONTENT_WIDTH / 2],
|
||||
visuallyRightToLeft: true,
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
width: { size: CONTENT_WIDTH / 2, type: WidthType.DXA },
|
||||
borders: noBorders,
|
||||
children: [new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
children: [new TextRun({ text: courtName, bold: true, font: "David", size: 26, rightToLeft: true })]
|
||||
})]
|
||||
}),
|
||||
new TableCell({
|
||||
width: { size: CONTENT_WIDTH / 2, type: WidthType.DXA },
|
||||
borders: noBorders,
|
||||
children: [new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.END,
|
||||
children: [new TextRun({ text: caseNumber, bold: true, font: "David", size: 26, rightToLeft: true })]
|
||||
})]
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// כותרת ראשית ממורכזת עם קו תחתון
|
||||
function mainTitle(text) {
|
||||
return new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 300, after: 300 },
|
||||
children: [new TextRun({ text, bold: true, font: "David", size: 28, rightToLeft: true, underline: {} })]
|
||||
});
|
||||
}
|
||||
|
||||
// כותרת משנה מיושרת לימין עם קו תחתון
|
||||
function subHeading(text) {
|
||||
return new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
spacing: { before: 240, after: 120 },
|
||||
children: [new TextRun({ text, bold: true, font: "David", size: 24, rightToLeft: true, underline: {} })]
|
||||
});
|
||||
}
|
||||
|
||||
// שימוש:
|
||||
const doc = new Document({
|
||||
numbering: {
|
||||
config: [{
|
||||
reference: "legal-clauses",
|
||||
levels: [{
|
||||
level: 0, format: LevelFormat.DECIMAL, text: "%1.",
|
||||
alignment: AlignmentType.START, suffix: "tab",
|
||||
style: { paragraph: { indent: { left: 360, hanging: 360 } } }
|
||||
}]
|
||||
}]
|
||||
},
|
||||
sections: [{
|
||||
properties: {
|
||||
page: { size: { width: PAGE_WIDTH, height: 16838 }, margin: MARGINS },
|
||||
bidi: true
|
||||
},
|
||||
children: [
|
||||
courtHeader("בית המשפט המחוזי בתל אביב", "ת\"א 12345-01-26"),
|
||||
mainTitle("כתב תביעה"),
|
||||
// ... פרטי צדדים, סעיפים, חתימה
|
||||
]
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
### תבנית 2: מכתב התראה
|
||||
|
||||
```javascript
|
||||
// מכתב התראה — ללא header בית משפט, עם פרטי משרד
|
||||
|
||||
function letterHeader(firmName, address, phone, email) {
|
||||
return [
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
children: [new TextRun({ text: firmName, bold: true, font: "David", size: 28, rightToLeft: true })]
|
||||
}),
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
children: [new TextRun({ text: address, font: "David", size: 22, rightToLeft: true })]
|
||||
}),
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
spacing: { after: 300 },
|
||||
children: [new TextRun({ text: `טל': ${phone} | ${email}`, font: "David", size: 22, rightToLeft: true })]
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function subjectLine(text) {
|
||||
return new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 200, after: 200 },
|
||||
children: [
|
||||
new TextRun({ text: "הנדון: ", bold: true, font: "David", size: 24, rightToLeft: true }),
|
||||
new TextRun({ text, bold: true, font: "David", size: 24, rightToLeft: true, underline: {} })
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// שימוש:
|
||||
sections: [{
|
||||
properties: { page: { ... }, bidi: true },
|
||||
children: [
|
||||
...letterHeader("משרד עו\"ד כהן ושות'", "רח' הרצל 1, תל אביב", "03-1234567", "office@cohen-law.co.il"),
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
children: [new TextRun({ text: "תאריך: 10.2.2026", font: "David", size: 24, rightToLeft: true })]
|
||||
}),
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.START,
|
||||
spacing: { before: 200 },
|
||||
children: [new TextRun({ text: "לכבוד: [שם הנמען]", font: "David", size: 24, rightToLeft: true })]
|
||||
}),
|
||||
subjectLine("התראה בטרם נקיטת הליכים משפטיים"),
|
||||
// ... גוף המכתב
|
||||
]
|
||||
}]
|
||||
```
|
||||
|
||||
### תבנית 3: הסכם/חוזה
|
||||
|
||||
```javascript
|
||||
// הסכם — הואילים, צדדים, חתימות בשני טורים
|
||||
|
||||
function contractTitle(text) {
|
||||
return new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 300 },
|
||||
children: [new TextRun({ text, bold: true, font: "David", size: 32, rightToLeft: true })]
|
||||
});
|
||||
}
|
||||
|
||||
function partyClause(label, name, id, address, alias) {
|
||||
return new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.BOTH,
|
||||
spacing: { after: 120 },
|
||||
children: [
|
||||
new TextRun({ text: `${label}: `, bold: true, font: "David", size: 24, rightToLeft: true }),
|
||||
new TextRun({ text: `${name}, ח.פ./ת.ז. ${id}, מ${address} (להלן: "`, font: "David", size: 24, rightToLeft: true }),
|
||||
new TextRun({ text: alias, bold: true, font: "David", size: 24, rightToLeft: true }),
|
||||
new TextRun({ text: '")', font: "David", size: 24, rightToLeft: true }),
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function signatureTable() {
|
||||
return new Table({
|
||||
width: { size: CONTENT_WIDTH, type: WidthType.DXA },
|
||||
columnWidths: [CONTENT_WIDTH / 2, CONTENT_WIDTH / 2],
|
||||
visuallyRightToLeft: true,
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
borders: noBorders,
|
||||
children: [
|
||||
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "_________________", font: "David", size: 24, rightToLeft: true })] }),
|
||||
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "צד א'", font: "David", size: 24, rightToLeft: true })] })
|
||||
]
|
||||
}),
|
||||
new TableCell({
|
||||
borders: noBorders,
|
||||
children: [
|
||||
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "_________________", font: "David", size: 24, rightToLeft: true })] }),
|
||||
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "צד ב'", font: "David", size: 24, rightToLeft: true })] })
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// שימוש:
|
||||
sections: [{
|
||||
properties: { page: { ... }, bidi: true },
|
||||
children: [
|
||||
contractTitle("הסכם שירותים"),
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "נערך ונחתם בתל אביב ביום __________", font: "David", size: 24, rightToLeft: true })]
|
||||
}),
|
||||
partyClause("מצד אחד", "[שם]", "[מספר]", "[כתובת]", "המזמין"),
|
||||
partyClause("מצד שני", "[שם]", "[מספר]", "[כתובת]", "הספק"),
|
||||
// הואילים...
|
||||
// סעיפים...
|
||||
new Paragraph({
|
||||
bidirectional: true, alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 400, after: 300 },
|
||||
children: [new TextRun({ text: "ולראיה באו הצדדים על החתום:", bold: true, font: "David", size: 24, rightToLeft: true })]
|
||||
}),
|
||||
signatureTable()
|
||||
]
|
||||
}]
|
||||
```
|
||||
ראה [`references/document-templates.md`](references/document-templates.md):
|
||||
- **תבנית 1: כתב טענות** — `courtHeader()`, `mainTitle()`, `subHeading()` + מספור
|
||||
- **תבנית 2: מכתב התראה** — `letterHeader()`, `subjectLine()` + פרטי משרד
|
||||
- **תבנית 3: הסכם/חוזה** — `contractTitle()`, `partyClause()`, `signatureTable()` + הואילים
|
||||
|
||||
---
|
||||
|
||||
@@ -962,8 +468,11 @@ sections: [{
|
||||
|
||||
## קבצי עזר
|
||||
|
||||
- **`references/document-types.md`** — מבנים מפורטים ל-9 סוגי מסמכים משפטיים
|
||||
- **`scripts/create-legal-doc.js`** — סקריפט בסיסי עם כל הגדרות ה-RTL המתוקנות
|
||||
- **[`references/document-types.md`](references/document-types.md)** — מבנים מפורטים ל-9 סוגי מסמכים
|
||||
- **[`references/document-templates.md`](references/document-templates.md)** — 3 תבניות מלאות (כתב טענות, מכתב, הסכם)
|
||||
- **[`references/tracked-changes.md`](references/tracked-changes.md)** — XML patterns לעקוב אחר שינויים
|
||||
- **[`references/advanced-features.md`](references/advanced-features.md)** — הערות שוליים, TOC, סקשנים, letterhead, hyperlinks
|
||||
- **`scripts/create-legal-doc.js`** — סקריפט בסיסי עם כל הגדרות RTL
|
||||
|
||||
---
|
||||
|
||||
|
||||
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)
|
||||
236
web-ui/AGENTS.md
236
web-ui/AGENTS.md
@@ -3,3 +3,239 @@
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology | Version |
|
||||
|-------|-----------|---------|
|
||||
| Framework | Next.js | 16.2.3 |
|
||||
| UI | React | 19.2.4 |
|
||||
| Styles | Tailwind CSS | v4 |
|
||||
| Components | shadcn/ui | latest via `shadcn` CLI |
|
||||
| Data fetching | TanStack Query | v5 |
|
||||
| Forms | react-hook-form + zod | v7 / v4 |
|
||||
| Language | TypeScript | 5 |
|
||||
| Direction | Hebrew RTL | `dir="rtl"` throughout |
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Regenerate API types from the live FastAPI schema — RUN AFTER EVERY BACKEND CHANGE
|
||||
npm run api:types
|
||||
|
||||
# Validate before every push
|
||||
npm run lint
|
||||
npm run build
|
||||
|
||||
# Local dev (rare — prod runs inside Docker; no local Python env exists)
|
||||
npm run dev # requires NEXT_PUBLIC_API_ORIGIN=http://127.0.0.1:8000 or similar
|
||||
```
|
||||
|
||||
**`npm run api:types` is mandatory** any time a FastAPI endpoint is added, removed, or its request/response shape changes. It fetches `https://legal-ai.nautilus.marcusgroup.org/openapi.json` and writes `src/lib/api/types.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Backend Proxy — `/api/*`
|
||||
|
||||
`next.config.ts` transparently rewrites all `/api/*` requests to the FastAPI backend:
|
||||
|
||||
- In Docker (production): `http://127.0.0.1:8000`
|
||||
- Override via env var: `NEXT_PUBLIC_API_ORIGIN`
|
||||
|
||||
**Never hardcode the backend origin in component code.** Always use relative paths like `/api/cases`.
|
||||
|
||||
The typed fetch wrapper lives in `src/lib/api/client.ts` — use `apiRequest<T>(path, options)`. It throws `ApiError` on non-2xx responses with the parsed body and status code.
|
||||
|
||||
---
|
||||
|
||||
## API Types — Never Edit by Hand
|
||||
|
||||
`src/lib/api/types.ts` is **auto-generated** by `openapi-typescript` from the live FastAPI OpenAPI schema.
|
||||
|
||||
- **Do NOT edit `src/lib/api/types.ts` manually** — changes will be overwritten on the next `npm run api:types` run.
|
||||
- The typed helper modules in `src/lib/api/` (e.g. `cases.ts`, `documents.ts`, `precedents.ts`) ARE hand-written and import from `types.ts`. These are safe to edit.
|
||||
- When adding a new API domain, create a new typed module in `src/lib/api/<domain>.ts` following the existing pattern.
|
||||
|
||||
---
|
||||
|
||||
## Tailwind CSS v4 — Breaking Changes from v3
|
||||
|
||||
Tailwind v4 has a completely different configuration model.
|
||||
|
||||
**What does NOT exist in v4:**
|
||||
- `tailwind.config.ts` / `tailwind.config.js` — there is no config file
|
||||
- `@tailwind base;` / `@tailwind components;` / `@tailwind utilities;` directives
|
||||
- `tailwind.config.theme.extend` object
|
||||
|
||||
**What v4 uses instead:**
|
||||
```css
|
||||
/* globals.css — already set up, do not change */
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@theme {
|
||||
/* Design tokens defined here as CSS custom properties */
|
||||
--color-navy: #0f172a;
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
- Custom tokens go inside `@theme {}` in `globals.css`.
|
||||
- Custom variants use `@custom-variant`.
|
||||
- Class names are the same (e.g. `bg-navy`, `text-gold`), but the config source is CSS, not JS.
|
||||
- PostCSS is configured via `@tailwindcss/postcss` (devDependency).
|
||||
|
||||
---
|
||||
|
||||
## shadcn/ui Components
|
||||
|
||||
Adding a new component:
|
||||
```bash
|
||||
npx shadcn add <component-name>
|
||||
# e.g. npx shadcn add table
|
||||
```
|
||||
|
||||
Installed components live in `src/components/ui/`. They are editable (shadcn copies the source, not a package import). The `radix-ui` package (v1.4) is the underlying primitive.
|
||||
|
||||
- Do NOT `npm install @radix-ui/react-*` directly — use `npx shadcn add` which installs the correct Radix version and generates the shadcn wrapper.
|
||||
- Design tokens in `globals.css` (`--color-navy`, `--color-gold`, etc.) are already mapped to the shadcn semantic tokens (`background`, `foreground`, `primary`, etc.), so shadcn components inherit the editorial/judicial aesthetic automatically.
|
||||
|
||||
---
|
||||
|
||||
## TanStack Query v5
|
||||
|
||||
**v5 has breaking API changes from v4.** Key patterns used in this codebase:
|
||||
|
||||
```typescript
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
// Reading data
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["cases"],
|
||||
queryFn: () => apiRequest<CaseListResponse>("/api/cases"),
|
||||
});
|
||||
|
||||
// Writing data
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: CreateCaseRequest) =>
|
||||
apiRequest<Case>("/api/cases", { method: "POST", body }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["cases"] });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**v5 changes from v4:**
|
||||
- `useQuery` no longer accepts positional arguments — always use the options object.
|
||||
- `isLoading` is replaced by `isPending` for mutations (but `isLoading` still works for queries).
|
||||
- `onSuccess`/`onError`/`onSettled` callbacks on `useQuery` are removed — use mutation callbacks or `useEffect` instead.
|
||||
- `getQueryData` / `setQueryData` are unchanged.
|
||||
|
||||
The shared `QueryClient` is created in `src/lib/api/client.ts` via `makeQueryClient()` and provided by `src/lib/providers.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## RTL — Hebrew UI Rules
|
||||
|
||||
**All UI is Hebrew, right-to-left.** The `<html>` element has `dir="rtl"` and `lang="he"`.
|
||||
|
||||
Use **logical CSS properties** instead of directional ones:
|
||||
|
||||
| Avoid (directional) | Use (logical) |
|
||||
|---------------------|--------------|
|
||||
| `ml-*` / `mr-*` | `ms-*` (start) / `me-*` (end) |
|
||||
| `pl-*` / `pr-*` | `ps-*` (start) / `pe-*` (end) |
|
||||
| `text-left` | `text-start` |
|
||||
| `text-right` | `text-end` |
|
||||
| `float-left` | `float-start` |
|
||||
| `border-l-*` | `border-s-*` |
|
||||
|
||||
In RTL, "start" = right side, "end" = left side. Using logical properties means the layout works automatically without RTL overrides.
|
||||
|
||||
Flexbox direction: `flex-row` in RTL naturally flows right-to-left. Use `flex-row-reverse` only when you need LTR inside an RTL context.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages
|
||||
│ ├── layout.tsx # Root layout — sets dir="rtl", applies fonts
|
||||
│ ├── globals.css # Tailwind v4 imports + design tokens + :root vars
|
||||
│ ├── cases/ # Case management pages
|
||||
│ ├── precedents/ # Precedent library pages
|
||||
│ ├── methodology/ # Methodology browser
|
||||
│ ├── training/ # Training document management
|
||||
│ ├── settings/ # Application settings
|
||||
│ └── skills/ # Skills management
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn primitives (editable copies)
|
||||
│ ├── app-shell.tsx # Top-level shell with nav
|
||||
│ ├── cases/ # Case-domain components
|
||||
│ ├── documents/ # Document viewer components
|
||||
│ ├── precedents/ # Precedent components
|
||||
│ └── compose/ # Decision drafting / block editor
|
||||
├── lib/
|
||||
│ ├── api/
|
||||
│ │ ├── types.ts # AUTO-GENERATED — never edit
|
||||
│ │ ├── client.ts # apiRequest<T> + QueryClient factory
|
||||
│ │ ├── cases.ts # Typed case API helpers
|
||||
│ │ ├── documents.ts # Typed document API helpers
|
||||
│ │ └── ... # One file per API domain
|
||||
│ ├── providers.tsx # TanStack Query + theme providers
|
||||
│ ├── utils.ts # cn() and other shared utilities
|
||||
│ ├── doc-types.ts # Document type constants
|
||||
│ └── sse.ts # Server-Sent Events helper for streaming
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Forms
|
||||
|
||||
Forms use **react-hook-form** (v7) with **zod** (v4) validation via `@hookform/resolvers`:
|
||||
|
||||
```typescript
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({ title: z.string().min(1) });
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const form = useForm<FormValues>({ resolver: zodResolver(schema) });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notifications / Toasts
|
||||
|
||||
Use **sonner** (`import { toast } from "sonner"`). The `<Toaster>` is mounted in `src/lib/providers.tsx`.
|
||||
|
||||
```typescript
|
||||
toast.success("התיק נשמר בהצלחה");
|
||||
toast.error("שגיאה בשמירה");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Streaming (SSE)
|
||||
|
||||
Server-sent events are used for long-running AI operations (drafting, analysis). The helper is in `src/lib/sse.ts`. Use it instead of raw `EventSource`.
|
||||
|
||||
---
|
||||
|
||||
## Deploy
|
||||
|
||||
This frontend runs **inside Docker via Coolify** — not as a standalone Node process.
|
||||
|
||||
- **No `npm run dev` on the server** — there is no local Python environment for the backend.
|
||||
- To see changes in production: `git commit` + `git push origin main` → Gitea Actions builds image → Coolify redeploys (~2-4 min).
|
||||
- Prod URL: `https://legal-ai.nautilus.marcusgroup.org`
|
||||
- The Next.js output is `standalone` (see `next.config.ts: output: "standalone"`).
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { usePrecedent } from "@/lib/api/precedent-library";
|
||||
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
|
||||
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
||||
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
|
||||
|
||||
const PRACTICE_AREA_LABELS: Record<string, string> = {
|
||||
rishuy_uvniya: "רישוי ובנייה",
|
||||
@@ -152,6 +153,15 @@ export default function PrecedentDetailPage({
|
||||
</CardContent>
|
||||
</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">
|
||||
<CardContent className="px-6 py-5">
|
||||
<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";
|
||||
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PaperclipTab } from "./_components/paperclip-tab";
|
||||
@@ -9,6 +9,7 @@ import { EnvironmentTab } from "./_components/environment-tab";
|
||||
import { ToolsTab } from "./_components/tools-tab";
|
||||
import { RegistrationsTab } from "./_components/registrations-tab";
|
||||
import { BlocksTab } from "./_components/blocks-tab";
|
||||
import { AgentsTab } from "./_components/agents-tab";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
@@ -30,31 +31,36 @@ export default function SettingsPage() {
|
||||
|
||||
<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>
|
||||
<TabsTrigger value="paperclip">
|
||||
<Building2 className="w-4 h-4" data-icon="inline-start" />
|
||||
Paperclip
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents">
|
||||
<Bot className="w-4 h-4" data-icon="inline-start" />
|
||||
סוכנים
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="environment">
|
||||
<Server className="w-4 h-4" data-icon="inline-start" />
|
||||
Environment
|
||||
סביבה
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tools">
|
||||
<Wrench className="w-4 h-4" data-icon="inline-start" />
|
||||
Tools
|
||||
כלים
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="blocks">
|
||||
<Layers className="w-4 h-4" data-icon="inline-start" />
|
||||
Blocks
|
||||
בלוקים
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="registrations">
|
||||
<Plug className="w-4 h-4" data-icon="inline-start" />
|
||||
Registrations
|
||||
רישומים
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="paperclip"><PaperclipTab /></TabsContent>
|
||||
<TabsContent value="agents"><AgentsTab /></TabsContent>
|
||||
<TabsContent value="environment"><EnvironmentTab /></TabsContent>
|
||||
<TabsContent value="tools"><ToolsTab /></TabsContent>
|
||||
<TabsContent value="blocks"><BlocksTab /></TabsContent>
|
||||
|
||||
@@ -5,8 +5,18 @@ import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { useAgentActivity, useSendComment } from "@/lib/api/agents";
|
||||
import type { PaperclipComment } from "@/lib/api/agents";
|
||||
import {
|
||||
useAgentActivity,
|
||||
useSendComment,
|
||||
useSubmitInteraction,
|
||||
} from "@/lib/api/agents";
|
||||
import type {
|
||||
Interaction,
|
||||
InteractionPayload,
|
||||
InteractionQuestion,
|
||||
InteractionTask,
|
||||
PaperclipComment,
|
||||
} from "@/lib/api/agents";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Bot,
|
||||
@@ -15,6 +25,9 @@ import {
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
HelpCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ── 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 ───────────────────────────────────────────────── */
|
||||
|
||||
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 interactionCount = data?.interactions?.length ?? 0;
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [commentCount]);
|
||||
}, [commentCount, interactionCount]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!body.trim()) return;
|
||||
@@ -224,6 +695,25 @@ export function AgentActivityFeed({
|
||||
}
|
||||
|
||||
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
|
||||
// we should NOT show the "agents are working, waiting for report" spinner.
|
||||
@@ -246,9 +736,9 @@ export function AgentActivityFeed({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Comments stream */}
|
||||
{/* Comments + interactions stream */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
||||
{comments.length === 0 ? (
|
||||
{feed.length === 0 ? (
|
||||
hasActiveIssue ? (
|
||||
<div className="text-center py-8 text-ink-faint text-sm">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||
@@ -261,9 +751,22 @@ export function AgentActivityFeed({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
comments.map((c) => (
|
||||
<CommentCard key={c.id} comment={c} issueMap={issueMap} />
|
||||
))
|
||||
feed.map((item) =>
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -15,14 +15,15 @@ import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { PartiesField } from "@/components/wizard/parties-field";
|
||||
import { useUpdateCase } from "@/lib/api/cases";
|
||||
import { caseUpdateSchema, expectedOutcomes, type CaseUpdateInput } from "@/lib/schemas/case";
|
||||
import type { CaseDetail } from "@/lib/api/cases";
|
||||
|
||||
/*
|
||||
* Inline edit dialog for core case fields. Uses react-hook-form + zod
|
||||
* directly (shadcn's <Form> registry entry wasn't available at init
|
||||
* time, so the styling is reproduced by hand in a lean form layout).
|
||||
* Inline edit dialog for all case fields set at creation time.
|
||||
* Uses react-hook-form + zod directly (shadcn's <Form> registry entry
|
||||
* wasn't available at init time, so the styling is reproduced by hand).
|
||||
*/
|
||||
|
||||
function FieldError({ message }: { message?: string }) {
|
||||
@@ -42,6 +43,10 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
|
||||
hearing_date: data.hearing_date ?? "",
|
||||
notes: "",
|
||||
expected_outcome: data.expected_outcome ?? "",
|
||||
appellants: data.appellants ?? [],
|
||||
respondents: data.respondents ?? [],
|
||||
property_address: data.property_address ?? "",
|
||||
permit_number: data.permit_number ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -54,6 +59,10 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
|
||||
hearing_date: data.hearing_date ?? "",
|
||||
notes: "",
|
||||
expected_outcome: data.expected_outcome ?? "",
|
||||
appellants: data.appellants ?? [],
|
||||
respondents: data.respondents ?? [],
|
||||
property_address: data.property_address ?? "",
|
||||
permit_number: data.permit_number ?? "",
|
||||
});
|
||||
}, [open, data, form]);
|
||||
|
||||
@@ -74,11 +83,11 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
|
||||
עריכת פרטי תיק
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg" dir="rtl">
|
||||
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto" dir="rtl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>עריכת פרטי תיק {data.case_number}</DialogTitle>
|
||||
<DialogDescription className="text-ink-muted">
|
||||
השינויים נשמרים ישירות ל-FastAPI. השדות הריקים נשארים ללא שינוי.
|
||||
השינויים נשמרים ישירות ל-DB. שינוי כותרת יסנכרן גם ל-Paperclip.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -95,6 +104,55 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
|
||||
<FieldError message={form.formState.errors.subject?.message} />
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-rule" />
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="appellants"
|
||||
render={({ field, fieldState }) => (
|
||||
<PartiesField
|
||||
label="עוררים"
|
||||
value={field.value ?? []}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="respondents"
|
||||
render={({ field, fieldState }) => (
|
||||
<PartiesField
|
||||
label="משיבים"
|
||||
value={field.value ?? []}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="h-px bg-rule" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="property_address" className="text-navy">כתובת הנכס</Label>
|
||||
<Input
|
||||
id="property_address"
|
||||
{...form.register("property_address")}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="permit_number" className="text-navy">מס׳ תכנית/בקשה</Label>
|
||||
<Input
|
||||
id="permit_number"
|
||||
{...form.register("permit_number")}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="hearing_date" className="text-navy">תאריך דיון</Label>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
@@ -16,11 +17,12 @@ import {
|
||||
import {
|
||||
usePrecedents,
|
||||
useDeletePrecedent,
|
||||
useRequestHalachotExtraction,
|
||||
isPrecedentActive,
|
||||
type Precedent,
|
||||
type PracticeArea,
|
||||
} 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 { 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 {
|
||||
if (!s) return "—";
|
||||
return s.replace(/[--]/g, "").trim();
|
||||
}
|
||||
|
||||
/* Shimmering pill — used while extraction is actively running.
|
||||
* Visually distinct from the static "queued" / "completed" pills. */
|
||||
// Show the "extract halachot" button only when the precedent hasn't had a
|
||||
// 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 }) {
|
||||
return (
|
||||
<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 }) {
|
||||
if (p.extraction_status === "failed") {
|
||||
return (
|
||||
@@ -103,37 +107,28 @@ function StatusPill({ p }: { p: Precedent }) {
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
// halacha_extraction_status === "completed"
|
||||
if (p.halachot_count === 0) {
|
||||
return <Badge variant="outline">ללא הלכות</Badge>;
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-gold-wash text-gold-deep border-gold/40"
|
||||
>
|
||||
<Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40">
|
||||
{p.approved_count}/{p.halachot_count} מאושרות
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function PrecedentRow({
|
||||
p, onEdit,
|
||||
}: {
|
||||
p: Precedent;
|
||||
onEdit: (id: string) => void;
|
||||
}) {
|
||||
function CourtRow({ 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(
|
||||
"מתבצע עיבוד — לא ניתן למחוק עכשיו. המתיני לסיום או רעני את הדף.",
|
||||
);
|
||||
toast.error("מתבצע עיבוד — לא ניתן למחוק עכשיו.");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`למחוק את ${p.case_number}? cascade ימחק את ה-chunks וההלכות.`)) return;
|
||||
if (!window.confirm(`למחוק את ${p.case_number}?`)) return;
|
||||
try {
|
||||
await del.mutateAsync(p.id);
|
||||
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 (
|
||||
<TableRow className="border-rule hover:bg-gold-wash/30 align-top">
|
||||
<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"
|
||||
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 className="text-ink whitespace-normal break-words max-w-[260px] py-3">
|
||||
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
||||
{p.court ? (
|
||||
<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>}
|
||||
{p.court ? <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>
|
||||
{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>
|
||||
)}
|
||||
) : <span className="text-ink-light">—</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-ink-muted text-[0.78rem]">
|
||||
{p.precedent_level ? (
|
||||
p.precedent_level
|
||||
) : (
|
||||
<span className="text-ink-light">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusPill p={p} />
|
||||
{p.precedent_level || <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"
|
||||
>
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost" size="sm" onClick={onDelete}
|
||||
{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"
|
||||
>
|
||||
className="text-danger hover:text-danger hover:bg-danger-bg disabled:opacity-30">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</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() {
|
||||
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
|
||||
const [precedentLevel, setPrecedentLevel] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const { data, isPending, error } = usePrecedents({
|
||||
const sharedFilters = {
|
||||
practiceArea: practiceArea || undefined,
|
||||
precedentLevel: precedentLevel || undefined,
|
||||
search: search.trim() || undefined,
|
||||
limit: 200,
|
||||
});
|
||||
};
|
||||
|
||||
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload", sourceType: "court_ruling" });
|
||||
const committee = usePrecedents({ ...sharedFilters, sourceKind: "all_committees" });
|
||||
|
||||
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-1 min-w-[200px]">
|
||||
<label className="text-[0.78rem] text-ink-muted">חיפוש (מספר תיק / שם / תקציר)</label>
|
||||
<label className="text-[0.78rem] text-ink-muted">חיפוש (מספר תיק / שם)</label>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="עע"מ 3975/22"
|
||||
placeholder="עע״מ 3975/22 / 1200-25"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[180px]">
|
||||
<label className="text-[0.78rem] text-ink-muted">תחום</label>
|
||||
<Select value={practiceArea || "_all"} onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}>
|
||||
@@ -248,70 +341,108 @@ export function LibraryListPanel() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
העלאת פסיקה
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
|
||||
{error.message}
|
||||
{/* Table 1 — Court rulings */}
|
||||
<section>
|
||||
<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 className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-rule-soft/60">
|
||||
<TableRow className="border-rule">
|
||||
<TableHead className="text-navy text-right">מס׳ / מראה מקום</TableHead>
|
||||
<TableHead className="text-navy text-right">שם / ערכאה</TableHead>
|
||||
<TableHead className="text-navy text-right">תאריך</TableHead>
|
||||
<TableHead className="text-navy text-right">תחום</TableHead>
|
||||
<TableHead className="text-navy text-right">רמה</TableHead>
|
||||
<TableHead className="text-navy text-right">הלכות</TableHead>
|
||||
<TableHead className="text-navy" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isPending ? (
|
||||
[...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 ? (
|
||||
{courts.error ? (
|
||||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
|
||||
{courts.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">
|
||||
<TableCell colSpan={7} className="text-center text-ink-muted py-10">
|
||||
אין פסיקה בקורפוס. העלה את פסק הדין הראשון.
|
||||
</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 text-right">רמה</TableHead>
|
||||
<TableHead className="text-navy text-right">הלכות</TableHead>
|
||||
<TableHead className="text-navy" />
|
||||
</TableRow>
|
||||
) : (
|
||||
data.items.map((p) => (
|
||||
<PrecedentRow key={p.id} p={p} onEdit={setEditingId} />
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{courts.isPending ? (
|
||||
<TableSkeleton cols={7} />
|
||||
) : !courts.data?.items.length ? (
|
||||
<TableRow className="border-rule">
|
||||
<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>
|
||||
)}
|
||||
{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} />
|
||||
<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);
|
||||
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";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Save, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type SourceType,
|
||||
} from "@/lib/api/precedent-library";
|
||||
import {
|
||||
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES,
|
||||
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, appealSubtypeLabel,
|
||||
} from "./practice-area";
|
||||
import { ExtractedHalachotSection } from "./extracted-halachot";
|
||||
|
||||
@@ -65,17 +65,19 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
|
||||
// Hydrate form when the record loads.
|
||||
useEffect(() => {
|
||||
if (!record) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
// React-approved derived-state pattern: sync form whenever a different
|
||||
// record arrives (including after save+refetch). Using setState during
|
||||
// render avoids the one-frame flash that useEffect would produce.
|
||||
const [syncedRecordId, setSyncedRecordId] = useState<string | null>(null);
|
||||
if (record && record.id !== syncedRecordId) {
|
||||
setSyncedRecordId(record.id as string);
|
||||
setForm({
|
||||
citation: record.case_number || "",
|
||||
case_name: record.case_name || "",
|
||||
court: record.court || "",
|
||||
decision_date: record.date ? record.date.slice(0, 10) : "",
|
||||
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,
|
||||
precedent_level: record.precedent_level || "",
|
||||
is_binding: record.is_binding ?? true,
|
||||
@@ -84,7 +86,7 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
||||
headnote: record.headnote || "",
|
||||
key_quote: (record as { key_quote?: string }).key_quote || "",
|
||||
});
|
||||
}, [record]);
|
||||
}
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -321,6 +321,7 @@ export function CaseWizard() {
|
||||
</Button>
|
||||
{isLast ? (
|
||||
<Button
|
||||
key="submit-btn"
|
||||
type="submit"
|
||||
disabled={mutate.isPending}
|
||||
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||
@@ -329,6 +330,7 @@ export function CaseWizard() {
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key="next-btn"
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||
|
||||
@@ -42,10 +42,80 @@ export type PaperclipAgent = {
|
||||
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 = {
|
||||
issues: PaperclipIssue[];
|
||||
comments: PaperclipComment[];
|
||||
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 ───────────────────────────────────────────────────
|
||||
@@ -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) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ export type Case = {
|
||||
processing_count?: number;
|
||||
committee_type?: string | null;
|
||||
hearing_date?: string | null;
|
||||
appellants?: string[] | null;
|
||||
respondents?: string[] | null;
|
||||
property_address?: string | null;
|
||||
permit_number?: string | null;
|
||||
};
|
||||
|
||||
export type CaseDocument = {
|
||||
|
||||
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;
|
||||
subject_tags: string[];
|
||||
source_kind: string;
|
||||
chair_name: string | null;
|
||||
district: string | null;
|
||||
extraction_status: string;
|
||||
halacha_extraction_status: string;
|
||||
metadata_extraction_requested_at: string | null;
|
||||
@@ -82,9 +84,20 @@ export type Halacha = {
|
||||
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 & {
|
||||
full_text: string;
|
||||
halachot: Halacha[];
|
||||
related_cases: RelatedCase[];
|
||||
};
|
||||
|
||||
export type SearchHit =
|
||||
@@ -137,6 +150,7 @@ export type ListFilters = {
|
||||
court?: string;
|
||||
precedentLevel?: string;
|
||||
sourceType?: SourceType;
|
||||
sourceKind?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
@@ -164,6 +178,7 @@ export function usePrecedents(filters: ListFilters = {}) {
|
||||
if (filters.court) p.set("court", filters.court);
|
||||
if (filters.precedentLevel) p.set("precedent_level", filters.precedentLevel);
|
||||
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.limit) p.set("limit", String(filters.limit));
|
||||
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<{
|
||||
case_name: string;
|
||||
court: string;
|
||||
|
||||
@@ -432,6 +432,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/cases/stale": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Api Stale Cases
|
||||
* @description Return cases that haven't been updated in N days and are not in a terminal/waiting status.
|
||||
*/
|
||||
get: operations["api_stale_cases_api_cases_stale_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/cases/{case_number}/archive": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1355,7 +1375,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* 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"];
|
||||
put?: never;
|
||||
@@ -1388,6 +1408,149 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1516,6 +1679,29 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1761,6 +1947,26 @@ export interface paths {
|
||||
patch: operations["api_resolve_feedback_api_feedback__feedback_id__resolve_patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/chair-feedback/weekly-summary": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Api Chair Feedback Weekly Summary
|
||||
* @description Return chair feedback from the last N days as a text summary for the CEO agent.
|
||||
*/
|
||||
get: operations["api_chair_feedback_weekly_summary_api_chair_feedback_weekly_summary_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/precedent-library/upload": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1853,6 +2059,40 @@ export interface paths {
|
||||
patch: operations["precedent_library_update_api_precedent_library__case_law_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/precedent-library/{case_law_id}/relations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Precedent Add Relation */
|
||||
post: operations["precedent_add_relation_api_precedent_library__case_law_id__relations_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/precedent-library/{case_law_id}/relations/{related_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Precedent Remove Relation */
|
||||
delete: operations["precedent_remove_relation_api_precedent_library__case_law_id__relations__related_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/precedent-library/{case_law_id}/request-metadata": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1864,8 +2104,8 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Precedent Request Metadata
|
||||
* @description Stamp the case_law row as needing metadata extraction. The local
|
||||
* MCP worker (`precedent_process_pending_metadata`) will pick it up.
|
||||
* @description Stamp the case_law row as needing metadata extraction AND wake the
|
||||
* Paperclip CEO so extraction runs automatically — same flow as upload.
|
||||
*/
|
||||
post: operations["precedent_request_metadata_api_precedent_library__case_law_id__request_metadata_post"];
|
||||
delete?: never;
|
||||
@@ -1915,6 +2155,69 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/internal-decisions/upload": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Internal Decisions Upload
|
||||
* @description Upload a planning appeals-committee decision to the internal corpus.
|
||||
*/
|
||||
post: operations["internal_decisions_upload_api_internal_decisions_upload_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/internal-decisions/migrate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Internal Decisions Migrate
|
||||
* @description 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)
|
||||
*/
|
||||
post: operations["internal_decisions_migrate_api_internal_decisions_migrate_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/internal-decisions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Internal Decisions List
|
||||
* @description List internal committee decisions with optional filters.
|
||||
*/
|
||||
get: operations["internal_decisions_list_api_internal_decisions_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/halachot": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2028,6 +2331,63 @@ export interface components {
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
/** Body_internal_decisions_upload_api_internal_decisions_upload_post */
|
||||
Body_internal_decisions_upload_api_internal_decisions_upload_post: {
|
||||
/** File */
|
||||
file: string;
|
||||
/** Case Number */
|
||||
case_number: string;
|
||||
/**
|
||||
* Case Name
|
||||
* @default
|
||||
*/
|
||||
case_name: string;
|
||||
/**
|
||||
* Court
|
||||
* @default
|
||||
*/
|
||||
court: string;
|
||||
/**
|
||||
* Decision Date
|
||||
* @default
|
||||
*/
|
||||
decision_date: string;
|
||||
/**
|
||||
* Chair Name
|
||||
* @default
|
||||
*/
|
||||
chair_name: string;
|
||||
/**
|
||||
* District
|
||||
* @default
|
||||
*/
|
||||
district: string;
|
||||
/**
|
||||
* Practice Area
|
||||
* @default
|
||||
*/
|
||||
practice_area: string;
|
||||
/**
|
||||
* Appeal Subtype
|
||||
* @default
|
||||
*/
|
||||
appeal_subtype: string;
|
||||
/**
|
||||
* Subject Tags
|
||||
* @default []
|
||||
*/
|
||||
subject_tags: string;
|
||||
/**
|
||||
* Is Binding
|
||||
* @default true
|
||||
*/
|
||||
is_binding: boolean;
|
||||
/**
|
||||
* Summary
|
||||
* @default
|
||||
*/
|
||||
summary: string;
|
||||
};
|
||||
/** Body_precedent_library_upload_api_precedent_library_upload_post */
|
||||
Body_precedent_library_upload_api_precedent_library_upload_post: {
|
||||
/** File */
|
||||
@@ -2295,6 +2655,27 @@ export interface components {
|
||||
/** Practice Areas */
|
||||
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: {
|
||||
/** Value */
|
||||
@@ -2349,6 +2730,16 @@ export interface components {
|
||||
*/
|
||||
pdf_document_id: string;
|
||||
};
|
||||
/** PrecedentRelationRequest */
|
||||
PrecedentRelationRequest: {
|
||||
/** Related Id */
|
||||
related_id: string;
|
||||
/**
|
||||
* Relation Type
|
||||
* @default same_case_chain
|
||||
*/
|
||||
relation_type: string;
|
||||
};
|
||||
/** PrecedentUpdateRequest */
|
||||
PrecedentUpdateRequest: {
|
||||
/** Case Name */
|
||||
@@ -2996,6 +3387,37 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
api_stale_cases_api_cases_stale_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
days?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
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_archive_case_api_cases__case_number__archive_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4491,6 +4913,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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4714,6 +5306,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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5133,6 +5745,38 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
api_chair_feedback_weekly_summary_api_chair_feedback_weekly_summary_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
days?: number;
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
precedent_library_upload_api_precedent_library_upload_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5174,6 +5818,7 @@ export interface operations {
|
||||
precedent_level?: string;
|
||||
source_type?: string;
|
||||
search?: string;
|
||||
source_kind?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
@@ -5358,6 +6003,73 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
precedent_add_relation_api_precedent_library__case_law_id__relations_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
case_law_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PrecedentRelationRequest"];
|
||||
};
|
||||
};
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
precedent_remove_relation_api_precedent_library__case_law_id__relations__related_id__delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
case_law_id: string;
|
||||
related_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
precedent_request_metadata_api_precedent_library__case_law_id__request_metadata_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5452,6 +6164,105 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
internal_decisions_upload_api_internal_decisions_upload_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["Body_internal_decisions_upload_api_internal_decisions_upload_post"];
|
||||
};
|
||||
};
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
internal_decisions_migrate_api_internal_decisions_migrate_post: {
|
||||
parameters: {
|
||||
query?: {
|
||||
source?: string;
|
||||
dry_run?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
internal_decisions_list_api_internal_decisions_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
district?: string;
|
||||
chair_name?: string;
|
||||
practice_area?: string;
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
halachot_list_api_halachot_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -89,6 +89,14 @@ export const caseUpdateSchema = z.object({
|
||||
.enum(expectedOutcomes.map((o) => o.value) as [string, ...string[]])
|
||||
.optional(),
|
||||
status: z.string().optional(),
|
||||
appellants: z
|
||||
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
|
||||
.optional(),
|
||||
respondents: z
|
||||
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
|
||||
.optional(),
|
||||
property_address: z.string().trim().max(200).optional(),
|
||||
permit_number: z.string().trim().max(100).optional(),
|
||||
});
|
||||
|
||||
export type CaseUpdateInput = z.infer<typeof caseUpdateSchema>;
|
||||
|
||||
590
web/app.py
590
web/app.py
@@ -20,9 +20,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "
|
||||
|
||||
import zipfile
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from pydantic import BaseModel
|
||||
|
||||
import asyncpg
|
||||
@@ -44,7 +44,10 @@ from web.mcp_env_catalog import (
|
||||
normalize_for_compare,
|
||||
)
|
||||
from web.progress_store import ProgressStore
|
||||
from web.paperclip_api import emit_case_status_webhook, pc_request
|
||||
from web.paperclip_client import (
|
||||
COMPANIES as PAPERCLIP_COMPANIES,
|
||||
accept_interaction as pc_accept_interaction,
|
||||
archive_project as pc_archive_project,
|
||||
create_project as pc_create_project,
|
||||
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_case_issues as pc_get_case_issues,
|
||||
get_issue_comments as pc_get_issue_comments,
|
||||
get_issue_interactions as pc_get_issue_interactions,
|
||||
get_project_url,
|
||||
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,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1128,6 +1135,36 @@ async def list_cases(
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/cases/stale")
|
||||
async def api_stale_cases(days: int = 3):
|
||||
"""Return cases that haven't been updated in N days and are not in a terminal/waiting status."""
|
||||
if days <= 0:
|
||||
return {"cases": [], "total": 0}
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT case_number, title, status,
|
||||
EXTRACT(DAY FROM (now() - updated_at))::int AS days_stale
|
||||
FROM cases
|
||||
WHERE status NOT IN ('final', 'new', 'exported')
|
||||
AND updated_at < now() - make_interval(days => $1)
|
||||
ORDER BY updated_at ASC -- oldest stale first (longest overdue = highest priority)
|
||||
""",
|
||||
days,
|
||||
)
|
||||
cases = [
|
||||
{
|
||||
"case_number": r["case_number"],
|
||||
"title": r["title"],
|
||||
"status": r["status"],
|
||||
"days_stale": r["days_stale"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"cases": cases, "total": len(cases)}
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/archive")
|
||||
async def api_archive_case(case_number: str):
|
||||
"""Move a case to the archive. Also archives the matching Paperclip project."""
|
||||
@@ -1203,6 +1240,10 @@ class CaseUpdateRequest(BaseModel):
|
||||
decision_date: str = ""
|
||||
tags: list[str] | None = None
|
||||
expected_outcome: str = ""
|
||||
appellants: list[str] | None = None
|
||||
respondents: list[str] | None = None
|
||||
property_address: str = ""
|
||||
permit_number: str = ""
|
||||
|
||||
|
||||
@app.post("/api/cases/create")
|
||||
@@ -1330,8 +1371,12 @@ async def api_case_get(case_number: str):
|
||||
|
||||
|
||||
@app.put("/api/cases/{case_number}")
|
||||
async def api_case_update(case_number: str, req: CaseUpdateRequest):
|
||||
async def api_case_update(case_number: str, req: CaseUpdateRequest, background_tasks: BackgroundTasks):
|
||||
"""Update case details."""
|
||||
# Capture old status before the update so we can detect changes.
|
||||
existing = await db.get_case_by_number(case_number)
|
||||
old_status = (existing or {}).get("status", "")
|
||||
|
||||
result = await cases_tools.case_update(
|
||||
case_number=case_number,
|
||||
status=req.status,
|
||||
@@ -1342,12 +1387,45 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest):
|
||||
decision_date=req.decision_date,
|
||||
tags=req.tags,
|
||||
expected_outcome=req.expected_outcome,
|
||||
appellants=req.appellants,
|
||||
respondents=req.respondents,
|
||||
property_address=req.property_address,
|
||||
permit_number=req.permit_number,
|
||||
)
|
||||
try:
|
||||
return json.loads(result)
|
||||
parsed = json.loads(result)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(404, result)
|
||||
|
||||
# Paperclip sync: update project name when title changes (fire-and-forget).
|
||||
old_title = (existing or {}).get("title", "")
|
||||
if req.title and req.title != old_title:
|
||||
background_tasks.add_task(
|
||||
paperclip_client.update_project_name,
|
||||
case_number=case_number,
|
||||
new_title=req.title,
|
||||
)
|
||||
|
||||
# Emit webhook when status changes (fire-and-forget via BackgroundTasks).
|
||||
new_status = req.status
|
||||
if new_status and old_status != new_status:
|
||||
prefix = case_number[:1]
|
||||
company_id = (
|
||||
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
|
||||
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
|
||||
else None
|
||||
)
|
||||
background_tasks.add_task(
|
||||
emit_case_status_webhook,
|
||||
case_number=case_number,
|
||||
old_status=old_status,
|
||||
new_status=new_status,
|
||||
company_id=company_id, # None is safe — plugin handles unknown company gracefully
|
||||
)
|
||||
logger.debug("webhook scheduled: case %s %s → %s", case_number, old_status, new_status)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
@app.delete("/api/cases")
|
||||
async def api_case_delete(case_number: str, remove_files: bool = False):
|
||||
@@ -2341,10 +2419,38 @@ async def api_mark_final(case_number: str, filename: str):
|
||||
if case_dir.exists():
|
||||
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 {
|
||||
"final_filename": final_name,
|
||||
"training_copy": str(training_dest),
|
||||
"status": "final",
|
||||
"ingest_final": ingest_status,
|
||||
"curator": curator_status,
|
||||
}
|
||||
|
||||
|
||||
@@ -2482,14 +2588,17 @@ async def api_start_workflow(case_number: str):
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
||||
|
||||
# 3. Wake the CEO agent
|
||||
# 3. Wake the CEO agent — must succeed before marking case as processing
|
||||
try:
|
||||
wakeup = await pc_wake_ceo(issue["issue_id"], case_number, issue.get("company_id", ""))
|
||||
except Exception as e:
|
||||
logger.warning("CEO wakeup failed for case %s: %s", case_number, e)
|
||||
wakeup = {"error": str(e)}
|
||||
logger.error("CEO wakeup failed for case %s: %s", case_number, e)
|
||||
raise HTTPException(
|
||||
502,
|
||||
f"נוצר issue {issue['identifier']} אך עירור ה-CEO נכשל: {e}. ניתן לנסות שנית.",
|
||||
)
|
||||
|
||||
# 4. Update case status to processing
|
||||
# 4. Update case status to processing (only after wakeup confirmed)
|
||||
await cases_tools.case_update(case_number, status="processing")
|
||||
|
||||
return {
|
||||
@@ -2507,17 +2616,26 @@ async def api_start_workflow(case_number: str):
|
||||
|
||||
@app.get("/api/cases/{case_number}/agents")
|
||||
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)
|
||||
if not issues:
|
||||
return {"issues": [], "comments": [], "agents": []}
|
||||
return {"issues": [], "comments": [], "agents": [], "interactions": []}
|
||||
|
||||
issue_ids = [i["id"] for i in issues]
|
||||
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):
|
||||
@@ -2551,6 +2669,42 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
||||
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 ────────────────────────────
|
||||
#
|
||||
# Source of truth for legal-ai env vars is Coolify (see memory:
|
||||
@@ -2977,8 +3131,16 @@ async def api_get_methodology(category: str):
|
||||
items = {}
|
||||
for key, default_val in defaults.items():
|
||||
if key in overrides:
|
||||
raw = overrides[key]["rule_value"]
|
||||
# asyncpg returns JSONB as a raw JSON string when no codec is registered.
|
||||
# Parse it back to a Python object so the frontend receives the correct type.
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
items[key] = {
|
||||
"value": overrides[key]["rule_value"],
|
||||
"value": raw,
|
||||
"is_override": True,
|
||||
"updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None,
|
||||
}
|
||||
@@ -3015,10 +3177,14 @@ async def api_update_methodology(category: str, key: str, req: MethodologyUpdate
|
||||
raise HTTPException(422, "content_checklists value must be a non-empty string")
|
||||
|
||||
pool = await db.get_pool()
|
||||
# json.dumps → text, then PostgreSQL casts text→jsonb.
|
||||
# Passing a Python list directly causes "expected str, got list" in asyncpg;
|
||||
# passing a str with ::jsonb causes double-encoding (stored as JSONB string).
|
||||
# ::text::jsonb bypasses asyncpg's codec and lets PostgreSQL parse the JSON.
|
||||
await pool.execute(
|
||||
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
|
||||
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::jsonb) "
|
||||
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::jsonb",
|
||||
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::text::jsonb) "
|
||||
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::text::jsonb",
|
||||
category, key, json.dumps(req.value, ensure_ascii=False),
|
||||
)
|
||||
|
||||
@@ -3123,6 +3289,159 @@ async def api_list_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")
|
||||
async def api_install_skill(file: UploadFile = File(...)):
|
||||
"""Install or update a Paperclip skill from a ZIP file.
|
||||
@@ -3746,6 +4065,34 @@ async def api_resolve_feedback(feedback_id: str, body: dict):
|
||||
return {"status": "resolved"}
|
||||
|
||||
|
||||
@app.get("/api/chair-feedback/weekly-summary")
|
||||
async def api_chair_feedback_weekly_summary(days: int = 7, limit: int = 100):
|
||||
"""Return chair feedback from the last N days as a text summary for the CEO agent."""
|
||||
if days <= 0:
|
||||
return {"summary": "", "entry_count": 0}
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT cf.feedback_text, c.case_number, c.title
|
||||
FROM chair_feedback cf
|
||||
LEFT JOIN cases c ON c.id = cf.case_id
|
||||
WHERE cf.created_at >= now() - make_interval(days => $1)
|
||||
ORDER BY cf.created_at DESC
|
||||
LIMIT $2
|
||||
""",
|
||||
days,
|
||||
limit,
|
||||
)
|
||||
if not rows:
|
||||
return {"summary": "", "entry_count": 0}
|
||||
lines = [
|
||||
f"- תיק {r['case_number'] or '—'} ({r['title'] or '—'}): {r['feedback_text']}"
|
||||
for r in rows
|
||||
]
|
||||
return {"summary": "\n".join(lines), "entry_count": len(rows)}
|
||||
|
||||
|
||||
# ── Background Processing ─────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -4058,13 +4405,15 @@ async def precedent_library_list(
|
||||
precedent_level: str = "",
|
||||
source_type: str = "",
|
||||
search: str = "",
|
||||
source_kind: str = "external_upload",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
):
|
||||
rows = await db.list_external_case_law(
|
||||
practice_area=practice_area, court=court,
|
||||
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)}
|
||||
|
||||
@@ -4145,6 +4494,37 @@ async def precedent_library_delete(case_law_id: str):
|
||||
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
|
||||
# `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run
|
||||
# from this container (no CLI, no claude.ai session). The endpoints below
|
||||
@@ -4154,18 +4534,41 @@ async def precedent_library_delete(case_law_id: str):
|
||||
# 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")
|
||||
async def precedent_request_metadata(case_law_id: str):
|
||||
"""Stamp the case_law row as needing metadata extraction. The local
|
||||
MCP worker (`precedent_process_pending_metadata`) will pick it up."""
|
||||
"""Stamp the case_law row as needing metadata extraction AND wake the
|
||||
Paperclip CEO so extraction runs automatically — same flow as upload."""
|
||||
try:
|
||||
cid = UUID(case_law_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "case_law_id לא תקין")
|
||||
ok = await db.request_metadata_extraction(cid)
|
||||
if not ok:
|
||||
raise HTTPException(404, "פסיקה לא נמצאה (או לא מסוג external_upload)")
|
||||
return {"queued": True, "case_law_id": case_law_id, "kind": "metadata"}
|
||||
raise HTTPException(404, "פסיקה לא נמצאה")
|
||||
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")
|
||||
@@ -4177,8 +4580,9 @@ async def precedent_request_halachot(case_law_id: str):
|
||||
raise HTTPException(400, "case_law_id לא תקין")
|
||||
ok = await db.request_halacha_extraction(cid)
|
||||
if not ok:
|
||||
raise HTTPException(404, "פסיקה לא נמצאה (או לא מסוג external_upload)")
|
||||
return {"queued": True, "case_law_id": case_law_id, "kind": "halacha"}
|
||||
raise HTTPException(404, "פסיקה לא נמצאה")
|
||||
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")
|
||||
@@ -4191,6 +4595,148 @@ async def precedent_queue_pending(kind: str = "metadata", limit: int = 20):
|
||||
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")
|
||||
async def halachot_list(
|
||||
case_law_id: str = "",
|
||||
|
||||
116
web/paperclip_api.py
Normal file
116
web/paperclip_api.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""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 datetime import datetime, timezone
|
||||
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
|
||||
|
||||
|
||||
async def emit_case_status_webhook(
|
||||
case_number: str,
|
||||
old_status: str,
|
||||
new_status: str,
|
||||
company_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> None:
|
||||
"""Notify the Paperclip plugin that a case status changed.
|
||||
|
||||
Fire-and-forget: logs errors but never raises, so callers aren't blocked.
|
||||
"""
|
||||
try:
|
||||
await pc_request(
|
||||
"POST",
|
||||
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
|
||||
json={
|
||||
"caseNumber": case_number,
|
||||
"oldStatus": old_status,
|
||||
"newStatus": new_status,
|
||||
"companyId": company_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
run_id=run_id,
|
||||
timeout=5.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"emit_case_status_webhook failed for case %s (%s → %s): %s",
|
||||
case_number, old_status, new_status, exc,
|
||||
)
|
||||
@@ -12,7 +12,8 @@ import os
|
||||
import uuid
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
|
||||
from web.paperclip_api import pc_request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,7 +22,8 @@ PAPERCLIP_DB_URL = os.environ.get(
|
||||
)
|
||||
|
||||
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", "")
|
||||
|
||||
# Default workspace attached to every new Paperclip project — points agents at
|
||||
@@ -43,6 +45,14 @@ CEO_AGENTS = {
|
||||
# Default for backwards compat
|
||||
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_APPEAL_TYPE_TO_COMPANY = {
|
||||
"רישוי": COMPANIES["licensing"],
|
||||
@@ -221,6 +231,21 @@ async def restore_project(case_number: str) -> dict:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def update_project_name(case_number: str, new_title: str) -> None:
|
||||
"""Update the Paperclip project name when a case title changes."""
|
||||
project_name = f"ערר {case_number} — {new_title}"[:200]
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
await conn.execute(
|
||||
"UPDATE projects SET name = $1, updated_at = now() WHERE name LIKE $2",
|
||||
project_name, f"%{case_number}%",
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to update Paperclip project name for case %s", case_number)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _ensure_default_workspace(
|
||||
conn: asyncpg.Connection,
|
||||
project_id: str,
|
||||
@@ -584,16 +609,15 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
||||
# Try Board API first — this triggers the event bus
|
||||
if PAPERCLIP_BOARD_API_KEY:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"{PAPERCLIP_API_URL}/api/board/issues/{issue_id}/comments",
|
||||
json={"body": body},
|
||||
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
||||
)
|
||||
if resp.status_code < 400:
|
||||
result = resp.json()
|
||||
logger.info("Posted comment via Board API on issue %s", issue_id)
|
||||
return {"comment_id": result.get("id", ""), "issue_id": issue_id, "method": "api"}
|
||||
resp = await pc_request(
|
||||
"POST",
|
||||
f"/api/board/issues/{issue_id}/comments",
|
||||
json={"body": body},
|
||||
)
|
||||
if resp.status_code < 400:
|
||||
result = resp.json()
|
||||
logger.info("Posted comment via Board API on issue %s", issue_id)
|
||||
return {"comment_id": result.get("id", ""), "issue_id": issue_id, "method": "api"}
|
||||
except Exception:
|
||||
logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id)
|
||||
|
||||
@@ -613,25 +637,118 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
||||
# Wake the correct CEO for this company
|
||||
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||
try:
|
||||
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
|
||||
payload = {
|
||||
"source": "on_demand",
|
||||
"triggerDetail": "manual",
|
||||
"reason": f"user_comment_{issue_id}",
|
||||
"payload": {"issueId": issue_id, "mutation": "comment"},
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
url, json=payload,
|
||||
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
await pc_request(
|
||||
"POST",
|
||||
f"/api/agents/{ceo_id}/wakeup",
|
||||
json={
|
||||
"source": "on_demand",
|
||||
"triggerDetail": "manual",
|
||||
"reason": f"user_comment_{issue_id}",
|
||||
"payload": {"issueId": issue_id, "mutation": "comment"},
|
||||
},
|
||||
raise_on_error=True,
|
||||
)
|
||||
except Exception:
|
||||
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"}
|
||||
|
||||
|
||||
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
|
||||
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
|
||||
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
|
||||
@@ -747,7 +864,6 @@ async def wake_for_precedent_extraction(
|
||||
return {"ok": False, "error": f"db: {e}"}
|
||||
|
||||
# Wake the CEO. Per Paperclip rules: must use API + carry issueId in payload.
|
||||
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
|
||||
payload = {
|
||||
"source": "automation",
|
||||
"triggerDetail": "precedent_library_upload",
|
||||
@@ -759,14 +875,13 @@ async def wake_for_precedent_extraction(
|
||||
},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
resp = await pc_request(
|
||||
"POST",
|
||||
f"/api/agents/{ceo_id}/wakeup",
|
||||
json=payload,
|
||||
raise_on_error=True,
|
||||
)
|
||||
result = resp.json()
|
||||
logger.info(
|
||||
"Precedent-extraction wakeup queued: issue=%s case_law_id=%s",
|
||||
identifier, case_law_id,
|
||||
@@ -787,7 +902,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")
|
||||
|
||||
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
|
||||
payload = {
|
||||
"source": "on_demand",
|
||||
"triggerDetail": "manual",
|
||||
@@ -798,13 +912,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 client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
logger.info("CEO agent wakeup for case %s: %s", case_number, result)
|
||||
return result
|
||||
resp = await pc_request(
|
||||
"POST",
|
||||
f"/api/agents/{ceo_id}/wakeup",
|
||||
json=payload,
|
||||
raise_on_error=True,
|
||||
)
|
||||
result = resp.json()
|
||||
logger.info("CEO agent wakeup for case %s: %s", case_number, 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