Compare commits
35 Commits
af2dc0df2a
...
feature/ho
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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 — שם הקונטקסט שלי מצטבר
|
||||||
@@ -87,6 +87,7 @@ tools:
|
|||||||
| כותב החלטה | 7ed8686f-24bc-49a3-bc02-67ca15b895a9 | כתיבת בלוקים ה-יב (Opus) |
|
| כותב החלטה | 7ed8686f-24bc-49a3-bc02-67ca15b895a9 | כתיבת בלוקים ה-יב (Opus) |
|
||||||
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
||||||
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
||||||
|
| מנהל ידע (Hermes) | CMP: 60dce831-5c5b-4bae-bda9-5282d506f0dc · CMPA: d6f7c55d-570a-46b8-8d72-1286d07da0d8 | סקירת החלטות סופיות, הצעות לעדכון style guide / lessons. **לא קורא ישירות מ-CEO** — מופעל אוטומטית מ-`web/app.py:api_mark_final` כשדפנה לוחצת "סמן כסופי" ב-UI. |
|
||||||
|
|
||||||
## כלל: כל issue חדש = תת-משימה
|
## כלל: כל issue חדש = תת-משימה
|
||||||
|
|
||||||
|
|||||||
@@ -1362,21 +1362,22 @@
|
|||||||
"description": "מסך אדמין ב-legal-ai UI שמציג את כל הגדרות הסוכנים (model, timeout, runtime_config, skills, budget) ומאפשר עריכה ושמירה. מונע SQL ישיר.",
|
"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) או רק קריאה?",
|
"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 מתאים.",
|
"testStrategy": "1. עמוד עולה ב-/admin/agents בlegal-ai UI. 2. עריכת timeoutSec ושמירה → SELECT ב-DB מאמת. 3. revision חדש ב-agent_config_revisions עם source מתאים.",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"16",
|
"16",
|
||||||
"17",
|
"17",
|
||||||
"25"
|
"25"
|
||||||
],
|
],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-04T17:29:25.686Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-05-04T16:44:27.554Z",
|
"lastModified": "2026-05-04T17:29:25.687Z",
|
||||||
"taskCount": 29,
|
"taskCount": 29,
|
||||||
"completedCount": 23,
|
"completedCount": 24,
|
||||||
"tags": [
|
"tags": [
|
||||||
"legal-ai"
|
"legal-ai"
|
||||||
]
|
]
|
||||||
|
|||||||
11
CLAUDE.md
11
CLAUDE.md
@@ -118,6 +118,8 @@
|
|||||||
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
|
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
|
||||||
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
|
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
|
||||||
├── mcp-server/ ← MCP server + services + tools
|
├── mcp-server/ ← MCP server + services + tools
|
||||||
|
├── adapters/ ← Paperclip external adapters (ראה למטה)
|
||||||
|
│ └── deepseek-paperclip-adapter/ ← `deepseek_local` (Hermes-pinned ל-DeepSeek profile)
|
||||||
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
|
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
|
||||||
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
|
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
|
||||||
```
|
```
|
||||||
@@ -180,6 +182,15 @@
|
|||||||
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
|
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
|
||||||
- שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`.
|
- שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`.
|
||||||
|
|
||||||
|
### External adapters — `deepseek_local`
|
||||||
|
- מיקום ה-package: [adapters/deepseek-paperclip-adapter/](adapters/deepseek-paperclip-adapter/) (לא ב-`node_modules`).
|
||||||
|
- רישום ב-Paperclip: רשומה ב-`~/.paperclip/adapter-plugins.json` (נטען אוטומטית ב-startup דרך `buildExternalAdapters`). אין צורך בעריכת `node_modules`.
|
||||||
|
- **מה ה-adapter עושה**: spawnל-`hermes chat` עם `HERMES_HOME=/home/chaim/.hermes/profiles/deepseek` כך שה-CLI טוען את `config.yaml` (`base_url=https://api.deepseek.com/v1`, `provider=custom`, `key_env=DEEPSEEK_API_KEY`) ואת `.env` (שמכיל את ה-key).
|
||||||
|
- **מודלים זמינים** (lookup ב-DeepSeek `/v1/models`): `deepseek-v4-pro` (default), `deepseek-v4-flash`. יופיעו כדרופ-דאון ב-UI.
|
||||||
|
- **התקנה מחדש / עדכון**: `curl -X POST -H "Authorization: Bearer pcapi_legal_install_key_2026" -H "Content-Type: application/json" -d '{"packageName":"/home/chaim/legal-ai/adapters/deepseek-paperclip-adapter","isLocalPath":true}' http://localhost:3100/api/adapters/install`. לעדכון hot — `POST /api/adapters/deepseek_local/reload`.
|
||||||
|
- **⚠ Cross-company sync**: `sync_agents_across_companies.py` **מדלג** על סוכנים עם `adapter_type` שונה בין CMP ל-CMPA. כשעוברים סוכן ל-`deepseek_local` חובה להחיל ידנית בשתי החברות לפני sync.
|
||||||
|
- **תוספת adapters עתידיים** (OpenAI ישיר, Anthropic ישיר, וכו'): אותו דפוס. ה-package הראשי חייב לייצא `createServerAdapter()` שמחזיר `{ type, label, models, agentConfigurationDoc, execute, testEnvironment, sessionCodec, listSkills, syncSkills, ... }`. ראה את [adapters/deepseek-paperclip-adapter/dist/index.js](adapters/deepseek-paperclip-adapter/dist/index.js) כתבנית.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## עקרונות כתיבה קריטיים
|
## עקרונות כתיבה קריטיים
|
||||||
|
|||||||
99
adapters/deepseek-paperclip-adapter/dist/index.js
vendored
Normal file
99
adapters/deepseek-paperclip-adapter/dist/index.js
vendored
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* DeepSeek (via Hermes) — external Paperclip adapter.
|
||||||
|
*
|
||||||
|
* Loaded by Paperclip's plugin-loader. Contract:
|
||||||
|
* The package's main module must export createServerAdapter() returning
|
||||||
|
* a single ServerAdapterModule object with all fields wired in.
|
||||||
|
*
|
||||||
|
* Runtime: spawns the local `hermes` CLI with HERMES_HOME pinned to a
|
||||||
|
* DeepSeek profile that defines model.base_url=https://api.deepseek.com/v1
|
||||||
|
* and model.key_env=DEEPSEEK_API_KEY.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ADAPTER_TYPE,
|
||||||
|
ADAPTER_LABEL,
|
||||||
|
DEEPSEEK_MODELS,
|
||||||
|
DEFAULT_PROFILE_HOME,
|
||||||
|
} from "./shared/constants.js";
|
||||||
|
import { execute } from "./server/execute.js";
|
||||||
|
import { testEnvironment } from "./server/test.js";
|
||||||
|
import { sessionCodec } from "./server/session-codec.js";
|
||||||
|
import { listSkills, syncSkills } from "./server/skills.js";
|
||||||
|
|
||||||
|
const AGENT_CONFIGURATION_DOC = `# DeepSeek (via Hermes) — Agent Configuration
|
||||||
|
|
||||||
|
DeepSeek-pinned variant of the Hermes adapter. Runs the local \`hermes\` CLI
|
||||||
|
with \`HERMES_HOME\` pointed at a DeepSeek profile (\`config.yaml\` declares
|
||||||
|
\`base_url=https://api.deepseek.com/v1\` and \`key_env=DEEPSEEK_API_KEY\`).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Hermes Agent installed (\`pip install hermes-agent\`) — \`hermes --version\` works.
|
||||||
|
- DeepSeek profile dir exists (default: \`/home/chaim/.hermes/profiles/deepseek\`)
|
||||||
|
with \`config.yaml\` + \`.env\` (containing \`DEEPSEEK_API_KEY\`).
|
||||||
|
|
||||||
|
## Core Configuration
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| model | string | \`deepseek-v4-pro\` | DeepSeek model id (\`deepseek-v4-pro\` or \`deepseek-v4-flash\`). |
|
||||||
|
| provider | string | \`custom\` | Hermes provider name. The DeepSeek profile defines \`provider: custom\` so \`custom\` is the right value. |
|
||||||
|
| hermesProfileHome | string | \`/home/chaim/.hermes/profiles/deepseek\` | Absolute path to a Hermes profile dir. Set per-agent if you maintain multiple DeepSeek profiles. |
|
||||||
|
| timeoutSec | number | 1800 | Execution timeout in seconds. |
|
||||||
|
| graceSec | number | 30 | SIGTERM grace period in seconds. |
|
||||||
|
|
||||||
|
## Tools / Workspace
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| toolsets | string | (profile default) | Comma-separated toolsets to enable. |
|
||||||
|
| persistSession | boolean | true | Resume sessions across heartbeats via \`--resume\`. |
|
||||||
|
| worktreeMode | boolean | false | Use git worktree for isolated changes. |
|
||||||
|
| checkpoints | boolean | false | Enable filesystem checkpoints. |
|
||||||
|
|
||||||
|
## Advanced
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| hermesCommand | string | \`hermes\` | Path to the hermes binary. |
|
||||||
|
| verbose | boolean | false | Enable verbose Hermes logs. |
|
||||||
|
| extraArgs | string[] | [] | Extra CLI args appended after standard flags. |
|
||||||
|
| env | object | {} | Extra environment variables passed to Hermes. \`HERMES_HOME\` here overrides \`hermesProfileHome\`. |
|
||||||
|
| promptTemplate | string | (default) | Override the default Paperclip wakeup prompt. |
|
||||||
|
| paperclipApiUrl | string | \`http://127.0.0.1:3100/api\` | Paperclip API URL injected into the prompt template. |
|
||||||
|
|
||||||
|
## Available template variables
|
||||||
|
|
||||||
|
\`{{agentId}}\`, \`{{agentName}}\`, \`{{companyId}}\`, \`{{companyName}}\`,
|
||||||
|
\`{{runId}}\`, \`{{taskId}}\`, \`{{taskTitle}}\`, \`{{taskBody}}\`,
|
||||||
|
\`{{commentId}}\`, \`{{wakeReason}}\`, \`{{projectName}}\`, \`{{paperclipApiUrl}}\`.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function createServerAdapter() {
|
||||||
|
return {
|
||||||
|
type: ADAPTER_TYPE,
|
||||||
|
label: ADAPTER_LABEL,
|
||||||
|
models: DEEPSEEK_MODELS,
|
||||||
|
agentConfigurationDoc: AGENT_CONFIGURATION_DOC,
|
||||||
|
|
||||||
|
execute,
|
||||||
|
testEnvironment,
|
||||||
|
sessionCodec,
|
||||||
|
listSkills,
|
||||||
|
syncSkills,
|
||||||
|
|
||||||
|
// Capability flags
|
||||||
|
supportsLocalAgentJwt: true,
|
||||||
|
supportsInstructionsBundle: false,
|
||||||
|
requiresMaterializedRuntimeSkills: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also export the loose constants for any caller that wants to inspect
|
||||||
|
// the package without invoking createServerAdapter (e.g., test harnesses).
|
||||||
|
export const type = ADAPTER_TYPE;
|
||||||
|
export const label = ADAPTER_LABEL;
|
||||||
|
export const models = DEEPSEEK_MODELS;
|
||||||
|
export const agentConfigurationDoc = AGENT_CONFIGURATION_DOC;
|
||||||
|
export const defaultProfileHome = DEFAULT_PROFILE_HOME;
|
||||||
352
adapters/deepseek-paperclip-adapter/dist/server/execute.js
vendored
Normal file
352
adapters/deepseek-paperclip-adapter/dist/server/execute.js
vendored
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
/**
|
||||||
|
* Server-side execution for the DeepSeek-via-Hermes adapter.
|
||||||
|
*
|
||||||
|
* Spawns `hermes chat -q "..." -Q -m <model> --provider custom` with
|
||||||
|
* HERMES_HOME pinned to a DeepSeek-configured profile so the same machine
|
||||||
|
* can run other Hermes-based agents on different providers in parallel.
|
||||||
|
*
|
||||||
|
* The Hermes CLI loads model.base_url, model.key_env (DEEPSEEK_API_KEY),
|
||||||
|
* and toolsets from <HERMES_HOME>/config.yaml + <HERMES_HOME>/.env.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
runChildProcess,
|
||||||
|
buildPaperclipEnv,
|
||||||
|
renderTemplate,
|
||||||
|
ensureAbsoluteDirectory,
|
||||||
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import {
|
||||||
|
HERMES_CLI,
|
||||||
|
DEFAULT_PROFILE_HOME,
|
||||||
|
DEFAULT_MODEL,
|
||||||
|
DEFAULT_PROVIDER,
|
||||||
|
DEFAULT_TIMEOUT_SEC,
|
||||||
|
DEFAULT_GRACE_SEC,
|
||||||
|
SESSION_ID_REGEX,
|
||||||
|
SESSION_ID_REGEX_LEGACY,
|
||||||
|
TOKEN_USAGE_REGEX,
|
||||||
|
COST_REGEX,
|
||||||
|
} from "../shared/constants.js";
|
||||||
|
|
||||||
|
function cfgString(v) {
|
||||||
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||||
|
}
|
||||||
|
function cfgNumber(v) {
|
||||||
|
return typeof v === "number" ? v : undefined;
|
||||||
|
}
|
||||||
|
function cfgBoolean(v) {
|
||||||
|
return typeof v === "boolean" ? v : undefined;
|
||||||
|
}
|
||||||
|
function cfgStringArray(v) {
|
||||||
|
return Array.isArray(v) && v.every((i) => typeof i === "string") ? v : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROMPT_TEMPLATE = `You are "{{agentName}}", an AI agent employee in a Paperclip-managed company powered by DeepSeek.
|
||||||
|
|
||||||
|
IMPORTANT: Use the \`terminal\` tool with \`curl\` for ALL Paperclip API calls (web_extract and browser cannot access localhost).
|
||||||
|
|
||||||
|
Your Paperclip identity:
|
||||||
|
Agent ID: {{agentId}}
|
||||||
|
Company ID: {{companyId}}
|
||||||
|
API Base: {{paperclipApiUrl}}
|
||||||
|
|
||||||
|
{{#taskId}}
|
||||||
|
## Assigned Task
|
||||||
|
|
||||||
|
Issue ID: {{taskId}}
|
||||||
|
Title: {{taskTitle}}
|
||||||
|
|
||||||
|
{{taskBody}}
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Work on the task using your tools.
|
||||||
|
2. When done, mark the issue completed:
|
||||||
|
\`curl -s -X PATCH "{{paperclipApiUrl}}/issues/{{taskId}}" -H "Content-Type: application/json" -d '{"status":"done"}'\`
|
||||||
|
3. Post a completion comment summarizing what you did:
|
||||||
|
\`curl -s -X POST "{{paperclipApiUrl}}/issues/{{taskId}}/comments" -H "Content-Type: application/json" -d '{"body":"DONE: <your summary here>"}'\`
|
||||||
|
{{/taskId}}
|
||||||
|
|
||||||
|
{{#commentId}}
|
||||||
|
## Comment on This Issue
|
||||||
|
|
||||||
|
Someone commented. Read it:
|
||||||
|
\`curl -s "{{paperclipApiUrl}}/issues/{{taskId}}/comments/{{commentId}}" | python3 -m json.tool\`
|
||||||
|
Address the comment, POST a reply if needed, then continue working.
|
||||||
|
{{/commentId}}
|
||||||
|
|
||||||
|
{{#noTask}}
|
||||||
|
## Heartbeat Wake — Check for Work
|
||||||
|
|
||||||
|
1. List your open issues:
|
||||||
|
\`curl -s "{{paperclipApiUrl}}/companies/{{companyId}}/issues?assigneeAgentId={{agentId}}"\`
|
||||||
|
2. Pick the highest priority and work on it. When done, follow steps 2-3 above.
|
||||||
|
3. If nothing to do, report briefly what you checked.
|
||||||
|
{{/noTask}}`;
|
||||||
|
|
||||||
|
function buildPrompt(ctx, config) {
|
||||||
|
const template = cfgString(config.promptTemplate) || DEFAULT_PROMPT_TEMPLATE;
|
||||||
|
const taskId = cfgString(ctx.context?.taskId);
|
||||||
|
const taskTitle = cfgString(ctx.context?.taskTitle) || "";
|
||||||
|
const taskBody = cfgString(ctx.context?.taskBody) || "";
|
||||||
|
const commentId = cfgString(ctx.context?.commentId) || "";
|
||||||
|
const wakeReason = cfgString(ctx.context?.wakeReason) || "";
|
||||||
|
const agentName = ctx.agent?.name || "DeepSeek Agent";
|
||||||
|
const companyName = cfgString(ctx.context?.companyName) || "";
|
||||||
|
const projectName = cfgString(ctx.context?.projectName) || "";
|
||||||
|
|
||||||
|
let paperclipApiUrl =
|
||||||
|
cfgString(config.paperclipApiUrl) ||
|
||||||
|
process.env.PAPERCLIP_API_URL ||
|
||||||
|
"http://127.0.0.1:3100/api";
|
||||||
|
if (!paperclipApiUrl.endsWith("/api")) {
|
||||||
|
paperclipApiUrl = paperclipApiUrl.replace(/\/+$/, "") + "/api";
|
||||||
|
}
|
||||||
|
|
||||||
|
const vars = {
|
||||||
|
agentId: ctx.agent?.id || "",
|
||||||
|
agentName,
|
||||||
|
companyId: ctx.agent?.companyId || "",
|
||||||
|
companyName,
|
||||||
|
runId: ctx.runId || "",
|
||||||
|
taskId: taskId || "",
|
||||||
|
taskTitle,
|
||||||
|
taskBody,
|
||||||
|
commentId,
|
||||||
|
wakeReason,
|
||||||
|
projectName,
|
||||||
|
paperclipApiUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rendered = template;
|
||||||
|
rendered = rendered.replace(/\{\{#taskId\}\}([\s\S]*?)\{\{\/taskId\}\}/g, taskId ? "$1" : "");
|
||||||
|
rendered = rendered.replace(/\{\{#noTask\}\}([\s\S]*?)\{\{\/noTask\}\}/g, taskId ? "" : "$1");
|
||||||
|
rendered = rendered.replace(/\{\{#commentId\}\}([\s\S]*?)\{\{\/commentId\}\}/g, commentId ? "$1" : "");
|
||||||
|
return renderTemplate(rendered, vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanResponse(raw) {
|
||||||
|
return raw
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) return true;
|
||||||
|
if (t.startsWith("[tool]") || t.startsWith("[hermes]") || t.startsWith("[paperclip]") || t.startsWith("[deepseek]")) return false;
|
||||||
|
if (t.startsWith("session_id:")) return false;
|
||||||
|
if (/^\[\d{4}-\d{2}-\d{2}T/.test(t)) return false;
|
||||||
|
if (/^\[done\]\s*┊/.test(t)) return false;
|
||||||
|
if (/^┊\s*[\p{Emoji_Presentation}]/u.test(t) && !/^┊\s*💬/.test(t)) return false;
|
||||||
|
if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(t)) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((line) => {
|
||||||
|
let t = line.replace(/^[\s]*┊\s*💬\s*/, "").trim();
|
||||||
|
t = t.replace(/^\[done\]\s*/, "").trim();
|
||||||
|
return t;
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHermesOutput(stdout, stderr) {
|
||||||
|
const combined = stdout + "\n" + stderr;
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
const sessionMatch = stdout.match(SESSION_ID_REGEX);
|
||||||
|
if (sessionMatch?.[1]) {
|
||||||
|
result.sessionId = sessionMatch[1];
|
||||||
|
const sessionLineIdx = stdout.lastIndexOf("\nsession_id:");
|
||||||
|
if (sessionLineIdx > 0) {
|
||||||
|
result.response = cleanResponse(stdout.slice(0, sessionLineIdx));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const legacyMatch = combined.match(SESSION_ID_REGEX_LEGACY);
|
||||||
|
if (legacyMatch?.[1]) result.sessionId = legacyMatch[1];
|
||||||
|
const cleaned = cleanResponse(stdout);
|
||||||
|
if (cleaned.length > 0) result.response = cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageMatch = combined.match(TOKEN_USAGE_REGEX);
|
||||||
|
if (usageMatch) {
|
||||||
|
result.usage = {
|
||||||
|
inputTokens: parseInt(usageMatch[1], 10) || 0,
|
||||||
|
outputTokens: parseInt(usageMatch[2], 10) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const costMatch = combined.match(COST_REGEX);
|
||||||
|
if (costMatch?.[1]) result.costUsd = parseFloat(costMatch[1]);
|
||||||
|
|
||||||
|
if (stderr.trim()) {
|
||||||
|
const errorLines = stderr
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => /error|exception|traceback|failed/i.test(line))
|
||||||
|
.filter((line) => !/INFO|DEBUG|warn/i.test(line));
|
||||||
|
if (errorLines.length > 0) result.errorMessage = errorLines.slice(0, 5).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function execute(ctx) {
|
||||||
|
const config = ctx.agent?.adapterConfig ?? {};
|
||||||
|
|
||||||
|
const hermesCmd = cfgString(config.hermesCommand) || HERMES_CLI;
|
||||||
|
const model = cfgString(config.model) || DEFAULT_MODEL;
|
||||||
|
const provider = cfgString(config.provider) || DEFAULT_PROVIDER;
|
||||||
|
const profileHome = cfgString(config.hermesProfileHome) || DEFAULT_PROFILE_HOME;
|
||||||
|
const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC;
|
||||||
|
const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC;
|
||||||
|
const toolsets = cfgString(config.toolsets) || cfgStringArray(config.enabledToolsets)?.join(",");
|
||||||
|
const extraArgs = cfgStringArray(config.extraArgs);
|
||||||
|
const persistSession = cfgBoolean(config.persistSession) !== false;
|
||||||
|
const worktreeMode = cfgBoolean(config.worktreeMode) === true;
|
||||||
|
const checkpoints = cfgBoolean(config.checkpoints) === true;
|
||||||
|
const useQuiet = cfgBoolean(config.quiet) !== false;
|
||||||
|
|
||||||
|
const prompt = buildPrompt(ctx, config);
|
||||||
|
|
||||||
|
const args = ["chat", "-q", prompt];
|
||||||
|
if (useQuiet) args.push("-Q");
|
||||||
|
if (model) args.push("-m", model);
|
||||||
|
args.push("--provider", provider);
|
||||||
|
if (toolsets) args.push("-t", toolsets);
|
||||||
|
if (worktreeMode) args.push("-w");
|
||||||
|
if (checkpoints) args.push("--checkpoints");
|
||||||
|
if (cfgBoolean(config.verbose) === true) args.push("-v");
|
||||||
|
args.push("--source", "tool");
|
||||||
|
args.push("--yolo");
|
||||||
|
|
||||||
|
const prevSessionId = cfgString(ctx.runtime?.sessionParams?.sessionId);
|
||||||
|
if (persistSession && prevSessionId) args.push("--resume", prevSessionId);
|
||||||
|
if (extraArgs?.length) args.push(...extraArgs);
|
||||||
|
|
||||||
|
// Pin Hermes to the DeepSeek profile by default. The agent can override
|
||||||
|
// by setting adapter_config.hermesProfileHome or adapter_config.env.HERMES_HOME.
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
...buildPaperclipEnv(ctx.agent),
|
||||||
|
HERMES_HOME: profileHome,
|
||||||
|
};
|
||||||
|
if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId;
|
||||||
|
const taskId = cfgString(ctx.context?.taskId);
|
||||||
|
if (taskId) env.PAPERCLIP_TASK_ID = taskId;
|
||||||
|
|
||||||
|
// Parity with hermes_local (paperclip-src/server/src/adapters/registry.ts:267):
|
||||||
|
// inject the per-run agent auth token so the agent can call the Paperclip API.
|
||||||
|
// Without this, every Paperclip API write from the running agent fails with 401.
|
||||||
|
//
|
||||||
|
// Resolve env from the runtime-resolved config (ctx.config.env contains plain
|
||||||
|
// strings — Paperclip's secrets service unwraps {type:"plain"|"secret_ref", ...}
|
||||||
|
// bindings before invocation in services/heartbeat.ts:5433-5437).
|
||||||
|
// Fall back to agent.adapterConfig.env with manual unwrapping for older paths.
|
||||||
|
function unwrapEnvValue(v) {
|
||||||
|
if (typeof v === "string") return v;
|
||||||
|
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||||
|
if (v.type === "plain" && typeof v.value === "string") return v.value;
|
||||||
|
}
|
||||||
|
return undefined; // skip secret_ref / unknown types — let resolver handle them
|
||||||
|
}
|
||||||
|
const resolvedUserEnv =
|
||||||
|
ctx.config && typeof ctx.config === "object" && ctx.config.env && typeof ctx.config.env === "object" && !Array.isArray(ctx.config.env)
|
||||||
|
? ctx.config.env
|
||||||
|
: null;
|
||||||
|
const rawUserEnv =
|
||||||
|
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||||
|
? config.env
|
||||||
|
: {};
|
||||||
|
// Prefer pre-resolved values from ctx.config.env when available; fall back to
|
||||||
|
// unwrapping raw bindings from agent.adapterConfig.env.
|
||||||
|
const flattenedUserEnv = {};
|
||||||
|
for (const [k, v] of Object.entries(rawUserEnv)) {
|
||||||
|
const resolved = resolvedUserEnv && typeof resolvedUserEnv[k] === "string" ? resolvedUserEnv[k] : unwrapEnvValue(v);
|
||||||
|
if (typeof resolved === "string") flattenedUserEnv[k] = resolved;
|
||||||
|
}
|
||||||
|
const userEnvApiKey = flattenedUserEnv.PAPERCLIP_API_KEY;
|
||||||
|
const explicitApiKey =
|
||||||
|
typeof userEnvApiKey === "string" && userEnvApiKey.trim().length > 0;
|
||||||
|
if (ctx.authToken && !explicitApiKey) env.PAPERCLIP_API_KEY = ctx.authToken;
|
||||||
|
|
||||||
|
// Apply unwrapped user env (may override HERMES_HOME, OPENAI_API_KEY, etc.).
|
||||||
|
Object.assign(env, flattenedUserEnv);
|
||||||
|
|
||||||
|
const cwd = cfgString(config.cwd) || cfgString(ctx.config?.workspaceDir) || ".";
|
||||||
|
try {
|
||||||
|
await ensureAbsoluteDirectory(cwd);
|
||||||
|
} catch {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[deepseek] Starting Hermes (model=${model}, provider=${provider}, profileHome=${env.HERMES_HOME}, timeout=${timeoutSec}s)\n`,
|
||||||
|
);
|
||||||
|
if (prevSessionId) {
|
||||||
|
await ctx.onLog("stdout", `[deepseek] Resuming session: ${prevSessionId}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reclassify benign Hermes stderr lines as stdout so the UI doesn't paint them red.
|
||||||
|
const wrappedOnLog = async (stream, chunk) => {
|
||||||
|
if (stream === "stderr") {
|
||||||
|
const trimmed = chunk.trimEnd();
|
||||||
|
const isBenign =
|
||||||
|
/^\[?\d{4}[-/]\d{2}[-/]\d{2}T/.test(trimmed) ||
|
||||||
|
/^[A-Z]+:\s+(INFO|DEBUG|WARN|WARNING)\b/.test(trimmed) ||
|
||||||
|
/Successfully registered all tools/.test(trimmed) ||
|
||||||
|
/MCP [Ss]erver/.test(trimmed) ||
|
||||||
|
/tool registered successfully/.test(trimmed) ||
|
||||||
|
/Application initialized/.test(trimmed);
|
||||||
|
if (isBenign) return ctx.onLog("stdout", chunk);
|
||||||
|
}
|
||||||
|
return ctx.onLog(stream, chunk);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forward ctx.onSpawn so Paperclip persists processPid/processGroupId to the
|
||||||
|
// heartbeat_runs row. Without it, the reaper cannot verify the child is alive
|
||||||
|
// (run.processPid is null) and treats the run as orphaned during long quiet
|
||||||
|
// phases (DeepSeek V4-Pro thinking can be silent for 60-90s per turn).
|
||||||
|
const result = await runChildProcess(ctx.runId, hermesCmd, args, {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec,
|
||||||
|
graceSec,
|
||||||
|
onLog: wrappedOnLog,
|
||||||
|
onSpawn: ctx.onSpawn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = parseHermesOutput(result.stdout || "", result.stderr || "");
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[deepseek] Exit code: ${result.exitCode ?? "null"}, timed out: ${result.timedOut}\n`,
|
||||||
|
);
|
||||||
|
if (parsed.sessionId) {
|
||||||
|
await ctx.onLog("stdout", `[deepseek] Session: ${parsed.sessionId}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionResult = {
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
signal: result.signal,
|
||||||
|
timedOut: result.timedOut,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
if (parsed.errorMessage) executionResult.errorMessage = parsed.errorMessage;
|
||||||
|
if (parsed.usage) executionResult.usage = parsed.usage;
|
||||||
|
if (parsed.costUsd !== undefined) executionResult.costUsd = parsed.costUsd;
|
||||||
|
if (parsed.response) executionResult.summary = parsed.response.slice(0, 2000);
|
||||||
|
|
||||||
|
executionResult.resultJson = {
|
||||||
|
result: parsed.response || "",
|
||||||
|
session_id: parsed.sessionId || null,
|
||||||
|
usage: parsed.usage || null,
|
||||||
|
cost_usd: parsed.costUsd ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (persistSession && parsed.sessionId) {
|
||||||
|
executionResult.sessionParams = { sessionId: parsed.sessionId };
|
||||||
|
executionResult.sessionDisplayId = parsed.sessionId.slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
return executionResult;
|
||||||
|
}
|
||||||
29
adapters/deepseek-paperclip-adapter/dist/server/session-codec.js
vendored
Normal file
29
adapters/deepseek-paperclip-adapter/dist/server/session-codec.js
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Session codec — Hermes uses a single sessionId for cross-heartbeat continuity
|
||||||
|
* via the --resume CLI flag. Same shape as the Hermes adapter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function readNonEmptyString(value) {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionCodec = {
|
||||||
|
deserialize(raw) {
|
||||||
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||||
|
const sessionId =
|
||||||
|
readNonEmptyString(raw.sessionId) ?? readNonEmptyString(raw.session_id);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
return { sessionId };
|
||||||
|
},
|
||||||
|
serialize(params) {
|
||||||
|
if (!params) return null;
|
||||||
|
const sessionId =
|
||||||
|
readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
return { sessionId };
|
||||||
|
},
|
||||||
|
getDisplayId(params) {
|
||||||
|
if (!params) return null;
|
||||||
|
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||||
|
},
|
||||||
|
};
|
||||||
171
adapters/deepseek-paperclip-adapter/dist/server/skills.js
vendored
Normal file
171
adapters/deepseek-paperclip-adapter/dist/server/skills.js
vendored
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* Skill snapshot for the DeepSeek-via-Hermes adapter.
|
||||||
|
*
|
||||||
|
* Hermes manages its own skills under ~/.hermes/skills/ (global; not per-profile).
|
||||||
|
* Paperclip-managed skills declared in adapter config are surfaced as
|
||||||
|
* "company_managed" entries — same behavior as the upstream Hermes adapter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
readPaperclipRuntimeSkillEntries,
|
||||||
|
resolvePaperclipDesiredSkillNames,
|
||||||
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import { ADAPTER_TYPE } from "../shared/constants.js";
|
||||||
|
|
||||||
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function asString(value) {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSkillFrontmatter(content) {
|
||||||
|
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||||
|
if (!match) return {};
|
||||||
|
const fm = {};
|
||||||
|
for (const line of match[1].split("\n")) {
|
||||||
|
const idx = line.indexOf(":");
|
||||||
|
if (idx === -1) continue;
|
||||||
|
const key = line.slice(0, idx).trim();
|
||||||
|
let val = line.slice(idx + 1).trim();
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
fm[key] = val;
|
||||||
|
}
|
||||||
|
return fm;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSkillEntry(key, skillMdPath, categoryPath) {
|
||||||
|
let description = null;
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(skillMdPath, "utf8");
|
||||||
|
description = parseSkillFrontmatter(content).description ?? null;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
runtimeName: key,
|
||||||
|
desired: true,
|
||||||
|
managed: false,
|
||||||
|
state: "installed",
|
||||||
|
origin: "user_installed",
|
||||||
|
originLabel: "Hermes skill",
|
||||||
|
locationLabel: `~/.hermes/skills/${categoryPath}`,
|
||||||
|
readOnly: true,
|
||||||
|
sourcePath: skillMdPath,
|
||||||
|
targetPath: null,
|
||||||
|
detail: description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanHermesSkills(skillsHome) {
|
||||||
|
const entries = [];
|
||||||
|
try {
|
||||||
|
const cats = await fs.readdir(skillsHome, { withFileTypes: true });
|
||||||
|
for (const cat of cats) {
|
||||||
|
if (!cat.isDirectory()) continue;
|
||||||
|
const catPath = path.join(skillsHome, cat.name);
|
||||||
|
const topSkill = path.join(catPath, "SKILL.md");
|
||||||
|
if (await fs.stat(topSkill).catch(() => null)) {
|
||||||
|
entries.push(await buildSkillEntry(cat.name, topSkill, cat.name));
|
||||||
|
}
|
||||||
|
const items = await fs.readdir(catPath, { withFileTypes: true }).catch(() => []);
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.isDirectory()) continue;
|
||||||
|
const skillMd = path.join(catPath, item.name, "SKILL.md");
|
||||||
|
if (await fs.stat(skillMd).catch(() => null)) {
|
||||||
|
entries.push(await buildSkillEntry(item.name, skillMd, `${cat.name}/${item.name}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ~/.hermes/skills/ doesn't exist
|
||||||
|
}
|
||||||
|
return entries.sort((a, b) => a.key.localeCompare(b.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSnapshot(config) {
|
||||||
|
const homedir =
|
||||||
|
asString(config.env?.HOME) ??
|
||||||
|
process.env.HOME ??
|
||||||
|
"/home/chaim";
|
||||||
|
const hermesSkillsHome = path.join(homedir, ".hermes", "skills");
|
||||||
|
|
||||||
|
const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
|
const desiredSkills = resolvePaperclipDesiredSkillNames(config, paperclipEntries);
|
||||||
|
const desiredSet = new Set(desiredSkills);
|
||||||
|
const availableByKey = new Map(paperclipEntries.map((e) => [e.key, e]));
|
||||||
|
|
||||||
|
const hermesSkillEntries = await scanHermesSkills(hermesSkillsHome);
|
||||||
|
const hermesKeys = new Set(hermesSkillEntries.map((e) => e.key));
|
||||||
|
|
||||||
|
const entries = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
for (const entry of paperclipEntries) {
|
||||||
|
const desired = desiredSet.has(entry.key);
|
||||||
|
entries.push({
|
||||||
|
key: entry.key,
|
||||||
|
runtimeName: entry.runtimeName,
|
||||||
|
desired,
|
||||||
|
managed: true,
|
||||||
|
state: desired ? "configured" : "available",
|
||||||
|
origin: entry.required ? "paperclip_required" : "company_managed",
|
||||||
|
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
||||||
|
readOnly: false,
|
||||||
|
sourcePath: entry.source,
|
||||||
|
targetPath: null,
|
||||||
|
detail: desired ? "Will be available on the next run via Hermes skill loading." : null,
|
||||||
|
required: Boolean(entry.required),
|
||||||
|
requiredReason: entry.requiredReason ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of hermesSkillEntries) {
|
||||||
|
if (availableByKey.has(entry.key)) continue;
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const desired of desiredSkills) {
|
||||||
|
if (availableByKey.has(desired) || hermesKeys.has(desired)) continue;
|
||||||
|
warnings.push(`Desired skill "${desired}" is not available in Paperclip or Hermes skills.`);
|
||||||
|
entries.push({
|
||||||
|
key: desired,
|
||||||
|
runtimeName: null,
|
||||||
|
desired: true,
|
||||||
|
managed: true,
|
||||||
|
state: "missing",
|
||||||
|
origin: "external_unknown",
|
||||||
|
originLabel: "External or unavailable",
|
||||||
|
readOnly: false,
|
||||||
|
sourcePath: null,
|
||||||
|
targetPath: null,
|
||||||
|
detail: "Cannot find this skill in Paperclip or ~/.hermes/skills/.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
adapterType: ADAPTER_TYPE,
|
||||||
|
supported: true,
|
||||||
|
mode: "persistent",
|
||||||
|
desiredSkills,
|
||||||
|
entries,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSkills(ctx) {
|
||||||
|
return buildSnapshot(ctx.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncSkills(ctx, _desired) {
|
||||||
|
return buildSnapshot(ctx.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDesiredSkillNames(config, availableEntries) {
|
||||||
|
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
|
}
|
||||||
164
adapters/deepseek-paperclip-adapter/dist/server/test.js
vendored
Normal file
164
adapters/deepseek-paperclip-adapter/dist/server/test.js
vendored
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Environment test for the DeepSeek (via Hermes) adapter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
HERMES_CLI,
|
||||||
|
ADAPTER_TYPE,
|
||||||
|
DEFAULT_PROFILE_HOME,
|
||||||
|
} from "../shared/constants.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
function asString(v) {
|
||||||
|
return typeof v === "string" ? v : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkCliInstalled(command) {
|
||||||
|
try {
|
||||||
|
await execFileAsync(command, ["--version"], { timeout: 10_000 });
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code === "ENOENT") {
|
||||||
|
return {
|
||||||
|
level: "error",
|
||||||
|
message: `Hermes CLI "${command}" not found in PATH`,
|
||||||
|
hint: "Install Hermes Agent: pip install hermes-agent",
|
||||||
|
code: "deepseek_hermes_cli_not_found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkProfile(profileHome) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(profileHome);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return {
|
||||||
|
level: "error",
|
||||||
|
message: `Profile path is not a directory: ${profileHome}`,
|
||||||
|
hint: "Create the directory or override hermesProfileHome in adapter config.",
|
||||||
|
code: "deepseek_profile_not_dir",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
level: "error",
|
||||||
|
message: `Hermes profile dir does not exist: ${profileHome}`,
|
||||||
|
hint: "Create the profile dir with config.yaml + .env (DEEPSEEK_API_KEY).",
|
||||||
|
code: "deepseek_profile_missing",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = path.join(profileHome, "config.yaml");
|
||||||
|
try {
|
||||||
|
await fs.stat(configPath);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
level: "error",
|
||||||
|
message: `Profile is missing config.yaml: ${configPath}`,
|
||||||
|
hint: "Add config.yaml with model.default + model.base_url + model.key_env.",
|
||||||
|
code: "deepseek_profile_no_config",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
level: "info",
|
||||||
|
message: `Profile resolved: ${profileHome}`,
|
||||||
|
code: "deepseek_profile_ok",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkApiKey(profileHome, configEnv) {
|
||||||
|
// 1. config.env (resolved by Paperclip from secrets)
|
||||||
|
if (configEnv && typeof configEnv === "object" && asString(configEnv.DEEPSEEK_API_KEY)) {
|
||||||
|
return {
|
||||||
|
level: "info",
|
||||||
|
message: "DEEPSEEK_API_KEY found in adapter env config",
|
||||||
|
code: "deepseek_api_key_in_config",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 2. Profile-local .env
|
||||||
|
try {
|
||||||
|
const envFile = path.join(profileHome, ".env");
|
||||||
|
const text = await fs.readFile(envFile, "utf-8");
|
||||||
|
if (/^\s*DEEPSEEK_API_KEY=/m.test(text)) {
|
||||||
|
return {
|
||||||
|
level: "info",
|
||||||
|
message: `DEEPSEEK_API_KEY found in ${envFile}`,
|
||||||
|
code: "deepseek_api_key_in_profile",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
// 3. Process env
|
||||||
|
if (process.env.DEEPSEEK_API_KEY) {
|
||||||
|
return {
|
||||||
|
level: "info",
|
||||||
|
message: "DEEPSEEK_API_KEY found in Paperclip process env",
|
||||||
|
code: "deepseek_api_key_in_process",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
level: "error",
|
||||||
|
message: "DEEPSEEK_API_KEY not found in adapter env, profile .env, or process env",
|
||||||
|
hint: "Add DEEPSEEK_API_KEY to <HERMES_HOME>/.env or to the agent's env secrets.",
|
||||||
|
code: "deepseek_api_key_missing",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testEnvironment(ctx) {
|
||||||
|
const config = ctx.config ?? {};
|
||||||
|
const command = asString(config.hermesCommand) || HERMES_CLI;
|
||||||
|
const profileHome = asString(config.hermesProfileHome) || DEFAULT_PROFILE_HOME;
|
||||||
|
const checks = [];
|
||||||
|
|
||||||
|
const cliCheck = await checkCliInstalled(command);
|
||||||
|
if (cliCheck) {
|
||||||
|
checks.push(cliCheck);
|
||||||
|
if (cliCheck.level === "error") {
|
||||||
|
return {
|
||||||
|
adapterType: ADAPTER_TYPE,
|
||||||
|
status: "fail",
|
||||||
|
checks,
|
||||||
|
testedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileCheck = await checkProfile(profileHome);
|
||||||
|
checks.push(profileCheck);
|
||||||
|
if (profileCheck.level === "error") {
|
||||||
|
return {
|
||||||
|
adapterType: ADAPTER_TYPE,
|
||||||
|
status: "fail",
|
||||||
|
checks,
|
||||||
|
testedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyCheck = await checkApiKey(profileHome, config.env);
|
||||||
|
checks.push(apiKeyCheck);
|
||||||
|
|
||||||
|
const model = asString(config.model);
|
||||||
|
checks.push({
|
||||||
|
level: "info",
|
||||||
|
message: model ? `Model: ${model}` : "Using profile default model",
|
||||||
|
code: "deepseek_model",
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasErrors = checks.some((c) => c.level === "error");
|
||||||
|
const hasWarnings = checks.some((c) => c.level === "warn");
|
||||||
|
return {
|
||||||
|
adapterType: ADAPTER_TYPE,
|
||||||
|
status: hasErrors ? "fail" : hasWarnings ? "warn" : "pass",
|
||||||
|
checks,
|
||||||
|
testedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
36
adapters/deepseek-paperclip-adapter/dist/shared/constants.js
vendored
Normal file
36
adapters/deepseek-paperclip-adapter/dist/shared/constants.js
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Shared constants for the DeepSeek (via Hermes) Paperclip adapter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ADAPTER_TYPE = "deepseek_local";
|
||||||
|
export const ADAPTER_LABEL = "DeepSeek (via Hermes)";
|
||||||
|
|
||||||
|
/** Default Hermes CLI binary name. */
|
||||||
|
export const HERMES_CLI = "hermes";
|
||||||
|
|
||||||
|
/** Default profile directory used as HERMES_HOME if the agent does not override it. */
|
||||||
|
export const DEFAULT_PROFILE_HOME = "/home/chaim/.hermes/profiles/deepseek";
|
||||||
|
|
||||||
|
/** Default model — V4-Pro is the strongest DeepSeek model currently exposed. */
|
||||||
|
export const DEFAULT_MODEL = "deepseek-v4-pro";
|
||||||
|
|
||||||
|
/** DeepSeek profiles in this stack use Hermes' "custom" provider (user-defined in profile config.yaml). */
|
||||||
|
export const DEFAULT_PROVIDER = "custom";
|
||||||
|
|
||||||
|
/** Default timeout (seconds) for one CLI invocation. */
|
||||||
|
export const DEFAULT_TIMEOUT_SEC = 1800;
|
||||||
|
|
||||||
|
/** Grace period (seconds) after SIGTERM before SIGKILL. */
|
||||||
|
export const DEFAULT_GRACE_SEC = 30;
|
||||||
|
|
||||||
|
/** Models that DeepSeek's API currently exposes (verified via /v1/models). */
|
||||||
|
export const DEEPSEEK_MODELS = [
|
||||||
|
{ id: "deepseek-v4-pro", label: "DeepSeek V4 Pro" },
|
||||||
|
{ id: "deepseek-v4-flash", label: "DeepSeek V4 Flash" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Regex for extracting session_id from quiet-mode Hermes output. */
|
||||||
|
export const SESSION_ID_REGEX = /^session_id:\s*(\S+)/m;
|
||||||
|
export const SESSION_ID_REGEX_LEGACY = /session[_ ](?:id|saved)[:\s]+([a-zA-Z0-9_-]+)/i;
|
||||||
|
export const TOKEN_USAGE_REGEX = /tokens?[:\s]+(\d+)\s*(?:input|in)\b.*?(\d+)\s*(?:output|out)\b/i;
|
||||||
|
export const COST_REGEX = /(?:cost|spent)[:\s]*\$?([\d.]+)/i;
|
||||||
25
adapters/deepseek-paperclip-adapter/package-lock.json
generated
Normal file
25
adapters/deepseek-paperclip-adapter/package-lock.json
generated
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "deepseek-paperclip-adapter",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "deepseek-paperclip-adapter",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@paperclipai/adapter-utils": "^2026.325.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@paperclipai/adapter-utils": {
|
||||||
|
"version": "2026.428.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.428.0.tgz",
|
||||||
|
"integrity": "sha512-kGHpE7rhePPCbnG3OwXbNuHZZuI+XyuFgNSiDnrEeiSbkI2c5XHM2WnWDCZ/NGHULfJW3lWhSxGMFoYqiy38vQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
adapters/deepseek-paperclip-adapter/package.json
Normal file
21
adapters/deepseek-paperclip-adapter/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "deepseek-paperclip-adapter",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Paperclip adapter for DeepSeek (V4-Pro / V4-Flash) — runs Hermes Agent locally pinned to a DeepSeek profile",
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@paperclipai/adapter-utils": "^2026.325.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -400,6 +400,54 @@
|
|||||||
- **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md)
|
- **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md)
|
||||||
- **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה
|
- **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 6.11 לקחים מערר 1200-25 (קרית ענבים, מאי 2026)
|
||||||
|
|
||||||
|
השוואה בין טיוטת הכותב לעריכת דפנה חשפה 7 דפוסי סגנון שלא היו מתועדים:
|
||||||
|
|
||||||
|
### א. סדר בלוקים — תכניות לפני טענות (1xxx)
|
||||||
|
בתיקי רישוי, דפנה מעדיפה שבלוק ט (תכניות חלות) יופיע **לפני** בלוק ז (טענות). הרציונל: הקורא צריך להכיר את המסגרת הנורמטיבית לפני שהוא קורא את טענות הצדדים.
|
||||||
|
|
||||||
|
**סדר נכון ל-1xxx:** ה → ו → **ט** → ו.ב (רקע מורחב) → ז → ח → י → יא → יב
|
||||||
|
|
||||||
|
### ב. תבנית "להלן מתוך" — חובה
|
||||||
|
כל התייחסות למסמך מקור מלווה ב-"להלן מתוך [שם המסמך]:" כ-placeholder לציטוט/צילום. **12 מופעים** בעריכה, **0** בטיוטה. זהו דפוס סגנוני מרכזי שחייב להיות אוטומטי.
|
||||||
|
|
||||||
|
דוגמאות:
|
||||||
|
- "להלן מתוך הוראות התכנית:"
|
||||||
|
- "להלן מתוך פרוטוקול הדיון בוועדה המקומית:"
|
||||||
|
- "להלן מתוך הבקשה להיתר:"
|
||||||
|
- "להלן מתוך מטרת התכנית:"
|
||||||
|
- "להלן מתוך תשריט מצב מוצע:"
|
||||||
|
|
||||||
|
### ג. רקע עובדתי מורחב — ציר זמן מלא
|
||||||
|
בלוק ו חייב לספר את "הסיפור" של התיק: הגשת בקשה → פרסום → מספר התנגדויות → ישיבות ועדה מקומית (תאריך + תוצאה לכל אחת) → החלטה סופית → הגשת ערר. הטיוטה נתנה שורה אחת (90 מילים); דפנה הרחיבה ל-3 ישיבות מפורטות (~420 מילים).
|
||||||
|
|
||||||
|
### ד. ניתוח "גשר תכנוני"
|
||||||
|
כשמבקש שימוש חורג גם מקדם תכנית — דפנה מנתחת: האם השימוש המבוקש **תואם** את התכנון העתידי (→ גשר לגיטימי, כמו בכוכבה תורן)? או **סותר** (→ סטייה כפולה)? מסגרת ניתוח שלמה (249 מילים) שלא הייתה בטיוטה.
|
||||||
|
|
||||||
|
### ה. עיגון כמותי
|
||||||
|
דפנה מוסיפה נתונים מספריים ספציפיים: "4,404.98 מ"ר לכלל היישוב vs 1,425 מ"ר מבוקש — 32%". המספרים מעגנים את ההחלטה במציאות ומקשים על ערעור.
|
||||||
|
|
||||||
|
### ו. כותרות שטוחות (Heading 2 בלבד)
|
||||||
|
דפנה השתמשה ב-Heading 2 לכל הסעיפים, כולל תת-נושאים בדיון. **אין Heading 3**. כל סעיף עומד בפני עצמו.
|
||||||
|
|
||||||
|
### ז. הבחנת תקדימים inline
|
||||||
|
במקום סעיף נפרד "הבחנה מתקדימי העוררת" — ההבחנות מנוסחות inline: "באשר ל-[שם פסק דין]" → מה ההבדל → סיכום. דוגמה: "באשר לבג"ץ 6525/15 עמק שווה... אולם ההבדל מהותי".
|
||||||
|
|
||||||
|
### ביטויי מעבר חדשים (מעריכה 1200-25)
|
||||||
|
| ביטוי | הקשר |
|
||||||
|
|-------|-------|
|
||||||
|
| "עינינו הרואות" | ממצא מתוך מסמך |
|
||||||
|
| "הנה כי כן" | לפיכך (פורמלי) |
|
||||||
|
| "נשוב כאן ונבחין" | חזרה להבחנת תקדים |
|
||||||
|
| "נוסיף ונבהיר" | הוספת הבהרה |
|
||||||
|
| "מסקנת הדברים" | סיכום סעיף |
|
||||||
|
| "משכבר קבענו" | הפניה לקביעה קודמת |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. מה עדיין לא ראינו
|
## 7. מה עדיין לא ראינו
|
||||||
|
|||||||
@@ -385,3 +385,64 @@ The draft's biggest structural error was adding the "נבאר" doctrinal paragra
|
|||||||
- [ ] Update voice-fingerprint: add new transition phrases
|
- [ ] Update voice-fingerprint: add new transition phrases
|
||||||
- [ ] Update architecture-by-outcome: add "clean acceptance" archetype
|
- [ ] Update architecture-by-outcome: add "clean acceptance" archetype
|
||||||
- [ ] Fix agent opening punctuation: "ונפרט;" not "נפרט."
|
- [ ] Fix agent opening punctuation: "ונפרט;" not "נפרט."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lessons from ערר 1200-25 (קרית ענבים — שימוש חורג, דחייה)
|
||||||
|
|
||||||
|
### Source
|
||||||
|
- Our draft: `data/cases/1200-25/exports/טיוטה-v1.docx` (3,181 words)
|
||||||
|
- Daphna's edit: `data/cases/1200-25/exports/עריכה-v1.docx` (4,313 words, +35%)
|
||||||
|
- Date: May 2026
|
||||||
|
|
||||||
|
### What the Edit Changed
|
||||||
|
|
||||||
|
#### 1. Block Order — Plans Before Claims
|
||||||
|
- **Draft:** ה→ו→ז→ח→ט→י→יא→יב (plans after procedures)
|
||||||
|
- **Edit:** ה→ו→**ט**→ו.ב→ז→ח→י→יא→יב (plans BEFORE claims)
|
||||||
|
- **Lesson:** In licensing cases (1xxx), the reader must understand the normative framework (plans) before reading the parties' arguments about those plans. Block ט should precede Block ז. The new order: opening → brief background → **applicable plans** → expanded background (application + committee proceedings) → claims → procedures → discussion.
|
||||||
|
|
||||||
|
#### 2. "להלן מתוך" Document Insertion Pattern
|
||||||
|
- **Draft:** 0 occurrences
|
||||||
|
- **Edit:** 12 occurrences of "להלן מתוך [document name]:"
|
||||||
|
- **Lesson:** Every reference to a source document must be accompanied by "להלן מתוך [שם המסמך]:" as a placeholder for a direct quote/image. This is a MANDATORY pattern, not optional. Examples: "להלן מתוך הוראות התכנית:", "להלן מתוך פרוטוקול הדיון:", "להלן מתוך הבקשה להיתר:"
|
||||||
|
|
||||||
|
#### 3. Expanded Factual Background (Block ו)
|
||||||
|
- **Draft:** ~90 words (3%), one paragraph
|
||||||
|
- **Edit:** ~420 words (10%), covering: (a) the application details, (b) 3 committee meetings with dates and outcomes, (c) the final decision
|
||||||
|
- **Lesson:** Block ו must tell the full "story" of the case: when the application was filed → when it was published → how many objections → when committee meetings were held → what was decided at each meeting → when the appeal was filed. Each meeting should have date + outcome.
|
||||||
|
|
||||||
|
#### 4. Bridge Planning Analysis ("גשר תכנוני")
|
||||||
|
- **Draft:** Not present
|
||||||
|
- **Edit:** 249 words — new analytical framework
|
||||||
|
- **Lesson:** When an applicant for deviation/variance is also promoting a plan for the same land, the decision must analyze: (a) is the pending plan harmonious with the requested use? If yes → the deviation can serve as a "bridge" until the plan is approved (cite כוכבה תורן). If no → the contradiction STRENGTHENS the rejection. The writer must check `search_case_documents` for pending plans and compare them with the requested use.
|
||||||
|
|
||||||
|
#### 5. Competing Plans Analysis
|
||||||
|
- **Draft:** Not present (1,033 words added)
|
||||||
|
- **Edit:** Detailed comparison of the site-specific plan (151-1382787) vs the comprehensive plan (151-1337534)
|
||||||
|
- **Lesson:** When there's a site-specific plan AND a comprehensive plan, the decision must: (a) describe each plan's scope, (b) compare the permitted uses, (c) show quantitative contradictions (e.g., "the comprehensive plan allocates 4,404 m² for ALL commerce in the settlement, while the request alone is for 1,425 m² — 32%"), (d) conclude whether there's harmony or contradiction. This is often the STRONGEST argument in the decision.
|
||||||
|
|
||||||
|
#### 6. Heading Level — Flat Structure
|
||||||
|
- **Draft:** Mixed Heading 2 + Heading 3 (nested subsections)
|
||||||
|
- **Edit:** All Heading 2 (flat structure)
|
||||||
|
- **Lesson:** Each section stands independently. No nesting. In the discussion, each analytical step is a separate Heading 2 section.
|
||||||
|
|
||||||
|
#### 7. Inline Precedent Distinguishing
|
||||||
|
- **Draft:** Separate section "הבחנה מתקדימי העוררת" (Heading 3)
|
||||||
|
- **Edit:** Each precedent distinguished inline with "באשר ל-[case name]" → what's different → conclusion
|
||||||
|
- **Lesson:** Don't create a separate "distinguishing" section. Address each precedent where it naturally comes up in the discussion, using "באשר ל..." as the opener.
|
||||||
|
|
||||||
|
### New Transition Phrases Identified
|
||||||
|
- **"עינינו הרואות"** — introducing a document-based finding ("our eyes see that...")
|
||||||
|
- **"הנה כי כן"** — therefore/accordingly (more formal than "לפיכך")
|
||||||
|
- **"נשוב כאן ונבחין"** — returning to distinguish a case
|
||||||
|
- **"נוסיף ונבהיר"** — adding clarification
|
||||||
|
- **"מסקנת הדברים"** — concluding a subsection
|
||||||
|
- **"משכבר קבענו"** — since we already established
|
||||||
|
|
||||||
|
### Applied To
|
||||||
|
- [x] Update legal-decision-lessons.md with lessons 1-7
|
||||||
|
- [x] Update daphna-voice-fingerprint.md with structural and style findings
|
||||||
|
- [ ] Update block-schema.md: block order for 1xxx cases (ט before ז)
|
||||||
|
- [ ] Update daphna-architecture-by-outcome.md: add "bridge planning" analysis for rejections
|
||||||
|
- [ ] Update writer system prompt: mandatory "להלן מתוך" pattern
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
|||||||
return await cases.case_delete(case_number, remove_files)
|
return await cases.case_delete(case_number, remove_files)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
|
||||||
|
"""קליטת טקסט ההחלטה הסופית (`סופי-{case}.docx` בתיקיית exports).
|
||||||
|
max_chars: 0=הכל, אחרת חיתוך לאורך הנתון. שימושי ל-Hermes Knowledge Curator."""
|
||||||
|
return await cases.case_get_final_text(case_number, max_chars)
|
||||||
|
|
||||||
|
|
||||||
# Precedent attachments (user-supplied legal support for the compose phase)
|
# Precedent attachments (user-supplied legal support for the compose phase)
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def precedent_attach(
|
async def precedent_attach(
|
||||||
@@ -209,6 +216,22 @@ async def precedent_library_delete(case_law_id: str) -> str:
|
|||||||
return await plib.precedent_library_delete(case_law_id)
|
return await plib.precedent_library_delete(case_law_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def precedent_link_cases(
|
||||||
|
case_law_id_a: str,
|
||||||
|
case_law_id_b: str,
|
||||||
|
relation_type: str = "same_case_chain",
|
||||||
|
) -> str:
|
||||||
|
"""קישור שתי פסיקות כקשורות (דו-כיווני, idempotent). relation_type: same_case_chain | overruled_by | distinguished."""
|
||||||
|
return await plib.precedent_link_cases(case_law_id_a, case_law_id_b, relation_type)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
|
||||||
|
"""הסרת קישור בין שתי פסיקות (דו-כיווני)."""
|
||||||
|
return await plib.precedent_unlink_cases(case_law_id_a, case_law_id_b)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def precedent_extract_halachot(case_law_id: str) -> str:
|
async def precedent_extract_halachot(case_law_id: str) -> str:
|
||||||
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
|
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
|
||||||
@@ -390,6 +413,35 @@ async def find_similar_cases(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_internal_decisions(
|
||||||
|
query: str,
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
district: str = "",
|
||||||
|
chair_name: str = "",
|
||||||
|
limit: int = 10,
|
||||||
|
include_halachot: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
|
||||||
|
|
||||||
|
מחזיר החלטות מהקורפוס הפנימי של ועדות הערר — נפרד מפסיקת בתי המשפט.
|
||||||
|
השתמש בו במקביל ל-search_precedent_library להצגת שתי שכבות נפרדות.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: שאילתת חיפוש בעברית
|
||||||
|
practice_area: rishuy_uvniya / betterment_levy / compensation_197
|
||||||
|
appeal_subtype: סינון לפי תת-סוג ערר
|
||||||
|
district: מחוז — ירושלים / מרכז / תל אביב / צפון / דרום / ארצי. ריק = כל המחוזות
|
||||||
|
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
|
||||||
|
limit: מספר תוצאות מקסימלי
|
||||||
|
include_halachot: האם לכלול הלכות שחולצו
|
||||||
|
"""
|
||||||
|
return await search.search_internal_decisions(
|
||||||
|
query, practice_area, appeal_subtype, district, chair_name, limit, include_halachot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Drafting
|
# Drafting
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def get_style_guide() -> str:
|
async def get_style_guide() -> str:
|
||||||
@@ -573,6 +625,43 @@ async def ingest_final_version(
|
|||||||
return await workflow.ingest_final_version(case_number, file_path, final_text)
|
return await workflow.ingest_final_version(case_number, file_path, final_text)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def internal_decision_migrate(
|
||||||
|
source: str = "both",
|
||||||
|
dry_run: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""העברת החלטות ועדת ערר קיימות לקורפוס הפנימי (פעולת admin).
|
||||||
|
|
||||||
|
source: 'style_corpus' | 'external_corpus' | 'both'
|
||||||
|
dry_run: אם true — מציג מה יקרה ללא כתיבה
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
from legal_mcp.services import internal_decisions as int_svc
|
||||||
|
if source not in {"style_corpus", "external_corpus", "both"}:
|
||||||
|
return "source חייב להיות style_corpus / external_corpus / both"
|
||||||
|
results: dict = {}
|
||||||
|
if source in {"style_corpus", "both"}:
|
||||||
|
results["style_corpus"] = await int_svc.migrate_from_style_corpus(dry_run=dry_run)
|
||||||
|
if source in {"external_corpus", "both"}:
|
||||||
|
results["external_corpus"] = await int_svc.migrate_from_external_corpus(dry_run=dry_run)
|
||||||
|
return _json.dumps(results, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def internal_decision_enrich(
|
||||||
|
dry_run: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""העשרת החלטות שהומגרו (חד-פעמי): תיקון מספר ערר + שם + תאריך + תור להלכות.
|
||||||
|
|
||||||
|
dry_run=True — מציג כמה רשומות יטופלו ללא כתיבה.
|
||||||
|
dry_run=False — מריץ בפועל: metadata extraction (תיקון case_number/case_name/date) ואחר כך תור חילוץ הלכות.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
from legal_mcp.services import internal_decisions as int_svc
|
||||||
|
result = await int_svc.enrich_migrated_entries(dry_run=dry_run)
|
||||||
|
return _json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def record_chair_feedback(
|
async def record_chair_feedback(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
|
|||||||
@@ -360,13 +360,9 @@ async def write_block(
|
|||||||
post_hearing_context=post_hearing_context,
|
post_hearing_context=post_hearing_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Restructure: sources first, then instructions
|
# source_context is already embedded inside formatted_prompt via {source_context} in the
|
||||||
prompt = (
|
# template. Do NOT prepend it again — doing so doubles the prompt size (was 465K chars).
|
||||||
f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n"
|
prompt = formatted_prompt
|
||||||
f"{source_context}\n\n"
|
|
||||||
f"---\n\n"
|
|
||||||
f"{formatted_prompt}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if instructions:
|
if instructions:
|
||||||
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||||
@@ -377,6 +373,19 @@ async def write_block(
|
|||||||
if not dir_doc.get("approved"):
|
if not dir_doc.get("approved"):
|
||||||
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
||||||
|
|
||||||
|
# Guard against context overflow before calling claude -p.
|
||||||
|
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
|
||||||
|
# In practice the CLI has crashed on prompts above ~400K chars, so use
|
||||||
|
# that as a conservative ceiling (well below the token limit).
|
||||||
|
_MAX_PROMPT_CHARS = 400_000
|
||||||
|
if len(prompt) > _MAX_PROMPT_CHARS:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Prompt too large for {block_id}: {len(prompt):,} chars "
|
||||||
|
f"(limit {_MAX_PROMPT_CHARS:,}). "
|
||||||
|
f"source_context: {len(source_context):,} chars. "
|
||||||
|
f"Reduce documents or call extract_appraiser_facts first."
|
||||||
|
)
|
||||||
|
|
||||||
# Call Claude via Claude Code session (no API)
|
# Call Claude via Claude Code session (no API)
|
||||||
model_key = block_cfg["model"]
|
model_key = block_cfg["model"]
|
||||||
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
||||||
@@ -414,16 +423,35 @@ def _build_case_context(case: dict, decision: dict | None) -> str:
|
|||||||
- תוצאה: {outcome_heb}"""
|
- תוצאה: {outcome_heb}"""
|
||||||
|
|
||||||
|
|
||||||
|
# Which doc_types are relevant per block.
|
||||||
|
# None → skip source docs entirely (block uses other context, e.g. claims_context)
|
||||||
|
# [] → include all doc types (default for unspecified blocks)
|
||||||
|
# [..] → include only the listed doc_type values
|
||||||
|
_BLOCK_DOC_TYPES: dict[str, list[str] | None] = {
|
||||||
|
"block-he": None, # only case_context needed; no full docs
|
||||||
|
"block-vav": ["appeal", "protocol"], # כתב ערר + פרוטוקול ועדה
|
||||||
|
"block-zayin": None, # claims_context is sufficient
|
||||||
|
"block-chet": ["protocol"], # פרוטוקול + השלמות טיעון
|
||||||
|
"block-tet": ["appraisal"], # שומות בלבד
|
||||||
|
# block-yod, block-yod-alef, block-he etc. default → all docs
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _build_source_context(case_id: UUID, block_id: str) -> str:
|
async def _build_source_context(case_id: UUID, block_id: str) -> str:
|
||||||
"""Get full document texts for the block.
|
"""Get document texts for the block, filtered by relevance.
|
||||||
|
|
||||||
Per Anthropic best practices: send full source documents, not truncated excerpts.
|
Per Anthropic best practices: send full source documents, not truncated excerpts.
|
||||||
Place documents at the TOP of the prompt (before instructions) for 30% better recall.
|
Per-block filtering prevents context overflow on large cases (9+ docs).
|
||||||
For grounding: instruct Claude to cite word-for-word from these documents.
|
|
||||||
"""
|
"""
|
||||||
|
allowed = _BLOCK_DOC_TYPES.get(block_id, []) # [] sentinel = not in map → all docs
|
||||||
|
if allowed is None:
|
||||||
|
return "" # this block doesn't need raw source docs
|
||||||
|
|
||||||
docs = await db.list_documents(case_id)
|
docs = await db.list_documents(case_id)
|
||||||
context_parts = []
|
context_parts = []
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
|
if allowed and doc["doc_type"] not in allowed:
|
||||||
|
continue
|
||||||
text = await db.get_document_text(UUID(doc["id"]))
|
text = await db.get_document_text(UUID(doc["id"]))
|
||||||
if text:
|
if text:
|
||||||
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")
|
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ async def query(
|
|||||||
"""
|
"""
|
||||||
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||||
|
|
||||||
|
if len(full_prompt) > 150_000:
|
||||||
|
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"claude", "-p",
|
"claude", "-p",
|
||||||
"--output-format", "json",
|
"--output-format", "json",
|
||||||
@@ -110,7 +113,8 @@ async def query(
|
|||||||
|
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
||||||
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}")
|
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
|
||||||
|
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}{size_info}")
|
||||||
|
|
||||||
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
||||||
if not stdout:
|
if not stdout:
|
||||||
|
|||||||
@@ -691,6 +691,30 @@ CREATE INDEX IF NOT EXISTS idx_prec_img_emb_case_law
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_V10_SQL = """
|
||||||
|
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS chair_name TEXT DEFAULT '';
|
||||||
|
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS district TEXT DEFAULT '';
|
||||||
|
ALTER TABLE cases ADD COLUMN IF NOT EXISTS chair_name TEXT DEFAULT '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_case_law_source_kind ON case_law(source_kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_case_law_chair ON case_law(chair_name) WHERE chair_name <> '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_case_law_district ON case_law(district) WHERE district <> '';
|
||||||
|
"""
|
||||||
|
|
||||||
|
SCHEMA_V11_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS case_law_relations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
case_law_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
|
||||||
|
related_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
|
||||||
|
relation_type TEXT NOT NULL DEFAULT 'same_case_chain',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
UNIQUE(case_law_id, related_id),
|
||||||
|
CHECK (case_law_id <> related_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clr_a ON case_law_relations(case_law_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clr_b ON case_law_relations(related_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute(SCHEMA_SQL)
|
await conn.execute(SCHEMA_SQL)
|
||||||
@@ -703,7 +727,9 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
|||||||
await conn.execute(SCHEMA_V7_SQL)
|
await conn.execute(SCHEMA_V7_SQL)
|
||||||
await conn.execute(SCHEMA_V8_SQL)
|
await conn.execute(SCHEMA_V8_SQL)
|
||||||
await conn.execute(SCHEMA_V9_SQL)
|
await conn.execute(SCHEMA_V9_SQL)
|
||||||
logger.info("Database schema initialized (v1-v9)")
|
await conn.execute(SCHEMA_V10_SQL)
|
||||||
|
await conn.execute(SCHEMA_V11_SQL)
|
||||||
|
logger.info("Database schema initialized (v1-v11)")
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -1724,6 +1750,59 @@ async def get_case_law(case_law_id: UUID) -> dict | None:
|
|||||||
return _row_to_case_law(row) if row else None
|
return _row_to_case_law(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def add_case_law_relation(
|
||||||
|
a_id: UUID, b_id: UUID, relation_type: str = "same_case_chain"
|
||||||
|
) -> None:
|
||||||
|
"""Link two case_law records bidirectionally. Idempotent (ON CONFLICT DO NOTHING)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO case_law_relations(case_law_id, related_id, relation_type)
|
||||||
|
VALUES($1, $2, $3)
|
||||||
|
ON CONFLICT (case_law_id, related_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
[(a_id, b_id, relation_type), (b_id, a_id, relation_type)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_case_law_relation(a_id: UUID, b_id: UUID) -> None:
|
||||||
|
"""Remove a bidirectional link between two case_law records."""
|
||||||
|
pool = await get_pool()
|
||||||
|
await pool.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM case_law_relations
|
||||||
|
WHERE (case_law_id = $1 AND related_id = $2)
|
||||||
|
OR (case_law_id = $2 AND related_id = $1)
|
||||||
|
""",
|
||||||
|
a_id,
|
||||||
|
b_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_case_law_relations(case_law_id: UUID) -> list[dict]:
|
||||||
|
"""Return all case_law records linked to case_law_id, ordered by date asc."""
|
||||||
|
pool = await get_pool()
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"""
|
||||||
|
SELECT cl.*, r.relation_type
|
||||||
|
FROM case_law_relations r
|
||||||
|
JOIN case_law cl ON cl.id = r.related_id
|
||||||
|
WHERE r.case_law_id = $1
|
||||||
|
ORDER BY cl.date ASC NULLS LAST
|
||||||
|
""",
|
||||||
|
case_law_id,
|
||||||
|
)
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
d = dict(row)
|
||||||
|
relation_type = d.pop("relation_type")
|
||||||
|
normalized = _row_to_case_law(d)
|
||||||
|
normalized["relation_type"] = relation_type
|
||||||
|
results.append(normalized)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
async def get_case_law_by_citation(case_number: str) -> dict | None:
|
async def get_case_law_by_citation(case_number: str) -> dict | None:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
row = await pool.fetchrow(
|
row = await pool.fetchrow(
|
||||||
@@ -1817,6 +1896,85 @@ async def create_external_case_law(
|
|||||||
return _row_to_case_law(row)
|
return _row_to_case_law(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_internal_committee_decision(
|
||||||
|
case_number: str,
|
||||||
|
case_name: str,
|
||||||
|
full_text: str,
|
||||||
|
court: str = "",
|
||||||
|
decision_date: date | None = None,
|
||||||
|
chair_name: str = "",
|
||||||
|
district: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
summary: str = "",
|
||||||
|
is_binding: bool = True,
|
||||||
|
document_id: UUID | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Upsert an appeals-committee decision as source_kind='internal_committee'.
|
||||||
|
|
||||||
|
If a row with this case_number already exists as cited_only, promotes it.
|
||||||
|
Idempotent: calling again updates metadata in-place.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
"SELECT id FROM case_law WHERE case_number = $1",
|
||||||
|
case_number,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
UPDATE case_law SET
|
||||||
|
case_name = $2,
|
||||||
|
court = COALESCE(NULLIF($3, ''), court),
|
||||||
|
date = COALESCE($4, date),
|
||||||
|
chair_name = COALESCE(NULLIF($5, ''), chair_name),
|
||||||
|
district = COALESCE(NULLIF($6, ''), district),
|
||||||
|
practice_area = $7,
|
||||||
|
appeal_subtype = $8,
|
||||||
|
subject_tags = $9,
|
||||||
|
summary = COALESCE(NULLIF($10, ''), summary),
|
||||||
|
full_text = $11,
|
||||||
|
source_type = 'appeals_committee',
|
||||||
|
source_kind = 'internal_committee',
|
||||||
|
is_binding = $12,
|
||||||
|
document_id = COALESCE($13, document_id),
|
||||||
|
extraction_status = 'processing',
|
||||||
|
halacha_extraction_status = 'pending'
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
existing["id"], case_name, court, decision_date,
|
||||||
|
chair_name, district, practice_area, appeal_subtype,
|
||||||
|
tags_json, summary, full_text, is_binding, document_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO case_law (
|
||||||
|
case_number, case_name, court, date, chair_name, district,
|
||||||
|
subject_tags, summary, full_text,
|
||||||
|
source_kind, source_type, document_id,
|
||||||
|
extraction_status, halacha_extraction_status,
|
||||||
|
practice_area, appeal_subtype, is_binding
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6,
|
||||||
|
$7, $8, $9,
|
||||||
|
'internal_committee', 'appeals_committee', $10,
|
||||||
|
'processing', 'pending',
|
||||||
|
$11, $12, $13
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
case_number, case_name, court, decision_date, chair_name, district,
|
||||||
|
tags_json, summary, full_text,
|
||||||
|
document_id, practice_area, appeal_subtype, is_binding,
|
||||||
|
)
|
||||||
|
return _row_to_case_law(row)
|
||||||
|
|
||||||
|
|
||||||
async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
|
async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
|
||||||
"""Patch metadata fields on a case_law row.
|
"""Patch metadata fields on a case_law row.
|
||||||
|
|
||||||
@@ -1825,7 +1983,7 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
|
|||||||
precedent_level, is_binding.
|
precedent_level, is_binding.
|
||||||
"""
|
"""
|
||||||
allowed = {
|
allowed = {
|
||||||
"case_name", "court", "date", "practice_area", "appeal_subtype",
|
"case_number", "case_name", "court", "date", "practice_area", "appeal_subtype",
|
||||||
"subject_tags", "summary", "headnote", "key_quote", "source_url",
|
"subject_tags", "summary", "headnote", "key_quote", "source_url",
|
||||||
"source_type", "precedent_level", "is_binding",
|
"source_type", "precedent_level", "is_binding",
|
||||||
}
|
}
|
||||||
@@ -1892,10 +2050,21 @@ async def list_external_case_law(
|
|||||||
search: str = "",
|
search: str = "",
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
|
source_kind: str = "external_upload",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""List chair-uploaded precedents, with simple filters."""
|
"""List chair-uploaded precedents, with simple filters.
|
||||||
|
|
||||||
|
source_kind="all_committees" expands to: source_kind='internal_committee'
|
||||||
|
OR (source_kind='external_upload' AND source_type='appeals_committee').
|
||||||
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
conditions = ["source_kind = 'external_upload'"]
|
if source_kind == "all_committees":
|
||||||
|
conditions = [
|
||||||
|
"(source_kind = 'internal_committee' OR "
|
||||||
|
"(source_kind = 'external_upload' AND source_type = 'appeals_committee'))"
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
conditions = [f"source_kind = '{source_kind}'"]
|
||||||
params: list = []
|
params: list = []
|
||||||
idx = 1
|
idx = 1
|
||||||
if practice_area:
|
if practice_area:
|
||||||
@@ -1927,6 +2096,7 @@ async def list_external_case_law(
|
|||||||
SELECT id, case_number, case_name, court, date, practice_area,
|
SELECT id, case_number, case_name, court, date, practice_area,
|
||||||
appeal_subtype, source_type, precedent_level, is_binding,
|
appeal_subtype, source_type, precedent_level, is_binding,
|
||||||
summary, headnote, subject_tags, source_kind,
|
summary, headnote, subject_tags, source_kind,
|
||||||
|
chair_name, district,
|
||||||
extraction_status, halacha_extraction_status,
|
extraction_status, halacha_extraction_status,
|
||||||
metadata_extraction_requested_at,
|
metadata_extraction_requested_at,
|
||||||
halacha_extraction_requested_at,
|
halacha_extraction_requested_at,
|
||||||
@@ -2194,8 +2364,14 @@ async def search_precedent_library_semantic(
|
|||||||
subject_tag: str = "",
|
subject_tag: str = "",
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
include_halachot: bool = True,
|
include_halachot: bool = True,
|
||||||
|
source_kind: str = "external_upload",
|
||||||
|
district: str = "",
|
||||||
|
chair_name: str = "",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Semantic search over chair-uploaded precedents.
|
"""Semantic search over precedents filtered by source_kind.
|
||||||
|
|
||||||
|
source_kind='external_upload' → court rulings (default)
|
||||||
|
source_kind='internal_committee' → appeals-committee decisions
|
||||||
|
|
||||||
Returns merged halachot + chunks. Halachot are pre-distilled rules, so
|
Returns merged halachot + chunks. Halachot are pre-distilled rules, so
|
||||||
they get a small score boost. Only ``approved`` / ``published`` halachot
|
they get a small score boost. Only ``approved`` / ``published`` halachot
|
||||||
@@ -2204,7 +2380,7 @@ async def search_precedent_library_semantic(
|
|||||||
"""
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
halacha_filters = ["h.review_status IN ('approved', 'published')"]
|
halacha_filters = ["h.review_status IN ('approved', 'published')"]
|
||||||
chunk_filters = ["cl.source_kind = 'external_upload'"]
|
chunk_filters = [f"cl.source_kind = '{source_kind}'"]
|
||||||
h_params: list = [query_embedding, limit]
|
h_params: list = [query_embedding, limit]
|
||||||
c_params: list = [query_embedding, limit]
|
c_params: list = [query_embedding, limit]
|
||||||
h_idx = 3
|
h_idx = 3
|
||||||
@@ -2249,13 +2425,27 @@ async def search_precedent_library_semantic(
|
|||||||
halacha_filters.append(f"${h_idx} = ANY(h.subject_tags)")
|
halacha_filters.append(f"${h_idx} = ANY(h.subject_tags)")
|
||||||
h_params.append(subject_tag)
|
h_params.append(subject_tag)
|
||||||
h_idx += 1
|
h_idx += 1
|
||||||
|
if district:
|
||||||
|
halacha_filters.append(f"cl.district = ${h_idx}")
|
||||||
|
h_params.append(district)
|
||||||
|
h_idx += 1
|
||||||
|
chunk_filters.append(f"cl.district = ${c_idx}")
|
||||||
|
c_params.append(district)
|
||||||
|
c_idx += 1
|
||||||
|
if chair_name:
|
||||||
|
halacha_filters.append(f"cl.chair_name = ${h_idx}")
|
||||||
|
h_params.append(chair_name)
|
||||||
|
h_idx += 1
|
||||||
|
chunk_filters.append(f"cl.chair_name = ${c_idx}")
|
||||||
|
c_params.append(chair_name)
|
||||||
|
c_idx += 1
|
||||||
|
|
||||||
halacha_sql = f"""
|
halacha_sql = f"""
|
||||||
SELECT h.id AS halacha_id, h.case_law_id, h.rule_statement,
|
SELECT h.id AS halacha_id, h.case_law_id, h.rule_statement,
|
||||||
h.reasoning_summary, h.supporting_quote, h.page_reference,
|
h.reasoning_summary, h.supporting_quote, h.page_reference,
|
||||||
h.practice_areas, h.subject_tags, h.confidence, h.rule_type,
|
h.practice_areas, h.subject_tags, h.confidence, h.rule_type,
|
||||||
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
|
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
|
||||||
cl.precedent_level,
|
cl.precedent_level, cl.chair_name, cl.district,
|
||||||
1 - (h.embedding <=> $1) AS score
|
1 - (h.embedding <=> $1) AS score
|
||||||
FROM halachot h
|
FROM halachot h
|
||||||
JOIN case_law cl ON cl.id = h.case_law_id
|
JOIN case_law cl ON cl.id = h.case_law_id
|
||||||
@@ -2269,7 +2459,7 @@ async def search_precedent_library_semantic(
|
|||||||
SELECT pc.id AS chunk_id, pc.case_law_id, pc.content,
|
SELECT pc.id AS chunk_id, pc.case_law_id, pc.content,
|
||||||
pc.section_type, pc.page_number,
|
pc.section_type, pc.page_number,
|
||||||
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
|
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
|
||||||
cl.precedent_level, cl.practice_area,
|
cl.precedent_level, cl.practice_area, cl.chair_name, cl.district,
|
||||||
1 - (pc.embedding <=> $1) AS score
|
1 - (pc.embedding <=> $1) AS score
|
||||||
FROM precedent_chunks pc
|
FROM precedent_chunks pc
|
||||||
JOIN case_law cl ON cl.id = pc.case_law_id
|
JOIN case_law cl ON cl.id = pc.case_law_id
|
||||||
@@ -2308,19 +2498,17 @@ async def precedent_library_stats() -> dict:
|
|||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
total = await conn.fetchval(
|
total = await conn.fetchval(
|
||||||
"SELECT COUNT(*) FROM case_law WHERE source_kind = 'external_upload'"
|
"SELECT COUNT(*) FROM case_law"
|
||||||
)
|
)
|
||||||
by_practice = await conn.fetch(
|
by_practice = await conn.fetch(
|
||||||
"""SELECT practice_area, COUNT(*) AS n
|
"""SELECT practice_area, COUNT(*) AS n
|
||||||
FROM case_law
|
FROM case_law
|
||||||
WHERE source_kind = 'external_upload'
|
|
||||||
GROUP BY practice_area
|
GROUP BY practice_area
|
||||||
ORDER BY n DESC"""
|
ORDER BY n DESC"""
|
||||||
)
|
)
|
||||||
by_level = await conn.fetch(
|
by_level = await conn.fetch(
|
||||||
"""SELECT precedent_level, COUNT(*) AS n
|
"""SELECT precedent_level, COUNT(*) AS n
|
||||||
FROM case_law
|
FROM case_law
|
||||||
WHERE source_kind = 'external_upload'
|
|
||||||
GROUP BY precedent_level
|
GROUP BY precedent_level
|
||||||
ORDER BY n DESC"""
|
ORDER BY n DESC"""
|
||||||
)
|
)
|
||||||
@@ -2354,22 +2542,31 @@ async def precedent_library_stats() -> dict:
|
|||||||
|
|
||||||
async def request_metadata_extraction(case_law_id: UUID) -> bool:
|
async def request_metadata_extraction(case_law_id: UUID) -> bool:
|
||||||
"""Stamp ``metadata_extraction_requested_at`` for the local MCP worker
|
"""Stamp ``metadata_extraction_requested_at`` for the local MCP worker
|
||||||
to pick up. Returns False if the row is missing."""
|
to pick up. Returns False if the row is missing.
|
||||||
|
|
||||||
|
Originally restricted to ``source_kind='external_upload'`` (see git
|
||||||
|
blame). Opened to all source kinds 2026-05-06 — internal_committee
|
||||||
|
rows can also need re-extraction (e.g. corrupted subject_tags from
|
||||||
|
an early ingest pipeline). The extractor itself preserves user
|
||||||
|
values (``precedent_metadata_extractor.extract_and_apply`` only
|
||||||
|
fills empty fields), so this is safe.
|
||||||
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
result = await pool.execute(
|
result = await pool.execute(
|
||||||
"UPDATE case_law SET metadata_extraction_requested_at = now() "
|
"UPDATE case_law SET metadata_extraction_requested_at = now() "
|
||||||
"WHERE id = $1 AND source_kind = 'external_upload'",
|
"WHERE id = $1",
|
||||||
case_law_id,
|
case_law_id,
|
||||||
)
|
)
|
||||||
return result == "UPDATE 1"
|
return result == "UPDATE 1"
|
||||||
|
|
||||||
|
|
||||||
async def request_halacha_extraction(case_law_id: UUID) -> bool:
|
async def request_halacha_extraction(case_law_id: UUID) -> bool:
|
||||||
"""Same but for halacha extraction."""
|
"""Same but for halacha extraction. See note on
|
||||||
|
:func:`request_metadata_extraction` re: opening to all source kinds."""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
result = await pool.execute(
|
result = await pool.execute(
|
||||||
"UPDATE case_law SET halacha_extraction_requested_at = now() "
|
"UPDATE case_law SET halacha_extraction_requested_at = now() "
|
||||||
"WHERE id = $1 AND source_kind = 'external_upload'",
|
"WHERE id = $1",
|
||||||
case_law_id,
|
case_law_id,
|
||||||
)
|
)
|
||||||
return result == "UPDATE 1"
|
return result == "UPDATE 1"
|
||||||
@@ -2389,12 +2586,15 @@ async def list_pending_extraction_requests(
|
|||||||
else "halacha_extraction_requested_at"
|
else "halacha_extraction_requested_at"
|
||||||
)
|
)
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
|
# Drop the legacy ``source_kind = 'external_upload'`` filter — without it
|
||||||
|
# internal_committee rows could be stamped (we opened that gate in
|
||||||
|
# request_metadata_extraction / request_halacha_extraction) but stayed
|
||||||
|
# invisible to the worker forever.
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
f"""SELECT id, case_number, case_name, court, date,
|
f"""SELECT id, case_number, case_name, court, date,
|
||||||
practice_area, is_binding, {col} AS requested_at
|
practice_area, is_binding, {col} AS requested_at
|
||||||
FROM case_law
|
FROM case_law
|
||||||
WHERE {col} IS NOT NULL
|
WHERE {col} IS NOT NULL
|
||||||
AND source_kind = 'external_upload'
|
|
||||||
ORDER BY {col} ASC
|
ORDER BY {col} ASC
|
||||||
LIMIT $1""",
|
LIMIT $1""",
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -88,8 +88,15 @@ async def search_precedent_library_hybrid(
|
|||||||
is_binding: bool | None = None,
|
is_binding: bool | None = None,
|
||||||
subject_tag: str = "",
|
subject_tag: str = "",
|
||||||
include_halachot: bool = True,
|
include_halachot: bool = True,
|
||||||
|
source_kind: str = "external_upload",
|
||||||
|
district: str = "",
|
||||||
|
chair_name: str = "",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Hybrid wrapper for precedent-library search."""
|
"""Hybrid wrapper for precedent-library search.
|
||||||
|
|
||||||
|
source_kind='external_upload' → court rulings (default)
|
||||||
|
source_kind='internal_committee' → appeals-committee decisions
|
||||||
|
"""
|
||||||
fetch_k = max(limit, config.VOYAGE_RERANK_FETCH_K) if config.MULTIMODAL_ENABLED else limit
|
fetch_k = max(limit, config.VOYAGE_RERANK_FETCH_K) if config.MULTIMODAL_ENABLED else limit
|
||||||
|
|
||||||
async def _base(limit: int) -> list[dict]:
|
async def _base(limit: int) -> list[dict]:
|
||||||
@@ -103,6 +110,9 @@ async def search_precedent_library_hybrid(
|
|||||||
subject_tag=subject_tag,
|
subject_tag=subject_tag,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
include_halachot=include_halachot,
|
include_halachot=include_halachot,
|
||||||
|
source_kind=source_kind,
|
||||||
|
district=district,
|
||||||
|
chair_name=chair_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
text_results = await rerank.maybe_rerank(
|
text_results = await rerank.maybe_rerank(
|
||||||
|
|||||||
376
mcp-server/src/legal_mcp/services/internal_decisions.py
Normal file
376
mcp-server/src/legal_mcp/services/internal_decisions.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
"""Orchestrator for the Internal Committee Decisions corpus.
|
||||||
|
|
||||||
|
Ingest pipeline:
|
||||||
|
text/file → INSERT case_law (source_kind='internal_committee')
|
||||||
|
→ chunk → embed → store precedent_chunks
|
||||||
|
→ queue halacha extraction
|
||||||
|
|
||||||
|
Migration helpers:
|
||||||
|
migrate_from_style_corpus() — re-index style_corpus entries as searchable
|
||||||
|
migrate_from_external_corpus() — reclassify external appeals-committee rows
|
||||||
|
|
||||||
|
All ועדות ערר (any district) belong here.
|
||||||
|
Judicial decisions (Supreme Court, Administrative Court) stay in external_upload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from legal_mcp import config
|
||||||
|
from legal_mcp.services import chunker, db, embeddings, extractor
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
|
||||||
|
|
||||||
|
_VALID_DISTRICTS = {"", "ירושלים", "מרכז", "תל אביב", "צפון", "דרום", "ארצי"}
|
||||||
|
|
||||||
|
_COURT_TO_DISTRICT = [
|
||||||
|
("ירושלים", "ירושלים"),
|
||||||
|
("תל אביב", "תל אביב"),
|
||||||
|
('ת"א', "תל אביב"),
|
||||||
|
("מרכז", "מרכז"),
|
||||||
|
("חיפה", "צפון"),
|
||||||
|
("צפון", "צפון"),
|
||||||
|
("דרום", "דרום"),
|
||||||
|
("ארצי", "ארצי"),
|
||||||
|
("ארצית", "ארצי"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_date(value) -> date | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value[:10])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(name: str) -> str:
|
||||||
|
base = Path(name).name
|
||||||
|
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"internal-{uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _district_from_court(court: str) -> str:
|
||||||
|
for keyword, district in _COURT_TO_DISTRICT:
|
||||||
|
if keyword in court:
|
||||||
|
return district
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_internal_decision(
|
||||||
|
*,
|
||||||
|
case_number: str,
|
||||||
|
case_name: str = "",
|
||||||
|
court: str = "",
|
||||||
|
decision_date=None,
|
||||||
|
chair_name: str = "",
|
||||||
|
district: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
summary: str = "",
|
||||||
|
is_binding: bool = True,
|
||||||
|
file_path: str | Path | None = None,
|
||||||
|
text: str | None = None,
|
||||||
|
document_id: UUID | None = None,
|
||||||
|
queue_halachot: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Ingest an appeals-committee decision into the internal corpus.
|
||||||
|
|
||||||
|
Either file_path or text must be provided.
|
||||||
|
If district is empty, it is inferred from court.
|
||||||
|
Returns: {"status": "completed", "case_law_id": "...", "chunks": N}
|
||||||
|
"""
|
||||||
|
if not file_path and not text:
|
||||||
|
raise ValueError("either file_path or text is required")
|
||||||
|
if not case_number.strip():
|
||||||
|
raise ValueError("case_number is required")
|
||||||
|
|
||||||
|
resolved_district = district.strip() or _district_from_court(court)
|
||||||
|
|
||||||
|
if file_path:
|
||||||
|
src = Path(file_path)
|
||||||
|
if not src.is_file():
|
||||||
|
raise FileNotFoundError(f"file not found: {src}")
|
||||||
|
dest_dir = INTERNAL_DECISIONS_DIR / (resolved_district or "other")
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
staged = dest_dir / f"{uuid4().hex[:8]}_{_safe_filename(src.name)}"
|
||||||
|
shutil.copy2(src, staged)
|
||||||
|
raw_text, page_count, page_offsets = await extractor.extract_text(str(staged))
|
||||||
|
raw_text = extractor.strip_nevo_preamble(raw_text or "").strip()
|
||||||
|
if not raw_text:
|
||||||
|
raise ValueError("no extractable text in file")
|
||||||
|
else:
|
||||||
|
raw_text = (text or "").strip()
|
||||||
|
if not raw_text:
|
||||||
|
raise ValueError("text is empty")
|
||||||
|
page_count = 0
|
||||||
|
page_offsets = None
|
||||||
|
|
||||||
|
record = await db.create_internal_committee_decision(
|
||||||
|
case_number=case_number.strip(),
|
||||||
|
case_name=(case_name.strip() or case_number.strip()),
|
||||||
|
full_text=raw_text,
|
||||||
|
court=court.strip(),
|
||||||
|
decision_date=_coerce_date(decision_date),
|
||||||
|
chair_name=chair_name.strip(),
|
||||||
|
district=resolved_district,
|
||||||
|
practice_area=practice_area,
|
||||||
|
appeal_subtype=appeal_subtype.strip(),
|
||||||
|
subject_tags=list(subject_tags or []),
|
||||||
|
summary=summary.strip(),
|
||||||
|
is_binding=is_binding,
|
||||||
|
document_id=document_id,
|
||||||
|
)
|
||||||
|
case_law_id = UUID(str(record["id"]))
|
||||||
|
|
||||||
|
try:
|
||||||
|
chunks = chunker.chunk_document(raw_text, page_offsets=page_offsets)
|
||||||
|
if not chunks:
|
||||||
|
await db.set_case_law_extraction_status(case_law_id, "completed")
|
||||||
|
await db.set_case_law_halacha_status(case_law_id, "completed")
|
||||||
|
return {"status": "completed", "case_law_id": str(case_law_id), "chunks": 0}
|
||||||
|
|
||||||
|
chunk_texts = [c.content for c in chunks]
|
||||||
|
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
|
||||||
|
chunk_dicts = [
|
||||||
|
{
|
||||||
|
"chunk_index": c.chunk_index,
|
||||||
|
"content": c.content,
|
||||||
|
"section_type": c.section_type,
|
||||||
|
"page_number": c.page_number,
|
||||||
|
"embedding": v,
|
||||||
|
}
|
||||||
|
for c, v in zip(chunks, chunk_vectors)
|
||||||
|
]
|
||||||
|
stored = await db.store_precedent_chunks(case_law_id, chunk_dicts)
|
||||||
|
|
||||||
|
await db.set_case_law_extraction_status(case_law_id, "completed")
|
||||||
|
await db.set_case_law_halacha_status(case_law_id, "pending")
|
||||||
|
if queue_halachot:
|
||||||
|
await db.request_halacha_extraction(case_law_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"case_law_id": str(case_law_id),
|
||||||
|
"chunks": stored,
|
||||||
|
"halachot_pending": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("ingest_internal_decision failed for %s", case_number)
|
||||||
|
await db.set_case_law_extraction_status(case_law_id, "failed")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_from_style_corpus(dry_run: bool = False, queue_halachot: bool = True) -> dict:
|
||||||
|
"""Re-index all style_corpus entries as searchable internal committee decisions.
|
||||||
|
|
||||||
|
Does NOT delete style_corpus rows — they remain for style analysis.
|
||||||
|
Skips entries that already exist in case_law as internal_committee.
|
||||||
|
"""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT decision_number, decision_date, full_text,
|
||||||
|
practice_area, appeal_subtype, subject_categories
|
||||||
|
FROM style_corpus
|
||||||
|
ORDER BY decision_date NULLS LAST"""
|
||||||
|
)
|
||||||
|
|
||||||
|
results = {"total": len(rows), "ingested": 0, "skipped": 0, "failed": 0, "dry_run": dry_run}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
case_number = (row["decision_number"] or "").strip()
|
||||||
|
if not case_number:
|
||||||
|
results["skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
existing = await pool.fetchval(
|
||||||
|
"SELECT id FROM case_law WHERE case_number = $1 AND source_kind = 'internal_committee'",
|
||||||
|
case_number,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
results["skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
results["ingested"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
subject_tags = list(row["subject_categories"] or [])
|
||||||
|
raw_pa = row["practice_area"] or ""
|
||||||
|
subtype = row["appeal_subtype"] or ""
|
||||||
|
# style_corpus stores 'appeals_committee' (source_type) instead of practice_area
|
||||||
|
_subtype_to_pa = {
|
||||||
|
"building_permit": "rishuy_uvniya",
|
||||||
|
"betterment_levy": "betterment_levy",
|
||||||
|
"compensation_197": "compensation_197",
|
||||||
|
}
|
||||||
|
practice_area = raw_pa if raw_pa in ("rishuy_uvniya", "betterment_levy", "compensation_197") \
|
||||||
|
else _subtype_to_pa.get(subtype, "")
|
||||||
|
await ingest_internal_decision(
|
||||||
|
case_number=case_number,
|
||||||
|
court="ועדת הערר לתכנון ובנייה — מחוז ירושלים",
|
||||||
|
decision_date=row["decision_date"],
|
||||||
|
chair_name="דפנה תמיר",
|
||||||
|
district="ירושלים",
|
||||||
|
practice_area=practice_area,
|
||||||
|
appeal_subtype=subtype,
|
||||||
|
subject_tags=subject_tags,
|
||||||
|
text=row["full_text"],
|
||||||
|
queue_halachot=queue_halachot,
|
||||||
|
)
|
||||||
|
results["ingested"] += 1
|
||||||
|
logger.info("Migrated style_corpus entry: %s", case_number)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to migrate %s: %s", case_number, e)
|
||||||
|
results["failed"] += 1
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_from_external_corpus(dry_run: bool = False) -> dict:
|
||||||
|
"""Reclassify external appeals-committee decisions to source_kind='internal_committee'.
|
||||||
|
|
||||||
|
Identifies rows by source_type='appeals_committee' and updates source_kind + district.
|
||||||
|
Existing precedent_chunks remain — no re-embedding needed.
|
||||||
|
"""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT id, case_number, court
|
||||||
|
FROM case_law
|
||||||
|
WHERE source_kind = 'external_upload'
|
||||||
|
AND source_type = 'appeals_committee'"""
|
||||||
|
)
|
||||||
|
|
||||||
|
results = {"total": len(rows), "updated": 0, "dry_run": dry_run}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
results["updated"] = len(rows)
|
||||||
|
results["preview"] = [
|
||||||
|
{"case_number": r["case_number"], "court": r["court"], "district": _district_from_court(r["court"] or "")}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return results
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
for row in rows:
|
||||||
|
district = _district_from_court(row["court"] or "")
|
||||||
|
await conn.execute(
|
||||||
|
"""UPDATE case_law
|
||||||
|
SET source_kind = 'internal_committee',
|
||||||
|
district = CASE WHEN $2 <> '' THEN $2 ELSE district END
|
||||||
|
WHERE id = $1""",
|
||||||
|
row["id"], district,
|
||||||
|
)
|
||||||
|
results["updated"] = len(rows)
|
||||||
|
|
||||||
|
logger.info("Migrated %d external appeals-committee rows to internal_committee", len(rows))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich_migrated_entries(dry_run: bool = False) -> dict:
|
||||||
|
"""One-time enrichment: run metadata extraction + halacha extraction on all
|
||||||
|
internal_committee entries that are waiting (halacha_status='pending',
|
||||||
|
metadata never requested).
|
||||||
|
|
||||||
|
Metadata extraction will:
|
||||||
|
- Fix case_number from the decision header text
|
||||||
|
- Fill case_name from the parties line
|
||||||
|
- Fill date if missing
|
||||||
|
|
||||||
|
Halacha extraction queues the LLM-based halacha extraction job.
|
||||||
|
"""
|
||||||
|
from legal_mcp.services import precedent_metadata_extractor, db as _db
|
||||||
|
|
||||||
|
pool = await _db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT id, case_number
|
||||||
|
FROM case_law
|
||||||
|
WHERE source_kind = 'internal_committee'
|
||||||
|
AND halacha_extraction_status = 'pending'
|
||||||
|
AND metadata_extraction_requested_at IS NULL
|
||||||
|
ORDER BY created_at"""
|
||||||
|
)
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"total": len(rows),
|
||||||
|
"metadata_updated": 0,
|
||||||
|
"halachot_queued": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"dry_run": dry_run,
|
||||||
|
}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return results
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
case_law_id = row["id"]
|
||||||
|
try:
|
||||||
|
meta = await precedent_metadata_extractor.extract_and_apply(
|
||||||
|
case_law_id, overwrite_case_number=True
|
||||||
|
)
|
||||||
|
if meta.get("status") in ("completed", "no_changes"):
|
||||||
|
results["metadata_updated"] += 1
|
||||||
|
logger.info(
|
||||||
|
"enrich_migrated: %s → fields=%s",
|
||||||
|
row["case_number"], meta.get("fields"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("enrich_migrated metadata failed for %s: %s", row["case_number"], e)
|
||||||
|
results["failed"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _db.request_halacha_extraction(case_law_id)
|
||||||
|
results["halachot_queued"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("enrich_migrated halacha queue failed for %s: %s", row["case_number"], e)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def search_internal(
|
||||||
|
query: str,
|
||||||
|
*,
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
district: str = "",
|
||||||
|
chair_name: str = "",
|
||||||
|
limit: int = 10,
|
||||||
|
include_halachot: bool = True,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Semantic search over internal committee decisions."""
|
||||||
|
from legal_mcp.services import hybrid_search
|
||||||
|
|
||||||
|
if not query.strip():
|
||||||
|
return []
|
||||||
|
query_vec = await embeddings.embed_query(query)
|
||||||
|
return await hybrid_search.search_precedent_library_hybrid(
|
||||||
|
query=query,
|
||||||
|
query_text_embedding=query_vec,
|
||||||
|
limit=limit,
|
||||||
|
practice_area=practice_area,
|
||||||
|
appeal_subtype=appeal_subtype,
|
||||||
|
include_halachot=include_halachot,
|
||||||
|
source_kind="internal_committee",
|
||||||
|
district=district,
|
||||||
|
chair_name=chair_name,
|
||||||
|
)
|
||||||
@@ -123,7 +123,7 @@ SUMMARY_STRATEGIES = {
|
|||||||
|
|
||||||
DISCUSSION_RULES: dict[str, list[str]] = {
|
DISCUSSION_RULES: dict[str, list[str]] = {
|
||||||
"universal": [
|
"universal": [
|
||||||
"פרק הדיון = אסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
"פרק הדיון = מאסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
||||||
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
|
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
|
||||||
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
|
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -257,11 +257,19 @@ async def reextract_halachot(
|
|||||||
case_law_id = UUID(case_law_id)
|
case_law_id = UUID(case_law_id)
|
||||||
|
|
||||||
record = await db.get_case_law(case_law_id)
|
record = await db.get_case_law(case_law_id)
|
||||||
if not record or record.get("source_kind") != "external_upload":
|
if not record:
|
||||||
raise ValueError("precedent not found or not chair-uploaded")
|
raise ValueError("precedent not found")
|
||||||
|
# Was restricted to source_kind='external_upload'; opened 2026-05-06 so
|
||||||
|
# internal_committee rows can also be re-extracted when ingest produced
|
||||||
|
# bad data. See note in db.request_metadata_extraction.
|
||||||
|
|
||||||
await progress("extracting_halachot", 50, "מחלץ הלכות מחדש")
|
await progress("extracting_halachot", 50, "מחלץ הלכות מחדש")
|
||||||
result = await halacha_extractor.extract(case_law_id)
|
result = await halacha_extractor.extract(case_law_id)
|
||||||
|
# Clear the queue timestamp on completion so the UI badge / worker queue
|
||||||
|
# don't keep showing this row. The queue worker (process_pending_extractions)
|
||||||
|
# already does this; mirror it here so per-record extraction drains too.
|
||||||
|
if result.get("status") in ("completed", "no_halachot"):
|
||||||
|
await db.clear_extraction_request(case_law_id, kind="halacha")
|
||||||
await progress(
|
await progress(
|
||||||
"completed",
|
"completed",
|
||||||
100,
|
100,
|
||||||
@@ -402,11 +410,16 @@ async def reextract_metadata(
|
|||||||
case_law_id = UUID(case_law_id)
|
case_law_id = UUID(case_law_id)
|
||||||
|
|
||||||
record = await db.get_case_law(case_law_id)
|
record = await db.get_case_law(case_law_id)
|
||||||
if not record or record.get("source_kind") != "external_upload":
|
if not record:
|
||||||
raise ValueError("precedent not found or not chair-uploaded")
|
raise ValueError("precedent not found")
|
||||||
|
# See note in db.request_metadata_extraction — opened to all source kinds.
|
||||||
|
|
||||||
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)")
|
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)")
|
||||||
result = await precedent_metadata_extractor.extract_and_apply(case_law_id)
|
result = await precedent_metadata_extractor.extract_and_apply(case_law_id)
|
||||||
|
# Clear the queue timestamp so the UI / worker stop showing this row.
|
||||||
|
# See note in reextract_halachot.
|
||||||
|
if result.get("status") in ("completed", "no_changes"):
|
||||||
|
await db.clear_extraction_request(case_law_id, kind="metadata")
|
||||||
fields = result.get("fields") or []
|
fields = result.get("fields") or []
|
||||||
msg = (
|
msg = (
|
||||||
f"מולאו {len(fields)} שדות: {', '.join(fields)}"
|
f"מולאו {len(fields)} שדות: {', '.join(fields)}"
|
||||||
@@ -425,13 +438,14 @@ async def delete_precedent(case_law_id: UUID | str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def get_precedent(case_law_id: UUID | str) -> dict | None:
|
async def get_precedent(case_law_id: UUID | str) -> dict | None:
|
||||||
"""Get a precedent with its halachot attached."""
|
"""Get a precedent with its halachot and related cases attached."""
|
||||||
if isinstance(case_law_id, str):
|
if isinstance(case_law_id, str):
|
||||||
case_law_id = UUID(case_law_id)
|
case_law_id = UUID(case_law_id)
|
||||||
record = await db.get_case_law(case_law_id)
|
record = await db.get_case_law(case_law_id)
|
||||||
if not record:
|
if not record:
|
||||||
return None
|
return None
|
||||||
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
|
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
|
||||||
|
record["related_cases"] = await db.get_case_law_relations(case_law_id)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
|
|||||||
"decision_date_iso": "YYYY-MM-DD — תאריך מתן ההחלטה כפי שמופיע בטקסט (בכותרת או בחתימה הסופית). אם לא ניתן לזהות במדויק — מחרוזת ריקה.",
|
"decision_date_iso": "YYYY-MM-DD — תאריך מתן ההחלטה כפי שמופיע בטקסט (בכותרת או בחתימה הסופית). אם לא ניתן לזהות במדויק — מחרוזת ריקה.",
|
||||||
"precedent_level": "אחד מ-4: 'עליון' / 'מנהלי' / 'ועדת_ערר_ארצית' / 'ועדת_ערר_מחוזית'. בחר לפי הערכאה שמסומנת בכותרת הפסק. אם לא ברור — מחרוזת ריקה.",
|
"precedent_level": "אחד מ-4: 'עליון' / 'מנהלי' / 'ועדת_ערר_ארצית' / 'ועדת_ערר_מחוזית'. בחר לפי הערכאה שמסומנת בכותרת הפסק. אם לא ברור — מחרוזת ריקה.",
|
||||||
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
|
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
|
||||||
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות."
|
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
|
||||||
|
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות."
|
||||||
}
|
}
|
||||||
|
|
||||||
## כללי איכות
|
## כללי איכות
|
||||||
@@ -161,12 +162,15 @@ async def extract_metadata(case_law_id: UUID | str) -> dict:
|
|||||||
out["source_type"] = st
|
out["source_type"] = st
|
||||||
if isinstance(result.get("court"), str):
|
if isinstance(result.get("court"), str):
|
||||||
out["court"] = result["court"].strip()
|
out["court"] = result["court"].strip()
|
||||||
|
if isinstance(result.get("case_number_clean"), str):
|
||||||
|
out["case_number_clean"] = result["case_number_clean"].strip()
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def apply_to_record(
|
async def apply_to_record(
|
||||||
case_law_id: UUID | str,
|
case_law_id: UUID | str,
|
||||||
suggested: dict,
|
suggested: dict,
|
||||||
|
overwrite_case_number: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Merge suggested metadata into the case_law row, filling ONLY empty fields.
|
"""Merge suggested metadata into the case_law row, filling ONLY empty fields.
|
||||||
|
|
||||||
@@ -178,6 +182,9 @@ async def apply_to_record(
|
|||||||
case_name has special handling: if the current case_name equals the
|
case_name has special handling: if the current case_name equals the
|
||||||
case_number (a tell-tale sign of the upload form sending the long
|
case_number (a tell-tale sign of the upload form sending the long
|
||||||
citation into both fields), treat it as empty and overwrite.
|
citation into both fields), treat it as empty and overwrite.
|
||||||
|
|
||||||
|
overwrite_case_number: when True, update case_number from case_number_clean
|
||||||
|
even if the field already has a value (used for one-time migration enrichment).
|
||||||
"""
|
"""
|
||||||
if isinstance(case_law_id, str):
|
if isinstance(case_law_id, str):
|
||||||
case_law_id = UUID(case_law_id)
|
case_law_id = UUID(case_law_id)
|
||||||
@@ -216,7 +223,17 @@ async def apply_to_record(
|
|||||||
fields_to_update["key_quote"] = s
|
fields_to_update["key_quote"] = s
|
||||||
|
|
||||||
cur_tags = record.get("subject_tags") or []
|
cur_tags = record.get("subject_tags") or []
|
||||||
if not cur_tags:
|
# Treat character-by-character corruption as empty. Early ingest
|
||||||
|
# pipelines stored a JSON string (`'["היטל השבחה"]'`) into a TEXT[]
|
||||||
|
# column, which Postgres split into individual chars:
|
||||||
|
# `['[', '"', 'ה', 'י', 'ט', 'ל', ' ', 'ה', 'ש', ...]`. Detection:
|
||||||
|
# 3+ elements where every element is at most 2 chars (legitimate
|
||||||
|
# tags are multi-character Hebrew words like `היטל_השבחה`).
|
||||||
|
is_corrupt = (
|
||||||
|
len(cur_tags) >= 3
|
||||||
|
and all(isinstance(t, str) and len(t) <= 2 for t in cur_tags)
|
||||||
|
)
|
||||||
|
if not cur_tags or is_corrupt:
|
||||||
sug_tags = suggested.get("subject_tags") or []
|
sug_tags = suggested.get("subject_tags") or []
|
||||||
if sug_tags:
|
if sug_tags:
|
||||||
fields_to_update["subject_tags"] = sug_tags
|
fields_to_update["subject_tags"] = sug_tags
|
||||||
@@ -250,6 +267,11 @@ async def apply_to_record(
|
|||||||
if c:
|
if c:
|
||||||
fields_to_update["court"] = c
|
fields_to_update["court"] = c
|
||||||
|
|
||||||
|
if overwrite_case_number:
|
||||||
|
cn = (suggested.get("case_number_clean") or "").strip()
|
||||||
|
if cn:
|
||||||
|
fields_to_update["case_number"] = cn
|
||||||
|
|
||||||
if not fields_to_update:
|
if not fields_to_update:
|
||||||
return {"updated": False, "fields": []}
|
return {"updated": False, "fields": []}
|
||||||
|
|
||||||
@@ -257,12 +279,15 @@ async def apply_to_record(
|
|||||||
return {"updated": True, "fields": list(fields_to_update.keys())}
|
return {"updated": True, "fields": list(fields_to_update.keys())}
|
||||||
|
|
||||||
|
|
||||||
async def extract_and_apply(case_law_id: UUID | str) -> dict:
|
async def extract_and_apply(
|
||||||
|
case_law_id: UUID | str,
|
||||||
|
overwrite_case_number: bool = False,
|
||||||
|
) -> dict:
|
||||||
"""Convenience wrapper: extract → merge into row → return summary."""
|
"""Convenience wrapper: extract → merge into row → return summary."""
|
||||||
suggested = await extract_metadata(case_law_id)
|
suggested = await extract_metadata(case_law_id)
|
||||||
if not suggested:
|
if not suggested:
|
||||||
return {"status": "no_metadata", "fields": []}
|
return {"status": "no_metadata", "fields": []}
|
||||||
result = await apply_to_record(case_law_id, suggested)
|
result = await apply_to_record(case_law_id, suggested, overwrite_case_number=overwrite_case_number)
|
||||||
return {
|
return {
|
||||||
"status": "completed" if result["updated"] else "no_changes",
|
"status": "completed" if result["updated"] else "no_changes",
|
||||||
"fields": result["fields"],
|
"fields": result["fields"],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from uuid import UUID
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import audit, db, git_sync, practice_area as pa
|
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -370,3 +370,66 @@ async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
|||||||
result["removed_files"] = True
|
result["removed_files"] = True
|
||||||
|
|
||||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
|
||||||
|
"""קליטת טקסט ההחלטה הסופית (`סופי-{case}.docx` בתיקיית exports).
|
||||||
|
|
||||||
|
בניגוד ל-`document_get_text` שעובד על שורות בטבלת `documents`,
|
||||||
|
הקובץ הסופי הוא רק קובץ בתיקייה (נוצר על ידי `api_mark_final`).
|
||||||
|
תומך בכל הפורמטים ש-extractor.extract_text מטפל בהם — מנסה
|
||||||
|
`.docx` תחילה, ואז `.pdf`, `.doc`, `.rtf`, `.txt`, `.md`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: מספר תיק הערר
|
||||||
|
max_chars: אם >0, חתוך את הטקסט המוחזר לאורך הזה. 0 = הכל.
|
||||||
|
"""
|
||||||
|
case_dir = config.find_case_dir(case_number)
|
||||||
|
exports_dir = case_dir / "exports"
|
||||||
|
final_stem = f"סופי-{case_number}"
|
||||||
|
|
||||||
|
final_path = None
|
||||||
|
for ext in (".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"):
|
||||||
|
candidate = exports_dir / f"{final_stem}{ext}"
|
||||||
|
if candidate.exists():
|
||||||
|
final_path = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
if final_path is None:
|
||||||
|
return json.dumps({
|
||||||
|
"status": "not_found",
|
||||||
|
"case_number": case_number,
|
||||||
|
"expected_path": str(exports_dir / f"{final_stem}.docx"),
|
||||||
|
"tried_extensions": [".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"],
|
||||||
|
"hint": (
|
||||||
|
"ההחלטה הסופית עדיין לא סומנה כ'סופית' ב-UI. "
|
||||||
|
"דפנה צריכה ללחוץ 'סמן כסופי' על קובץ הטיוטה הנכון."
|
||||||
|
),
|
||||||
|
}, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
text, page_count, _ = await extractor.extract_text(str(final_path))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("case_get_final_text: extraction failed for %s", case_number)
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"case_number": case_number,
|
||||||
|
"file_path": str(final_path),
|
||||||
|
"error": str(e),
|
||||||
|
}, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
text = text or ""
|
||||||
|
truncated = False
|
||||||
|
if max_chars > 0 and len(text) > max_chars:
|
||||||
|
text = text[:max_chars]
|
||||||
|
truncated = True
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"status": "ok",
|
||||||
|
"case_number": case_number,
|
||||||
|
"file_path": str(final_path),
|
||||||
|
"text_length": len(text),
|
||||||
|
"page_count": page_count,
|
||||||
|
"truncated": truncated,
|
||||||
|
"text": text,
|
||||||
|
}, ensure_ascii=False, indent=2)
|
||||||
|
|||||||
@@ -116,6 +116,54 @@ async def precedent_library_get(case_law_id: str) -> str:
|
|||||||
return _ok(record)
|
return _ok(record)
|
||||||
|
|
||||||
|
|
||||||
|
async def precedent_link_cases(
|
||||||
|
case_law_id_a: str,
|
||||||
|
case_law_id_b: str,
|
||||||
|
relation_type: str = "same_case_chain",
|
||||||
|
) -> str:
|
||||||
|
"""קישור שתי פסיקות כקשורות זו לזו (דו-כיווני). idempotent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_law_id_a: UUID של פסיקה ראשונה.
|
||||||
|
case_law_id_b: UUID של פסיקה שנייה.
|
||||||
|
relation_type: same_case_chain | overruled_by | distinguished
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
a = UUID(case_law_id_a)
|
||||||
|
b = UUID(case_law_id_b)
|
||||||
|
except ValueError:
|
||||||
|
return _err("case_law_id לא תקין")
|
||||||
|
rec_a = await db.get_case_law(a)
|
||||||
|
rec_b = await db.get_case_law(b)
|
||||||
|
if not rec_a:
|
||||||
|
return _err(f"פסיקה {case_law_id_a} לא נמצאה")
|
||||||
|
if not rec_b:
|
||||||
|
return _err(f"פסיקה {case_law_id_b} לא נמצאה")
|
||||||
|
await db.add_case_law_relation(a, b, relation_type)
|
||||||
|
return _ok({
|
||||||
|
"linked": True,
|
||||||
|
"relation_type": relation_type,
|
||||||
|
"a": {"id": case_law_id_a, "case_number": rec_a.get("case_number"), "court": rec_a.get("court")},
|
||||||
|
"b": {"id": case_law_id_b, "case_number": rec_b.get("case_number"), "court": rec_b.get("court")},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
|
||||||
|
"""הסרת קישור בין שתי פסיקות (דו-כיווני).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_law_id_a: UUID של פסיקה ראשונה.
|
||||||
|
case_law_id_b: UUID של פסיקה שנייה.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
a = UUID(case_law_id_a)
|
||||||
|
b = UUID(case_law_id_b)
|
||||||
|
except ValueError:
|
||||||
|
return _err("case_law_id לא תקין")
|
||||||
|
await db.remove_case_law_relation(a, b)
|
||||||
|
return _ok({"unlinked": True, "a": case_law_id_a, "b": case_law_id_b})
|
||||||
|
|
||||||
|
|
||||||
async def precedent_library_delete(case_law_id: str) -> str:
|
async def precedent_library_delete(case_law_id: str) -> str:
|
||||||
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
|
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -179,3 +179,63 @@ async def find_similar_cases(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
async def search_internal_decisions(
|
||||||
|
query: str,
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
district: str = "",
|
||||||
|
chair_name: str = "",
|
||||||
|
limit: int = 10,
|
||||||
|
include_halachot: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: שאילתת חיפוש בעברית
|
||||||
|
practice_area: rishuy_uvniya / betterment_levy / compensation_197
|
||||||
|
appeal_subtype: סינון לפי תת-סוג ערר
|
||||||
|
district: מחוז — ירושלים / מרכז / תל אביב / צפון / דרום / ארצי. ריק = כל המחוזות
|
||||||
|
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
|
||||||
|
limit: מספר תוצאות מקסימלי
|
||||||
|
include_halachot: האם לכלול הלכות שחולצו
|
||||||
|
"""
|
||||||
|
from legal_mcp.services import internal_decisions as int_svc
|
||||||
|
|
||||||
|
results = await int_svc.search_internal(
|
||||||
|
query,
|
||||||
|
practice_area=practice_area,
|
||||||
|
appeal_subtype=appeal_subtype,
|
||||||
|
district=district,
|
||||||
|
chair_name=chair_name,
|
||||||
|
limit=limit,
|
||||||
|
include_halachot=include_halachot,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return "לא נמצאו החלטות ועדת ערר רלוונטיות."
|
||||||
|
|
||||||
|
formatted = []
|
||||||
|
for r in results:
|
||||||
|
entry = {
|
||||||
|
"score": round(float(r["score"]), 4),
|
||||||
|
"type": r.get("type", "passage"),
|
||||||
|
"case_number": r.get("case_number"),
|
||||||
|
"case_name": r.get("case_name"),
|
||||||
|
"court": r.get("court"),
|
||||||
|
"district": r.get("district"),
|
||||||
|
"chair_name": r.get("chair_name"),
|
||||||
|
"decision_date": r.get("decision_date"),
|
||||||
|
}
|
||||||
|
if r.get("type") == "halacha":
|
||||||
|
entry["rule"] = r.get("rule_statement")
|
||||||
|
entry["quote"] = r.get("supporting_quote")
|
||||||
|
entry["rule_type"] = r.get("rule_type")
|
||||||
|
else:
|
||||||
|
entry["content"] = r.get("content", "")
|
||||||
|
entry["section"] = r.get("section_type")
|
||||||
|
entry["page"] = r.get("page_number")
|
||||||
|
formatted.append(entry)
|
||||||
|
|
||||||
|
return json.dumps(formatted, ensure_ascii=False, indent=2)
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import db
|
from legal_mcp.services import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def workflow_status(case_number: str) -> str:
|
async def workflow_status(case_number: str) -> str:
|
||||||
"""סטטוס תהליך עבודה מלא לתיק - מסמכים, עיבוד, טיוטות.
|
"""סטטוס תהליך עבודה מלא לתיק - מסמכים, עיבוד, טיוטות.
|
||||||
@@ -315,10 +318,29 @@ async def ingest_final_version(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = await learning_loop.process_final_version(case_id, final_text)
|
result = await learning_loop.process_final_version(case_id, final_text)
|
||||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
|
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# Auto-ingest into internal committee decisions corpus (best-effort).
|
||||||
|
try:
|
||||||
|
from legal_mcp.services import internal_decisions as int_svc
|
||||||
|
await int_svc.ingest_internal_decision(
|
||||||
|
case_number=case_number,
|
||||||
|
case_name=case.get("title", ""),
|
||||||
|
decision_date=case.get("decision_date"),
|
||||||
|
chair_name=case.get("chair_name", ""),
|
||||||
|
district="ירושלים",
|
||||||
|
practice_area=case.get("practice_area", ""),
|
||||||
|
appeal_subtype=case.get("appeal_subtype", ""),
|
||||||
|
text=final_text,
|
||||||
|
)
|
||||||
|
result["internal_corpus_ingested"] = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e)
|
||||||
|
result["internal_corpus_ingested"] = False
|
||||||
|
|
||||||
|
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
# ── Chair feedback tools ──────────────────────────────────────────
|
# ── Chair feedback tools ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
87
scripts/.archive/run_curator_deepseek_test.sh
Executable file
87
scripts/.archive/run_curator_deepseek_test.sh
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-off A/B test runner: runs the Knowledge Curator (Hermes) on CMP-78 using
|
||||||
|
# DeepSeek V4-Pro instead of the default Sonnet 4.5 (via marcus/sonnet gateway).
|
||||||
|
# Compare against CMP-80 which runs with the default config.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROFILE_HOME="/home/chaim/.hermes/profiles/curator-cmp-deepseek"
|
||||||
|
PAPERCLIP_API_URL="http://localhost:3100/api"
|
||||||
|
# CMP curator agent's Paperclip key (from Infisical: nautilus /legal-ai HERMES_CURATOR_CMP_PAPERCLIP_KEY)
|
||||||
|
PAPERCLIP_API_KEY="pcp_c87edcf306d06fce13fac701bb6d747191d61dba5b51e903"
|
||||||
|
PAPERCLIP_TASK_ID="beb745e5-7195-40c5-9ac0-e9682c2c5184" # CMP-78
|
||||||
|
PAPERCLIP_TASK_KEY="$PAPERCLIP_TASK_ID"
|
||||||
|
PAPERCLIP_TASK_TITLE="[ערר 1130-25] סקירת ידע — Knowledge Curator (DeepSeek A/B test)"
|
||||||
|
PAPERCLIP_RUN_ID="deepseek-ab-$(date +%s)"
|
||||||
|
PAPERCLIP_WAKE_REASON="manual_deepseek_ab_test"
|
||||||
|
|
||||||
|
# Rendered prompt — copy of the curator template with mustache variables resolved
|
||||||
|
# manually for CMP-78. We also add a clear "[ניסוי DeepSeek V4-Pro]" prefix so
|
||||||
|
# the resulting comment is distinguishable from the default-Sonnet run on CMP-80.
|
||||||
|
read -r -d '' PROMPT <<'EOF' || true
|
||||||
|
אתה מנהל ידע (Knowledge Curator) של ועדת הערר. נעור על תיק שדפנה סימנה כסופי.
|
||||||
|
|
||||||
|
תיק: [ערר 1130-25] סקירת ידע — Knowledge Curator
|
||||||
|
issue ID: beb745e5-7195-40c5-9ac0-e9682c2c5184
|
||||||
|
run reason: manual_deepseek_ab_test
|
||||||
|
|
||||||
|
**הקשר חשוב — ניסוי A/B:** זוהי ריצה ידנית באמצעות DeepSeek V4-Pro במקום ה-Sonnet הרגיל. כל ה-comment שתפרסם חייב להתחיל בכותרת `[ניסוי DeepSeek V4-Pro]` כדי שנוכל להבדיל מהריצה המקבילה ב-CMP-80 (שרצה עם Sonnet). אל תעיר סוכנים אחרים. אל תיצור issues חדשים. אל תפתח interaction.
|
||||||
|
|
||||||
|
הוראות:
|
||||||
|
דפנה סימנה את ההחלטה הסופית של תיק 1130-25 כסופית.
|
||||||
|
קובץ סופי: `סופי-1130-25.docx`
|
||||||
|
|
||||||
|
סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.
|
||||||
|
חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, ממוספר.
|
||||||
|
|
||||||
|
# שלבי ביצוע
|
||||||
|
|
||||||
|
## 1. קונטקסט
|
||||||
|
- קרא את MEMORY.md שלך (memory tool) — מה כבר זיהית.
|
||||||
|
- קרא `/home/chaim/legal-ai/skills/decision/SKILL.md` (file tool) — מה כבר תועד.
|
||||||
|
|
||||||
|
## 2. נתונים
|
||||||
|
- `mcp__legal-ai__case_get` עם case_number `1130-25` — מטא-דאטה.
|
||||||
|
- `mcp__legal-ai__case_get_final_text` עם case_number `1130-25` — קרא את הטקסט המלא של ההחלטה הסופית.
|
||||||
|
- אם רלוונטי: `mcp__legal-ai__search_decisions` להשוואה לחלטות קודמות.
|
||||||
|
|
||||||
|
## 3. ניתוח
|
||||||
|
חפש 3-5 דפוסים/פערים. לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת.
|
||||||
|
|
||||||
|
## 4. כתוב comment הממצאים
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg b "$BODY" '{body:$b}')" \
|
||||||
|
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/comments"
|
||||||
|
```
|
||||||
|
|
||||||
|
פורמט ה-body:
|
||||||
|
- שורה ראשונה: `[ניסוי DeepSeek V4-Pro]`
|
||||||
|
- אחר כך פסקה אחת מבוא קצרה
|
||||||
|
- אחר כך הממצאים ממוספרים
|
||||||
|
|
||||||
|
## 5. סגור את ה-issue
|
||||||
|
```bash
|
||||||
|
curl -sS -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"done"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
# כללים
|
||||||
|
- אל תעדכן קבצים (skills/, lessons.py, DB) בעצמך. רק comment.
|
||||||
|
- אל תיצור issues חדשים.
|
||||||
|
- אל תעיר סוכנים אחרים.
|
||||||
|
- אל תפתח interaction.
|
||||||
|
- בעיה? comment קצר עם הסיבה + סגור (status=done).
|
||||||
|
EOF
|
||||||
|
|
||||||
|
export HERMES_HOME="$PROFILE_HOME"
|
||||||
|
export PAPERCLIP_API_URL PAPERCLIP_API_KEY PAPERCLIP_TASK_ID PAPERCLIP_TASK_KEY \
|
||||||
|
PAPERCLIP_TASK_TITLE PAPERCLIP_RUN_ID PAPERCLIP_WAKE_REASON
|
||||||
|
|
||||||
|
echo "=== DeepSeek V4-Pro Curator A/B test on CMP-78 ==="
|
||||||
|
echo "HERMES_HOME=$HERMES_HOME"
|
||||||
|
echo "TASK_ID=$PAPERCLIP_TASK_ID"
|
||||||
|
echo "RUN_ID=$PAPERCLIP_RUN_ID"
|
||||||
|
echo "Starting Hermes..."
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
hermes -z "$PROMPT" --yolo chat 2>&1
|
||||||
116
scripts/.archive/run_curator_deepseek_test_v2.sh
Executable file
116
scripts/.archive/run_curator_deepseek_test_v2.sh
Executable file
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# A/B test runner #2: DeepSeek V4-Pro on CMP-78 — WITH interaction step
|
||||||
|
# (matching the full Sonnet baseline workflow on CMP-80, including ask_user_questions).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROFILE_HOME="/home/chaim/.hermes/profiles/curator-cmp-deepseek"
|
||||||
|
PAPERCLIP_API_URL="http://localhost:3100/api"
|
||||||
|
PAPERCLIP_API_KEY="pcp_c87edcf306d06fce13fac701bb6d747191d61dba5b51e903"
|
||||||
|
PAPERCLIP_TASK_ID="beb745e5-7195-40c5-9ac0-e9682c2c5184" # CMP-78
|
||||||
|
PAPERCLIP_TASK_KEY="$PAPERCLIP_TASK_ID"
|
||||||
|
PAPERCLIP_TASK_TITLE="[ערר 1130-25] סקירת ידע — DeepSeek V4-Pro test #2 (with interaction)"
|
||||||
|
PAPERCLIP_RUN_ID="deepseek-ab2-$(date +%s)"
|
||||||
|
PAPERCLIP_WAKE_REASON="manual_deepseek_ab_test_v2_with_interaction"
|
||||||
|
|
||||||
|
read -r -d '' PROMPT <<'EOF' || true
|
||||||
|
אתה מנהל ידע (Knowledge Curator) של ועדת הערר. נעור על תיק שדפנה סימנה כסופי.
|
||||||
|
|
||||||
|
תיק: [ערר 1130-25] סקירת ידע — Knowledge Curator
|
||||||
|
issue ID: beb745e5-7195-40c5-9ac0-e9682c2c5184
|
||||||
|
run reason: manual_deepseek_ab_test_v2_with_interaction
|
||||||
|
|
||||||
|
**הקשר חשוב — ניסוי A/B #2:** זוהי ריצה שנייה ידנית באמצעות DeepSeek V4-Pro, הפעם **עם interaction מלא** כדי להשוות הוגנת מול ריצת Sonnet ב-CMP-80. כל הפלטים שתפרסם חייבים להתחיל בכותרת `[ניסוי DeepSeek V4-Pro #2 — עם interaction]`. אל תעיר סוכנים אחרים. אל תיצור issues חדשים.
|
||||||
|
|
||||||
|
הוראות:
|
||||||
|
דפנה סימנה את ההחלטה הסופית של תיק 1130-25 כסופית.
|
||||||
|
קובץ סופי: `סופי-1130-25.docx`
|
||||||
|
|
||||||
|
סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.
|
||||||
|
חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, ממוספר.
|
||||||
|
|
||||||
|
# שלבי ביצוע
|
||||||
|
|
||||||
|
## 1. קונטקסט
|
||||||
|
- קרא את MEMORY.md שלך (memory tool) — מה כבר זיהית.
|
||||||
|
- קרא `/home/chaim/legal-ai/skills/decision/SKILL.md` (file tool) — מה כבר תועד.
|
||||||
|
|
||||||
|
## 2. נתונים
|
||||||
|
- `mcp__legal-ai__case_get` עם case_number `1130-25` — מטא-דאטה.
|
||||||
|
- `mcp__legal-ai__case_get_final_text` עם case_number `1130-25` — קרא את הטקסט המלא של ההחלטה הסופית.
|
||||||
|
|
||||||
|
## 3. ניתוח
|
||||||
|
חפש 3-5 דפוסים/פערים. לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת.
|
||||||
|
|
||||||
|
## 4. כתוב comment הממצאים
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg b "$BODY" '{body:$b}')" \
|
||||||
|
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/comments"
|
||||||
|
```
|
||||||
|
|
||||||
|
פורמט ה-body:
|
||||||
|
- שורה ראשונה: `[ניסוי DeepSeek V4-Pro #2 — עם interaction]`
|
||||||
|
- אחר כך פסקה אחת מבוא קצרה
|
||||||
|
- אחר כך הממצאים ממוספרים
|
||||||
|
|
||||||
|
## 5. פתח interaction מסוג ask_user_questions
|
||||||
|
זה השלב שעבד את Sonnet הרבה זמן — בוא נראה כמה זמן יקח לך.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/interactions" \
|
||||||
|
-d '{
|
||||||
|
"kind": "ask_user_questions",
|
||||||
|
"idempotencyKey": "curator-deepseek-v2:'"$PAPERCLIP_TASK_ID"':select",
|
||||||
|
"title": "[DeepSeek] איזה ממצאים שווים עדכון?",
|
||||||
|
"continuationPolicy": "wake_assignee",
|
||||||
|
"payload": {
|
||||||
|
"version": 1,
|
||||||
|
"submitLabel": "אשר בחירה",
|
||||||
|
"questions": [{
|
||||||
|
"id": "findings_to_propose",
|
||||||
|
"prompt": "סמן את הממצאים שאני אכין כהצעת עדכון ל-style guide",
|
||||||
|
"selectionMode": "multi",
|
||||||
|
"options": [
|
||||||
|
{"id":"f1","label":"<מילוי לפי ממצא 1>","description":"<תקציר>"},
|
||||||
|
{"id":"f2","label":"<מילוי לפי ממצא 2>","description":"<תקציר>"}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
מלא את ה-options לפי הממצאים שלך — אופציה אחת לכל ממצא ממוספר.
|
||||||
|
|
||||||
|
## 6. עדכן issue ל-status=in_review (לא done — ממתינים לבחירת חיים)
|
||||||
|
```bash
|
||||||
|
curl -sS -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"in_review"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
# כללים
|
||||||
|
- אל תעדכן קבצים (skills/, lessons.py, DB) בעצמך. רק comment + interaction.
|
||||||
|
- אל תיצור issues חדשים.
|
||||||
|
- אל תעיר סוכנים אחרים.
|
||||||
|
- בעיה? comment קצר עם הסיבה + סגור (status=done).
|
||||||
|
EOF
|
||||||
|
|
||||||
|
export HERMES_HOME="$PROFILE_HOME"
|
||||||
|
export PAPERCLIP_API_URL PAPERCLIP_API_KEY PAPERCLIP_TASK_ID PAPERCLIP_TASK_KEY \
|
||||||
|
PAPERCLIP_TASK_TITLE PAPERCLIP_RUN_ID PAPERCLIP_WAKE_REASON
|
||||||
|
|
||||||
|
echo "=== DeepSeek V4-Pro #2 (with interaction) — CMP-78 ==="
|
||||||
|
echo "HERMES_HOME=$HERMES_HOME"
|
||||||
|
echo "TASK_ID=$PAPERCLIP_TASK_ID"
|
||||||
|
echo "RUN_ID=$PAPERCLIP_RUN_ID"
|
||||||
|
echo "Started: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
START_EPOCH=$(date +%s)
|
||||||
|
hermes -z "$PROMPT" --yolo chat 2>&1
|
||||||
|
END_EPOCH=$(date +%s)
|
||||||
|
DURATION=$((END_EPOCH - START_EPOCH))
|
||||||
|
echo ""
|
||||||
|
echo "=== Run finished ==="
|
||||||
|
echo "Ended: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
echo "Duration: ${DURATION}s ($((DURATION/60))m $((DURATION%60))s)"
|
||||||
106
scripts/.archive/run_curator_sonnet_rerun.sh
Executable file
106
scripts/.archive/run_curator_sonnet_rerun.sh
Executable file
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# A/B test #3: Sonnet 4.5 re-run on CMP-78 — same task as DeepSeek #2 but with Sonnet.
|
||||||
|
# Goal: check if Sonnet is consistent across runs (esp. the case-outcome detection),
|
||||||
|
# given that the original Sonnet baseline on CMP-80 misread the outcome as "דחייה"
|
||||||
|
# while the actual result is "קבלה חלקית".
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROFILE_HOME="/home/chaim/.hermes/profiles/curator-cmp" # default Sonnet profile
|
||||||
|
PAPERCLIP_API_URL="http://localhost:3100/api"
|
||||||
|
PAPERCLIP_API_KEY="pcp_c87edcf306d06fce13fac701bb6d747191d61dba5b51e903"
|
||||||
|
PAPERCLIP_TASK_ID="beb745e5-7195-40c5-9ac0-e9682c2c5184" # CMP-78
|
||||||
|
PAPERCLIP_TASK_KEY="$PAPERCLIP_TASK_ID"
|
||||||
|
PAPERCLIP_TASK_TITLE="[ערר 1130-25] סקירת ידע — Sonnet rerun (consistency check)"
|
||||||
|
PAPERCLIP_RUN_ID="sonnet-rerun-$(date +%s)"
|
||||||
|
PAPERCLIP_WAKE_REASON="manual_sonnet_consistency_rerun"
|
||||||
|
|
||||||
|
read -r -d '' PROMPT <<'EOF' || true
|
||||||
|
אתה מנהל ידע (Knowledge Curator) של ועדת הערר. נעור על תיק שדפנה סימנה כסופי.
|
||||||
|
|
||||||
|
תיק: [ערר 1130-25] סקירת ידע — Knowledge Curator
|
||||||
|
issue ID: beb745e5-7195-40c5-9ac0-e9682c2c5184
|
||||||
|
run reason: manual_sonnet_consistency_rerun
|
||||||
|
|
||||||
|
**הקשר חשוב — ניסוי A/B #3:** זוהי ריצה חוזרת ידנית באמצעות Sonnet 4.5 (אותו מודל שהריץ ב-CMP-80) — בדיקת עקביות. כל הפלטים שתפרסם חייבים להתחיל בכותרת `[ניסוי Sonnet 4.5 — ריצה חוזרת על CMP-78]`. אל תעיר סוכנים אחרים. אל תיצור issues חדשים.
|
||||||
|
|
||||||
|
הוראות:
|
||||||
|
דפנה סימנה את ההחלטה הסופית של תיק 1130-25 כסופית.
|
||||||
|
קובץ סופי: `סופי-1130-25.docx`
|
||||||
|
|
||||||
|
סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.
|
||||||
|
חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, ממוספר.
|
||||||
|
|
||||||
|
# שלבי ביצוע
|
||||||
|
|
||||||
|
## 1. קונטקסט
|
||||||
|
- קרא את MEMORY.md שלך (memory tool) — מה כבר זיהית.
|
||||||
|
- קרא `/home/chaim/legal-ai/skills/decision/SKILL.md` (file tool) — מה כבר תועד.
|
||||||
|
|
||||||
|
## 2. נתונים
|
||||||
|
- `mcp__legal-ai__case_get` עם case_number `1130-25` — מטא-דאטה.
|
||||||
|
- `mcp__legal-ai__case_get_final_text` עם case_number `1130-25` — קרא את הטקסט המלא של ההחלטה הסופית.
|
||||||
|
|
||||||
|
**שים לב במיוחד**: זהה במדויק את **תוצאת ההחלטה** (קבלה / קבלה חלקית / דחייה) על סמך הטקסט עצמו, לא על סמך הנחות.
|
||||||
|
|
||||||
|
## 3. ניתוח
|
||||||
|
חפש 3-5 דפוסים/פערים. לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת.
|
||||||
|
|
||||||
|
## 4. כתוב comment הממצאים
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg b "$BODY" '{body:$b}')" \
|
||||||
|
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/comments"
|
||||||
|
```
|
||||||
|
|
||||||
|
פורמט ה-body:
|
||||||
|
- שורה ראשונה: `[ניסוי Sonnet 4.5 — ריצה חוזרת על CMP-78]`
|
||||||
|
- שורה שנייה: `**תוצאת ההחלטה הזו: <קבלה / קבלה חלקית / דחייה>** — ציין מפורשות
|
||||||
|
- אחר כך פסקה אחת מבוא קצרה
|
||||||
|
- אחר כך הממצאים ממוספרים
|
||||||
|
|
||||||
|
## 5. פתח interaction מסוג ask_user_questions
|
||||||
|
זהה לפלואו של Sonnet באמת. אם תקבל "Agent run id required" — נסה כמה דרכים, ואם לא הולך, פרסם comment עם רשימת אופציות לבחירה.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/interactions" \
|
||||||
|
-d '{
|
||||||
|
"kind": "ask_user_questions",
|
||||||
|
"idempotencyKey": "curator-sonnet-rerun:'"$PAPERCLIP_TASK_ID"':select",
|
||||||
|
"title": "[Sonnet rerun] איזה ממצאים שווים עדכון?",
|
||||||
|
"continuationPolicy": "wake_assignee",
|
||||||
|
"payload": {"version": 1, "submitLabel": "אשר בחירה",
|
||||||
|
"questions": [{"id": "findings_to_propose", "prompt": "סמן ממצאים", "selectionMode": "multi", "options": []}]}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. עדכן issue ל-status=in_review
|
||||||
|
```bash
|
||||||
|
curl -sS -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"in_review"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
# כללים
|
||||||
|
- אל תעדכן קבצים בעצמך. רק comment + interaction.
|
||||||
|
- אל תיצור issues חדשים.
|
||||||
|
- אל תעיר סוכנים אחרים.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
export HERMES_HOME="$PROFILE_HOME"
|
||||||
|
export PAPERCLIP_API_URL PAPERCLIP_API_KEY PAPERCLIP_TASK_ID PAPERCLIP_TASK_KEY \
|
||||||
|
PAPERCLIP_TASK_TITLE PAPERCLIP_RUN_ID PAPERCLIP_WAKE_REASON
|
||||||
|
|
||||||
|
echo "=== Sonnet 4.5 rerun (consistency check) — CMP-78 ==="
|
||||||
|
echo "HERMES_HOME=$HERMES_HOME"
|
||||||
|
echo "TASK_ID=$PAPERCLIP_TASK_ID"
|
||||||
|
echo "RUN_ID=$PAPERCLIP_RUN_ID"
|
||||||
|
echo "Started: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
START_EPOCH=$(date +%s)
|
||||||
|
hermes -z "$PROMPT" --yolo chat 2>&1
|
||||||
|
END_EPOCH=$(date +%s)
|
||||||
|
DURATION=$((END_EPOCH - START_EPOCH))
|
||||||
|
echo ""
|
||||||
|
echo "=== Run finished ==="
|
||||||
|
echo "Ended: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
echo "Duration: ${DURATION}s ($((DURATION/60))m $((DURATION%60))s)"
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
|--------|------|---------|-----------|
|
|--------|------|---------|-----------|
|
||||||
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
|
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
|
||||||
| `sync_missing_agent_skills.py` | python | סקריפט "אל-כשל" להוספת `paperclipSkillSync` ל-`הגהת מסמכים` ו-`מנתח משפטי` שפיספסו את ה-sync ההיסטורי (Gap #28). תומך `--verify`/`--dry-run`/`--apply`. גיבוי אוטומטי ל-`agents-pre-skill-sync-*.sql`. דורש `PAPERCLIP_BOARD_API_KEY` (Infisical /paperclip ב-nautilus env). idempotent. | חד-פעמי (בוצע 2026-05-04). שמור לרפרנס |
|
| `sync_missing_agent_skills.py` | python | סקריפט "אל-כשל" להוספת `paperclipSkillSync` ל-`הגהת מסמכים` ו-`מנתח משפטי` שפיספסו את ה-sync ההיסטורי (Gap #28). תומך `--verify`/`--dry-run`/`--apply`. גיבוי אוטומטי ל-`agents-pre-skill-sync-*.sql`. דורש `PAPERCLIP_BOARD_API_KEY` (Infisical /paperclip ב-nautilus env). idempotent. | חד-פעמי (בוצע 2026-05-04). שמור לרפרנס |
|
||||||
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** | ידני אחרי כל שינוי |
|
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — הסקריפט מדלג על הסוכן עם warning. בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות לפני sync.** | ידני אחרי כל שינוי |
|
||||||
|
| `fix_paperclipai_skills_drift.py` | python | סקריפט חד-פעמי (בוצע 2026-05-04) שניקה drift על `paperclipai/*` skills בין CMP ל-CMPA. הסיר `paperclip-dev` מכל 14 הסוכנים, ודאג ש-`paperclip-converting-plans-to-tasks` קיים רק על CEO ו-analyst. תומך `--apply` (ברירת מחדל: dry-run). דורש `PAPERCLIP_BOARD_API_KEY`. נשמר לרפרנס למקרה שhdrift חוזר. | חד-פעמי (בוצע) |
|
||||||
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
||||||
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
||||||
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
||||||
@@ -53,6 +54,9 @@
|
|||||||
| `seed-appeals.py` | seeding תיקי ערר ראשוניים ל-DB | MCP: `case_create()` |
|
| `seed-appeals.py` | seeding תיקי ערר ראשוניים ל-DB | MCP: `case_create()` |
|
||||||
| `seed-knowledge.py` | seeding לקחים, ביטויי מעבר, פסיקה | MCP: `record_chair_feedback()`, `precedent_attach()` |
|
| `seed-knowledge.py` | seeding לקחים, ביטויי מעבר, פסיקה | MCP: `record_chair_feedback()`, `precedent_attach()` |
|
||||||
| `validate-decision.py` | ולידציה מול block-schema | MCP: `validate_decision()` + `qa_validator.py` |
|
| `validate-decision.py` | ולידציה מול block-schema | MCP: `validate_decision()` + `qa_validator.py` |
|
||||||
|
| `run_curator_deepseek_test.sh` | A/B test #1 (2026-05-05) — Hermes Curator על CMP-78 דרך DeepSeek V4-Pro ב-`provider:custom`, ללא interaction. תוצאה: 6:33 דק׳, 5 ממצאי סגנון/לקסיקון, פי 3 מהיר מ-Sonnet baseline (CMP-80) ופי ~20 זול. **הסקריפט נקודתי לתיק 1130-25 — לא להריץ שוב** | החלפת Curator לאדפטר DeepSeek מקומי (בתהליך) |
|
||||||
|
| `run_curator_deepseek_test_v2.sh` | A/B test #2 (2026-05-05) — אותו run אבל עם interaction. תוצאה: 9:08 דק׳, 5 ממצאים, היחיד מ-4 הריצות שזיהה תוצאה עובדתית נכונה (קבלה חלקית). interaction נכשל ב-API ("Agent run id required" בריצה ידנית). | החלפת Curator לאדפטר DeepSeek מקומי |
|
||||||
|
| `run_curator_sonnet_rerun.sh` | A/B test #3 (2026-05-05) — ריצה חוזרת של Sonnet 4.5 על אותו CMP-78. תוצאה: 12:52 דק׳ (לעומת 20:13 בריצה המקורית — כי בלי לולאת interaction.json). זיהה תוצאה שגויה ("דחייה") **בעקביות עם הריצה המקורית** — Sonnet עקבי-בטעות, DeepSeek אקראי. | בדיקה חד-פעמית — לא להריץ שוב |
|
||||||
|
|
||||||
## סקריפטים שנמחקו (git history בלבד)
|
## סקריפטים שנמחקו (git history בלבד)
|
||||||
|
|
||||||
|
|||||||
134
scripts/fix_paperclipai_skills_drift.py
Normal file
134
scripts/fix_paperclipai_skills_drift.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fix paperclipai/* skill drift across CMP+CMPA agents.
|
||||||
|
|
||||||
|
Goal: zero drift on paperclipai/* skills between master(CMP) and mirror(CMPA).
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
* Remove ``paperclipai/paperclip/paperclip-dev`` from all 14 agents (not relevant
|
||||||
|
for legal work — it's for maintaining Paperclip itself).
|
||||||
|
* Ensure ``paperclipai/paperclip/paperclip-converting-plans-to-tasks`` exists
|
||||||
|
on CEO + analyst agents in both companies (planning skill).
|
||||||
|
* Remove ``paperclipai/paperclip/paperclip-converting-plans-to-tasks`` from any
|
||||||
|
other agent in either company that currently has it.
|
||||||
|
|
||||||
|
Local/* and company/* skills are not touched — they're scoped to a company
|
||||||
|
by design and drift is expected.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
PAPERCLIP_BOARD_API_KEY=pbk_... python scripts/fix_paperclipai_skills_drift.py # dry-run
|
||||||
|
PAPERCLIP_BOARD_API_KEY=pbk_... python scripts/fix_paperclipai_skills_drift.py --apply # commit
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
|
||||||
|
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY")
|
||||||
|
|
||||||
|
COMPANIES = {
|
||||||
|
"licensing": ("CMP ", "42a7acd0-30c5-4cbd-ac97-7424f65df294"),
|
||||||
|
"betterment": ("CMPA", "8639e837-4c9d-47fa-a76b-95788d651896"),
|
||||||
|
}
|
||||||
|
|
||||||
|
DEV_SKILL = "paperclipai/paperclip/paperclip-dev"
|
||||||
|
CONVERTING_SKILL = "paperclipai/paperclip/paperclip-converting-plans-to-tasks"
|
||||||
|
|
||||||
|
# Hebrew names of the agents that should retain converting-plans-to-tasks.
|
||||||
|
CONVERTING_TARGETS = {"עוזר משפטי", "מנתח משפטי"}
|
||||||
|
|
||||||
|
|
||||||
|
def headers() -> dict[str, str]:
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
sys.exit("PAPERCLIP_BOARD_API_KEY not set — fetch from Infisical first.")
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_company_agents(client: httpx.AsyncClient, company_id: str) -> list[dict]:
|
||||||
|
r = await client.get(f"{PAPERCLIP_API_URL}/api/companies/{company_id}/agents", headers=headers())
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def compute_changes(agent: dict) -> tuple[bool, list[str], list[str]]:
|
||||||
|
skill_sync = (agent.get("adapterConfig") or {}).get("paperclipSkillSync") or {}
|
||||||
|
old = list(skill_sync.get("desiredSkills") or [])
|
||||||
|
new = [s for s in old if s != DEV_SKILL]
|
||||||
|
if agent["name"] in CONVERTING_TARGETS:
|
||||||
|
if CONVERTING_SKILL not in new:
|
||||||
|
new.append(CONVERTING_SKILL)
|
||||||
|
else:
|
||||||
|
new = [s for s in new if s != CONVERTING_SKILL]
|
||||||
|
return (sorted(old) != sorted(new), old, new)
|
||||||
|
|
||||||
|
|
||||||
|
async def patch_agent(
|
||||||
|
client: httpx.AsyncClient, agent_id: str, current_skill_sync: dict, new_skills: list[str]
|
||||||
|
) -> None:
|
||||||
|
body = {
|
||||||
|
"adapterConfig": {
|
||||||
|
"paperclipSkillSync": {**current_skill_sync, "desiredSkills": new_skills},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r = await client.patch(
|
||||||
|
f"{PAPERCLIP_API_URL}/api/agents/{agent_id}", headers=headers(), json=body, timeout=15
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="commit changes (default: dry-run)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
mode = "APPLY" if args.apply else "DRY-RUN"
|
||||||
|
print(f"=== {mode}: fixing paperclipai/* skill drift ===\n")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
all_agents: list[dict] = []
|
||||||
|
for label, (_, cid) in COMPANIES.items():
|
||||||
|
agents = await fetch_company_agents(client, cid)
|
||||||
|
for a in agents:
|
||||||
|
a["_company_label"] = COMPANIES[label][0]
|
||||||
|
all_agents.extend(agents)
|
||||||
|
|
||||||
|
changes_planned = 0
|
||||||
|
for a in sorted(all_agents, key=lambda x: (x["_company_label"], x["name"])):
|
||||||
|
changed, old, new = compute_changes(a)
|
||||||
|
label = a["_company_label"]
|
||||||
|
if not changed:
|
||||||
|
print(f" {label} {a['name']:20} no change")
|
||||||
|
continue
|
||||||
|
changes_planned += 1
|
||||||
|
removed = sorted(set(old) - set(new))
|
||||||
|
added = sorted(set(new) - set(old))
|
||||||
|
print(f" {label} {a['name']:20} -{len(removed)} +{len(added)}")
|
||||||
|
for s in removed:
|
||||||
|
print(f" - {s}")
|
||||||
|
for s in added:
|
||||||
|
print(f" + {s}")
|
||||||
|
if args.apply:
|
||||||
|
skill_sync = (a.get("adapterConfig") or {}).get("paperclipSkillSync") or {}
|
||||||
|
try:
|
||||||
|
await patch_agent(client, a["id"], skill_sync, new)
|
||||||
|
print(" ✓ patched")
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
print(f" ✗ failed: {e.response.status_code} {e.response.text[:200]}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
print(f"\n{mode}: {changes_planned} agents would change")
|
||||||
|
if not args.apply and changes_planned > 0:
|
||||||
|
print("Run with --apply to commit.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -293,18 +293,25 @@ description: This skill should be used when writing legal decisions (החלטו
|
|||||||
|
|
||||||
### 7.5 שלושה מקורות פסיקה — אל תבלבל
|
### 7.5 שלושה מקורות פסיקה — אל תבלבל
|
||||||
|
|
||||||
המערכת מפרידה בין שלושה קורפוסי פסיקה. כל אחד מהם משמש למטרה אחרת ויש כלי MCP נפרד לחיפוש בו:
|
המערכת מפרידה בין **ארבעה** קורפוסי פסיקה. כל אחד מהם משמש למטרה אחרת ויש כלי MCP נפרד לחיפוש בו:
|
||||||
|
|
||||||
| קורפוס | טבלה | כלי חיפוש | תפקיד |
|
| קורפוס | טבלה | כלי חיפוש | תפקיד |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| תקדימי דפנה (סגנון) | `style_corpus` + `paragraph_embeddings` | `search_decisions` | החלטות שדפנה עצמה כתבה. מקור לסגנון, ניסוחים, ג'וריספרודנציה אישית. |
|
| לימוד סגנון | `style_corpus` | (לא לחיפוש תוכן) | ממשק /training — ניתוח "הקול" של היו"ר: טון, ביטויי מעבר, מבנה פסקאות. **אין לחפש כאן תוכן משפטי.** |
|
||||||
| ספריית הפסיקה הסמכותית | `case_law` (`source_kind='external_upload'`) + `halachot` | `search_precedent_library` | פסיקה חיצונית מחייבת — עליון, מנהלי, ועדות ערר אחרות — עם הלכות שאושרו ע"י דפנה. **המקור היחיד לציטוטים בבלוק י לפי CREAC.** |
|
| החלטות ועדות ערר | `case_law` (`source_kind='internal_committee'`) + `halachot` | `search_internal_decisions` | **כל** ועדות הערר לתכנון ובנייה (כל המחוזות). מסונן לפי `district` ו-`chair_name`. מקור לעקביות פנימית ופרקטיקה ארצית. |
|
||||||
| ציטוטים שצורפו ידנית | `case_precedents` | `precedent_search_library` | quotes שדפנה צירפה לתיק ספציפי בעבר. דומה לקורפוס סמכותי אך פר-תיק, ידני, לא עוברת חילוץ הלכות. |
|
| פסיקת בתי משפט | `case_law` (`source_kind='external_upload'`) + `halachot` | `search_precedent_library` | בתי משפט: עליון, מנהלי, בג"ץ. **המקור היחיד לציטוטים מחייבים בבלוק י לפי CREAC.** |
|
||||||
|
| ציטוטים ידניים | `case_precedents` | `precedent_search_library` | quotes שצורפו לתיק ספציפי בעבר. פר-תיק, ידני. |
|
||||||
|
|
||||||
**הזרימה הסטנדרטית בבלוק י:**
|
**הזרימה הסטנדרטית בבלוק י — חפש במקביל:**
|
||||||
1. `search_decisions` קודם — בדוק אם דפנה כבר הכריעה בסוגיה דומה (חיסכון דוקטרינרי / הבחנה).
|
1. `search_internal_decisions(district="ירושלים")` — האם ועדת ערר ירושלים הכריעה בסוגיה? (עקביות פנימית)
|
||||||
2. `search_precedent_library` — חפש את הכלל המחייב והציטוט התומך לפסקת CREAC.
|
- אם יש תוצאה רלוונטית: הצג תחת **"החלטות ועדת ערר ירושלים"** והתייחס לה בניתוח.
|
||||||
3. אם הצדדים הפנו לפסיקה שלא בקורפוס — דפנה מעלה אותה דרך `/precedents` ב-UI; חילוץ ההלכות אוטומטי וההלכות מחכות לאישורה.
|
2. `search_internal_decisions()` (ריק = כל המחוזות) — פרקטיקה ארצית של ועדות אחרות.
|
||||||
|
- הצג תחת **"החלטות ועדות ערר אחרות"** — כמשל/השוואה, לא כמחייב.
|
||||||
|
3. `search_precedent_library` — כלל מחייב מבית משפט לפסקת CREAC.
|
||||||
|
- הצג תחת **"פסיקת בתי משפט"** — זה המקור לציטוט מחייב.
|
||||||
|
4. אם הצדדים הפנו לפסיקה שלא בקורפוס — דפנה מעלה אותה דרך `/precedents` ב-UI.
|
||||||
|
|
||||||
|
**חשוב:** החלטות ועדת ערר הן פרקטיקה, לא מחייב. ציטוט מחייב בבלוק י מגיע רק מ-`search_precedent_library`.
|
||||||
|
|
||||||
**איסור על המצאת ציטוטים** — ציטוט פסיקה חייב להגיע מאחד מהקורפוסים. אם אין הלכה מאושרת תומכת בנקודה — אל תמציא; ציין שהנושא דורש הוספת פסיקה לקורפוס.
|
**איסור על המצאת ציטוטים** — ציטוט פסיקה חייב להגיע מאחד מהקורפוסים. אם אין הלכה מאושרת תומכת בנקודה — אל תמציא; ציין שהנושא דורש הוספת פסיקה לקורפוס.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { usePrecedent } from "@/lib/api/precedent-library";
|
import { usePrecedent } from "@/lib/api/precedent-library";
|
||||||
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
|
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
|
||||||
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
||||||
|
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
|
||||||
|
|
||||||
const PRACTICE_AREA_LABELS: Record<string, string> = {
|
const PRACTICE_AREA_LABELS: Record<string, string> = {
|
||||||
rishuy_uvniya: "רישוי ובנייה",
|
rishuy_uvniya: "רישוי ובנייה",
|
||||||
@@ -152,6 +153,15 @@ export default function PrecedentDetailPage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<RelatedCasesSection
|
||||||
|
caseId={id}
|
||||||
|
related={data.related_cases ?? []}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5">
|
||||||
<ExtractedHalachotSection halachot={data.halachot ?? []} />
|
<ExtractedHalachotSection halachot={data.halachot ?? []} />
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ export function AgentsTab() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-5 py-4 flex items-center justify-between gap-3 flex-wrap">
|
<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">
|
<div className="text-[0.85rem] text-ink-muted">
|
||||||
{data.pairs.length} סוכנים × 2 חברות (CMP master / CMPA mirror)
|
{data.pairs.length} סוכנים × 2 חברות (CMP master / CMPA mirror)
|
||||||
{totalDrift > 0 && (
|
{totalDrift > 0 && (
|
||||||
@@ -347,6 +348,10 @@ export function AgentsTab() {
|
|||||||
<span className="text-danger ms-2">· {missingCount} זוגות לא שלמים</span>
|
<span className="text-danger ms-2">· {missingCount} זוגות לא שלמים</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-[0.7rem] text-ink-light">
|
||||||
|
פערי skills מחושבים על paperclipai/* בלבד. local/* ו-company/* מסוננים — שם שונה בין החברות הוא צפוי.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Trash2, Plus, Pencil } from "lucide-react";
|
import Link from "next/link";
|
||||||
|
import { Trash2, Plus, Pencil, Wand2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
@@ -16,11 +17,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
usePrecedents,
|
usePrecedents,
|
||||||
useDeletePrecedent,
|
useDeletePrecedent,
|
||||||
|
useRequestHalachotExtraction,
|
||||||
isPrecedentActive,
|
isPrecedentActive,
|
||||||
type Precedent,
|
type Precedent,
|
||||||
type PracticeArea,
|
type PracticeArea,
|
||||||
} from "@/lib/api/precedent-library";
|
} from "@/lib/api/precedent-library";
|
||||||
import { PRACTICE_AREAS, PRECEDENT_LEVELS, practiceAreaShort } from "./practice-area";
|
import { PRACTICE_AREAS, practiceAreaShort } from "./practice-area";
|
||||||
import { PrecedentUploadSheet } from "./precedent-upload-sheet";
|
import { PrecedentUploadSheet } from "./precedent-upload-sheet";
|
||||||
import { PrecedentEditSheet } from "./precedent-edit-sheet";
|
import { PrecedentEditSheet } from "./precedent-edit-sheet";
|
||||||
|
|
||||||
@@ -33,17 +35,25 @@ function formatDate(iso: string | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The upload form (and Nevo PDFs) embed Unicode bidi marks (RTL/LTR/embedding/
|
|
||||||
* isolate) inside the citation. They render as zero-width but visually push
|
|
||||||
* the text away from the cell edge. Strip them for display only — DB still
|
|
||||||
* has the original. */
|
|
||||||
function cleanCitation(s: string | null | undefined): string {
|
function cleanCitation(s: string | null | undefined): string {
|
||||||
if (!s) return "—";
|
if (!s) return "—";
|
||||||
return s.replace(/[--]/g, "").trim();
|
return s.replace(/[--]/g, "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shimmering pill — used while extraction is actively running.
|
// Show the "extract halachot" button only when the precedent hasn't had a
|
||||||
* Visually distinct from the static "queued" / "completed" pills. */
|
// successful (or even attempted) extraction yet. Hide while processing or
|
||||||
|
// after completion to avoid duplicate requests.
|
||||||
|
function needsHalachaExtraction(p: Precedent): boolean {
|
||||||
|
if (p.extraction_status !== "completed") return false; // text not ready
|
||||||
|
if (p.halacha_extraction_status === "processing") return false;
|
||||||
|
if (p.halacha_extraction_status === "completed") return false;
|
||||||
|
if (p.halacha_extraction_status === "pending" && p.halacha_extraction_requested_at) {
|
||||||
|
return false; // already queued
|
||||||
|
}
|
||||||
|
// Remaining cases: pending+no-requested_at (never tried) or failed (retry).
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function ActivePill({ label }: { label: string }) {
|
function ActivePill({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -55,12 +65,6 @@ function ActivePill({ label }: { label: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Five distinct states. The "queued" state is what the user actually
|
|
||||||
* sees most of the time (after upload, both extractions are auto-queued
|
|
||||||
* but the local MCP worker hasn't drained them yet); "מחלץ" / "מעבד"
|
|
||||||
* shimmers and only appears while the extractor is actively running.
|
|
||||||
*/
|
|
||||||
function StatusPill({ p }: { p: Precedent }) {
|
function StatusPill({ p }: { p: Precedent }) {
|
||||||
if (p.extraction_status === "failed") {
|
if (p.extraction_status === "failed") {
|
||||||
return (
|
return (
|
||||||
@@ -103,37 +107,28 @@ function StatusPill({ p }: { p: Precedent }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// halacha_extraction_status === "completed"
|
|
||||||
if (p.halachot_count === 0) {
|
if (p.halachot_count === 0) {
|
||||||
return <Badge variant="outline">ללא הלכות</Badge>;
|
return <Badge variant="outline">ללא הלכות</Badge>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40">
|
||||||
variant="outline"
|
|
||||||
className="bg-gold-wash text-gold-deep border-gold/40"
|
|
||||||
>
|
|
||||||
{p.approved_count}/{p.halachot_count} מאושרות
|
{p.approved_count}/{p.halachot_count} מאושרות
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrecedentRow({
|
function CourtRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void }) {
|
||||||
p, onEdit,
|
|
||||||
}: {
|
|
||||||
p: Precedent;
|
|
||||||
onEdit: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const del = useDeletePrecedent();
|
const del = useDeletePrecedent();
|
||||||
|
const reqHalachot = useRequestHalachotExtraction();
|
||||||
const active = isPrecedentActive(p);
|
const active = isPrecedentActive(p);
|
||||||
|
const showExtractHalachot = needsHalachaExtraction(p);
|
||||||
|
|
||||||
const onDelete = async () => {
|
const onDelete = async () => {
|
||||||
if (active) {
|
if (active) {
|
||||||
toast.error(
|
toast.error("מתבצע עיבוד — לא ניתן למחוק עכשיו.");
|
||||||
"מתבצע עיבוד — לא ניתן למחוק עכשיו. המתיני לסיום או רעני את הדף.",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!window.confirm(`למחוק את ${p.case_number}? cascade ימחק את ה-chunks וההלכות.`)) return;
|
if (!window.confirm(`למחוק את ${p.case_number}?`)) return;
|
||||||
try {
|
try {
|
||||||
await del.mutateAsync(p.id);
|
await del.mutateAsync(p.id);
|
||||||
toast.success("נמחק");
|
toast.success("נמחק");
|
||||||
@@ -142,65 +137,62 @@ function PrecedentRow({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onExtractHalachot = async () => {
|
||||||
|
try {
|
||||||
|
await reqHalachot.mutateAsync(p.id);
|
||||||
|
toast.success("סומן לחילוץ הלכות. הריצי מ-Claude Code: precedent_process_pending_halachot");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow className="border-rule hover:bg-gold-wash/30 align-top">
|
<TableRow className="border-rule hover:bg-gold-wash/30 align-top">
|
||||||
<TableCell
|
<TableCell
|
||||||
/* shadcn TableCell defaults to whitespace-nowrap which forces the
|
|
||||||
* row wider than the container; for this column we want the long
|
|
||||||
* citation to wrap onto a second line instead of triggering the
|
|
||||||
* horizontal scroll on the table wrapper. min-w/max-w keeps the
|
|
||||||
* column wide enough to avoid awkward 2-word lines while leaving
|
|
||||||
* room for the other columns. */
|
|
||||||
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3"
|
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
>
|
>
|
||||||
<span dir="auto">{cleanCitation(p.case_number)}</span>
|
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
|
||||||
|
{cleanCitation(p.case_number)}
|
||||||
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3">
|
<TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3">
|
||||||
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
||||||
{p.court ? (
|
{p.court ? <div className="text-[0.72rem] text-ink-muted">{p.court}</div> : null}
|
||||||
<div className="text-[0.72rem] text-ink-muted">{p.court}</div>
|
|
||||||
) : null}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-ink-muted">
|
|
||||||
{p.date ? formatDate(p.date) : <span className="text-ink-light">—</span>}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-ink-muted">{p.date ? formatDate(p.date) : <span className="text-ink-light">—</span>}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{p.practice_area ? (
|
{p.practice_area ? (
|
||||||
<Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30">
|
<Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30">
|
||||||
{practiceAreaShort(p.practice_area)}
|
{practiceAreaShort(p.practice_area)}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : <span className="text-ink-light">—</span>}
|
||||||
<span className="text-ink-light">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-ink-muted text-[0.78rem]">
|
<TableCell className="text-ink-muted text-[0.78rem]">
|
||||||
{p.precedent_level ? (
|
{p.precedent_level || <span className="text-ink-light">—</span>}
|
||||||
p.precedent_level
|
|
||||||
) : (
|
|
||||||
<span className="text-ink-light">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusPill p={p} />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell><StatusPill p={p} /></TableCell>
|
||||||
<TableCell className="text-end">
|
<TableCell className="text-end">
|
||||||
<div className="flex items-center gap-1 justify-end">
|
<div className="flex items-center gap-1 justify-end">
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => onEdit(p.id)}
|
||||||
variant="ghost" size="sm" onClick={() => onEdit(p.id)}
|
aria-label={`ערוך את ${p.case_number}`} title="ערוך"
|
||||||
aria-label={`ערוך את ${p.case_number}`}
|
className="text-ink-muted hover:text-navy">
|
||||||
title="ערוך פרטים"
|
|
||||||
className="text-ink-muted hover:text-navy"
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{showExtractHalachot && (
|
||||||
variant="ghost" size="sm" onClick={onDelete}
|
<Button variant="ghost" size="sm" onClick={onExtractHalachot}
|
||||||
|
disabled={reqHalachot.isPending}
|
||||||
|
aria-label={`חלץ הלכות מ-${p.case_number}`}
|
||||||
|
title="חלץ הלכות"
|
||||||
|
className="text-gold-deep hover:text-gold-deep hover:bg-gold-wash disabled:opacity-30">
|
||||||
|
<Wand2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={onDelete}
|
||||||
disabled={del.isPending || active}
|
disabled={del.isPending || active}
|
||||||
aria-label={`מחק את ${p.case_number}`}
|
aria-label={`מחק את ${p.case_number}`}
|
||||||
title={active ? "מתבצע עיבוד — לא ניתן למחוק" : "מחק"}
|
title={active ? "מתבצע עיבוד — לא ניתן למחוק" : "מחק"}
|
||||||
className="text-danger hover:text-danger hover:bg-danger-bg disabled:opacity-30"
|
className="text-danger hover:text-danger hover:bg-danger-bg disabled:opacity-30">
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,33 +201,134 @@ function PrecedentRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CommitteeRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void }) {
|
||||||
|
const del = useDeletePrecedent();
|
||||||
|
const reqHalachot = useRequestHalachotExtraction();
|
||||||
|
const active = isPrecedentActive(p);
|
||||||
|
const showExtractHalachot = needsHalachaExtraction(p);
|
||||||
|
|
||||||
|
const onDelete = async () => {
|
||||||
|
if (active) {
|
||||||
|
toast.error("מתבצע עיבוד — לא ניתן למחוק עכשיו.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`למחוק את ${p.case_number}?`)) return;
|
||||||
|
try {
|
||||||
|
await del.mutateAsync(p.id);
|
||||||
|
toast.success("נמחק");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onExtractHalachot = async () => {
|
||||||
|
try {
|
||||||
|
await reqHalachot.mutateAsync(p.id);
|
||||||
|
toast.success("סומן לחילוץ הלכות. הריצי מ-Claude Code: precedent_process_pending_halachot");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow className="border-rule hover:bg-gold-wash/30 align-top">
|
||||||
|
<TableCell
|
||||||
|
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
|
||||||
|
{cleanCitation(p.case_number)}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-ink whitespace-normal break-words max-w-[220px] py-3">
|
||||||
|
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-ink-muted text-[0.78rem]">
|
||||||
|
{p.district || <span className="text-ink-light">—</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-ink-muted text-[0.78rem]">
|
||||||
|
{p.chair_name || <span className="text-ink-light">—</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-ink-muted">{p.date ? formatDate(p.date) : <span className="text-ink-light">—</span>}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{p.practice_area ? (
|
||||||
|
<Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30">
|
||||||
|
{practiceAreaShort(p.practice_area)}
|
||||||
|
</Badge>
|
||||||
|
) : <span className="text-ink-light">—</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><StatusPill p={p} /></TableCell>
|
||||||
|
<TableCell className="text-end">
|
||||||
|
<div className="flex items-center gap-1 justify-end">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onEdit(p.id)}
|
||||||
|
aria-label={`ערוך את ${p.case_number}`} title="ערוך"
|
||||||
|
className="text-ink-muted hover:text-navy">
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{showExtractHalachot && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onExtractHalachot}
|
||||||
|
disabled={reqHalachot.isPending}
|
||||||
|
aria-label={`חלץ הלכות מ-${p.case_number}`}
|
||||||
|
title="חלץ הלכות"
|
||||||
|
className="text-gold-deep hover:text-gold-deep hover:bg-gold-wash disabled:opacity-30">
|
||||||
|
<Wand2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={onDelete}
|
||||||
|
disabled={del.isPending || active}
|
||||||
|
aria-label={`מחק את ${p.case_number}`}
|
||||||
|
title={active ? "מתבצע עיבוד — לא ניתן למחוק" : "מחק"}
|
||||||
|
className="text-danger hover:text-danger hover:bg-danger-bg disabled:opacity-30">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton({ cols }: { cols: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<TableRow key={i} className="border-rule">
|
||||||
|
{[...Array(cols)].map((_, j) => (
|
||||||
|
<TableCell key={j}><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LibraryListPanel() {
|
export function LibraryListPanel() {
|
||||||
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
|
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
|
||||||
const [precedentLevel, setPrecedentLevel] = useState("");
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [uploadOpen, setUploadOpen] = useState(false);
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data, isPending, error } = usePrecedents({
|
const sharedFilters = {
|
||||||
practiceArea: practiceArea || undefined,
|
practiceArea: practiceArea || undefined,
|
||||||
precedentLevel: precedentLevel || undefined,
|
|
||||||
search: search.trim() || undefined,
|
search: search.trim() || undefined,
|
||||||
limit: 200,
|
limit: 200,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload", sourceType: "court_ruling" });
|
||||||
|
const committee = usePrecedents({ ...sharedFilters, sourceKind: "all_committees" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-8">
|
||||||
|
{/* Shared filters */}
|
||||||
<div className="flex items-end gap-3 flex-wrap">
|
<div className="flex items-end gap-3 flex-wrap">
|
||||||
<div className="flex-1 min-w-[200px]">
|
<div className="flex-1 min-w-[200px]">
|
||||||
<label className="text-[0.78rem] text-ink-muted">חיפוש (מספר תיק / שם / תקציר)</label>
|
<label className="text-[0.78rem] text-ink-muted">חיפוש (מספר תיק / שם)</label>
|
||||||
<Input
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="עע"מ 3975/22"
|
placeholder="עע״מ 3975/22 / 1200-25"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-[180px]">
|
<div className="min-w-[180px]">
|
||||||
<label className="text-[0.78rem] text-ink-muted">תחום</label>
|
<label className="text-[0.78rem] text-ink-muted">תחום</label>
|
||||||
<Select value={practiceArea || "_all"} onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}>
|
<Select value={practiceArea || "_all"} onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}>
|
||||||
@@ -248,29 +341,25 @@ export function LibraryListPanel() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-[170px]">
|
|
||||||
<label className="text-[0.78rem] text-ink-muted">רמת תקדים</label>
|
|
||||||
<Select value={precedentLevel || "_all"} onValueChange={(v) => setPrecedentLevel(v === "_all" ? "" : v)}>
|
|
||||||
<SelectTrigger><SelectValue placeholder="הכל" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="_all">הכל</SelectItem>
|
|
||||||
{PRECEDENT_LEVELS.map((l) => (
|
|
||||||
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={() => setUploadOpen(true)} className="bg-navy text-parchment hover:bg-navy-soft">
|
<Button onClick={() => setUploadOpen(true)} className="bg-navy text-parchment hover:bg-navy-soft">
|
||||||
<Plus className="w-4 h-4 me-1" />
|
<Plus className="w-4 h-4 me-1" />
|
||||||
העלאת פסיקה
|
העלאת פסיקה
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{/* Table 1 — Court rulings */}
|
||||||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
|
<section>
|
||||||
{error.message}
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<h3 className="text-base font-semibold text-navy">פסיקת בתי משפט</h3>
|
||||||
|
{courts.data && (
|
||||||
|
<Badge variant="outline" className="text-ink-muted text-[0.72rem]">
|
||||||
|
{courts.data.count}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{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>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||||
@@ -287,31 +376,73 @@ export function LibraryListPanel() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isPending ? (
|
{courts.isPending ? (
|
||||||
[...Array(5)].map((_, i) => (
|
<TableSkeleton cols={7} />
|
||||||
<TableRow key={i} className="border-rule">
|
) : !courts.data?.items.length ? (
|
||||||
{[...Array(7)].map((_, j) => (
|
|
||||||
<TableCell key={j}>
|
|
||||||
<Skeleton className="h-5 w-full" />
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : !data?.items.length ? (
|
|
||||||
<TableRow className="border-rule">
|
<TableRow className="border-rule">
|
||||||
<TableCell colSpan={7} className="text-center text-ink-muted py-10">
|
<TableCell colSpan={7} className="text-center text-ink-muted py-8">
|
||||||
אין פסיקה בקורפוס. העלה את פסק הדין הראשון.
|
אין פסיקת בתי משפט בקורפוס.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
data.items.map((p) => (
|
courts.data.items.map((p) => (
|
||||||
<PrecedentRow key={p.id} p={p} onEdit={setEditingId} />
|
<CourtRow key={p.id} p={p} onEdit={setEditingId} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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} />
|
<PrecedentUploadSheet open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||||
<PrecedentEditSheet
|
<PrecedentEditSheet
|
||||||
|
|||||||
222
web-ui/src/components/precedents/link-related-dialog.tsx
Normal file
222
web-ui/src/components/precedents/link-related-dialog.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link2, Loader2, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
usePrecedents,
|
||||||
|
useLinkRelatedCase,
|
||||||
|
useUnlinkRelatedCase,
|
||||||
|
RelatedCase,
|
||||||
|
} from "@/lib/api/precedent-library";
|
||||||
|
|
||||||
|
const LEVEL_LABELS: Record<string, string> = {
|
||||||
|
"עליון": "עליון",
|
||||||
|
"מנהלי": "מנהלי",
|
||||||
|
"ועדת_ערר_ארצית": "ארצי",
|
||||||
|
"ועדת_ערר_מחוזית": "מחוזי",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_COLORS: Record<string, string> = {
|
||||||
|
"עליון": "bg-red-50 text-red-700 border-red-200",
|
||||||
|
"מנהלי": "bg-orange-50 text-orange-700 border-orange-200",
|
||||||
|
"ועדת_ערר_ארצית": "bg-blue-50 text-blue-700 border-blue-200",
|
||||||
|
"ועדת_ערר_מחוזית": "bg-green-50 text-green-700 border-green-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Search Dialog ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type DialogProps = {
|
||||||
|
caseId: string;
|
||||||
|
currentRelated: RelatedCase[];
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const { mutateAsync: linkCase, isPending } = useLinkRelatedCase(caseId);
|
||||||
|
const alreadyLinkedIds = new Set([...currentRelated.map((r) => r.id), caseId]);
|
||||||
|
|
||||||
|
const { data, isPending: searching } = usePrecedents(
|
||||||
|
query.length >= 2 ? { search: query, limit: 10 } : {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidates = (data?.items ?? []).filter((p) => !alreadyLinkedIds.has(p.id));
|
||||||
|
|
||||||
|
async function handleLink(relatedId: string) {
|
||||||
|
try {
|
||||||
|
await linkCase({ relatedId });
|
||||||
|
toast.success("הפסיקות קושרו");
|
||||||
|
setQuery("");
|
||||||
|
} catch {
|
||||||
|
toast.error("שגיאה בקישור");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg" dir="rtl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-navy">קשר החלטה קשורה</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
placeholder="חפש לפי מספר תיק, שם, ערכאה..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{query.length >= 2 && (
|
||||||
|
<div className="space-y-1 max-h-72 overflow-y-auto">
|
||||||
|
{searching ? (
|
||||||
|
<div className="flex items-center gap-2 text-ink-muted text-sm py-3">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" /> מחפש...
|
||||||
|
</div>
|
||||||
|
) : candidates.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm py-3 text-center">לא נמצאו תוצאות</p>
|
||||||
|
) : (
|
||||||
|
candidates.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => handleLink(p.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full text-right flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule hover:bg-surface/60 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-navy truncate">
|
||||||
|
{p.case_name || p.case_number}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted font-mono" dir="ltr">
|
||||||
|
{p.case_number}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{p.precedent_level && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[0.62rem] shrink-0 ${LEVEL_COLORS[p.precedent_level] ?? ""}`}
|
||||||
|
>
|
||||||
|
{LEVEL_LABELS[p.precedent_level] ?? p.precedent_level}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{query.length > 0 && query.length < 2 && (
|
||||||
|
<p className="text-ink-muted text-xs">הקלד לפחות 2 תווים</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Related Case Card ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RelatedCaseCard({ caseId, related }: { caseId: string; related: RelatedCase }) {
|
||||||
|
const { mutateAsync: unlinkCase, isPending } = useUnlinkRelatedCase(caseId);
|
||||||
|
|
||||||
|
async function handleUnlink() {
|
||||||
|
try {
|
||||||
|
await unlinkCase(related.id);
|
||||||
|
toast.success("הקישור הוסר");
|
||||||
|
} catch {
|
||||||
|
toast.error("שגיאה בהסרת הקישור");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule bg-surface">
|
||||||
|
<a
|
||||||
|
href={`/precedents/${related.id}`}
|
||||||
|
className="min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-navy truncate">
|
||||||
|
{related.case_name || related.case_number}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||||
|
{related.precedent_level && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[0.62rem] ${LEVEL_COLORS[related.precedent_level] ?? ""}`}
|
||||||
|
>
|
||||||
|
{LEVEL_LABELS[related.precedent_level] ?? related.precedent_level}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{related.court && (
|
||||||
|
<span className="text-[0.7rem] text-ink-muted truncate">{related.court}</span>
|
||||||
|
)}
|
||||||
|
{related.date && (
|
||||||
|
<span className="text-[0.7rem] text-ink-muted tabular-nums" dir="ltr">
|
||||||
|
{related.date.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={handleUnlink}
|
||||||
|
disabled={isPending}
|
||||||
|
className="p-1 rounded hover:bg-danger-bg hover:text-danger transition-colors text-ink-muted disabled:opacity-40 shrink-0"
|
||||||
|
title="הסר קישור"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public section component ─────────────────────────────────────────
|
||||||
|
|
||||||
|
type SectionProps = {
|
||||||
|
caseId: string;
|
||||||
|
related: RelatedCase[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RelatedCasesSection({ caseId, related }: SectionProps) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-navy text-sm font-semibold">
|
||||||
|
החלטות קשורות{related.length > 0 ? ` (${related.length})` : ""}
|
||||||
|
</h3>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setDialogOpen(true)}>
|
||||||
|
<Link2 className="w-3.5 h-3.5 me-1" /> קשר החלטה
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{related.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm">אין החלטות קשורות עדיין</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{related.map((r) => (
|
||||||
|
<RelatedCaseCard key={r.id} caseId={caseId} related={r} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LinkDialog
|
||||||
|
caseId={caseId}
|
||||||
|
currentRelated={related}
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,3 +35,17 @@ export function practiceAreaShort(value: string | null | undefined): string {
|
|||||||
const match = PRACTICE_AREAS.find((p) => p.value === value);
|
const match = PRACTICE_AREAS.find((p) => p.value === value);
|
||||||
return match ? match.short : value;
|
return match ? match.short : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display label for ``appeal_subtype``. The field is free text — the chair
|
||||||
|
* usually types specific subjects like "תכנית רחביה" or "סופיות ההחלטה" —
|
||||||
|
* but the LLM extractor sometimes mistakenly stuffs the practice_area
|
||||||
|
* enum value (``betterment_levy``, ``rishuy_uvniya``, ``compensation_197``)
|
||||||
|
* in there. When that happens, translate to Hebrew so the UI doesn't
|
||||||
|
* leak English. Otherwise return the value unchanged.
|
||||||
|
*/
|
||||||
|
export function appealSubtypeLabel(value: string | null | undefined): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const enumMatch = PRACTICE_AREAS.find((p) => p.value === value);
|
||||||
|
return enumMatch ? enumMatch.label : value;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Save, Sparkles } from "lucide-react";
|
import { Save, Sparkles } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
type SourceType,
|
type SourceType,
|
||||||
} from "@/lib/api/precedent-library";
|
} from "@/lib/api/precedent-library";
|
||||||
import {
|
import {
|
||||||
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES,
|
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, appealSubtypeLabel,
|
||||||
} from "./practice-area";
|
} from "./practice-area";
|
||||||
import { ExtractedHalachotSection } from "./extracted-halachot";
|
import { ExtractedHalachotSection } from "./extracted-halachot";
|
||||||
|
|
||||||
@@ -65,17 +65,19 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
|
|
||||||
const [form, setForm] = useState<FormState>(EMPTY);
|
const [form, setForm] = useState<FormState>(EMPTY);
|
||||||
|
|
||||||
// Hydrate form when the record loads.
|
// React-approved derived-state pattern: sync form whenever a different
|
||||||
useEffect(() => {
|
// record arrives (including after save+refetch). Using setState during
|
||||||
if (!record) return;
|
// render avoids the one-frame flash that useEffect would produce.
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
const [syncedRecordId, setSyncedRecordId] = useState<string | null>(null);
|
||||||
|
if (record && record.id !== syncedRecordId) {
|
||||||
|
setSyncedRecordId(record.id as string);
|
||||||
setForm({
|
setForm({
|
||||||
citation: record.case_number || "",
|
citation: record.case_number || "",
|
||||||
case_name: record.case_name || "",
|
case_name: record.case_name || "",
|
||||||
court: record.court || "",
|
court: record.court || "",
|
||||||
decision_date: record.date ? record.date.slice(0, 10) : "",
|
decision_date: record.date ? record.date.slice(0, 10) : "",
|
||||||
practice_area: (record.practice_area || "") as PracticeArea,
|
practice_area: (record.practice_area || "") as PracticeArea,
|
||||||
appeal_subtype: record.appeal_subtype || "",
|
appeal_subtype: appealSubtypeLabel(record.appeal_subtype),
|
||||||
source_type: (record.source_type || "") as SourceType,
|
source_type: (record.source_type || "") as SourceType,
|
||||||
precedent_level: record.precedent_level || "",
|
precedent_level: record.precedent_level || "",
|
||||||
is_binding: record.is_binding ?? true,
|
is_binding: record.is_binding ?? true,
|
||||||
@@ -84,7 +86,7 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
headnote: record.headnote || "",
|
headnote: record.headnote || "",
|
||||||
key_quote: (record as { key_quote?: string }).key_quote || "",
|
key_quote: (record as { key_quote?: string }).key_quote || "",
|
||||||
});
|
});
|
||||||
}, [record]);
|
}
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent) => {
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export type Precedent = {
|
|||||||
headnote: string;
|
headnote: string;
|
||||||
subject_tags: string[];
|
subject_tags: string[];
|
||||||
source_kind: string;
|
source_kind: string;
|
||||||
|
chair_name: string | null;
|
||||||
|
district: string | null;
|
||||||
extraction_status: string;
|
extraction_status: string;
|
||||||
halacha_extraction_status: string;
|
halacha_extraction_status: string;
|
||||||
metadata_extraction_requested_at: string | null;
|
metadata_extraction_requested_at: string | null;
|
||||||
@@ -82,9 +84,20 @@ export type Halacha = {
|
|||||||
precedent_level?: string;
|
precedent_level?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RelatedCase = {
|
||||||
|
id: string;
|
||||||
|
case_number: string;
|
||||||
|
case_name: string;
|
||||||
|
court: string;
|
||||||
|
precedent_level: string;
|
||||||
|
date: string | null;
|
||||||
|
relation_type: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PrecedentDetail = Precedent & {
|
export type PrecedentDetail = Precedent & {
|
||||||
full_text: string;
|
full_text: string;
|
||||||
halachot: Halacha[];
|
halachot: Halacha[];
|
||||||
|
related_cases: RelatedCase[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SearchHit =
|
export type SearchHit =
|
||||||
@@ -137,6 +150,7 @@ export type ListFilters = {
|
|||||||
court?: string;
|
court?: string;
|
||||||
precedentLevel?: string;
|
precedentLevel?: string;
|
||||||
sourceType?: SourceType;
|
sourceType?: SourceType;
|
||||||
|
sourceKind?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
@@ -164,6 +178,7 @@ export function usePrecedents(filters: ListFilters = {}) {
|
|||||||
if (filters.court) p.set("court", filters.court);
|
if (filters.court) p.set("court", filters.court);
|
||||||
if (filters.precedentLevel) p.set("precedent_level", filters.precedentLevel);
|
if (filters.precedentLevel) p.set("precedent_level", filters.precedentLevel);
|
||||||
if (filters.sourceType) p.set("source_type", filters.sourceType);
|
if (filters.sourceType) p.set("source_type", filters.sourceType);
|
||||||
|
if (filters.sourceKind) p.set("source_kind", filters.sourceKind);
|
||||||
if (filters.search) p.set("search", filters.search);
|
if (filters.search) p.set("search", filters.search);
|
||||||
if (filters.limit) p.set("limit", String(filters.limit));
|
if (filters.limit) p.set("limit", String(filters.limit));
|
||||||
if (filters.offset) p.set("offset", String(filters.offset));
|
if (filters.offset) p.set("offset", String(filters.offset));
|
||||||
@@ -353,6 +368,40 @@ export function useDeletePrecedent() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useLinkRelatedCase(caseId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: { relatedId: string; relationType?: string }) =>
|
||||||
|
apiRequest<{ linked: boolean }>(
|
||||||
|
`/api/precedent-library/${encodeURIComponent(caseId)}/relations`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
related_id: vars.relatedId,
|
||||||
|
relation_type: vars.relationType ?? "same_case_chain",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnlinkRelatedCase(caseId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (relatedId: string) =>
|
||||||
|
apiRequest<{ unlinked: boolean }>(
|
||||||
|
`/api/precedent-library/${encodeURIComponent(caseId)}/relations/${encodeURIComponent(relatedId)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type PrecedentPatch = Partial<{
|
export type PrecedentPatch = Partial<{
|
||||||
case_name: string;
|
case_name: string;
|
||||||
court: string;
|
court: string;
|
||||||
|
|||||||
@@ -1355,7 +1355,7 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Api Case Agents
|
* Api Case Agents
|
||||||
* @description Get all Paperclip agent activity for a case: issues, comments, agent status.
|
* @description Get all Paperclip agent activity for a case: issues, comments, interactions, agent status.
|
||||||
*/
|
*/
|
||||||
get: operations["api_case_agents_api_cases__case_number__agents_get"];
|
get: operations["api_case_agents_api_cases__case_number__agents_get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
@@ -1388,6 +1388,149 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/cases/{case_number}/agents/interaction-response": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Api Post Interaction Response
|
||||||
|
* @description Submit a user's answer to a Paperclip issue-thread interaction.
|
||||||
|
*
|
||||||
|
* Routes to /respond | /accept | /reject based on `action`. Paperclip
|
||||||
|
* auto-wakes the issue assignee after a successful submission.
|
||||||
|
*/
|
||||||
|
post: operations["api_post_interaction_response_api_cases__case_number__agents_interaction_response_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/settings/mcp/env": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Api Mcp Env
|
||||||
|
* @description List all catalog env vars with Coolify (authoritative) + container values.
|
||||||
|
*/
|
||||||
|
get: operations["api_mcp_env_api_settings_mcp_env_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/settings/mcp/env/{key}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/**
|
||||||
|
* Api Mcp Env Update
|
||||||
|
* @description Update a non-secret env var in Coolify. Requires redeploy to take effect.
|
||||||
|
*/
|
||||||
|
patch: operations["api_mcp_env_update_api_settings_mcp_env__key__patch"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/settings/mcp/env/redeploy": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Api Mcp Env Redeploy
|
||||||
|
* @description Trigger Coolify redeploy of the legal-ai app.
|
||||||
|
*/
|
||||||
|
post: operations["api_mcp_env_redeploy_api_settings_mcp_env_redeploy_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/settings/mcp/tools": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Api Mcp Tools
|
||||||
|
* @description List all MCP tools registered in legal_mcp.
|
||||||
|
*/
|
||||||
|
get: operations["api_mcp_tools_api_settings_mcp_tools_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/settings/mcp/registrations": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Api Mcp Registrations
|
||||||
|
* @description List MCP server registrations from host config files.
|
||||||
|
*/
|
||||||
|
get: operations["api_mcp_registrations_api_settings_mcp_registrations_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/settings/mcp/blocks": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Api Mcp Blocks
|
||||||
|
* @description List the 12-block decision schema (read-only reference).
|
||||||
|
*/
|
||||||
|
get: operations["api_mcp_blocks_api_settings_mcp_blocks_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/settings/paperclip-companies": {
|
"/api/settings/paperclip-companies": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1516,6 +1659,29 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/paperclip-agents": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Api List Paperclip Agents
|
||||||
|
* @description List all Paperclip agents grouped into master+mirror pairs with drift detection.
|
||||||
|
*
|
||||||
|
* Read-only. Source of truth: Paperclip ``GET /api/companies/{id}/agents`` API
|
||||||
|
* (not direct DB) — keeps us decoupled from Paperclip's schema changes.
|
||||||
|
*/
|
||||||
|
get: operations["api_list_paperclip_agents_api_admin_paperclip_agents_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/admin/skills/install": {
|
"/api/admin/skills/install": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2295,6 +2461,27 @@ export interface components {
|
|||||||
/** Practice Areas */
|
/** Practice Areas */
|
||||||
practice_areas?: string[] | null;
|
practice_areas?: string[] | null;
|
||||||
};
|
};
|
||||||
|
/** InteractionResponseRequest */
|
||||||
|
InteractionResponseRequest: {
|
||||||
|
/** Issue Id */
|
||||||
|
issue_id: string;
|
||||||
|
/** Interaction Id */
|
||||||
|
interaction_id: string;
|
||||||
|
/**
|
||||||
|
* Action
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
action: "respond" | "accept" | "reject";
|
||||||
|
/** Payload */
|
||||||
|
payload: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** McpEnvUpdateRequest */
|
||||||
|
McpEnvUpdateRequest: {
|
||||||
|
/** Value */
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
/** MethodologyUpdateRequest */
|
/** MethodologyUpdateRequest */
|
||||||
MethodologyUpdateRequest: {
|
MethodologyUpdateRequest: {
|
||||||
/** Value */
|
/** Value */
|
||||||
@@ -4491,6 +4678,176 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
api_post_interaction_response_api_cases__case_number__agents_interaction_response_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
case_number: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["InteractionResponseRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_mcp_env_api_settings_mcp_env_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_mcp_env_update_api_settings_mcp_env__key__patch: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["McpEnvUpdateRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_mcp_env_redeploy_api_settings_mcp_env_redeploy_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_mcp_tools_api_settings_mcp_tools_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_mcp_registrations_api_settings_mcp_registrations_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_mcp_blocks_api_settings_mcp_blocks_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
api_paperclip_companies_api_settings_paperclip_companies_get: {
|
api_paperclip_companies_api_settings_paperclip_companies_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4714,6 +5071,26 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
api_list_paperclip_agents_api_admin_paperclip_agents_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
api_install_skill_api_admin_skills_install_post: {
|
api_install_skill_api_admin_skills_install_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
310
web/app.py
310
web/app.py
@@ -20,7 +20,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "
|
|||||||
|
|
||||||
import zipfile
|
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 fastapi.responses import FileResponse, StreamingResponse
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -44,7 +44,7 @@ from web.mcp_env_catalog import (
|
|||||||
normalize_for_compare,
|
normalize_for_compare,
|
||||||
)
|
)
|
||||||
from web.progress_store import ProgressStore
|
from web.progress_store import ProgressStore
|
||||||
from web.paperclip_api import pc_request
|
from web.paperclip_api import emit_case_status_webhook, pc_request
|
||||||
from web.paperclip_client import (
|
from web.paperclip_client import (
|
||||||
COMPANIES as PAPERCLIP_COMPANIES,
|
COMPANIES as PAPERCLIP_COMPANIES,
|
||||||
accept_interaction as pc_accept_interaction,
|
accept_interaction as pc_accept_interaction,
|
||||||
@@ -62,6 +62,7 @@ from web.paperclip_client import (
|
|||||||
respond_to_interaction as pc_respond_to_interaction,
|
respond_to_interaction as pc_respond_to_interaction,
|
||||||
restore_project as pc_restore_project,
|
restore_project as pc_restore_project,
|
||||||
wake_ceo_agent as pc_wake_ceo,
|
wake_ceo_agent as pc_wake_ceo,
|
||||||
|
wake_curator_for_final as pc_wake_curator_for_final,
|
||||||
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
|
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1336,8 +1337,12 @@ async def api_case_get(case_number: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.put("/api/cases/{case_number}")
|
@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."""
|
"""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(
|
result = await cases_tools.case_update(
|
||||||
case_number=case_number,
|
case_number=case_number,
|
||||||
status=req.status,
|
status=req.status,
|
||||||
@@ -1350,10 +1355,30 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest):
|
|||||||
expected_outcome=req.expected_outcome,
|
expected_outcome=req.expected_outcome,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return json.loads(result)
|
parsed = json.loads(result)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
raise HTTPException(404, result)
|
raise HTTPException(404, result)
|
||||||
|
|
||||||
|
# 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")
|
@app.delete("/api/cases")
|
||||||
async def api_case_delete(case_number: str, remove_files: bool = False):
|
async def api_case_delete(case_number: str, remove_files: bool = False):
|
||||||
@@ -2347,10 +2372,38 @@ async def api_mark_final(case_number: str, filename: str):
|
|||||||
if case_dir.exists():
|
if case_dir.exists():
|
||||||
commit_and_push(case_dir, f"גרסה סופית: {final_name}")
|
commit_and_push(case_dir, f"גרסה סופית: {final_name}")
|
||||||
|
|
||||||
|
# ingest_final_version into the case_law corpus is NOT called here:
|
||||||
|
# it uses claude_session under the hood, which only works when invoked
|
||||||
|
# from the local MCP server (Claude CLI present), not from this
|
||||||
|
# FastAPI container. Run it manually via Claude Code / MCP when needed.
|
||||||
|
# See: ~/.claude/projects/-home-chaim-legal-ai/memory/feedback_claude_session_local_only.md
|
||||||
|
ingest_status: dict = {"status": "skipped", "reason": "container_no_claude_cli"}
|
||||||
|
|
||||||
|
# Best-effort: wake the Knowledge Curator (Hermes) to analyze the
|
||||||
|
# signed final and propose updates to skills/lessons. Non-fatal on
|
||||||
|
# failure so marking final never breaks for the user.
|
||||||
|
curator_status: dict = {"status": "skipped"}
|
||||||
|
try:
|
||||||
|
# Company by case-number prefix: 1xxx=CMP (licensing), 8/9xxx=CMPA (betterment)
|
||||||
|
prefix = case_number[:1]
|
||||||
|
company_id = (
|
||||||
|
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
|
||||||
|
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
curator_status = await pc_wake_curator_for_final(
|
||||||
|
case_number, final_name, company_id=company_id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("curator wakeup failed for %s: %s", case_number, e)
|
||||||
|
curator_status = {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"final_filename": final_name,
|
"final_filename": final_name,
|
||||||
"training_copy": str(training_dest),
|
"training_copy": str(training_dest),
|
||||||
"status": "final",
|
"status": "final",
|
||||||
|
"ingest_final": ingest_status,
|
||||||
|
"curator": curator_status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3028,8 +3081,16 @@ async def api_get_methodology(category: str):
|
|||||||
items = {}
|
items = {}
|
||||||
for key, default_val in defaults.items():
|
for key, default_val in defaults.items():
|
||||||
if key in overrides:
|
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] = {
|
items[key] = {
|
||||||
"value": overrides[key]["rule_value"],
|
"value": raw,
|
||||||
"is_override": True,
|
"is_override": True,
|
||||||
"updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None,
|
"updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None,
|
||||||
}
|
}
|
||||||
@@ -3066,10 +3127,14 @@ async def api_update_methodology(category: str, key: str, req: MethodologyUpdate
|
|||||||
raise HTTPException(422, "content_checklists value must be a non-empty string")
|
raise HTTPException(422, "content_checklists value must be a non-empty string")
|
||||||
|
|
||||||
pool = await db.get_pool()
|
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(
|
await pool.execute(
|
||||||
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
|
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
|
||||||
"VALUES (gen_random_uuid(), '_global', $1, $2, $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::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),
|
category, key, json.dumps(req.value, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3191,6 +3256,8 @@ _AGENT_NAME_ORDER = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Fields that should match between master (CMP) and mirror (CMPA). Drift = bug.
|
# 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 = (
|
_DRIFT_FIELDS = (
|
||||||
"model",
|
"model",
|
||||||
"effort",
|
"effort",
|
||||||
@@ -3204,10 +3271,21 @@ _DRIFT_FIELDS = (
|
|||||||
"wakeOnDemand",
|
"wakeOnDemand",
|
||||||
"maxConcurrentRuns",
|
"maxConcurrentRuns",
|
||||||
"budget_monthly_cents",
|
"budget_monthly_cents",
|
||||||
"status",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _shape_paperclip_agent(raw: dict, company_id: str, company_name: str) -> dict:
|
||||||
"""Flatten a Paperclip agent row into the shape the UI consumes."""
|
"""Flatten a Paperclip agent row into the shape the UI consumes."""
|
||||||
ac = raw.get("adapterConfig") or {}
|
ac = raw.get("adapterConfig") or {}
|
||||||
@@ -3252,6 +3330,9 @@ def _compute_drift(master: dict | None, mirror: dict | None) -> list[dict]:
|
|||||||
for field in _DRIFT_FIELDS:
|
for field in _DRIFT_FIELDS:
|
||||||
m_val = master.get(field)
|
m_val = master.get(field)
|
||||||
i_val = mirror.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:
|
if m_val != i_val:
|
||||||
drift.append({"field": field, "master": m_val, "mirror": i_val})
|
drift.append({"field": field, "master": m_val, "mirror": i_val})
|
||||||
return drift
|
return drift
|
||||||
@@ -4246,13 +4327,15 @@ async def precedent_library_list(
|
|||||||
precedent_level: str = "",
|
precedent_level: str = "",
|
||||||
source_type: str = "",
|
source_type: str = "",
|
||||||
search: str = "",
|
search: str = "",
|
||||||
|
source_kind: str = "external_upload",
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
):
|
):
|
||||||
rows = await db.list_external_case_law(
|
rows = await db.list_external_case_law(
|
||||||
practice_area=practice_area, court=court,
|
practice_area=practice_area, court=court,
|
||||||
precedent_level=precedent_level, source_type=source_type,
|
precedent_level=precedent_level, source_type=source_type,
|
||||||
search=search, limit=limit, offset=offset,
|
search=search, source_kind=source_kind,
|
||||||
|
limit=limit, offset=offset,
|
||||||
)
|
)
|
||||||
return {"items": rows, "count": len(rows)}
|
return {"items": rows, "count": len(rows)}
|
||||||
|
|
||||||
@@ -4333,6 +4416,37 @@ async def precedent_library_delete(case_law_id: str):
|
|||||||
return {"deleted": True, "case_law_id": case_law_id}
|
return {"deleted": True, "case_law_id": case_law_id}
|
||||||
|
|
||||||
|
|
||||||
|
class PrecedentRelationRequest(BaseModel):
|
||||||
|
related_id: str
|
||||||
|
relation_type: str = "same_case_chain"
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/precedent-library/{case_law_id}/relations")
|
||||||
|
async def precedent_add_relation(case_law_id: str, req: PrecedentRelationRequest):
|
||||||
|
try:
|
||||||
|
a = UUID(case_law_id)
|
||||||
|
b = UUID(req.related_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "case_law_id לא תקין")
|
||||||
|
if not await db.get_case_law(a):
|
||||||
|
raise HTTPException(404, "פסיקה לא נמצאה")
|
||||||
|
if not await db.get_case_law(b):
|
||||||
|
raise HTTPException(404, f"פסיקה קשורה {req.related_id} לא נמצאה")
|
||||||
|
await db.add_case_law_relation(a, b, req.relation_type)
|
||||||
|
return {"linked": True, "case_law_id": case_law_id, "related_id": req.related_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/precedent-library/{case_law_id}/relations/{related_id}")
|
||||||
|
async def precedent_remove_relation(case_law_id: str, related_id: str):
|
||||||
|
try:
|
||||||
|
a = UUID(case_law_id)
|
||||||
|
b = UUID(related_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "case_law_id לא תקין")
|
||||||
|
await db.remove_case_law_relation(a, b)
|
||||||
|
return {"unlinked": True, "case_law_id": case_law_id, "related_id": related_id}
|
||||||
|
|
||||||
|
|
||||||
# Halacha and metadata extraction are LLM-driven and rely on the local
|
# Halacha and metadata extraction are LLM-driven and rely on the local
|
||||||
# `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run
|
# `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run
|
||||||
# from this container (no CLI, no claude.ai session). The endpoints below
|
# from this container (no CLI, no claude.ai session). The endpoints below
|
||||||
@@ -4342,18 +4456,41 @@ async def precedent_library_delete(case_law_id: str):
|
|||||||
# drain the queue.
|
# drain the queue.
|
||||||
|
|
||||||
|
|
||||||
|
async def _wake_ceo_for_precedent(case_law_id: UUID, kind: str) -> dict:
|
||||||
|
"""Trigger Paperclip CEO to drain the precedent extraction queue, mirroring
|
||||||
|
the upload flow (see ``precedent_library_upload`` → ``pc_wake_for_precedent_extraction``).
|
||||||
|
|
||||||
|
Best-effort — any failure is logged but doesn't surface to the user, who
|
||||||
|
can still invoke ``mcp__legal-ai__precedent_process_pending`` manually.
|
||||||
|
Returns a dict with the wakeup outcome for inclusion in the API response.
|
||||||
|
"""
|
||||||
|
record = await db.get_case_law(case_law_id)
|
||||||
|
if not record:
|
||||||
|
return {"ok": False, "skipped": "record_missing"}
|
||||||
|
try:
|
||||||
|
return await pc_wake_for_precedent_extraction(
|
||||||
|
case_law_id=str(case_law_id),
|
||||||
|
citation=str(record.get("case_number") or ""),
|
||||||
|
practice_area=str(record.get("practice_area") or ""),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("precedent-extraction wakeup failed (non-fatal, kind=%s)", kind)
|
||||||
|
return {"ok": False, "error": "wakeup_failed"}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/precedent-library/{case_law_id}/request-metadata")
|
@app.post("/api/precedent-library/{case_law_id}/request-metadata")
|
||||||
async def precedent_request_metadata(case_law_id: str):
|
async def precedent_request_metadata(case_law_id: str):
|
||||||
"""Stamp the case_law row as needing metadata extraction. The local
|
"""Stamp the case_law row as needing metadata extraction AND wake the
|
||||||
MCP worker (`precedent_process_pending_metadata`) will pick it up."""
|
Paperclip CEO so extraction runs automatically — same flow as upload."""
|
||||||
try:
|
try:
|
||||||
cid = UUID(case_law_id)
|
cid = UUID(case_law_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(400, "case_law_id לא תקין")
|
raise HTTPException(400, "case_law_id לא תקין")
|
||||||
ok = await db.request_metadata_extraction(cid)
|
ok = await db.request_metadata_extraction(cid)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(404, "פסיקה לא נמצאה (או לא מסוג external_upload)")
|
raise HTTPException(404, "פסיקה לא נמצאה")
|
||||||
return {"queued": True, "case_law_id": case_law_id, "kind": "metadata"}
|
wakeup = await _wake_ceo_for_precedent(cid, kind="metadata")
|
||||||
|
return {"queued": True, "case_law_id": case_law_id, "kind": "metadata", "wakeup": wakeup}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/precedent-library/{case_law_id}/request-halachot")
|
@app.post("/api/precedent-library/{case_law_id}/request-halachot")
|
||||||
@@ -4365,8 +4502,9 @@ async def precedent_request_halachot(case_law_id: str):
|
|||||||
raise HTTPException(400, "case_law_id לא תקין")
|
raise HTTPException(400, "case_law_id לא תקין")
|
||||||
ok = await db.request_halacha_extraction(cid)
|
ok = await db.request_halacha_extraction(cid)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(404, "פסיקה לא נמצאה (או לא מסוג external_upload)")
|
raise HTTPException(404, "פסיקה לא נמצאה")
|
||||||
return {"queued": True, "case_law_id": case_law_id, "kind": "halacha"}
|
wakeup = await _wake_ceo_for_precedent(cid, kind="halacha")
|
||||||
|
return {"queued": True, "case_law_id": case_law_id, "kind": "halacha", "wakeup": wakeup}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/precedent-library/queue/pending")
|
@app.get("/api/precedent-library/queue/pending")
|
||||||
@@ -4379,6 +4517,148 @@ async def precedent_queue_pending(kind: str = "metadata", limit: int = 20):
|
|||||||
return {"items": items, "count": len(items)}
|
return {"items": items, "count": len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
from legal_mcp.services import internal_decisions as int_decisions_service # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/internal-decisions/upload")
|
||||||
|
async def internal_decisions_upload(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
case_number: str = Form(...),
|
||||||
|
case_name: str = Form(""),
|
||||||
|
court: str = Form(""),
|
||||||
|
decision_date: str = Form(""),
|
||||||
|
chair_name: str = Form(""),
|
||||||
|
district: str = Form(""),
|
||||||
|
practice_area: str = Form(""),
|
||||||
|
appeal_subtype: str = Form(""),
|
||||||
|
subject_tags: str = Form("[]"),
|
||||||
|
is_binding: bool = Form(True),
|
||||||
|
summary: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Upload a planning appeals-committee decision to the internal corpus."""
|
||||||
|
if practice_area and practice_area not in _PRACTICE_AREAS:
|
||||||
|
raise HTTPException(400, "practice_area לא תקין")
|
||||||
|
if not case_number.strip():
|
||||||
|
raise HTTPException(400, "case_number חובה")
|
||||||
|
|
||||||
|
suffix = Path(file.filename or "").suffix.lower()
|
||||||
|
if suffix not in ALLOWED_EXTENSIONS:
|
||||||
|
raise HTTPException(400, f"סוג קובץ לא נתמך: {suffix}")
|
||||||
|
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
staged = UPLOAD_DIR / f"int_{uuid4().hex[:8]}_{file.filename}"
|
||||||
|
size = 0
|
||||||
|
with staged.open("wb") as out:
|
||||||
|
while chunk := await file.read(1024 * 1024):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_FILE_SIZE:
|
||||||
|
staged.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(413, "קובץ גדול מדי")
|
||||||
|
out.write(chunk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tags = json.loads(subject_tags) if subject_tags else []
|
||||||
|
if not isinstance(tags, list):
|
||||||
|
tags = []
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
tags = []
|
||||||
|
|
||||||
|
task_id = str(uuid4())
|
||||||
|
await _progress.set(task_id, {
|
||||||
|
"status": "queued", "filename": file.filename or "",
|
||||||
|
"stage": "queued", "percent": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
try:
|
||||||
|
await int_decisions_service.ingest_internal_decision(
|
||||||
|
case_number=case_number.strip(),
|
||||||
|
case_name=case_name.strip(),
|
||||||
|
court=court.strip(),
|
||||||
|
decision_date=decision_date or None,
|
||||||
|
chair_name=chair_name.strip(),
|
||||||
|
district=district.strip(),
|
||||||
|
practice_area=practice_area,
|
||||||
|
appeal_subtype=appeal_subtype.strip(),
|
||||||
|
subject_tags=tags,
|
||||||
|
is_binding=is_binding,
|
||||||
|
summary=summary.strip(),
|
||||||
|
file_path=staged,
|
||||||
|
)
|
||||||
|
await _progress.set(task_id, {"status": "completed", "percent": 100})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("internal-decisions upload failed")
|
||||||
|
await _progress.set(task_id, {"status": "failed", "error": str(e)})
|
||||||
|
finally:
|
||||||
|
staged.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
asyncio.create_task(_run())
|
||||||
|
return {"task_id": task_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/internal-decisions/migrate")
|
||||||
|
async def internal_decisions_migrate(
|
||||||
|
source: str = "both",
|
||||||
|
dry_run: bool = True,
|
||||||
|
):
|
||||||
|
"""Migrate existing data to the internal committee corpus.
|
||||||
|
|
||||||
|
source: 'style_corpus' | 'external_corpus' | 'both'
|
||||||
|
dry_run: if true, only report what would be done (no writes)
|
||||||
|
"""
|
||||||
|
if source not in {"style_corpus", "external_corpus", "both"}:
|
||||||
|
raise HTTPException(400, "source חייב להיות style_corpus / external_corpus / both")
|
||||||
|
|
||||||
|
results: dict = {}
|
||||||
|
if source in {"style_corpus", "both"}:
|
||||||
|
results["style_corpus"] = await int_decisions_service.migrate_from_style_corpus(dry_run=dry_run)
|
||||||
|
if source in {"external_corpus", "both"}:
|
||||||
|
results["external_corpus"] = await int_decisions_service.migrate_from_external_corpus(dry_run=dry_run)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/internal-decisions")
|
||||||
|
async def internal_decisions_list(
|
||||||
|
district: str = "",
|
||||||
|
chair_name: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
):
|
||||||
|
"""List internal committee decisions with optional filters."""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
conditions = ["source_kind = 'internal_committee'"]
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
if district:
|
||||||
|
conditions.append(f"district = ${idx}")
|
||||||
|
params.append(district)
|
||||||
|
idx += 1
|
||||||
|
if chair_name:
|
||||||
|
conditions.append(f"chair_name = ${idx}")
|
||||||
|
params.append(chair_name)
|
||||||
|
idx += 1
|
||||||
|
if practice_area:
|
||||||
|
conditions.append(f"practice_area = ${idx}")
|
||||||
|
params.append(practice_area)
|
||||||
|
idx += 1
|
||||||
|
params.append(limit)
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
rows = await conn.fetch(
|
||||||
|
f"SELECT id, case_number, case_name, court, district, chair_name, "
|
||||||
|
f"date, practice_area, appeal_subtype, extraction_status, halacha_extraction_status "
|
||||||
|
f"FROM case_law WHERE {where} ORDER BY date DESC NULLS LAST LIMIT ${idx}",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
total = await conn.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM case_law WHERE source_kind = 'internal_committee'"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"items": [dict(r) for r in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/halachot")
|
@app.get("/api/halachot")
|
||||||
async def halachot_list(
|
async def halachot_list(
|
||||||
case_law_id: str = "",
|
case_law_id: str = "",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -81,3 +82,35 @@ async def pc_request(
|
|||||||
if raise_on_error:
|
if raise_on_error:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp
|
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.utcnow().isoformat() + "Z",
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ CEO_AGENTS = {
|
|||||||
# Default for backwards compat
|
# Default for backwards compat
|
||||||
CEO_AGENT_ID = CEO_AGENTS[COMPANIES["licensing"]]
|
CEO_AGENT_ID = CEO_AGENTS[COMPANIES["licensing"]]
|
||||||
|
|
||||||
|
# Knowledge Curator (Hermes) agent per company — woken after a case is
|
||||||
|
# marked final, to analyze the signed decision and propose updates to
|
||||||
|
# the style guide / lessons. POC stage 1.
|
||||||
|
CURATOR_AGENTS = {
|
||||||
|
COMPANIES["licensing"]: "60dce831-5c5b-4bae-bda9-5282d506f0dc", # CMP curator
|
||||||
|
COMPANIES["betterment"]: "d6f7c55d-570a-46b8-8d72-1286d07da0d8", # CMPA curator
|
||||||
|
}
|
||||||
|
|
||||||
# Fallback mapping — used only when DB lookup returns no results
|
# Fallback mapping — used only when DB lookup returns no results
|
||||||
_FALLBACK_APPEAL_TYPE_TO_COMPANY = {
|
_FALLBACK_APPEAL_TYPE_TO_COMPANY = {
|
||||||
"רישוי": COMPANIES["licensing"],
|
"רישוי": COMPANIES["licensing"],
|
||||||
@@ -898,3 +906,92 @@ async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "")
|
|||||||
result = resp.json()
|
result = resp.json()
|
||||||
logger.info("CEO agent wakeup for case %s: %s", case_number, result)
|
logger.info("CEO agent wakeup for case %s: %s", case_number, result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def wake_curator_for_final(
|
||||||
|
case_number: str,
|
||||||
|
final_filename: str,
|
||||||
|
company_id: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Wake the Knowledge Curator (Hermes) when a case is marked final.
|
||||||
|
|
||||||
|
Creates a child issue under the main case issue, assigns it to the
|
||||||
|
curator, and triggers wakeup. Best-effort — silently skips if no
|
||||||
|
curator is configured for the company or no main issue is found.
|
||||||
|
|
||||||
|
Returns ``{"status": "ok"|"skipped", ...}``.
|
||||||
|
"""
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping curator wakeup")
|
||||||
|
return {"status": "skipped", "reason": "no_api_key"}
|
||||||
|
|
||||||
|
curator_id = CURATOR_AGENTS.get(company_id)
|
||||||
|
if not curator_id:
|
||||||
|
logger.info("No curator configured for company %s — skipping", company_id)
|
||||||
|
return {"status": "skipped", "reason": "no_curator", "company_id": company_id}
|
||||||
|
|
||||||
|
issues = await get_case_issues(case_number)
|
||||||
|
if not issues:
|
||||||
|
logger.warning("No Paperclip issues found for case %s — skipping curator", case_number)
|
||||||
|
return {"status": "skipped", "reason": "no_issue"}
|
||||||
|
|
||||||
|
main_issue = next((i for i in issues if i.get("status") == "in_progress"), None) or issues[0]
|
||||||
|
main_issue_id = main_issue["id"]
|
||||||
|
|
||||||
|
description = (
|
||||||
|
f"דפנה סימנה את ההחלטה הסופית של תיק {case_number} כסופית.\n"
|
||||||
|
f"קובץ סופי: `{final_filename}`\n\n"
|
||||||
|
f"סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.\n"
|
||||||
|
f"חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, "
|
||||||
|
f"ממוספר. עדכן את MEMORY.md שלך. סגור את ה-issue (status=done)."
|
||||||
|
)
|
||||||
|
child_resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/issues/{main_issue_id}/children",
|
||||||
|
json={
|
||||||
|
"title": f"[ערר {case_number}] סקירת ידע — Knowledge Curator",
|
||||||
|
"description": description,
|
||||||
|
"status": "in_progress",
|
||||||
|
"priority": "low",
|
||||||
|
"assigneeAgentId": curator_id,
|
||||||
|
},
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
sub_issue = child_resp.json()
|
||||||
|
sub_issue_id = sub_issue["id"]
|
||||||
|
|
||||||
|
# Tag plugin_state for case-number visibility on the case page
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
await _link_case_to_issue(conn, sub_issue_id, case_number)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("plugin_state link failed for sub_issue=%s: %s", sub_issue_id, e)
|
||||||
|
|
||||||
|
# Trigger wakeup (use API per Paperclip rule — never DB insert)
|
||||||
|
wake_resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/agents/{curator_id}/wakeup",
|
||||||
|
json={
|
||||||
|
"source": "on_demand",
|
||||||
|
"triggerDetail": "manual",
|
||||||
|
"reason": f"final_marked_{case_number}",
|
||||||
|
"payload": {
|
||||||
|
"issueId": sub_issue_id,
|
||||||
|
"mutation": "assignment",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Curator wakeup for case %s: sub_issue=%s curator=%s wake=%s",
|
||||||
|
case_number, sub_issue_id, curator_id, wake_resp.status_code,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"sub_issue_id": sub_issue_id,
|
||||||
|
"curator_id": curator_id,
|
||||||
|
"main_issue_id": main_issue_id,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user