Compare commits
40 Commits
worktree-a
...
worktree-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c870ac691 | |||
| ff3a2f398c | |||
| 5a2a989e9b | |||
| df60636876 | |||
| d65c335a4a | |||
| dc203c77eb | |||
| 180f70bcc0 | |||
| 1544669c7a | |||
| 63387c0d7d | |||
| 70c28c87ef | |||
| 2f50a8cc79 | |||
| 079a489f0e | |||
| 720057bc72 | |||
| d8bb2c7c0c | |||
| 5f5b13c6a4 | |||
| b394177207 | |||
| 3f48dc0e11 | |||
| c218864f36 | |||
| c22935c008 | |||
| 58a2f33187 | |||
| 363485d025 | |||
| eebb193fd3 | |||
| 6aac4a73f6 | |||
| 81ea43a70a | |||
| b8f6226fb1 | |||
| 20a1da0a0e | |||
| 90de9831c3 | |||
| f91cb2a660 | |||
| bf9559c61a | |||
| fbdbc64366 | |||
| 08419e4434 | |||
| 2ebaa82f85 | |||
| 79d9ea55d8 | |||
| 86e66cc5bd | |||
| 42ea1a7c58 | |||
| 10e05700cc | |||
| 11acdac337 | |||
| 4eb3312e9b | |||
| 574998021e | |||
| cc4d757fce |
@@ -15,8 +15,12 @@ tools:
|
||||
- mcp__legal-ai__document_get_text
|
||||
- mcp__legal-ai__extract_claims
|
||||
- mcp__legal-ai__extract_appraiser_facts
|
||||
- mcp__legal-ai__get_appraiser_facts
|
||||
- mcp__legal-ai__get_claims
|
||||
- mcp__legal-ai__aggregate_claims_to_arguments
|
||||
- mcp__legal-ai__get_legal_arguments
|
||||
- mcp__legal-ai__analyze_protocol
|
||||
- mcp__legal-ai__get_protocol_analysis
|
||||
- mcp__legal-ai__search_case_documents
|
||||
- mcp__legal-ai__search_decisions
|
||||
- mcp__legal-ai__search_precedent_library
|
||||
@@ -123,9 +127,9 @@ tools:
|
||||
4. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type ו-party_hint מתאימים)
|
||||
- **מסמך גדול (>15,000 תווים):** מאז phase 1 של מערכת הניתוח, ה-chunking הסמנטי + מקבילות + retry מטופל אוטומטית. גם מסמך של 100K+ תווים ירוץ עד הסוף. אם בכל זאת נכשל — דווח ב-issue.
|
||||
- **טיפול בכשל:** אם `extract_claims` החזיר `partial=true` או 0 טענות ממסמך לא ריק — נסה שוב פעם אחת. אם עדיין נכשל — סטטוס issue = `blocked`, פרסם comment עם הפירוט.
|
||||
5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.**
|
||||
5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.** מיד אחריו הרץ `mcp__legal-ai__get_appraiser_facts(case_number)` כדי **לקרוא בחזרה** את מה שנשמר ולוודא שהחילוץ אכן נחת — אל תדווח על חילוץ שלא אימתת בקריאה.
|
||||
6. וודא שכל פריט מסווג ל-claim_type הנכון
|
||||
7. **קבץ טענות לטיעונים משפטיים** — לאחר שכל הטענות חולצו וסוּוגו, הרץ `aggregate_claims_to_arguments(case_number)` שמקבץ את הפרופוזיציות הגולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד). זהו קלט מובנה לבלוק ז (טענות הצדדים) ולבלוק י (דיון) — הכותב נשען עליו. אם 0 טענות חולצו — דלג. הפלט עובר שער-אישור (ראה `get_legal_arguments`).
|
||||
7. **קבץ טענות לטיעונים משפטיים** — לאחר שכל הטענות חולצו וסוּוגו, הרץ `aggregate_claims_to_arguments(case_number)` שמקבץ את הפרופוזיציות הגולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד). זהו קלט מובנה לבלוק ז (טענות הצדדים) ולבלוק י (דיון) — הכותב נשען עליו. אם 0 טענות חולצו — דלג. הפלט עובר שער-אישור — קרא אותו בחזרה עם `mcp__legal-ai__get_legal_arguments(case_number)` ואמת שמספר הטיעונים לכל צד סביר לפני שאתה ממשיך.
|
||||
|
||||
### שלב 2: ניתוח מעמיק
|
||||
הצג במבנה הבא:
|
||||
@@ -143,6 +147,8 @@ tools:
|
||||
**טענות סף** (אם קיימות):
|
||||
חוסר סמכות, שיהוי, התיישנות, אי-מיצוי הליכים, חוסר יריבות, מעשה בית דין — הצג כל אחת עם עמדת שני הצדדים. לכל טענת סף הוסף **עמדת ועדת הערר** (שדה ריק ליו"ר). אם אין — כתוב: "לא זוהו טענות סף."
|
||||
|
||||
**מבנה השדות — זהה לזה של הסוגיות:** כל טענת סף היא H3 משלה, והשדות בתוכה נכתבים בתחילת שורה (`**טענה (claim):**`) — **לא** כפריטי רשימה (`- **טענה (claim):**`) ולא בכותרת מעוטרת. השדה `**עמדת ועדת הערר:**` נכתב בשורה נפרדת והערך מתחתיו. זהו השדה שדפנה עורכת ישירות מה-UI — סטייה מהפורמט מסתירה אותו ואת יתר השדות של אותה טענה מהמסך.
|
||||
|
||||
**תקן ביקורת**: ציין את תקן הביקורת של הוועדה בתיק זה — "הוועדה מפעילה שיקול דעת תכנוני עצמאי" (ברישוי) או "הוועדה בוחנת את תקינות השומה המכרעת" (בהיטל השבחה) או תקן אחר לפי סוג ההליך.
|
||||
|
||||
**מפת דרכים**: לאחר זיהוי טענות הסף ולפני הדיון בסוגיות — כתוב פסקת מפה: "X שאלות עומדות להכרעה: (1)...; (2)...; (3)..." — כדי שהקורא ידע מראש מה לצפות.
|
||||
@@ -268,6 +274,29 @@ search_precedent_library(
|
||||
|
||||
**מינימום:** מספר queries ב-Q1+Q2+Q3 לקורפוס הסמכותי = מספר טענות סף + מספר סוגיות מרכזיות. אם זיהית 5 סוגיות + 2 טענות סף → לפחות 7 queries.
|
||||
|
||||
## משימה על-פי-דרישה: ניתוח פרוטוקול-הדיון
|
||||
|
||||
**זו אינה חלק מהזרימה הרגילה** — היא מגיעה כ-issue ייעודי כשחיים לוחץ "נתח את פרוטוקול הדיון" בפאנל **"מה קרה בדיון"** (טאב טיעונים-ועמדות). ה-issue יאמר במפורש להריץ `analyze_protocol`.
|
||||
|
||||
```
|
||||
mcp__legal-ai__analyze_protocol(case_number="<מספר-התיק>")
|
||||
mcp__legal-ai__analyze_protocol(case_number="<מספר-התיק>", document_id="<uuid>") # כשיש כמה פרוטוקולים
|
||||
```
|
||||
|
||||
הכלי משווה את **פרוטוקול דיון ועדת-הערר** מול **הטיעונים המאוגדים**, ומסווג כל שינוי ל-`strengthened` / `newly_raised` / `dropped`, עם `evidence_quote` מהפרוטוקול לכל שורה (INV-AH — אין שורה בלי ציטוט).
|
||||
|
||||
**שני תנאים מוקדמים — בדוק אותם לפני שאתה מריץ:**
|
||||
1. **פרוטוקול של ועדת-הערר.** הכלי בוחר אוטומטית פרוטוקול שה-`protocol_scope` שלו אינו `lower`; פרוטוקול של הוועדה **המקומית** מזין רקע (בלוק ו) בלבד ולא מוביל את ההשוואה. אם כל הפרוטוקולים בתיק הם `lower` — אין דיון-ערר להשוות אליו.
|
||||
2. **טיעונים מאוגדים.** הרץ `get_legal_arguments(case_number)`. אם ריק — הרץ קודם `aggregate_claims_to_arguments`.
|
||||
|
||||
אם תנאי חסר — **אל תעקוף**: כתוב comment בעברית שמפרט מה חסר, וסגור `blocked`.
|
||||
|
||||
`re-run` מחליף את הניתוח הקודם (idempotent). לקריאה בלבד, בלי ניתוח מחדש: `mcp__legal-ai__get_protocol_analysis(case_number, change_type="")`.
|
||||
|
||||
**דווח ב-comment בעברית**: כמה טענות התחזקו, כמה נטענו לראשונה, כמה ירדו — ומה החידוד המרכזי שעלה בדיון.
|
||||
|
||||
> ⚠️ **הרץ את הכלי בקדמה — לעולם לא ברקע.** שיגור ל-background וסיום התור מסיים את ה-run, וקבוצת-התהליכים נהרגת יחד איתו: הניתוח נקטע ולא נשמר דבר (נצפה ב-CMP-229, 2026-08-04). הכלי לוקח כמה דקות; זה תקין. חכה לו.
|
||||
|
||||
## שלב 6: בדיקת שלמות — לפני שמסיימים!
|
||||
|
||||
**לפני סיום, בצע את הבדיקות הבאות. אם בדיקה נכשלת — אל תסיים כ-"done".**
|
||||
@@ -347,7 +376,18 @@ wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח מ
|
||||
1. ...
|
||||
|
||||
## 5. טענות סף
|
||||
[אם קיימות — כולל שאלות משפטיות + עמדת ועדת הערר לכל טענה]
|
||||
[אם אין — "לא זוהו טענות סף." אם יש — טענה אחת לכל H3, במבנה הזה:]
|
||||
|
||||
### ס-1 — [כותרת הטענה] ([מי מעלה])
|
||||
|
||||
**טענה (claim):** ...
|
||||
**תשובה (response):** ...
|
||||
**שאלה משפטית:** ...
|
||||
|
||||
**עמדת ועדת הערר:**
|
||||
[ימולא ע"י יו"ר הוועדה]
|
||||
|
||||
### ס-2 — ...
|
||||
|
||||
**תקן ביקורת:** [שיקול דעת עצמאי / בחינת תקינות השומה / אחר]
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ tools:
|
||||
- mcp__legal-ai__halacha_corroboration
|
||||
- mcp__legal-ai__corroboration_rebuild
|
||||
- mcp__legal-ai__extract_appraiser_facts
|
||||
- mcp__legal-ai__get_appraiser_facts
|
||||
- mcp__legal-ai__extract_plans
|
||||
- mcp__legal-ai__plan_get
|
||||
- mcp__legal-ai__plan_search
|
||||
@@ -704,6 +705,8 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
```
|
||||
⚠️ אם מחזיר `status="sides_missing"` → דווח לחיים שאין תיוג `appraiser_side` במסמכי השומה (`document_update` עם `appraiser_side` בערכים `committee`/`appellant`/`deciding`). עצור עד שיתוקן.
|
||||
|
||||
אחרי החילוץ — הרץ `mcp__legal-ai__get_appraiser_facts(case_number="...")` כדי **לקרוא בחזרה** ולוודא שהעובדות אכן נשמרו. חילוץ שדיווח הצלחה אך לא נקרא בחזרה אינו ראיה שהנתונים שם.
|
||||
|
||||
אם הטבלה כבר מלאה — `write_interim_draft` ידלג על ההרצה אוטומטית, אז גם בלי הצעד הזה זה יעבוד.
|
||||
|
||||
3. **כתיבת 5 הבלוקים:**
|
||||
@@ -820,45 +823,38 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
|
||||
---
|
||||
|
||||
**תבנית issue לכותב ההחלטה — חובה בכל issue שמוקצה לכותב:**
|
||||
**מסמך-ההכוונה לכותב — הפק את התדריך שהיית רוצה לקבל, לא טופס למילוי:**
|
||||
|
||||
כל issue לכותב חייב לכלול את **כל** הסעיפים הבאים. אסור לשלוח issue עם משפט כמו "הועבר לכתיבה" — זה חסר תועלת. הכותב צריך הכל מוכן מראש.
|
||||
כשאתה מעביר תיק לכותב אתה מבצע את **פעולת-ההיסק המרכזית שלך**: להמיר את ניתוח-המנתח + הכרעות-היו"ר למסמך שמאפשר לכותב לנסח החלטה חדה בסגנון דפנה **בלי לחזור אליך**. אל תמלא טופס — הפעל שיפוט משפטי. תדריך טוב:
|
||||
|
||||
- **מוביל בהכרעה ובסוגיה המכריעה** — קבע איזו סוגיה נושאת את התוצאה ומה מייתר את מה, והצב אותה ראשונה.
|
||||
- **בונה כל סוגיה כסילוגיזם** (כלל → עובדות → מסקנה) עם התקדים והמסמך הספציפיים.
|
||||
- **מזהה אדנים עצמאיים** — אם יותר מנימוק אחד מספיק לבדו לתוצאה, אמור זאת מפורשות, כך שנפילת אדן בערעור לא תפיל את ההחלטה.
|
||||
- **בודק עקביות פנימית** — אם שתי הכרעות עלולות להיראות סותרות (למשל דחיית טענה פרשנית אחת וקבלת אחרת), סמן את המתח והסבר את האבחנה לפני שעורך-דין יטען לו.
|
||||
- **עונה לנקודה החזקה של הצד המפסיד** — לא מתעלם ממנה.
|
||||
- **משקלל את הכרעות-היו"ר** ומעביר אותן מילולית.
|
||||
|
||||
**מה התדריך חייב להכיל** (החוזה מול הכותב — אל תשמיט אף רכיב; אל תשלח issue עם "הועבר לכתיבה"):
|
||||
|
||||
```markdown
|
||||
## הנחיות כתיבה — ערר {case_number}
|
||||
|
||||
### 1. תוצאה ומצב
|
||||
- **תוצאה:** {דחייה / קבלה חלקית / קבלה מלאה}
|
||||
- **טיוטה קיימת:** {כן/לא}. אם כן: נתיב מלא לקובץ + הנחיה "קרא את הטיוטה, השתמש בה כבסיס, אל תכתוב מאפס"
|
||||
- **הוראות עריכה מתוך הטיוטה:** {רשימה מדויקת של מה חיים ביקש לשנות — פסקאות, תוכן, placeholders}
|
||||
- **תוצאה:** {דחייה / קבלה חלקית / קבלה מלאה} — עם נימוק קצר ומהי הראיה הניצחת.
|
||||
- **טיוטה קיימת:** {כן/לא}. אם כן: נתיב מלא + "קרא, השתמש כבסיס, אל תכתוב מאפס".
|
||||
- **הוראות עריכה מהטיוטה:** {מה חיים ביקש לשנות — פסקאות, תוכן, placeholders}.
|
||||
|
||||
### 2. סדר סוגיות + מבנה סילוגיסטי
|
||||
לכל סוגיה שצריך לכתוב/לערוך — מבנה סילוגיסטי מלא:
|
||||
|
||||
**סוגיה N: {כותרת}**
|
||||
- סוג ניתוח: {כלל ברור / איזון אינטרסים / מידתיות / שיקול דעת}
|
||||
- כלל (הנחה עליונה): {הוראת תכנית / סעיף חוק / הלכה — ציטוט מדויק}
|
||||
- עובדות (הנחה תחתונה): {העובדות הספציפיות שצריך להחיל — הפנייה למסמך מקור ספציפי}
|
||||
- מסקנה: {מה נובע מהחלת הכלל על העובדות}
|
||||
- תקדימים: {שם פסק דין + מה הוא קובע + למה רלוונטי}
|
||||
- מסמכי מקור: {שמות קבצים ספציפיים ב-data/cases/{case_number}/documents/originals/}
|
||||
### 2. סוגיות — סדר סילוגיסטי, המכריעה מובילה
|
||||
לכל סוגיה: סוג-ניתוח (כלל ברור / איזון / מידתיות / שיקול-דעת) · כלל (ציטוט מדויק של הוראת-תכנית/חוק/הלכה) · עובדות (בהפניה למסמך-מקור ספציפי) · מסקנה · תקדימים (שם + מה קובע + רלוונטיות) · מסמכי-מקור (ב-data/cases/{case_number}/documents/originals/). סמן אדנים עצמאיים, מוקשי-עקביות ומענה לצד המפסיד היכן שהם קיימים.
|
||||
|
||||
### 3. טיפול בטענות
|
||||
| # | טענה | טיפול | סוגיה |
|
||||
|---|------|-------|-------|
|
||||
| 1 | {טענה} | דיון מלא / קיבוץ / דילוג | {באיזו סוגיה} |
|
||||
...
|
||||
טבלה: # | טענה | טיפול (דיון מלא / קיבוץ / דילוג) | סוגיה.
|
||||
|
||||
### 4. chair directions
|
||||
- העתק מלא של עמדות הוועדה מ-analysis-and-research.md (או הפנייה: "קרא get_chair_directions").
|
||||
- **עטוף את ההעתק המילולי בתגית `<chair_directions>…</chair_directions>`** — כך הכותב מבחין בין הוראות-היו"ר המילוליות לבין הערות ה-CEO, ואינו דורס אותן. בתוך התגית: טקסט מילולי בלבד, בלי פרפרזה.
|
||||
### 4. הנחיות-היו"ר
|
||||
העתק מילולי של עמדות-הוועדה מ-analysis-and-research.md (או "קרא get_chair_directions"), **עטוף ב-`<chair_directions>…</chair_directions>`** — טקסט מילולי בלבד בלי פרפרזה, כדי שהכותב לא ידרוס אותן.
|
||||
|
||||
### 5. הנחיות סגנון
|
||||
- ניטרליות: בלוק ו = עובדות בלבד, בלי ציטוטים מצדדים
|
||||
- ללא כפילות: בלוק י מפנה לבלוקים קודמים
|
||||
- טענות מקוריות: בלוק ז = כתבי טענות מקוריים
|
||||
- אורך מינימלי לדיון: 1,500 מילים לבלוק י
|
||||
- פסיקה: חובה לצטט לפחות 3 תקדימים בדיון
|
||||
ניטרליות (בלוק ו = עובדות בלבד, בלי ציטוטי-צדדים) · ללא כפילות (בלוק י מפנה לקודמים) · טענות מקוריות (בלוק ז) · דיון ≥ 1,500 מילים · ≥ 3 תקדימים בדיון.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -17,6 +17,7 @@ tools:
|
||||
- mcp__legal-ai__search_precedent_library
|
||||
- mcp__legal-ai__search_internal_decisions
|
||||
- mcp__legal-ai__precedent_library_get
|
||||
- mcp__legal-ai__precedent_library_list
|
||||
- mcp__legal-ai__precedent_list
|
||||
- mcp__legal-ai__halacha_review
|
||||
---
|
||||
|
||||
25
.gitea/workflows/agent-tool-grants.yaml
Normal file
25
.gitea/workflows/agent-tool-grants.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: INV-AG3 Agent Tool Grants
|
||||
|
||||
# Hard gate for INV-AG3 (docs/spec/X4-agents.md §2א): a subagent's `tools:`
|
||||
# frontmatter is a CLOSED allow-list, so any MCP tool an agent is TOLD to run —
|
||||
# by the backend delegation in web/, or by its own instructions — must appear
|
||||
# there. Built after analyze_protocol shipped without a grant (2026-06-30) and
|
||||
# the analyst was handed an issue instructing it to run a tool it could not
|
||||
# call (CMP-229, 2026-08-04). Pure-stdlib check (no venv) — fast, runs on every
|
||||
# PR and on push to main.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
agent-tool-grants:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: INV-AG3 — agent tool-grant guard
|
||||
run: python3 scripts/agent_tool_grants_guard.py
|
||||
40
CLAUDE.md
40
CLAUDE.md
@@ -1,6 +1,6 @@
|
||||
# עוזר משפטי — Legal Decision Assistant
|
||||
|
||||
> **אינדקס דק.** הכללים הקריטיים נמצאים כאן; העומק התפעולי (Deploy, Paperclip-ops, adapters, מבנה-תיקיות, Chair-Feedback, TaskMaster מלא) הוצא ל-[`docs/operations-runbook.md`](docs/operations-runbook.md) כדי לרזות את ההקשר הנטען בכל סשן.
|
||||
> **אינדקס דק.** הכללים הקריטיים נמצאים כאן; העומק התפעולי (Deploy, Paperclip-ops, adapters, מבנה-תיקיות, Chair-Feedback, עבודה מול Gitea Issues) הוצא ל-[`docs/operations-runbook.md`](docs/operations-runbook.md) כדי לרזות את ההקשר הנטען בכל סשן.
|
||||
|
||||
## רקע הפרויקט
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
| [`.claude/agents/HEARTBEAT.md`](.claude/agents/HEARTBEAT.md) | checklist הפעלת סוכן — routing, company filtering, quirks, wakeup עם UUID נכון | **לפני כל עבודה על סוכנים** |
|
||||
| [`skills/dafna-decision-template/SKILL.md`](skills/dafna-decision-template/SKILL.md) | export DOCX לפי styles של תבנית Word של דפנה — line classification, dash policy, placeholder handling | לפני export DOCX |
|
||||
| [`docs/corpus-graph.md`](docs/corpus-graph.md) | **מפת הקורפוס** (`/graph`) — גרף ציטוטים אינטראקטיבי נייטיב; 6 שכבות (פסיקה/נושא/תחום/הלכות/חוסרי‑מחקר/יומונים), אנליטיקה (PageRank/אשכולות), endpoints, ואיך מוסיפים שכבה | לפני עבודה על דף `/graph` או `web/graph_api.py` |
|
||||
| [`docs/operations-runbook.md`](docs/operations-runbook.md) | **עומק תפעולי** — Deploy (Coolify/pm2), Paperclip-ops מלא (wakeup, sync, webhook, scheduled jobs, adapters), מבנה-תיקיות, Chair-Feedback, TaskMaster | לפני עבודה על Deploy / אינטגרציית-Paperclip / adapters |
|
||||
| [`docs/taskmaster-to-gitea-migration.md`](docs/taskmaster-to-gitea-migration.md) | **מיגרציית ניהול-המשימות** — TaskMaster→Gitea Issues (2026-08-05), טבלת מיפוי מלאה, ומלכודת המספור בהפניות `(#N)` ישנות | כשנתקלים בהפניה `(#N)` בקומיט ישן, או כשמחפשים משימה שהייתה ב-TaskMaster |
|
||||
| [`docs/operations-runbook.md`](docs/operations-runbook.md) | **עומק תפעולי** — Deploy (Coolify/pm2), Paperclip-ops מלא (wakeup, sync, webhook, scheduled jobs, adapters), מבנה-תיקיות, Chair-Feedback, Gitea Issues API | לפני עבודה על Deploy / אינטגרציית-Paperclip / adapters |
|
||||
|
||||
---
|
||||
|
||||
@@ -114,7 +115,7 @@ cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד
|
||||
6. **אל תיגע** בשינויים לא-מתויקים שאינם שלך בעץ הראשי — הם של סשן אחר. אם העץ הראשי על ענף זר — אל תתייק עליו.
|
||||
|
||||
> **בידוד-DB:** ה-worktree מבודד-קבצים בלבד — לא בידוד-repo ולא בידוד-DB. **אל תריץ migrations מ-2 worktrees במקביל** על Postgres המשותף (`localhost:5433`) — סכמה שאף סשן לא מצפה לה ([Run agents in parallel](https://code.claude.com/docs/en/agents)).
|
||||
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w`. כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. ניתוח מלא: TaskMaster `legal-ai` #104 (נסגר cancelled — "לתעד, לא לבדד").
|
||||
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w`. כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. ניתוח מלא: ארכיון TaskMaster `legal-ai` #104 (נסגר cancelled — "לתעד, לא לבדד").
|
||||
|
||||
---
|
||||
|
||||
@@ -144,9 +145,36 @@ cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד
|
||||
## כלל: עדכון `scripts/SCRIPTS.md`
|
||||
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/` — **חובה לעדכן את `scripts/SCRIPTS.md`** (תפקיד, סטטוס, החלפה).
|
||||
|
||||
## ניהול משימות — TaskMaster AI
|
||||
**תמיד** TaskMaster (לא TASKS.md ידני). קובץ קנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (tags: `master`, `legal-ai`). פקודות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`.
|
||||
> **⚠️ מלכוד cwd ב-CLI:** `--tag` בוחר קבוצה *בתוך* הקובץ — לא לאיזה קובץ לכתוב (ה-CLI מאתר לפי cwd). תמיד `cd ~/legal-ai` לפני כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את הקובץ ישירות. פירוט: [`docs/operations-runbook.md`](docs/operations-runbook.md).
|
||||
## ניהול משימות — Gitea Issues ⚠️
|
||||
|
||||
> **מקור-האמת לעבודה הוא [Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues) — לא TaskMaster, לא הקובץ הזה, לא זיכרון.**
|
||||
> **TaskMaster הוצא משימוש ב-2026-08-05.** 26 המשימות הפתוחות הועברו ל-issues #433–#458 (מיפוי מלא: [`docs/taskmaster-to-gitea-migration.md`](docs/taskmaster-to-gitea-migration.md)). `.taskmaster/tasks/tasks.json` נשמר לקריאה-בלבד כארכיון היסטורי — **אין לכתוב אליו ואין ליצור בו משימות חדשות.**
|
||||
|
||||
המוסכמות מגיעות מ-[`infra/project-template`](https://gitea.marcus-law.co.il/infra/project-template) — אותה מערכת תיוג ואותו workflow כמו שאר פרויקטי המשרד.
|
||||
|
||||
### Session-Start Protocol
|
||||
כשנפתח סשן עם "איפה אנחנו עומדים / מה נשאר / בוא נמשיך" — **אל תשחזר מצב מזיכרון.** קרא את ה-issues החיים קודם:
|
||||
1. `GET /api/v1/repos/ezer-mishpati/legal-ai/issues?state=open` (טוקן מ-`~/.git-credentials` או Infisical `/apps/legal-ai` `GITEA_TOKEN`).
|
||||
2. הבא בתור = ה-issue בעל העדיפות הגבוהה ביותר (`p0-blocker` → `p1-high` → `p2-normal` → `p3-low`; בתוך עדיפות — `type:bug` קודם).
|
||||
3. **דלג** על `status:in-progress` (מישהו עובד), `status:in-review` (יש PR), `status:needs-decision` (ממתין להכרעת חיים/דפנה), `status:blocked`, `status:deferred`.
|
||||
4. רק **אחרי** קריאת ה-issues — הצע את הצעד הבא.
|
||||
|
||||
### Issue-First
|
||||
- **שום קוד בלי issue.** כל תקלה/צורך/משימה → issue קודם.
|
||||
- כל issue חייב: **What & Why** + **Acceptance Criteria** + הקשר. issue ללא AC אינו `status:ready` ואינו נלקח.
|
||||
- **Branch naming:** `<type>/<issue>-<slug>` (`feat`/`fix`/`chore`/`refactor`/`docs`/`test`) — יצירת branch = בלעדיות על ה-issue.
|
||||
- ה-PR מפנה ל-issue (`Closes #N`) ומצהיר invariants (ראה [`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md)).
|
||||
|
||||
### מערכת התיוג
|
||||
| שכבה | ערכים |
|
||||
|------|-------|
|
||||
| `type:` | `bug` · `feature` · `chore` · `spike` |
|
||||
| `priority:` | `p0-blocker` · `p1-high` · `p2-normal` · `p3-low` |
|
||||
| `status:` | `ready` · `in-progress` · `in-review` · `needs-decision` · `blocked` · `deferred` · `good-first-issue` |
|
||||
| `size:` | `xs` (<חצי יום) · `s` (~יום) · `m` (2-3 ימים) · `l` (שבוע+ — לשקול פיצול) |
|
||||
| `area:` | `agents` · `retrieval` · `extraction` · `writing` · `backend` · `frontend` · `mcp` · `infra` · `docs` · `ci` · `security` |
|
||||
|
||||
> ⚠️ **הפניות `(#N)` בהודעות-commit מלפני 2026-08-05 מתייחסות למזהי TaskMaster, לא למספרי issue.** מספרי ה-issue של legal-ai מתחילים ב-#433; מתחת לזה המספרים שייכים ל-PRs ולשני issues ישנים.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
|
||||
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |
|
||||
| legal-chat-service | גשר claude CLI לטאב הצ'אט ב-/training (pm2, loopback) | `127.0.0.1:8770` |
|
||||
| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` |
|
||||
| Infisical | ניהול סודות — פרויקט **All Infrastructure** (`2c462576-b125-4279-b0ec-7220dbf51ccf`), env `main`. סודות המערכת מפוזרים על שלוש תיקיות-אחיות: `/apps/legal-ai` · `/apps/paperclip` · `/apps/paperclip-hermes` | `secret.marcus-law.co.il` |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,9 +195,33 @@ CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **
|
||||
|
||||
---
|
||||
|
||||
## ניהול משימות — TaskMaster AI (פירוט)
|
||||
## ניהול משימות — Gitea Issues (פירוט)
|
||||
|
||||
- קובץ המשימות הקנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (יחסי ל-project root, **לא** `~/.taskmaster/tasks/tasks.json`). מכיל את כל ה-tags של legal-ai (`master`, `legal-ai`).
|
||||
- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`
|
||||
- לפני התחלת עבודה → `next_task`; אחרי סיום → `update_task` עם status=done; משימה מורכבת → `expand_task`
|
||||
- **⚠️ מלכוד cwd ב-CLI:** הדגל `--tag` בוחר קבוצה לוגית *בתוך* הקובץ — הוא **לא** בוחר לאיזה `tasks.json` לכתוב. ה-CLI מאתר את הקובץ לפי ה-cwd. תמיד `cd ~/legal-ai` לפני `task-master add-task` או כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את `~/legal-ai/.taskmaster/tasks/tasks.json` ישירות.
|
||||
> **TaskMaster AI הוצא משימוש ב-2026-08-05.** מקור-האמת לעבודה הוא
|
||||
> [Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues).
|
||||
> ה-workflow, מערכת התיוג ו-Session-Start Protocol מתועדים ב-[`CLAUDE.md`](../CLAUDE.md);
|
||||
> טבלת המיפוי TaskMaster→issue ב-[`taskmaster-to-gitea-migration.md`](taskmaster-to-gitea-migration.md).
|
||||
|
||||
### עבודה מול ה-API
|
||||
הטוקן: `~/.git-credentials` (שורת `gitea.nautilus`) או Infisical `/apps/legal-ai` `GITEA_TOKEN`.
|
||||
|
||||
```bash
|
||||
API=https://gitea.nautilus.marcusgroup.org/api/v1/repos/ezer-mishpati/legal-ai
|
||||
T=$(grep -m1 'gitea.nautilus' ~/.git-credentials | sed 's|.*://[^:]*:\([^@]*\)@.*|\1|')
|
||||
|
||||
# מה הבא בתור (דילוג על in-progress/in-review/blocked/deferred/needs-decision)
|
||||
curl -s -H "Authorization: token $T" "$API/issues?state=open&labels=priority:p1-high"
|
||||
|
||||
# issue חדש
|
||||
curl -s -X POST -H "Authorization: token $T" -H "Content-Type: application/json" \
|
||||
"$API/issues" -d '{"title":"...","body":"## What & Why\n...\n## Acceptance Criteria\n...","labels":[<ids>]}'
|
||||
|
||||
# תוויות (id ← שם)
|
||||
curl -s -H "Authorization: token $T" "$API/labels?limit=100"
|
||||
```
|
||||
|
||||
### ארכיון TaskMaster
|
||||
`~/legal-ai/.taskmaster/tasks/tasks.json` **מוקפא לקריאה-בלבד** — 166 משימות שהושלמו,
|
||||
4 שבוטלו, ו-26 הפתוחות שהועברו. שימושי לחפירה היסטורית ("למה עשינו X"), אבל
|
||||
**אין לכתוב אליו**. אם צריך הקשר ממשימה ישנה — לחפש בקובץ הזה, ולפתוח issue חדש
|
||||
אם נדרשת עבודה.
|
||||
|
||||
@@ -146,9 +146,29 @@ another company`, [X2 §2](X2-multi-company.md)).
|
||||
**כלל:** ה-frontmatter `tools:` של כל סוכן מעניק **בדיוק** את הכלים שהוראותיו דורשות — כל כלי שההוראות
|
||||
מצריכות מוענק, וכלי שמוענק-ולא-בשימוש נבחן. מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||
(שערים מוגדרים) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים); מקביל ל-[X9 INV-TOOL6](X9-mcp-tool-contract.md).
|
||||
**מקור-סמכות:** frontmatter `tools:` מול ה-instructions בקבצי-[.claude/agents/](../../.claude/agents/). (פרויקטלי-תפעולי.)
|
||||
**אכיפה:** בדיקת-עקביות tools↔instructions (FU-13 ✅ 2026-06-06). אכיפה אוטומטית עתידית — בתת-פרויקט 5 (spec-guardian).
|
||||
**הפרה ידועה:** — (טופל ב-FU-13: legal-analyst קיבל `aggregate_claims_to_arguments`; researcher כבר היה תקין; `extract_references`/`extract_internal_citations` הם מטלת-researcher, לא analyst — ראה §2א).
|
||||
|
||||
> **ה-frontmatter הוא allow-list סגורה.** כלי הרשום בשרת-ה-MCP אך חסר מהרשימה **אינו ניתן לקריאה**
|
||||
> ע"י הסוכן — גם כשהשרת מחובר לחלוטין. הסוכן חווה זאת כ"הכלים לא נחשפים לסשן", ובלי הבנת המנגנון
|
||||
> הוא נוטה **לעקוף** (SQL ישיר, סקריפט מקומי) במקום לדווח — וזה מסלול מקביל, כלומר הפרת G2.
|
||||
> **"מורים להריץ" כולל את ה-backend:** טקסט של issue שנוצר ב-`web/` ומכיל `mcp__legal-ai__X` הוא
|
||||
> הוראה לכל דבר, ולכן מחייב הענקה.
|
||||
|
||||
**מקור-סמכות:** frontmatter `tools:` מול ה-instructions בקבצי-[.claude/agents/](../../.claude/agents/)
|
||||
**ומול הוראות-ה-backend** ב-`web/`. (פרויקטלי-תפעולי.)
|
||||
**אכיפה:** ✅ **אוטומטית מ-2026-08-04** — [`scripts/agent_tool_grants_guard.py`](../../scripts/agent_tool_grants_guard.py),
|
||||
שער-CI קשיח ([`.gitea/workflows/agent-tool-grants.yaml`](../../.gitea/workflows/agent-tool-grants.yaml)),
|
||||
בדפוס [`leak_guard.py`](../../scripts/leak_guard.py) של G12. ארבעה כללים: (1) כל `mcp__legal-ai__X`
|
||||
ב-`web/` מוענק לסוכן כלשהו · (2) כל `mcp__legal-ai__X` בגוף קובץ-סוכן מוענק **באותו** קובץ ·
|
||||
(3) אין הענקה לכלי שאינו רשום בשרת · (4) שם-כלי בגרשיים-הפוכים ללא תחילית — מוענק, או מסווג
|
||||
מפורשות ב-`CONTRASTIVE_OK` (אזכור ניגודי / מטלת-סוכן-אחר / שם-עמודה מתנגש). לא-סוכנים ולכן
|
||||
מוחרגים: `hermes-curator.md` ו-`legal-analyst-gemini-critique.md` (בלי frontmatter בכוונה —
|
||||
האדפטר שולח פרומפט גולמי) ו-`HEARTBEAT.md` (checklist משותף).
|
||||
**הפרה ידועה:** — (היסטוריה: FU-13 ✅ 2026-06-06 — `aggregate_claims_to_arguments` ל-analyst;
|
||||
`extract_references`/`extract_internal_citations` הם מטלת-researcher, ראה §2א. **הישנות 2026-08-04**
|
||||
— האכיפה הידנית לא החזיקה: `analyze_protocol` (24e3e2f, 2026-06-30) נרשם בשרת בלי הענקה, ו-#226
|
||||
הוסיף delegation שמורה למנתח להריץ אותו → CMP-229 נשרף בשתי הרצות ועקף ל-SQL ידני. נסגר יחד עם
|
||||
`get_protocol_analysis`/`get_legal_arguments`/`get_appraiser_facts` ו-`precedent_library_list` ל-QA,
|
||||
והאכיפה הועברה ל-CI כדי שלא תישען שוב על משמעת ידנית.)
|
||||
|
||||
### INV-AG4: שער שטן-מליץ — red-team לידים לא-סמכותיים תחת אישור-יו"ר
|
||||
**כלל:** אחרי שלב-הניתוח (`analysis-and-research.md` תקין) וב**לפני** הפעלת הכותב, ה-CEO מפעיל
|
||||
|
||||
61
docs/taskmaster-to-gitea-migration.md
Normal file
61
docs/taskmaster-to-gitea-migration.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# מיגרציה: TaskMaster → Gitea Issues
|
||||
|
||||
**תאריך: 2026-08-05.** ניהול המשימות של legal-ai עבר מ-TaskMaster AI ל-[Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues), לפי המוסכמות של [`infra/project-template`](https://gitea.marcus-law.co.il/infra/project-template).
|
||||
|
||||
## מה השתנה
|
||||
|
||||
- **מקור-האמת לעבודה = Gitea Issues.** לא TaskMaster, לא `CLAUDE.md`, לא זיכרון.
|
||||
- **`.taskmaster/tasks/tasks.json` הוקפא** — נשמר לקריאה-בלבד כארכיון היסטורי (166 משימות שהושלמו + 4 שבוטלו). אין לכתוב אליו ואין ליצור בו משימות חדשות.
|
||||
- **26 המשימות הפתוחות** הועברו ל-issues **#433–#458**, כל אחת עם `type` / `priority` / `area` / `status`, וגוף במבנה *What & Why → הקשר → Acceptance Criteria*.
|
||||
- **30 תוויות** נוצרו בריפו: 5 שכבות (`type`/`priority`/`status`/`size`/`area`). שכבות `type`/`priority`/`status`/`size` זהות לתבנית; שכבת `area` הותאמה לתחומי legal-ai.
|
||||
|
||||
## ⚠️ מלכודת מספור
|
||||
|
||||
ב-Gitea, issues ו-PRs חולקים מרחב-מספרים אחד. מספרי ה-issue של המיגרציה מתחילים ב-**#433** כי #1–#432 כבר תפוסים (2 issues ישנים + כל ה-PRs).
|
||||
|
||||
**לכן: כל הפניה `(#N)` בהודעת-commit מלפני 2026-08-05 מתייחסת למזהה TaskMaster — לא למספר issue.** למשל `fix(retrieval): ... (#232)` מפנה ל-TaskMaster #232, בעוד #232 ב-Gitea הוא PR ישן. הטבלה למטה היא הגשר.
|
||||
|
||||
## טבלת המיפוי
|
||||
|
||||
| TaskMaster | Gitea | תוויות | כותרת |
|
||||
|---|---|---|---|
|
||||
| #14 | [#433](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/433) | type:chore · priority:p3-low · status:deferred | Upgrade: speed up halacha+metadata extraction |
|
||||
| #116 | [#434](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/434) | type:chore · priority:p3-low · status:deferred | P2 (אופציונלי) — resume תוך-פאנל: פירוק הפאנל ל-map per-item |
|
||||
| #121 | [#435](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/435) | type:spike · priority:p1-high · status:needs-decision | מיפוי כל שערי-האישור-האנושי (חיים/דפנה) והכרעה פר-מקום: להחליף ב"קונצנזוס תלת-מודלי ללא-אדם"? |
|
||||
| #124 | [#436](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/436) | type:feature · priority:p3-low · status:ready | קטלוג עלון חודשי "עו"ד על נדל"ן" — פיצול ל-N רשומות digest (design-open) |
|
||||
| #125 | [#437](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/437) | type:chore · priority:p1-high · status:in-progress | אופרציונליזציה של לולאת רכישת-הסגנון — סגירת ממצאי אודיט #122 |
|
||||
| #126 | [#438](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/438) | type:chore · priority:p2-normal · status:in-progress | ניקוי persona "Hermes" → Curator + כיבוי self-learning אינרטי בפרופילי ה-curator |
|
||||
| #128 | [#439](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/439) | type:chore · priority:p2-normal · status:ready | X14 finalize: ניקוי-דיסק אחרי soak + replication off-site מתוזמן (SNSD) |
|
||||
| #129 | [#440](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/440) | type:chore · priority:p2-normal · status:ready | INV-STG1 Phase 2: read-wire ה-pipeline ל-ensure_local → כתיבת-בלוב ל-S3 בלבד (ללא דיסק) |
|
||||
| #147 | [#441](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/441) | type:bug · priority:p3-low | חילוץ-הלכות: אטומיות-מלאה ל-force re-extract (extract-then-swap / staging_run_id) |
|
||||
| #150 | [#442](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/442) | type:feature · priority:p2-normal | משיכת פרטי-תב"ע אוטומטית למרשם-התכניות (V38) + עיון בתכניות-מאושרות |
|
||||
| #154 | [#443](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/443) | type:feature · priority:p1-high | X11 Phase 2 — חיווט סמכות-תקדים מודעת-טיפול לסוכני-המחקר |
|
||||
| #155 | [#444](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/444) | type:bug · priority:p1-high | באג עמידות: ריצת-heartbeat כפולה על אותו issue דורסת טיוטת-קובץ תוך כדי כתיבה |
|
||||
| #163 | [#445](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/445) | type:bug · priority:p2-normal | חקירת אנומליית chunking — פסיקות עליון ארוכות מקבלות chunk יחיד → 0 הלכות |
|
||||
| #209 | [#446](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/446) | type:bug · priority:p3-low | Plugin status-sync מכוון ל-issue-ילד ומחזיר done→in_progress |
|
||||
| #217 | [#447](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/447) | type:chore · priority:p2-normal | בלוק-י: צמצום-הקשר ממוקד-רלוונטיות (איכות + מניעת-דחיסה) |
|
||||
| #218 | [#448](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/448) | type:feature · priority:p1-high · status:in-progress | Escalation primitive מדורג-חומרה בשער-הפלטפורמה (השראת Gastown gt escalate) |
|
||||
| #219 | [#449](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/449) | type:feature · priority:p1-high · status:in-progress | טלמטריה מובנית (OpenTelemetry) על שכבת-הסוכנים (השראת Gastown OTel) |
|
||||
| #220 | [#450](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/450) | type:feature · priority:p2-normal · status:in-progress | Seance — תשאול סשן-קודם לצמצום re-work של blind-heartbeats (השראת Gastown gt seance) |
|
||||
| #221 | [#451](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/451) | type:feature · priority:p2-normal · status:deferred | Molecules — פייפליין 12-הבלוקים כתבנית הצהרתית עם checkpoint-recovery (השראת Gastown formulas) |
|
||||
| #222 | [#452](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/452) | type:feature · priority:p2-normal · status:in-progress | טקסונומיית-בריאות מפורשת לסוכן ב-dashboard (Zombie/Stalled/Working/Idle — השראת Gastown gt feed --problems) |
|
||||
| #225 | [#453](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/453) | type:spike · priority:p2-normal · status:needs-decision | בית-אב לדוקטרינה-משפטית של דפנה (chair legal-doctrine home) — תכנון בלבד |
|
||||
| #228 | [#454](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/454) | type:bug · priority:p1-high | תשובת-interaction/הערה על issue בבעלות-אדם לא מעירה את ה-CEO — לנתב דרך פרימיטיב CEO-child (#227) |
|
||||
| #229 | [#455](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/455) | type:bug · priority:p1-high | block_writer שומר טקסט-שיחה של המודל לתוך תוכן הבלוק — נדרש שער-ניקוי בכתיבה |
|
||||
| #230 | [#456](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/456) | type:bug · priority:p2-normal | extractor._text_quality_ok לא מזהה סדר-קריאה RTL משובש ב-PDF דיגיטלי |
|
||||
| #231 | [#457](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/457) | type:bug · priority:p1-high · status:ready | סוכני Paperclip מנותקים משרת ה-MCP legal-ai — ACP הפך לברירת-מחדל ב-claude_local |
|
||||
| #233 | [#458](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/458) | type:bug · priority:p1-high · status:ready | צבירת-הטיעונים מוחקת צד שלם בשקט — 310 טענות עוררים → 0 טיעונים, status ok |
|
||||
|
||||
## משימות שנסגרו בזמן המיגרציה (לא הועברו)
|
||||
|
||||
| TaskMaster | למה נסגרה |
|
||||
|---|---|
|
||||
| #223 | PR #393 מוזג — בחירת פרוטוקול לפי `protocol_scope`. אומת ב-1069-04-26. |
|
||||
| #224 | PRs #392 + #403 מוזגו — `claims.party_name` במקור + פיצול משיבים. אומת ב-1069-04-26. |
|
||||
| #232 | PR #431 מוזג — 104 החלטות ועדות-ערר היו מסוננות מהחיפוש (סינון בברירת-מחדל). מלכודת 3 נסגרה ב-PR #432. |
|
||||
|
||||
## מה עדיין דורש עבודה ידנית
|
||||
|
||||
- **`size:` לא הוקצה** לאף issue — TaskMaster לא החזיק הערכות-גודל, ולא רציתי להמציא אותן. לפי כלל התבנית, issue ללא AC+size אינו `status:ready`.
|
||||
- **21 מתוך 26 issues ללא Acceptance Criteria** — הן הועברו מ-TaskMaster בלי `testStrategy`, וגוף ה-issue מסמן זאת במפורש. לפני לקיחת issue כזה — להגדיר AC, להקצות `size`, ולסמן `status:ready`.
|
||||
- 5 שכן קיבלו `status:ready` (היה להן `testStrategy`): #436, #439, #440, #457, #458.
|
||||
@@ -6,6 +6,7 @@ Run with: python -m legal_mcp.server
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -41,11 +42,56 @@ async def lifespan(server: FastMCP) -> AsyncIterator[None]:
|
||||
logger.info("Ezer Mishpati MCP server stopped")
|
||||
|
||||
|
||||
# HTTP listener address, used only when MCP_TRANSPORT selects an HTTP transport.
|
||||
#
|
||||
# These MUST be passed to the constructor rather than left to FastMCP's own
|
||||
# FASTMCP_HOST / FASTMCP_PORT environment settings: FastMCP.__init__ declares
|
||||
# `host: str = "127.0.0.1"` and `port: int = 8000` as explicit keyword defaults
|
||||
# and forwards them into Settings(**settings). In pydantic-settings, explicit
|
||||
# init kwargs outrank environment variables — so FASTMCP_PORT is silently
|
||||
# ignored and the server binds 8000 regardless (verified 2026-08-05; on this
|
||||
# host 8000 is already taken, so it failed loudly by luck rather than design).
|
||||
#
|
||||
# Default to loopback. The bearer gate below is the real protection; the narrow
|
||||
# bind is defence in depth, not a substitute for it.
|
||||
MCP_HTTP_HOST = os.environ.get("MCP_HTTP_HOST", "127.0.0.1")
|
||||
MCP_HTTP_PORT = int(os.environ.get("MCP_HTTP_PORT", "8790"))
|
||||
|
||||
# Bearer gate — wired only when an HTTP transport is actually selected (#231.2).
|
||||
#
|
||||
# stdio must never require a token: the pipe is the boundary there, and every
|
||||
# interactive session reaches us that way. Demanding a token on stdio would
|
||||
# break all of them for no security gain.
|
||||
#
|
||||
# Building the verifier at import time (rather than inside main()) is deliberate:
|
||||
# FastMCP takes `token_verifier` and `auth` as constructor arguments, so a
|
||||
# missing token has to fail here — before the listener exists — not after it is
|
||||
# already accepting connections.
|
||||
_http_transport = os.environ.get("MCP_TRANSPORT", "stdio").strip() in ("sse", "streamable-http")
|
||||
_auth_kwargs: dict = {}
|
||||
if _http_transport:
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
|
||||
from legal_mcp.services.http_auth import StaticTokenVerifier, load_token
|
||||
|
||||
_base_url = f"http://{MCP_HTTP_HOST}:{MCP_HTTP_PORT}"
|
||||
_auth_kwargs = {
|
||||
"token_verifier": StaticTokenVerifier(load_token()),
|
||||
# AuthSettings is what switches on the SDK's BearerAuthBackend. We are a
|
||||
# resource server with a pre-shared token, not an OAuth client, so both
|
||||
# URLs simply point at ourselves — they exist to satisfy the protected-
|
||||
# resource metadata contract, and nothing issues tokens from them.
|
||||
"auth": AuthSettings(issuer_url=_base_url, resource_server_url=_base_url),
|
||||
}
|
||||
|
||||
# Create MCP server
|
||||
mcp = FastMCP(
|
||||
"Ezer Mishpati - עוזר משפטי",
|
||||
instructions="מערכת AI לסיוע בניסוח החלטות משפטיות בסגנון דפנה תמיר",
|
||||
lifespan=lifespan,
|
||||
host=MCP_HTTP_HOST,
|
||||
port=MCP_HTTP_PORT,
|
||||
**_auth_kwargs,
|
||||
)
|
||||
|
||||
# ── Import and register tools ───────────────────────────────────────
|
||||
@@ -1260,7 +1306,44 @@ async def corroboration_rebuild(case_law_id: str = "") -> dict:
|
||||
|
||||
|
||||
def main():
|
||||
mcp.run(transport="stdio")
|
||||
"""Run the server on the transport named by ``MCP_TRANSPORT`` (default stdio).
|
||||
|
||||
ONE server, two transports — deliberately not a second implementation (G2).
|
||||
The tool registry, services and DB pool above are shared verbatim; only the
|
||||
wire protocol differs, so a tool can never exist on one transport and not
|
||||
the other.
|
||||
|
||||
- ``stdio`` (default) — the historical path. Every interactive Claude Code
|
||||
session reaches us this way via the ``legal-ai`` entry in ``~/.claude.json``.
|
||||
Changing this default would break them all, so it stays the default.
|
||||
- ``streamable-http`` — required by the agent-platform port. Agents driven
|
||||
over the Agent Client Protocol receive their MCP servers from the *client*
|
||||
at session start, and that channel accepts HTTP servers only: each is
|
||||
injected as ``{type:"http", name, url, headers:[Bearer]}``. A stdio server
|
||||
has no path into such a session at all, which is why platform-driven agents
|
||||
lost all 108 tools when their execution engine changed — TaskMaster #231.
|
||||
Platform-side wiring lives behind the port (``web/agent_platform_port.py``),
|
||||
not here; this module only has to be reachable over HTTP.
|
||||
|
||||
Host/port come from FastMCP's own ``FASTMCP_HOST`` / ``FASTMCP_PORT`` settings.
|
||||
Bind to loopback only: this transport carries no authentication yet, and the
|
||||
registry includes destructive tools (``case_delete``, ``precedent_library_delete``).
|
||||
The Bearer gate is #231.2 and MUST land before this is reachable off-host.
|
||||
"""
|
||||
transport = os.environ.get("MCP_TRANSPORT", "stdio").strip() or "stdio"
|
||||
valid = ("stdio", "sse", "streamable-http")
|
||||
if transport not in valid:
|
||||
# Fail loudly — a typo must not silently fall back to stdio and leave
|
||||
# the HTTP listener absent while everything "looks" fine (§6).
|
||||
raise SystemExit(
|
||||
f"MCP_TRANSPORT={transport!r} is not one of {valid}",
|
||||
)
|
||||
if transport != "stdio":
|
||||
logger.info(
|
||||
"Serving MCP over %s on %s:%s",
|
||||
transport, mcp.settings.host, mcp.settings.port,
|
||||
)
|
||||
mcp.run(transport=transport)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -147,6 +147,38 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
class AggregationFailed(RuntimeError):
|
||||
"""One side's aggregation call failed. Never swallowed — see #233."""
|
||||
|
||||
|
||||
#: Largest number of propositions sent to Claude in a single aggregation call.
|
||||
#:
|
||||
#: Above roughly this size the model stops returning a JSON array and the whole
|
||||
#: side is lost. The per-brief split added for the respondent side (#224) hid
|
||||
#: this by accident — it kept those calls small — but appellant and committee
|
||||
#: speak with a single voice and are never split by brief, so a big appeal goes
|
||||
#: out in one oversized call. 1069-04-26 sent 310 propositions and got nothing
|
||||
#: back (#233).
|
||||
#:
|
||||
#: Chunking is a real trade-off, not a free win: each chunk is grouped in
|
||||
#: isolation, so a side split across chunks can end up with more (and slightly
|
||||
#: overlapping) arguments than a single pass would have produced. Losing an
|
||||
#: entire litigant is worse, and the alternative — a smaller prompt per
|
||||
#: proposition — would degrade every case to fix the large ones.
|
||||
MAX_PROPS_PER_CALL = 80
|
||||
|
||||
|
||||
def _chunk(propositions: list[dict], size: int) -> list[list[dict]]:
|
||||
"""Split propositions into calls of at most ``size``, keeping claim order.
|
||||
|
||||
Order matters: claims arrive sorted by ``claim_index``, so neighbouring
|
||||
propositions usually belong to the same head of argument. Slicing in order
|
||||
keeps related material together instead of scattering one argument across
|
||||
chunks.
|
||||
"""
|
||||
return [propositions[i:i + size] for i in range(0, len(propositions), size)]
|
||||
|
||||
|
||||
async def _aggregate_party(
|
||||
party: str, propositions: list[dict], party_name: str = "",
|
||||
) -> list[dict]:
|
||||
@@ -155,9 +187,27 @@ async def _aggregate_party(
|
||||
``party_name`` names the specific pleading when this is a split side
|
||||
(respondent / permit_applicant brief), so the prompt scopes to that
|
||||
litigant's position only (#224).
|
||||
|
||||
Sides larger than ``MAX_PROPS_PER_CALL`` are aggregated in several calls and
|
||||
concatenated; a failure in any chunk raises rather than returning a partial
|
||||
side quietly.
|
||||
"""
|
||||
if not propositions:
|
||||
return []
|
||||
|
||||
if len(propositions) > MAX_PROPS_PER_CALL:
|
||||
chunks = _chunk(propositions, MAX_PROPS_PER_CALL)
|
||||
logger.info(
|
||||
"argument_aggregator: party '%s'%s has %d propositions — "
|
||||
"aggregating in %d calls of up to %d",
|
||||
party, f" ({party_name})" if party_name else "",
|
||||
len(propositions), len(chunks), MAX_PROPS_PER_CALL,
|
||||
)
|
||||
out: list[dict] = []
|
||||
for chunk in chunks:
|
||||
out.extend(await _aggregate_party(party, chunk, party_name=party_name))
|
||||
return out
|
||||
|
||||
prompt = _build_prompt(party, propositions, party_name=party_name)
|
||||
|
||||
try:
|
||||
@@ -171,11 +221,18 @@ async def _aggregate_party(
|
||||
) from e
|
||||
|
||||
if not isinstance(raw_result, list):
|
||||
logger.warning(
|
||||
"argument_aggregator: Claude returned non-list (%s) for party '%s'",
|
||||
type(raw_result).__name__, party,
|
||||
# NOT a silent []: returning empty here is indistinguishable from "this
|
||||
# side genuinely has no arguments", and the caller would then report
|
||||
# status=completed while a whole litigant vanished. That is exactly what
|
||||
# happened to the appellant side of 1069-04-26 — 310 claims in, 0
|
||||
# arguments out, "completed" (#233). Raise so the caller records it in
|
||||
# ``errors`` and the status degrades to completed_with_errors (§6).
|
||||
raise AggregationFailed(
|
||||
f"Claude returned {type(raw_result).__name__}, not a list, for party "
|
||||
f"'{party}'{f' ({party_name})' if party_name else ''} with "
|
||||
f"{len(propositions)} propositions — the side would otherwise be "
|
||||
f"dropped without trace",
|
||||
)
|
||||
return []
|
||||
|
||||
out: list[dict] = []
|
||||
for entry in raw_result:
|
||||
@@ -276,6 +333,12 @@ async def aggregate_claims_to_arguments(
|
||||
group_key = f"{party}·{party_name}" if party_name else party
|
||||
try:
|
||||
arguments = await _aggregate_party(party, props, party_name=party_name)
|
||||
except AggregationFailed as e:
|
||||
# A side that failed is NOT a side with no arguments. Record it so
|
||||
# the status degrades and the caller can see which litigant is
|
||||
# missing (#233).
|
||||
errors.append(f"{group_key}: {e}")
|
||||
continue
|
||||
except RuntimeError as e:
|
||||
# Most likely cause: Claude CLI not installed (running from
|
||||
# the container). Don't crash — record the gap and continue.
|
||||
|
||||
@@ -23,8 +23,10 @@ from pathlib import Path
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import db, embeddings, claude_session, audit, storage
|
||||
from legal_mcp.services.lessons import (
|
||||
ANTI_PATTERNS as _ANTI_PATTERNS,
|
||||
OUTCOME_LABELS_HE,
|
||||
PRACTICE_AREA_OVERRIDES,
|
||||
anti_pattern_directive,
|
||||
canonical_outcome,
|
||||
get_content_checklist,
|
||||
get_methodology_summary,
|
||||
@@ -369,6 +371,7 @@ async def write_block(
|
||||
block_id: str,
|
||||
instructions: str = "",
|
||||
effort_override: str | None = None,
|
||||
model_override: str | None = None,
|
||||
) -> dict:
|
||||
"""כתיבת בלוק יחיד בהחלטה.
|
||||
|
||||
@@ -381,6 +384,12 @@ async def write_block(
|
||||
THIS call only — used by the #208 model/effort calibration harness
|
||||
to A/B efforts without mutating the pinned defaults. Production
|
||||
callers leave it None and get the deterministic per-block effort.
|
||||
model_override: optional per-call generation model id (e.g.
|
||||
"claude-opus-5"). Same contract as effort_override — the #208
|
||||
harness A/Bs MODELS without mutating the pinned GENERATION_MODEL.
|
||||
Pass the BASE id only: the 1M-context escalation (#216) is applied
|
||||
on top automatically for large prompts, so an override never
|
||||
silently loses the 1M window. Production callers leave it None.
|
||||
|
||||
Returns:
|
||||
dict עם content, word_count, block_id, generation_type
|
||||
@@ -468,6 +477,12 @@ async def write_block(
|
||||
if instructions:
|
||||
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||
|
||||
# LAST in the prompt, deliberately (see lessons.anti_pattern_directive): the
|
||||
# same canonical rule already appears inside style_context, but ~47K chars
|
||||
# deep, where it measurably fails to bind. Restating it here is the only
|
||||
# change the A/B isolated as effective — so nothing may be appended after it.
|
||||
prompt += "\n\n" + anti_pattern_directive()
|
||||
|
||||
# Block י requires approved direction
|
||||
if block_id == "block-yod":
|
||||
dir_doc = (decision or {}).get("direction_doc") or {}
|
||||
@@ -478,7 +493,12 @@ async def write_block(
|
||||
# escalate to the 1M-context build (`[1m]`) instead of failing the block —
|
||||
# block-yod legitimately carries the whole case as source-context. The 400K
|
||||
# ceiling was an artifact of the old 200K-only build, NOT a model limit.
|
||||
gen_model = GENERATION_MODEL_1M if len(prompt) > _CTX_1M_THRESHOLD_CHARS else GENERATION_MODEL
|
||||
# model_override (#208 harness) swaps the BASE id only — the 1M decision below
|
||||
# still applies, so an A/B'd model keeps the same context-window behaviour as
|
||||
# the pinned default instead of silently falling back to the 200K build.
|
||||
_base_model = model_override or GENERATION_MODEL
|
||||
_model_1m = GENERATION_MODEL_1M if _base_model == GENERATION_MODEL else f"{_base_model}[1m]"
|
||||
gen_model = _model_1m if len(prompt) > _CTX_1M_THRESHOLD_CHARS else _base_model
|
||||
|
||||
# Final guard: even the 1M build is finite (~2M Hebrew chars of input). Cap at
|
||||
# 1.5M chars (~750K tokens) to leave room for output + a safety margin under 1M.
|
||||
@@ -1107,6 +1127,16 @@ async def _build_style_context(practice_area: str = "") -> str:
|
||||
# ── למידה מצטברת (T15) — עריכות היו"ר ב-/methodology + לקחי /training ──
|
||||
# גובר על ברירות-המחדל לעיל. כך כל מה שלמדנו עד היום מגיע לכותב.
|
||||
learned: list[str] = []
|
||||
# The canonical anti-patterns are rendered UNCONDITIONALLY, before any DB
|
||||
# call. They used to be produced inside the overrides loop below — so a
|
||||
# failure on an EARLIER category (e.g. golden_ratios) aborted the loop and
|
||||
# dropped the style invariants from the prompt silently, with only a generic
|
||||
# "overrides not loaded" warning to show for it (§6). A chair-override
|
||||
# outage must not be able to un-teach Dafna's structural style.
|
||||
learned.append("\n**אנטי-דפוסים (להימנע) — כתוב נרטיב משפטי רציף; הימנע מ:**")
|
||||
for ap in _ANTI_PATTERNS:
|
||||
learned.append(f"- {ap['note']}")
|
||||
|
||||
try:
|
||||
for cat, label in (
|
||||
("golden_ratios", "יחסי-זהב (אחוזי-סעיפים)"),
|
||||
@@ -1125,10 +1155,8 @@ async def _build_style_context(practice_area: str = "") -> str:
|
||||
# corrects them, and drafts keep emitting them (the gap that left
|
||||
# 8137 with 28 hits). Chair additions layer on top; they never
|
||||
# remove the canonical ones.
|
||||
from legal_mcp.services.lessons import ANTI_PATTERNS as _ANTI
|
||||
learned.append(f"\n**{label} — כתוב נרטיב משפטי רציף; הימנע מ:**")
|
||||
for ap in _ANTI:
|
||||
learned.append(f"- {ap['note']}")
|
||||
# The canonical list is already rendered above, outside this try —
|
||||
# here we only layer the chair's ADDITIONS on top of it.
|
||||
for k, v in (ov or {}).items():
|
||||
learned.append(f"- (יו\"ר) {k}: {json.dumps(v, ensure_ascii=False)}")
|
||||
continue
|
||||
@@ -1264,6 +1292,12 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
|
||||
if instructions:
|
||||
formatted_prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||
|
||||
# Same closing directive, same position, same canonical source as write_block.
|
||||
# This is the EXTERNAL-writer path (legal-writer agent) — if the rule were
|
||||
# applied only in write_block, agent-written blocks would keep emitting the
|
||||
# anti-patterns and the two writers would drift apart (G2).
|
||||
formatted_prompt += "\n\n" + anti_pattern_directive()
|
||||
|
||||
# Block י requires approved direction
|
||||
if block_id == "block-yod":
|
||||
dir_doc = (decision or {}).get("direction_doc") or {}
|
||||
|
||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import (
|
||||
@@ -34,8 +35,132 @@ logger = logging.getLogger(__name__)
|
||||
_SUGGEST_PER_ISSUE = 4
|
||||
_SUGGEST_FLOOR = 0.45
|
||||
|
||||
#: Concurrent per-argument retrievals. The fan-out used to be unbounded — one
|
||||
#: task per legal argument — which is self-defeating, not merely risky: each
|
||||
#: task opens a Voyage embed call, and past ~8 in flight Voyage throttles, so
|
||||
#: every request stalls together. Measured on this corpus (32 arguments):
|
||||
#:
|
||||
#: one search alone ...... 1.2s
|
||||
#: 32 unbounded .......... 30.4s ← 25× a single search
|
||||
#: 32 at 8 concurrent .... 22.1s ← bounded is FASTER
|
||||
#:
|
||||
#: A 69-argument case (1069-04-26) therefore blew past the 30s proxy timeout and
|
||||
#: returned 500, and overlapping page reloads piled contention into Postgres
|
||||
#: deadlocks. Bounding the fan-out both fixes the failure and speeds it up.
|
||||
_MAX_CONCURRENT_LOOKUPS = 8
|
||||
|
||||
async def build_view(case_number: str) -> dict:
|
||||
def _passes_floor(hit: dict) -> bool:
|
||||
"""Is this hit similar enough to the argument to suggest to the chair?
|
||||
|
||||
Reads ``relevance`` (always a cosine similarity), NOT ``score`` — ``score``
|
||||
becomes a rank-fusion value (~0.008-0.02) as soon as the lexical leg returns
|
||||
rows, and comparing that against a cosine-calibrated floor rejected every
|
||||
hit. That is why this tab showed no supporting precedent for any argument.
|
||||
|
||||
``relevance is None`` means the row came from the lexical leg only, so no
|
||||
cosine was ever computed. It is KEPT: it earned its place by BM25 rank
|
||||
inside an already-tiny top-k, and dropping it would silently hide exact
|
||||
phrase/docket matches — the very hits a chair searches for by name.
|
||||
"""
|
||||
rel = hit.get("relevance")
|
||||
if rel is None:
|
||||
return True
|
||||
return float(rel) >= _SUGGEST_FLOOR
|
||||
|
||||
|
||||
#: Wall-clock ceiling for the whole retrieval phase. The proxy gives up at 30s;
|
||||
#: cutting ourselves off earlier lets us return the suggestions that DID land
|
||||
#: instead of a 500 that shows the chair nothing. Partial results are labelled
|
||||
#: (``retrieval_complete: false``) rather than passed off as the full picture.
|
||||
_RETRIEVAL_BUDGET_S = 22.0
|
||||
|
||||
|
||||
#: Cached views, keyed by case number → (fingerprint, expires_at|None, view).
|
||||
#: Assembling a view costs 20+ seconds of Voyage embeds and vector searches, and
|
||||
#: the result changes only when its inputs do. Keyed by a FINGERPRINT of those
|
||||
#: inputs rather than a guessed TTL: a chair who verifies a precedent must see it
|
||||
#: reflected on the next load, not after a timer expires.
|
||||
_view_cache: dict[str, tuple[str, float | None, dict]] = {}
|
||||
|
||||
#: One lock per case so two concurrent loads of the same page don't both compute.
|
||||
#: Reload-during-a-slow-load is how the original outage compounded into Postgres
|
||||
#: deadlocks; the second reader now waits for the first and gets its result.
|
||||
_view_locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
#: A partial view (retrieval budget exhausted) is still worth caching — otherwise
|
||||
#: every reload re-pays 22 seconds — but only briefly, so the missing suggestions
|
||||
#: get another chance while the corpus is quieter.
|
||||
_PARTIAL_TTL_S = 90.0
|
||||
|
||||
|
||||
async def _inputs_fingerprint(case_id: UUID) -> str:
|
||||
"""Cheap signature of everything the view is derived from.
|
||||
|
||||
Changes when: arguments are re-aggregated, the chair attaches or verifies a
|
||||
precedent, or the corpus grows (new rulings change the suggestions). One
|
||||
round-trip — the point is to be far cheaper than the 20s it guards.
|
||||
"""
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
(SELECT count(*)::text || ':' || coalesce(max(updated_at)::text, '-')
|
||||
FROM legal_arguments WHERE case_id = $1) AS args,
|
||||
(SELECT count(*)::text || ':' || coalesce(max(updated_at)::text, '-')
|
||||
FROM case_precedents WHERE case_id = $1) AS attached,
|
||||
(SELECT count(*)::text || ':' || coalesce(max(created_at)::text, '-')
|
||||
FROM case_law WHERE searchable = true) AS corpus
|
||||
""",
|
||||
case_id,
|
||||
)
|
||||
return f"{row['args']}|{row['attached']}|{row['corpus']}"
|
||||
|
||||
|
||||
def invalidate(case_number: str = "") -> None:
|
||||
"""Drop cached views — one case, or all when called with no argument.
|
||||
|
||||
Callers that mutate the inputs may use this for immediacy, but correctness
|
||||
does not depend on it: the fingerprint check catches any change on the next
|
||||
read regardless of whether anyone remembered to invalidate (G1 — the guard
|
||||
lives at the source of truth, not in every writer's memory).
|
||||
"""
|
||||
if case_number:
|
||||
_view_cache.pop(case_number, None)
|
||||
else:
|
||||
_view_cache.clear()
|
||||
|
||||
|
||||
async def build_view(case_number: str, *, use_cache: bool = True) -> dict:
|
||||
"""Assemble the citation-verification view, served from cache when unchanged."""
|
||||
if not use_cache:
|
||||
return await _build_view_uncached(case_number)
|
||||
|
||||
lock = _view_locks.setdefault(case_number, asyncio.Lock())
|
||||
async with lock:
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return {"status": "case_not_found", "case_number": case_number,
|
||||
"arguments": []}
|
||||
cid = case["id"]
|
||||
fp = await _inputs_fingerprint(UUID(cid) if isinstance(cid, str) else cid)
|
||||
|
||||
hit = _view_cache.get(case_number)
|
||||
if hit is not None:
|
||||
cached_fp, expires_at, view = hit
|
||||
fresh = cached_fp == fp and (expires_at is None or time.monotonic() < expires_at)
|
||||
if fresh:
|
||||
return {**view, "cached": True}
|
||||
|
||||
view = await _build_view_uncached(case_number)
|
||||
if view.get("status") == "ok":
|
||||
expires_at = (None if view.get("retrieval_complete", True)
|
||||
else time.monotonic() + _PARTIAL_TTL_S)
|
||||
_view_cache[case_number] = (fp, expires_at, view)
|
||||
return {**view, "cached": False}
|
||||
|
||||
|
||||
async def _build_view_uncached(case_number: str) -> dict:
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return {"status": "case_not_found", "case_number": case_number, "arguments": []}
|
||||
@@ -83,13 +208,46 @@ async def build_view(case_number: str) -> dict:
|
||||
logger.warning("citation_verification search failed (%s): %s", title[:30], e)
|
||||
# Resolve the authority breakdown for the hit set in one batched query.
|
||||
clids = [UUID(str(h["case_law_id"])) for h in hits
|
||||
if h.get("case_law_id") and float(h.get("score", 0) or 0) >= _SUGGEST_FLOOR]
|
||||
if h.get("case_law_id") and _passes_floor(h)]
|
||||
authority = await db.citation_authority(clids) if clids else {}
|
||||
return hits, authority
|
||||
|
||||
# Fan out the expensive per-argument retrieval concurrently — was a sequential
|
||||
# waterfall (N args × Voyage embed + vector search each). gather preserves order.
|
||||
fetched = await asyncio.gather(*(_fetch(a) for a in args)) if args else []
|
||||
# Fan out the expensive per-argument retrieval, but BOUNDED — see
|
||||
# _MAX_CONCURRENT_LOOKUPS. gather preserves order, so the zip below still
|
||||
# pairs each argument with its own result.
|
||||
_sem = asyncio.Semaphore(_MAX_CONCURRENT_LOOKUPS)
|
||||
|
||||
async def _fetch_bounded(a: dict) -> tuple[list[dict], dict]:
|
||||
async with _sem:
|
||||
return await _fetch(a)
|
||||
|
||||
retrieval_complete = True
|
||||
fetched: list[tuple[list[dict], dict]] = []
|
||||
if args:
|
||||
# Harvest whatever finished inside the budget, per argument. Deliberately
|
||||
# NOT wait_for(gather(...)): that cancels every task on timeout, so one
|
||||
# slow lookup would throw away the 30 that already succeeded and the page
|
||||
# would show nothing at all.
|
||||
tasks = [asyncio.ensure_future(_fetch_bounded(a)) for a in args]
|
||||
done, pending = await asyncio.wait(tasks, timeout=_RETRIEVAL_BUDGET_S)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
if pending:
|
||||
# Let the cancellations settle before the caller's DB pool unwinds —
|
||||
# a task cancelled mid-query otherwise surfaces as a stray
|
||||
# "connection_lost" future with no owner.
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
retrieval_complete = False
|
||||
logger.warning(
|
||||
"citation_verification: retrieval budget of %.0fs exhausted for %s "
|
||||
"— %d of %d arguments returned suggestions, the rest are empty",
|
||||
_RETRIEVAL_BUDGET_S, case_number, len(done), len(args),
|
||||
)
|
||||
for t in tasks:
|
||||
if t in done and not t.cancelled() and t.exception() is None:
|
||||
fetched.append(t.result())
|
||||
else:
|
||||
fetched.append(([], {}))
|
||||
|
||||
out_args: list[dict] = []
|
||||
n_verified = 0
|
||||
@@ -104,7 +262,7 @@ async def build_view(case_number: str) -> dict:
|
||||
clid = str(h.get("case_law_id") or "")
|
||||
if not clid or clid in seen:
|
||||
continue
|
||||
if float(h.get("score", 0) or 0) < _SUGGEST_FLOOR:
|
||||
if not _passes_floor(h):
|
||||
continue
|
||||
seen.add(clid)
|
||||
att = attached_by_arg.get(aid, {}).get(clid)
|
||||
@@ -137,6 +295,11 @@ async def build_view(case_number: str) -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"case_number": case_number,
|
||||
# False when the retrieval budget ran out: the attached/verified rows and
|
||||
# the radar are complete, but the corpus SUGGESTIONS are missing. The UI
|
||||
# must say so — an empty suggestion list otherwise reads as "no precedent
|
||||
# in the corpus supports this argument", which is a different claim.
|
||||
"retrieval_complete": retrieval_complete,
|
||||
"arguments": out_args,
|
||||
"summary": {
|
||||
"arguments_total": len(out_args),
|
||||
|
||||
@@ -5324,21 +5324,19 @@ async def list_external_case_law(
|
||||
search: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
) -> list[dict]:
|
||||
"""List chair-uploaded precedents, with simple filters.
|
||||
|
||||
source_kind="" (default) = the whole corpus — court rulings *and*
|
||||
appeals-committee decisions. The old ``external_upload`` default hid
|
||||
every committee decision from a plain listing (#232).
|
||||
source_kind="all_committees" expands to: source_kind='internal_committee'
|
||||
OR (source_kind='external_upload' AND source_type='appeals_committee').
|
||||
"""
|
||||
pool = await get_pool()
|
||||
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}'"]
|
||||
sk_clause = _source_kind_clause(source_kind)
|
||||
conditions = [sk_clause] if sk_clause else []
|
||||
params: list = []
|
||||
idx = 1
|
||||
if practice_area:
|
||||
@@ -5358,13 +5356,19 @@ async def list_external_case_law(
|
||||
params.append(source_type)
|
||||
idx += 1
|
||||
if search:
|
||||
# Case-number separator normalisation (#232, trap 2): committee numbers
|
||||
# are stored hyphenated ("83-16") while people write them with a slash
|
||||
# ("83/16"). Fold both sides to '/' so either form finds the row —
|
||||
# matching at the point of comparison rather than asking every caller
|
||||
# to guess the stored form.
|
||||
conditions.append(
|
||||
f"(case_number ILIKE ${idx} OR case_name ILIKE ${idx} "
|
||||
f"OR summary ILIKE ${idx} OR headnote ILIKE ${idx})"
|
||||
f"OR summary ILIKE ${idx} OR headnote ILIKE ${idx} "
|
||||
f"OR replace(case_number, '-', '/') ILIKE replace(${idx}, '-', '/'))"
|
||||
)
|
||||
params.append(f"%{search}%")
|
||||
idx += 1
|
||||
where_sql = " AND ".join(conditions)
|
||||
where_sql = " AND ".join(conditions) if conditions else "TRUE"
|
||||
params.extend([limit, offset])
|
||||
sql = f"""
|
||||
SELECT id, case_number, case_name, court, date, practice_area,
|
||||
@@ -7775,6 +7779,45 @@ async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
#: Accepted ``source_kind`` selectors. ``""``/``"all"`` mean *no filter* —
|
||||
#: the whole corpus, court rulings and appeals-committee decisions alike.
|
||||
_SOURCE_KIND_SELECTORS = frozenset(
|
||||
{"", "all", "all_committees", "external_upload", "internal_committee", "cited_only"}
|
||||
)
|
||||
|
||||
|
||||
def _source_kind_clause(source_kind: str, column_prefix: str = "") -> str:
|
||||
"""Return the SQL predicate for a ``source_kind`` selector, or "" for none.
|
||||
|
||||
Single definition for every caller (G2) — the selector vocabulary was
|
||||
previously restated at each search/list site, which is how the
|
||||
``external_upload`` default silently hid 104 appeals-committee
|
||||
decisions from ``search_precedent_library`` (#232).
|
||||
|
||||
``column_prefix`` is the table alias plus dot (e.g. ``"cl."``) or "" when
|
||||
the query selects from ``case_law`` directly.
|
||||
|
||||
Raises ValueError on an unknown selector rather than interpolating it —
|
||||
the value reaches SQL by f-string, so the whitelist is also what keeps
|
||||
that safe.
|
||||
"""
|
||||
sk = (source_kind or "").strip()
|
||||
if sk not in _SOURCE_KIND_SELECTORS:
|
||||
raise ValueError(
|
||||
f"source_kind לא מוכר: {source_kind!r}. "
|
||||
f"ערכים חוקיים: {sorted(_SOURCE_KIND_SELECTORS - {''})} או '' (הכל)"
|
||||
)
|
||||
if sk in ("", "all"):
|
||||
return ""
|
||||
p = column_prefix
|
||||
if sk == "all_committees":
|
||||
return (
|
||||
f"({p}source_kind = 'internal_committee' OR "
|
||||
f"({p}source_kind = 'external_upload' AND {p}source_type = 'appeals_committee'))"
|
||||
)
|
||||
return f"{p}source_kind = '{sk}'"
|
||||
|
||||
|
||||
async def search_precedent_library_semantic(
|
||||
query_embedding: list[float],
|
||||
practice_area: str = "",
|
||||
@@ -7785,14 +7828,15 @@ async def search_precedent_library_semantic(
|
||||
subject_tag: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
district: str = "",
|
||||
chair_name: str = "",
|
||||
) -> list[dict]:
|
||||
"""Semantic search over precedents filtered by source_kind.
|
||||
"""Semantic search over precedents, optionally filtered by source_kind.
|
||||
|
||||
source_kind='external_upload' → court rulings (default)
|
||||
source_kind='internal_committee' → appeals-committee decisions
|
||||
source_kind='' → the whole corpus (default, #232)
|
||||
source_kind='external_upload' → court rulings only
|
||||
source_kind='internal_committee' → appeals-committee decisions only
|
||||
|
||||
Returns merged halachot + chunks. Halachot are pre-distilled rules, so
|
||||
they get a small score boost. Only ``approved`` / ``published`` halachot
|
||||
@@ -7800,12 +7844,15 @@ async def search_precedent_library_semantic(
|
||||
of halacha review status.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
sk_clause = _source_kind_clause(source_kind, "cl.")
|
||||
halacha_filters = [
|
||||
"h.review_status <> 'rejected'", # #153: include background; rank verified higher
|
||||
f"cl.source_kind = '{source_kind}'",
|
||||
"cl.searchable = true",
|
||||
]
|
||||
chunk_filters = [f"cl.source_kind = '{source_kind}'", "cl.searchable = true"]
|
||||
chunk_filters = ["cl.searchable = true"]
|
||||
if sk_clause:
|
||||
halacha_filters.append(sk_clause)
|
||||
chunk_filters.append(sk_clause)
|
||||
h_params: list = [query_embedding, limit]
|
||||
c_params: list = [query_embedding, limit]
|
||||
h_idx = 3
|
||||
@@ -7932,6 +7979,14 @@ async def search_precedent_library_semantic(
|
||||
# Calibrated so the average (≈0.85) stays at +0.05 (legacy value).
|
||||
_conf = float(d.get("confidence") or 0.0)
|
||||
d["score"] = float(d["score"]) + max(_conf * 0.06, 0.0)
|
||||
# Stable cosine-scale relevance, carried alongside ``score``.
|
||||
# ``score`` is the RANKING signal and downstream fusion overwrites it
|
||||
# with an RRF value (~0.008-0.02) whenever the lexical leg returns
|
||||
# rows — a different scale entirely. Anything that THRESHOLDS must
|
||||
# read ``relevance`` instead, which always means "cosine similarity
|
||||
# to the query" no matter which fusion stages ran. See
|
||||
# hybrid_search._merge_sem_lex.
|
||||
d["relevance"] = d["score"]
|
||||
d["type"] = "halacha"
|
||||
# authority is DERIVED from the source, never stored (INV-DM7)
|
||||
d["authority"] = halacha_quality.derive_authority(d.get("precedent_level"))
|
||||
@@ -7943,6 +7998,7 @@ async def search_precedent_library_semantic(
|
||||
if d.get("decision_date") is not None:
|
||||
d["decision_date"] = d["decision_date"].isoformat()
|
||||
d["score"] = float(d["score"])
|
||||
d["relevance"] = d["score"] # cosine anchor — see the halacha branch above
|
||||
d["type"] = "passage"
|
||||
_maybe_swap_parent(d)
|
||||
results.append(d)
|
||||
@@ -8017,7 +8073,7 @@ async def search_precedent_library_lexical(
|
||||
appeal_subtype: str = "",
|
||||
is_binding: bool | None = None,
|
||||
subject_tag: str = "",
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
district: str = "",
|
||||
chair_name: str = "",
|
||||
limit: int = 30,
|
||||
@@ -8043,12 +8099,15 @@ async def search_precedent_library_lexical(
|
||||
return []
|
||||
|
||||
pool = await get_pool()
|
||||
sk_clause = _source_kind_clause(source_kind, "cl.")
|
||||
halacha_filters = [
|
||||
"h.review_status <> 'rejected'", # #153: include background; rank verified higher
|
||||
f"cl.source_kind = '{source_kind}'",
|
||||
"cl.searchable = true",
|
||||
]
|
||||
chunk_filters = [f"cl.source_kind = '{source_kind}'", "cl.searchable = true"]
|
||||
chunk_filters = ["cl.searchable = true"]
|
||||
if sk_clause:
|
||||
halacha_filters.append(sk_clause)
|
||||
chunk_filters.append(sk_clause)
|
||||
# $1 = query, $2 = limit. Filters append starting at $3.
|
||||
h_params: list = [query, limit]
|
||||
c_params: list = [query, limit]
|
||||
|
||||
106
mcp-server/src/legal_mcp/services/http_auth.py
Normal file
106
mcp-server/src/legal_mcp/services/http_auth.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Bearer-token gate for the HTTP transport of the MCP server (#231.2).
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
Over ``stdio`` the protection is the pipe itself: only a process that already
|
||||
runs as this user can speak to the server. ``streamable-http`` removes that
|
||||
property entirely — anything that can reach the socket can call any of the 108
|
||||
registered tools, and the registry includes ``case_delete``,
|
||||
``precedent_library_delete``, ``document_upload`` and every block-writing tool.
|
||||
An unauthenticated listener is therefore a delete-any-case endpoint.
|
||||
|
||||
The agent platform already speaks this dialect: it injects each runtime MCP
|
||||
server as ``{type:"http", name, url, headers:[{name:"Authorization",
|
||||
value:"Bearer <token>"}]}``. So a static bearer token is exactly the shape the
|
||||
caller will present — no negotiation, no OAuth dance.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- We implement the SDK's own ``TokenVerifier`` protocol and let
|
||||
``BearerAuthBackend`` do the enforcement, rather than adding bespoke
|
||||
middleware. One auth path, the framework's (G2).
|
||||
- The token is read from the environment, which the service unit populates from
|
||||
Infisical. It is never defaulted, never logged, and never embedded here.
|
||||
- Comparison is constant-time: a naive ``==`` leaks the token byte-by-byte to a
|
||||
caller who can time responses.
|
||||
- ``verify_token`` returns ``None`` (not an exception) on mismatch — that is the
|
||||
protocol's "reject" signal and yields a clean 401 instead of a 500 that would
|
||||
read as a server fault.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
|
||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||
|
||||
logger = logging.getLogger("legal_mcp.http_auth")
|
||||
|
||||
#: Environment variable carrying the shared bearer token.
|
||||
#:
|
||||
#: Name matches the Infisical key exactly — All Infrastructure / env `main` /
|
||||
#: `/apps/legal-ai` / ``MCP_HTTP_SHARED_SECRET``, tagged ``credentials``. Keeping
|
||||
#: the two identical means nobody has to hold a mapping in their head, and it
|
||||
#: follows the two bridge tokens already in that folder
|
||||
#: (``COURT_FETCH_SHARED_SECRET``, ``LEGAL_CHAT_SHARED_SECRET``).
|
||||
TOKEN_ENV = "MCP_HTTP_SHARED_SECRET"
|
||||
|
||||
#: Minimum acceptable token length. Short tokens are brute-forceable; refusing
|
||||
#: them at startup is cheaper than discovering it from an access log.
|
||||
MIN_TOKEN_LEN = 32
|
||||
|
||||
#: Reported as the authenticated principal. Single shared token today, so this
|
||||
#: is a constant rather than a real identity — kept explicit so that audit rows
|
||||
#: never imply per-agent attribution we cannot actually make.
|
||||
CLIENT_ID = "mcp-http-shared"
|
||||
|
||||
|
||||
class MissingTokenError(RuntimeError):
|
||||
"""Raised when the HTTP transport is requested without a usable token.
|
||||
|
||||
Deliberately fatal. The tempting alternative — start anyway and log a
|
||||
warning — produces a listener that looks healthy and answers every
|
||||
destructive tool call. Refusing to boot is the safe failure (§6: never
|
||||
swallow, never degrade silently).
|
||||
"""
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
"""Return the configured bearer token, or raise if it is unusable."""
|
||||
token = (os.environ.get(TOKEN_ENV) or "").strip()
|
||||
if not token:
|
||||
raise MissingTokenError(
|
||||
f"{TOKEN_ENV} is not set. The HTTP transport exposes destructive "
|
||||
f"tools and will not start without a bearer token. Set it from "
|
||||
f"Infisical, or use MCP_TRANSPORT=stdio.",
|
||||
)
|
||||
if len(token) < MIN_TOKEN_LEN:
|
||||
raise MissingTokenError(
|
||||
f"{TOKEN_ENV} is shorter than {MIN_TOKEN_LEN} characters — refusing "
|
||||
f"to start. Generate a long random token.",
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
class StaticTokenVerifier(TokenVerifier):
|
||||
"""Verifies the single shared bearer token presented by the platform.
|
||||
|
||||
Not an identity system: it answers "may this caller in at all", not "who is
|
||||
it". Per-agent attribution would need per-agent tokens, which is a later
|
||||
step once profiles exist (#231.4).
|
||||
"""
|
||||
|
||||
def __init__(self, expected: str) -> None:
|
||||
self._expected = expected
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
# compare_digest over bytes; it tolerates unequal lengths without
|
||||
# short-circuiting, which is the whole point.
|
||||
if not hmac.compare_digest(token.encode("utf-8"),
|
||||
self._expected.encode("utf-8")):
|
||||
# No token material in the log line — only the fact of a rejection.
|
||||
logger.warning("Rejected MCP HTTP request: bearer token mismatch")
|
||||
return None
|
||||
return AccessToken(token=token, client_id=CLIENT_ID, scopes=[])
|
||||
@@ -98,15 +98,16 @@ async def search_precedent_library_hybrid(
|
||||
is_binding: bool | None = None,
|
||||
subject_tag: str = "",
|
||||
include_halachot: bool = True,
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
district: str = "",
|
||||
chair_name: str = "",
|
||||
max_per_case_law: int = 2,
|
||||
) -> list[dict]:
|
||||
"""Hybrid wrapper for precedent-library search.
|
||||
|
||||
source_kind='external_upload' → court rulings (default)
|
||||
source_kind='internal_committee' → appeals-committee decisions
|
||||
source_kind='' → the whole corpus (default, #232)
|
||||
source_kind='external_upload' → court rulings only
|
||||
source_kind='internal_committee' → appeals-committee decisions only
|
||||
max_per_case_law: MMR-style diversity cap — at most N hits per
|
||||
case_law_id in the final ranked list (default 2). Prevents a
|
||||
single precedent from monopolizing the result list when many of
|
||||
@@ -302,6 +303,21 @@ def _merge_sem_lex(
|
||||
if key in lex_row_by_key else 0.0
|
||||
d["lex_rank"] = lex_rank or 0
|
||||
d["score"] = sem_term + lex_term
|
||||
# ``score`` is now an RRF value (~0.008-0.02), NOT a cosine. Carry the
|
||||
# cosine forward under ``relevance`` so thresholding callers keep a
|
||||
# stable scale — without it, a caller comparing score >= 0.45 silently
|
||||
# drops every result the moment the lexical leg returns anything, which
|
||||
# is exactly how the citation-verification tab ended up showing no
|
||||
# supporting precedent for any argument.
|
||||
#
|
||||
# A lexical-only row has no cosine at all. It gets ``relevance = None``
|
||||
# rather than 0.0: "we did not measure this" is not "measured as
|
||||
# irrelevant", and the row earned its place by BM25 rank. Callers decide
|
||||
# (see case_citation_verification) — but they must decide knowingly.
|
||||
if key in sem_row_by_key:
|
||||
d["relevance"] = float(sem_row_by_key[key].get("relevance", d["sem_score"]))
|
||||
else:
|
||||
d["relevance"] = None
|
||||
merged.append(d)
|
||||
|
||||
merged.sort(key=lambda x: -float(x["score"]))
|
||||
|
||||
@@ -59,6 +59,28 @@ ANTI_PATTERNS: list[dict] = [
|
||||
"note": "רשימות תבליטים באנליזה — דפנה כותבת נרטיב רציף"},
|
||||
]
|
||||
|
||||
|
||||
def anti_pattern_directive() -> str:
|
||||
"""The closing style directive, rendered from ANTI_PATTERNS (the same list
|
||||
style_distance scores against — one source, two renderings, not two rules).
|
||||
|
||||
WHY THIS EXISTS SEPARATELY FROM the style-context rendering: the rule was
|
||||
already reaching the writer, buried ~47K chars deep inside style_context,
|
||||
and drafts kept emitting the very patterns it forbids. A measured A/B over
|
||||
the signed finals (9 cases, 60 generations, 2026-07-28) showed that the SAME
|
||||
rule restated at the END of the assembled prompt cuts anti-pattern hits by
|
||||
72–93% on both blocks and both models:
|
||||
|
||||
block-vav opus-4-8 1.75 → 0.12 | opus-5 2.25 → 0.62
|
||||
block-zayin opus-4-8 4.57 → 0.43 | opus-5 4.43 → 0.43
|
||||
|
||||
So this is a POSITION fix, not a new instruction. Keep it last in the prompt.
|
||||
"""
|
||||
lines = ["## כלל-סגנון מחייב (גובר על כל דוגמה בהקשר שלמעלה)",
|
||||
"כתוב נרטיב משפטי רציף בלבד — פסקאות שלמות. אסור:"]
|
||||
lines += [f"- {ap['note']}" for ap in ANTI_PATTERNS]
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Paragraph length guidance (word counts) ────────────────────────
|
||||
|
||||
PARAGRAPH_LENGTHS = {
|
||||
|
||||
@@ -477,7 +477,7 @@ async def list_precedents(
|
||||
precedent_level: str = "",
|
||||
source_type: str = "",
|
||||
search: str = "",
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
@@ -503,12 +503,18 @@ async def search_library(
|
||||
subject_tag: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
source_kind: str = "",
|
||||
) -> list[dict]:
|
||||
"""Semantic search merging halachot (rule-level) and chunks (passage-level).
|
||||
|
||||
Only ``approved`` / ``published`` halachot are returned, per chair-review
|
||||
policy. Chunks are returned regardless of halacha review status.
|
||||
|
||||
``source_kind=""`` (default) covers the whole corpus — court rulings and
|
||||
appeals-committee decisions together. It used to be hard-wired to
|
||||
``external_upload`` here, which made 104 committee decisions
|
||||
unreachable through this entry point (#232).
|
||||
|
||||
When ``VOYAGE_RERANK_ENABLED`` is set, results are passed through
|
||||
voyage rerank-2 (cross-encoder). The +0.05 halacha boost from
|
||||
``search_precedent_library_semantic`` is preserved before rerank
|
||||
@@ -529,4 +535,5 @@ async def search_library(
|
||||
is_binding=is_binding,
|
||||
subject_tag=subject_tag,
|
||||
include_halachot=include_halachot,
|
||||
source_kind=source_kind,
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import date as date_type
|
||||
from uuid import UUID
|
||||
|
||||
@@ -72,7 +73,7 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
|
||||
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
|
||||
"proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY' → 'ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר' → 'בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.",
|
||||
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
|
||||
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.",
|
||||
"case_number_clean": "מספר הערר/תיק **בדיוק כפי שמופיע בכותרת** — רק ספרות ומפרידים. שתי צורות קיימות ושתיהן חוקיות: דו-חלקית ('1062/24', '8031/21') ותלת-חלקית של ועדות ערר ('1094-09-19', '85074-09-24' — סידורי-חודש-שנה). **העתק את הספרות מהמסמך; אל תשלים, אל תנחש ואל תתקן ספרה.** ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. **אם המספר אינו מופיע בטקסט — מחרוזת ריקה** (הקוד דוחה ממילא מספר שאינו מעוגן בטקסט).",
|
||||
"chair_name": "שם יו\\\"ר ההרכב של **ההחלטה הזו** — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. כמעט תמיד מופיע — בשני מקומות: (א) בכותרת/רובריקה בראש המסמך, ליד 'בפני:' / 'בהרכב:' / רשימת חברי הוועדה; (ב) בבלוק-החתימה בסוף ההחלטה, אחרי 'ההחלטה ניתנה' — שם מופיעים זה-לצד-זה מזכיר/ת הוועדה והיו\\\"ר (למשל בשתי עמודות: בצד אחד 'פלוני, עו\\\"ד / מזכיר ועדת הערר' ובצד השני 'אלמוני, עו\\\"ד / יו\\\"ר ועדת הערר'). **קח את השם שמעליו/לצדו כתוב 'יו\\\"ר' — לא את המזכיר/ה.** השאר שם פרטי+משפחה בלבד, בלי תוארים ('עו\\\"ד', 'אדריכל', 'עו\\\"ד דפנה תמיר'→'דפנה תמיר'). **אזהרה קריטית:** אל תיקח שם יו\\\"ר של פסק/החלטה אחרים ש**מצוטטים** בגוף ההחלטה (למשל 'כפי שנקבע ברשותה של יו\\\"ר פלונית בערר אחר...') — אלה תקדימים מצוטטים, לא היו\\\"ר של ההחלטה הנוכחית. אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
||||
"district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים' → 'ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו' → 'תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
||||
"parties": "שמות הצדדים בשורה אחת בצורה 'עורר נ\\' משיב' — בדיוק כפי שמופיעים בכותרת/רובריקה. בלי הדגשה, בלי מספר-תיק, בלי תוארים מיותרים. למשל 'ישיבת חברת אהבת שלום נ\\' תאיה' או 'ראם חיים נ\\' הוועדה המקומית לתכנון ובניה ירושלים'. אם הצדדים אינם מופיעים בטקסט (למשל החלטה שמתחילה בגוף בלי רובריקה) — מחרוזת ריקה. **אל תמציא שמות.**",
|
||||
@@ -242,6 +243,37 @@ def _is_clean_docket(s: str) -> bool:
|
||||
return bool(_DOCKET_RE.fullmatch((s or "").strip()))
|
||||
|
||||
|
||||
def _strip_invisibles(s: str) -> str:
|
||||
"""Drop Unicode format chars (category Cf) — RLM/LRM/ZWJ and friends.
|
||||
|
||||
Hebrew legal PDFs carry bidi marks *inside* docket numbers, so a plain
|
||||
substring test against the raw text misses a docket that is plainly there.
|
||||
"""
|
||||
return "".join(ch for ch in (s or "") if unicodedata.category(ch) != "Cf")
|
||||
|
||||
|
||||
def _docket_grounded(docket: str, *sources: str) -> bool:
|
||||
"""True when every digit group of ``docket`` appears, in order, in a source.
|
||||
|
||||
INV-AH (quote-or-retract) applied to the identity field. ``_is_clean_docket``
|
||||
only checks the *shape*, so a model that misreads one digit produces a
|
||||
perfectly well-formed but wrong docket — which is exactly how ערר 1094-09-19
|
||||
(פדילה) was stored as ``1094-09-14`` while all five case documents said
|
||||
...-19 (#232, trap 3). Shape validation cannot catch that; grounding can.
|
||||
|
||||
Tolerant of the separator (``-`` vs ``/``), of whitespace around it, and of
|
||||
bidi marks, so a real docket in the source still matches. Sources are the
|
||||
document text and the value being replaced — never the LLM's own output,
|
||||
which would make the check circular.
|
||||
"""
|
||||
parts = re.split(r"[-/]", (docket or "").strip())
|
||||
if not parts or not all(p.isdigit() for p in parts):
|
||||
return False
|
||||
pattern = r"\s*[-/]\s*".join(re.escape(p) for p in parts)
|
||||
haystack = _strip_invisibles("\n".join(s or "" for s in sources))
|
||||
return re.search(pattern, haystack) is not None
|
||||
|
||||
|
||||
def _source_type_for_level(level: str) -> str:
|
||||
"""Derive source_type from precedent_level — the library section is driven by
|
||||
source_type, so the two MUST agree (an LLM slip pairing
|
||||
@@ -400,6 +432,23 @@ async def apply_to_record(
|
||||
"already owned by another non-internal row (likely duplicate)",
|
||||
cur_cn, cn_clean,
|
||||
)
|
||||
elif not _docket_grounded(
|
||||
cn_clean,
|
||||
record.get("full_text") or "",
|
||||
cur_cn,
|
||||
record.get("citation_formatted") or "",
|
||||
):
|
||||
# The docket is well-formed but appears nowhere in the decision text
|
||||
# or in the value it would replace — i.e. the model produced digits
|
||||
# it cannot point at. case_number is the identity field; a wrong one
|
||||
# silently detaches the row from every reference to the real case.
|
||||
# Refuse the write and say so (§6) rather than trust the shape.
|
||||
logger.warning(
|
||||
"metadata_extractor: case_number normalization %r→%r REFUSED — the "
|
||||
"docket does not appear in the decision text or in the current "
|
||||
"value (ungrounded extraction, INV-AH). Keeping %r.",
|
||||
cur_cn, cn_clean, cur_cn,
|
||||
)
|
||||
else:
|
||||
fields_to_update["case_number"] = cn_clean
|
||||
|
||||
|
||||
@@ -43,8 +43,18 @@ SUBSECTION_RE = re.compile(r"^###\s+(.+?)$", re.MULTILINE)
|
||||
# Matches "**LABEL:**" field markers — handles both inline and block variants:
|
||||
# "**עמדת המבקשת:** Some text on same line"
|
||||
# "**שאלות משפטיות:**\n1. First question"
|
||||
# and both the bare and the list-item form, because the analyst agent writes
|
||||
# threshold claims as a bullet list ("- **עמדת ועדת הערר:**") while it writes
|
||||
# issues bare. Group 1 is the list marker (or None), group 2 is the label.
|
||||
# The label itself must not contain ** or newlines.
|
||||
FIELD_LABEL_RE = re.compile(r"^\*\*([^\n*]+?):\*\*[ \t]*", re.MULTILINE)
|
||||
FIELD_LABEL_RE = re.compile(
|
||||
r"^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)?\*\*([^\n*]+?):\*\*[ \t]*",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# Terminators that end a field's content even without a following field label:
|
||||
# a heading, or a horizontal rule closing the subsection.
|
||||
FIELD_TERMINATOR_RE = re.compile(r"^(?:#{2,}[ \t]|[ \t]*---[ \t]*$)", re.MULTILINE)
|
||||
|
||||
# Matches the case number in the H1
|
||||
CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILINE)
|
||||
@@ -53,6 +63,17 @@ CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILIN
|
||||
DATE_RE = re.compile(r"^תאריך:\s*(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _is_chair_label(label: str) -> bool:
|
||||
"""Is this field label the chair-position field?
|
||||
|
||||
Matches on prefix, not equality, because the analyst sometimes decorates the
|
||||
label with a parenthetical — "עמדת ועדת הערר (הכוונת יו"ר 24.6)". Requiring
|
||||
equality made the reader treat those as ordinary fields and the writer append
|
||||
a second, duplicate block instead of updating the existing one.
|
||||
"""
|
||||
return label.strip().startswith(CHAIR_POSITION_LABEL)
|
||||
|
||||
|
||||
def _is_placeholder(text: str) -> bool:
|
||||
"""Check if a field value is one of the placeholder strings (empty)."""
|
||||
stripped = text.strip()
|
||||
@@ -135,7 +156,7 @@ def _extract_fields(text: str) -> list[dict]:
|
||||
|
||||
fields = []
|
||||
for i, m in enumerate(matches):
|
||||
label = m.group(1).strip()
|
||||
label = m.group(2).strip()
|
||||
content_start = m.end()
|
||||
content_end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
content = text[content_start:content_end].strip()
|
||||
@@ -164,11 +185,17 @@ def _build_subsection_dict(
|
||||
parts = title.split(": ", 1)
|
||||
display_title = parts[1] if len(parts) > 1 else title
|
||||
|
||||
# Only the *first* chair-position field is the editable one — the same one
|
||||
# update_chair_position writes to. A subsection that carries more than one
|
||||
# (e.g. an H3 covering "סוגיות 4–6") keeps the extras as ordinary fields
|
||||
# rather than silently reading back a different field than the one saved.
|
||||
chair_position = ""
|
||||
chair_seen = False
|
||||
regular_fields = []
|
||||
for f in fields:
|
||||
if f["label"] == CHAIR_POSITION_LABEL:
|
||||
if not chair_seen and _is_chair_label(f["label"]):
|
||||
chair_position = _normalize_chair_position(f["content"])
|
||||
chair_seen = True
|
||||
else:
|
||||
regular_fields.append(f)
|
||||
|
||||
@@ -311,6 +338,46 @@ def _find_subsection_by_id(
|
||||
return None
|
||||
|
||||
|
||||
def _split_trailing_rule(body: str) -> tuple[str, str]:
|
||||
"""Split a subsection body into (content, trailing "---" separator).
|
||||
|
||||
Returns ("<body>", "") when the subsection has no closing rule.
|
||||
"""
|
||||
m = re.search(r"\n[ \t]*---[ \t]*\s*\Z", body)
|
||||
if not m:
|
||||
return body, ""
|
||||
return body[: m.start()], body[m.start() :]
|
||||
|
||||
|
||||
def _chair_field_span(body: str) -> tuple[int, int] | None:
|
||||
"""Locate the chair-position field's *content* range in a subsection body.
|
||||
|
||||
Returns (content_start, content_end) — the slice the chair's text occupies,
|
||||
excluding the "**LABEL:**" marker itself so the marker line (and any list
|
||||
bullet in front of it) survives an update untouched. Returns None when the
|
||||
subsection has no chair-position field yet.
|
||||
|
||||
Field boundaries come from FIELD_LABEL_RE — the same definition the reader
|
||||
uses — so what update writes is exactly what parse reads back (G2: one
|
||||
definition of a field, not two that can drift).
|
||||
"""
|
||||
matches = list(FIELD_LABEL_RE.finditer(body))
|
||||
for i, m in enumerate(matches):
|
||||
if not _is_chair_label(m.group(2)):
|
||||
continue
|
||||
content_start = m.end()
|
||||
if i + 1 < len(matches):
|
||||
content_end = matches[i + 1].start()
|
||||
else:
|
||||
content_end = len(body)
|
||||
# A heading or closing rule ends the field even without a next label.
|
||||
term = FIELD_TERMINATOR_RE.search(body, content_start, content_end)
|
||||
if term:
|
||||
content_end = term.start()
|
||||
return content_start, content_end
|
||||
return None
|
||||
|
||||
|
||||
def update_chair_position(
|
||||
file_path: Path, section_id: str, new_text: str
|
||||
) -> dict[str, Any]:
|
||||
@@ -330,40 +397,62 @@ def update_chair_position(
|
||||
|
||||
_abs_start, _abs_end, subsection_body = found
|
||||
|
||||
# Find the "**עמדת ועדת הערר:**" label within this subsection
|
||||
label_pattern = re.compile(
|
||||
r"(\*\*" + re.escape(CHAIR_POSITION_LABEL) + r":\*\*)\s*\n?([^*]*?)(?=\n\*\*|\n##|\n---|\Z)",
|
||||
re.DOTALL,
|
||||
)
|
||||
m = label_pattern.search(subsection_body)
|
||||
if not m:
|
||||
# Label not present — append it at the end of the subsection
|
||||
# (just before the trailing --- if any)
|
||||
new_block = f"\n\n**{CHAIR_POSITION_LABEL}:**\n{new_text.strip()}\n"
|
||||
new_subsection = subsection_body.rstrip() + new_block
|
||||
new_content = content[:_abs_start] + new_subsection + content[_abs_end:]
|
||||
span = _chair_field_span(subsection_body)
|
||||
body_text = new_text.strip() or CHAIR_POSITION_PLACEHOLDERS[0]
|
||||
|
||||
if span is None:
|
||||
# Label not present — append it at the end of the subsection,
|
||||
# before a trailing horizontal rule if there is one.
|
||||
head, tail = _split_trailing_rule(subsection_body)
|
||||
new_block = f"\n\n**{CHAIR_POSITION_LABEL}:**\n{body_text}\n"
|
||||
new_subsection = head.rstrip() + new_block + tail
|
||||
else:
|
||||
# Replace the existing content of the chair_position field
|
||||
replacement = f"{m.group(1)}\n{new_text.strip() if new_text.strip() else CHAIR_POSITION_PLACEHOLDERS[0]}\n"
|
||||
# Replace the existing content of the chair_position field, keeping the
|
||||
# label line exactly as written (including any list marker) so the file
|
||||
# structure the analyst produced is preserved.
|
||||
content_start, content_end = span
|
||||
new_subsection = (
|
||||
subsection_body[: m.start()] + replacement + subsection_body[m.end():]
|
||||
subsection_body[:content_start].rstrip("\r\n \t")
|
||||
+ f"\n{body_text}\n"
|
||||
+ subsection_body[content_end:]
|
||||
)
|
||||
new_content = content[:_abs_start] + new_subsection + content[_abs_end:]
|
||||
|
||||
new_content = content[:_abs_start] + new_subsection + content[_abs_end:]
|
||||
|
||||
# Atomic write
|
||||
tmp_path = file_path.with_suffix(file_path.suffix + ".tmp")
|
||||
tmp_path.write_text(new_content, encoding="utf-8") # noqa: STG1 — atomic .tmp; in-place edit, S3 re-sync in Phase-2 read-wiring
|
||||
os.replace(tmp_path, file_path)
|
||||
|
||||
preview = new_text.strip()[:120]
|
||||
# Read-after-write: a position the parser cannot read back is not saved,
|
||||
# however cleanly the write itself succeeded. Reporting success here is what
|
||||
# let a whole class of format drift hide behind a green "נשמר" in the UI.
|
||||
stored = _stored_chair_position(file_path, section_id)
|
||||
expected = _normalize_chair_position(new_text)
|
||||
if stored != expected:
|
||||
raise RuntimeError(
|
||||
f"העמדה נכתבה ל-{file_path.name} אך לא נקראה בחזרה עבור {section_id} "
|
||||
f"— ככל הנראה מבנה השדה בקובץ חורג מהתבנית הצפויה"
|
||||
)
|
||||
|
||||
return {
|
||||
"saved": True,
|
||||
"section_id": section_id,
|
||||
"preview": preview,
|
||||
"position": stored,
|
||||
"preview": stored[:120],
|
||||
"timestamp": datetime.now(IL_TZ).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _stored_chair_position(file_path: Path, section_id: str) -> str:
|
||||
"""Re-parse the file and return the chair position now stored for a section."""
|
||||
parsed = parse(file_path)
|
||||
for item in parsed.get("threshold_claims", []) + parsed.get("issues", []):
|
||||
if item["id"] == section_id:
|
||||
return item.get("chair_position", "") or ""
|
||||
return ""
|
||||
|
||||
|
||||
# ── Chair directions extraction (for downstream agents) ─────────
|
||||
|
||||
|
||||
|
||||
@@ -176,7 +176,13 @@ def block_distance_to_final(
|
||||
outcome = canonical_outcome(outcome)
|
||||
diff = compute_diff_stats(regenerated_text or "", final_section_text or "")
|
||||
change_percent = diff["change_percent"]
|
||||
anti_total = count_anti_patterns(regenerated_text or "")["total"]
|
||||
anti = count_anti_patterns(regenerated_text or "")
|
||||
anti_total = anti["total"]
|
||||
# Per-pattern breakdown, not just the total: a calibration run that only
|
||||
# reports "anti=4" cannot tell you WHICH rule was broken, so it cannot say
|
||||
# what to fix. (Diagnosing the 2026-07-28 model A/B needed exactly this and
|
||||
# had to fall back on inference.)
|
||||
anti_by_pattern = {name: h["count"] for name, h in anti["by_pattern"].items()}
|
||||
|
||||
section = _BLOCK_TO_SECTION.get(block_id)
|
||||
regen_words = len((regenerated_text or "").split())
|
||||
@@ -205,6 +211,7 @@ def block_distance_to_final(
|
||||
"final_words": final_words,
|
||||
"change_percent": change_percent,
|
||||
"anti_pattern_total": anti_total,
|
||||
"anti_by_pattern": anti_by_pattern,
|
||||
"golden_ratio_deviation_pp": ratio_dev,
|
||||
"distance": distance,
|
||||
}
|
||||
|
||||
@@ -96,10 +96,13 @@ async def precedent_library_list(
|
||||
precedent_level: str = "",
|
||||
source_type: str = "",
|
||||
search: str = "",
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים."""
|
||||
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים.
|
||||
|
||||
source_kind ריק (ברירת מחדל) = כל הקורפוס, כולל החלטות ועדות ערר.
|
||||
"""
|
||||
rows = await precedent_library.list_precedents(
|
||||
practice_area=practice_area,
|
||||
court=court,
|
||||
@@ -266,8 +269,9 @@ async def search_precedent_library(
|
||||
subject_tag: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
source_kind: str = "",
|
||||
) -> str:
|
||||
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית.
|
||||
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית — פסקי דין **והחלטות ועדות ערר**.
|
||||
|
||||
מחזיר תוצאות מעורבות: הלכות (rule-level, מאושרות בלבד) + קטעי טקסט
|
||||
(passage-level). הלכות מקבלות boost קל בדירוג כי הן מזוקקות מראש.
|
||||
@@ -282,6 +286,9 @@ async def search_precedent_library(
|
||||
subject_tag: סינון לפי תגית נושא (לדוגמה "מועד_קביעת_שומה").
|
||||
limit: מספר תוצאות מקסימלי.
|
||||
include_halachot: האם לכלול הלכות (ברירת מחדל: כן).
|
||||
source_kind: ריק (ברירת מחדל) = כל הקורפוס — פסקי דין והחלטות ועדות
|
||||
ערר יחד. "external_upload" = פסקי בתי משפט בלבד;
|
||||
"internal_committee" = החלטות ועדות ערר בלבד.
|
||||
|
||||
Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}.
|
||||
"""
|
||||
@@ -299,6 +306,7 @@ async def search_precedent_library(
|
||||
subject_tag=subject_tag,
|
||||
limit=limit,
|
||||
include_halachot=include_halachot,
|
||||
source_kind=source_kind,
|
||||
)
|
||||
# X11 Phase 2 (#154): attach the incoming-citation authority breakdown so the
|
||||
# research agent can WEIGH and ARGUE authority ("הלכה שאומצה ב-N החלטות ועדת-ערר")
|
||||
|
||||
60
mcp-server/tests/test_anti_pattern_directive.py
Normal file
60
mcp-server/tests/test_anti_pattern_directive.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""The style invariants must actually REACH the writer.
|
||||
|
||||
Both tests here cover defects found by the 2026-07-28 model×prompt A/B over the
|
||||
signed finals: the canonical anti-patterns were present in the prompt but buried
|
||||
~47K chars into style_context (where they measurably failed to bind), and they
|
||||
were rendered inside a try/except that an unrelated DB failure could abort.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import block_writer
|
||||
from legal_mcp.services.lessons import ANTI_PATTERNS, anti_pattern_directive
|
||||
|
||||
|
||||
def test_directive_renders_every_canonical_anti_pattern():
|
||||
"""One source, two renderings — the directive may not drift from the list
|
||||
style_distance scores against."""
|
||||
text = anti_pattern_directive()
|
||||
for ap in ANTI_PATTERNS:
|
||||
assert ap["note"] in text, f"missing anti-pattern in directive: {ap['name']}"
|
||||
|
||||
|
||||
def test_both_writer_paths_append_the_directive_last():
|
||||
"""write_block (in-process) and get_block_context (legal-writer agent) must
|
||||
both close with the directive — otherwise the two writers drift (G2)."""
|
||||
import inspect
|
||||
src = inspect.getsource(block_writer)
|
||||
for fn in ("async def write_block(", "async def get_block_context("):
|
||||
start = src.index(fn)
|
||||
# bound the search to this function: up to the next top-level def
|
||||
rest = src[start + len(fn):]
|
||||
nxt = rest.find("\nasync def ")
|
||||
body = rest[: nxt if nxt != -1 else len(rest)]
|
||||
assert "anti_pattern_directive()" in body, f"{fn} does not append the style directive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_style_context_keeps_anti_patterns_when_overrides_fail(monkeypatch):
|
||||
"""A chair-override outage must not silently un-teach the structural style.
|
||||
|
||||
Regression: the canonical list used to be emitted inside the overrides loop,
|
||||
so a throw on an EARLIER category (golden_ratios) dropped it entirely.
|
||||
"""
|
||||
async def _boom(*a, **k):
|
||||
raise RuntimeError("methodology table unavailable")
|
||||
|
||||
async def _empty(*a, **k):
|
||||
return []
|
||||
|
||||
# Every DB accessor this function touches is stubbed — the test must not open
|
||||
# a real connection (a live pool here leaks across the shared event loop and
|
||||
# breaks unrelated tests later in the run).
|
||||
monkeypatch.setattr(block_writer.db, "get_style_patterns", _empty)
|
||||
monkeypatch.setattr(block_writer.db, "get_methodology_overrides", _boom)
|
||||
monkeypatch.setattr(block_writer.db, "get_recent_decision_lessons", _empty)
|
||||
|
||||
ctx = await block_writer._build_style_context("היטל השבחה")
|
||||
|
||||
assert "נרטיב משפטי רציף" in ctx
|
||||
for ap in ANTI_PATTERNS:
|
||||
assert ap["note"] in ctx, f"anti-pattern dropped on override failure: {ap['name']}"
|
||||
54
mcp-server/tests/test_argument_aggregator.py
Normal file
54
mcp-server/tests/test_argument_aggregator.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Regression tests for argument aggregation (#233).
|
||||
|
||||
Both tests cover the same 2026-08-05 incident from different angles: the
|
||||
appellant side of 1069-04-26 sent 310 propositions in one Claude call, the call
|
||||
came back as something other than a JSON array, and the code logged a warning
|
||||
and returned ``[]``. The caller could not tell that apart from "this side has no
|
||||
arguments", so ``aggregate_claims_to_arguments`` reported ``completed`` with the
|
||||
central litigant of the appeal missing entirely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services.argument_aggregator import (
|
||||
MAX_PROPS_PER_CALL,
|
||||
AggregationFailed,
|
||||
_aggregate_party,
|
||||
_chunk,
|
||||
)
|
||||
|
||||
|
||||
def test_chunk_preserves_every_proposition_and_their_order():
|
||||
"""Chunking must not drop or reorder — losing claims here is invisible."""
|
||||
props = [{"i": i} for i in range(310)]
|
||||
|
||||
chunks = _chunk(props, MAX_PROPS_PER_CALL)
|
||||
|
||||
assert sum(len(c) for c in chunks) == 310, "propositions were lost"
|
||||
assert [p for c in chunks for p in c] == props, "order changed"
|
||||
assert all(len(c) <= MAX_PROPS_PER_CALL for c in chunks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_list_reply_raises_instead_of_dropping_the_side(monkeypatch):
|
||||
"""A malformed reply must surface, never look like an empty side.
|
||||
|
||||
This is the exact 1069-04-26 failure. If this test ever goes back to
|
||||
asserting ``== []``, the silent-drop bug has been reintroduced.
|
||||
"""
|
||||
async def _query_json(prompt, tools=""): # noqa: ARG001
|
||||
return {"error": "not a list"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"legal_mcp.services.argument_aggregator.claude_session.query_json",
|
||||
_query_json,
|
||||
)
|
||||
|
||||
with pytest.raises(AggregationFailed) as excinfo:
|
||||
await _aggregate_party("appellant", [{"id": "x", "claim_text": "t"}])
|
||||
|
||||
# The message has to name the side, or an operator reading
|
||||
# completed_with_errors cannot tell which litigant went missing.
|
||||
assert "appellant" in str(excinfo.value)
|
||||
125
mcp-server/tests/test_citation_view_cache.py
Normal file
125
mcp-server/tests/test_citation_view_cache.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""The citation view is cached by input fingerprint, not by a guessed TTL.
|
||||
|
||||
Assembling it costs 20+ seconds of Voyage embeds and vector searches. A TTL
|
||||
would force a choice between staleness and cost: a chair who verifies a
|
||||
precedent must see it on the very next load, and a timer cannot promise that.
|
||||
Keying on a fingerprint of the inputs (arguments · attachments · corpus) makes
|
||||
freshness a property of the data rather than of the clock.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import case_citation_verification as ccv
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_cache():
|
||||
ccv.invalidate()
|
||||
yield
|
||||
ccv.invalidate()
|
||||
|
||||
|
||||
def _stub(monkeypatch, fingerprint: str, calls: list, *, complete: bool = True):
|
||||
"""Point build_view at a fake case, a controllable fingerprint, and a
|
||||
counted assembler, so these tests never touch the corpus."""
|
||||
async def fake_case(_cn):
|
||||
return {"id": "00000000-0000-0000-0000-000000000001"}
|
||||
|
||||
async def fake_fp(_cid):
|
||||
return fingerprint
|
||||
|
||||
async def fake_build(cn):
|
||||
calls.append(cn)
|
||||
return {"status": "ok", "case_number": cn, "arguments": [],
|
||||
"retrieval_complete": complete, "summary": {}}
|
||||
|
||||
monkeypatch.setattr(ccv.db, "get_case_by_number", fake_case)
|
||||
monkeypatch.setattr(ccv, "_inputs_fingerprint", fake_fp)
|
||||
monkeypatch.setattr(ccv, "_build_view_uncached", fake_build)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_load_is_served_from_cache(monkeypatch):
|
||||
calls: list = []
|
||||
_stub(monkeypatch, "fp-1", calls)
|
||||
first = await ccv.build_view("1069-04-26")
|
||||
second = await ccv.build_view("1069-04-26")
|
||||
assert first["cached"] is False
|
||||
assert second["cached"] is True
|
||||
assert len(calls) == 1, "the expensive assembly must run once"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_changed_inputs_invalidate_without_anyone_calling_invalidate(monkeypatch):
|
||||
"""The chair verifies a precedent → fingerprint moves → next load rebuilds.
|
||||
Correctness must not depend on a writer remembering to clear the cache."""
|
||||
calls: list = []
|
||||
_stub(monkeypatch, "fp-before", calls)
|
||||
await ccv.build_view("1069-04-26")
|
||||
|
||||
_stub(monkeypatch, "fp-after", calls) # e.g. a new case_precedents row
|
||||
again = await ccv.build_view("1069-04-26")
|
||||
assert again["cached"] is False
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_loads_do_not_stampede(monkeypatch):
|
||||
"""Reload-during-a-slow-load is how the original outage compounded into
|
||||
Postgres deadlocks. The later readers must wait, not pile on."""
|
||||
calls: list = []
|
||||
|
||||
async def fake_case(_cn):
|
||||
return {"id": "00000000-0000-0000-0000-000000000001"}
|
||||
|
||||
async def fake_fp(_cid):
|
||||
return "fp-1"
|
||||
|
||||
async def slow_build(cn):
|
||||
calls.append(cn)
|
||||
await asyncio.sleep(0.05)
|
||||
return {"status": "ok", "case_number": cn, "arguments": [],
|
||||
"retrieval_complete": True, "summary": {}}
|
||||
|
||||
monkeypatch.setattr(ccv.db, "get_case_by_number", fake_case)
|
||||
monkeypatch.setattr(ccv, "_inputs_fingerprint", fake_fp)
|
||||
monkeypatch.setattr(ccv, "_build_view_uncached", slow_build)
|
||||
|
||||
await asyncio.gather(*(ccv.build_view("1069-04-26") for _ in range(6)))
|
||||
assert len(calls) == 1, "six concurrent loads, one assembly"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_view_expires_so_missing_suggestions_get_retried(monkeypatch):
|
||||
"""A budget-exhausted view is cached to stop reload storms, but it must not
|
||||
become permanent — the arguments with no suggestions deserve another try."""
|
||||
calls: list = []
|
||||
_stub(monkeypatch, "fp-1", calls, complete=False)
|
||||
await ccv.build_view("1069-04-26")
|
||||
_fp, expires_at, _view = ccv._view_cache["1069-04-26"]
|
||||
assert expires_at is not None, "a partial view must carry an expiry"
|
||||
|
||||
monkeypatch.setattr(ccv.time, "monotonic", lambda: expires_at + 1)
|
||||
after = await ccv.build_view("1069-04-26")
|
||||
assert after["cached"] is False
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_view_does_not_expire_on_a_timer(monkeypatch):
|
||||
calls: list = []
|
||||
_stub(monkeypatch, "fp-1", calls, complete=True)
|
||||
await ccv.build_view("1069-04-26")
|
||||
_fp, expires_at, _view = ccv._view_cache["1069-04-26"]
|
||||
assert expires_at is None, "freshness comes from the fingerprint, not a clock"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_use_cache_false_always_rebuilds(monkeypatch):
|
||||
calls: list = []
|
||||
_stub(monkeypatch, "fp-1", calls)
|
||||
await ccv.build_view("1069-04-26")
|
||||
await ccv.build_view("1069-04-26", use_cache=False)
|
||||
assert len(calls) == 2
|
||||
81
mcp-server/tests/test_citation_view_fanout.py
Normal file
81
mcp-server/tests/test_citation_view_fanout.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""The citation-verification view must not fan out without a bound.
|
||||
|
||||
`build_view` used to spawn one retrieval task per legal argument with a bare
|
||||
`asyncio.gather`. Each task opens a Voyage embed call, and past ~8 in flight
|
||||
Voyage throttles — so the unbounded version was *slower* than a bounded one
|
||||
(32 arguments: 30.4s unbounded vs 22.1s at 8), and a 69-argument case blew past
|
||||
the 30s proxy timeout and returned 500 while overlapping reloads piled
|
||||
contention into Postgres deadlocks.
|
||||
|
||||
These tests pin the two properties that fix gave us: the fan-out is bounded,
|
||||
and a timeout yields the results that DID land instead of nothing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import case_citation_verification as ccv
|
||||
|
||||
|
||||
def test_concurrency_bound_is_set_and_modest():
|
||||
assert 1 <= ccv._MAX_CONCURRENT_LOOKUPS <= 16, (
|
||||
"the bound exists to stay under Voyage's throttle point — a large value "
|
||||
"reintroduces the stall this was added to fix"
|
||||
)
|
||||
|
||||
|
||||
def test_retrieval_budget_leaves_room_under_the_proxy_timeout():
|
||||
"""The proxy gives up at 30s; we must cut ourselves off before that."""
|
||||
assert 0 < ccv._RETRIEVAL_BUDGET_S < 30
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semaphore_actually_caps_in_flight_work():
|
||||
"""A semaphore of N never lets N+1 coroutines run the body at once."""
|
||||
limit = ccv._MAX_CONCURRENT_LOOKUPS
|
||||
sem = asyncio.Semaphore(limit)
|
||||
in_flight = 0
|
||||
peak = 0
|
||||
|
||||
async def worker():
|
||||
nonlocal in_flight, peak
|
||||
async with sem:
|
||||
in_flight += 1
|
||||
peak = max(peak, in_flight)
|
||||
await asyncio.sleep(0.01)
|
||||
in_flight -= 1
|
||||
|
||||
await asyncio.gather(*(worker() for _ in range(limit * 4)))
|
||||
assert peak <= limit
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_harvests_finished_work_instead_of_discarding_it():
|
||||
"""The regression: wait_for(gather(...)) cancels everything on timeout, so
|
||||
one slow lookup threw away every result that had already succeeded. The
|
||||
harvest pattern must keep them."""
|
||||
|
||||
async def quick(i):
|
||||
await asyncio.sleep(0.01)
|
||||
return i
|
||||
|
||||
async def never():
|
||||
await asyncio.sleep(30)
|
||||
return "unreachable"
|
||||
|
||||
tasks = [asyncio.ensure_future(quick(i)) for i in range(5)]
|
||||
tasks.append(asyncio.ensure_future(never()))
|
||||
|
||||
done, pending = await asyncio.wait(tasks, timeout=0.3)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
harvested = [
|
||||
t.result() if (t in done and not t.cancelled() and t.exception() is None) else None
|
||||
for t in tasks
|
||||
]
|
||||
assert harvested[:5] == [0, 1, 2, 3, 4], "finished work must survive the timeout"
|
||||
assert harvested[5] is None, "the unfinished one is empty, not fabricated"
|
||||
assert len(pending) == 1
|
||||
65
mcp-server/tests/test_docket_grounding.py
Normal file
65
mcp-server/tests/test_docket_grounding.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""#232 trap 3 — a well-formed docket is not necessarily the right docket.
|
||||
|
||||
ערר (מרכז) 1094-09-19 (פדילה) was stored as ``1094-09-14``: shape-valid, so
|
||||
``_is_clean_docket`` waved it through, but wrong — and case_number is the
|
||||
identity field, so the row detached from every reference to the real case.
|
||||
Grounding the digits in the source text is what shape validation cannot do.
|
||||
"""
|
||||
|
||||
from legal_mcp.services.precedent_metadata_extractor import (
|
||||
_docket_grounded,
|
||||
_is_clean_docket,
|
||||
_strip_invisibles,
|
||||
)
|
||||
|
||||
|
||||
HEADER = "ערר (ועדות ערר - תכנון ובנייה מרכז) 1094-09-19 פדילה אברהים נ' הוועדה המקומית"
|
||||
|
||||
|
||||
def test_the_regression_shape_valid_but_wrong_digit():
|
||||
"""Both pass the shape check; only the real one is grounded."""
|
||||
assert _is_clean_docket("1094-09-14")
|
||||
assert _is_clean_docket("1094-09-19")
|
||||
assert _docket_grounded("1094-09-19", HEADER)
|
||||
assert not _docket_grounded("1094-09-14", HEADER)
|
||||
|
||||
|
||||
def test_separator_and_spacing_are_tolerated():
|
||||
"""A real docket must still match when the source writes it differently."""
|
||||
assert _docket_grounded("1094-09-19", "בערר 1094/09/19 נקבע")
|
||||
assert _docket_grounded("4768/22", "עת\"מ 4768-22 פלוני")
|
||||
assert _docket_grounded("1132-09-24", "תיק 1132 - 09 - 24")
|
||||
|
||||
|
||||
def test_bidi_marks_inside_the_number_do_not_defeat_grounding():
|
||||
"""Hebrew legal PDFs embed RLM/LRM between digits and separators."""
|
||||
noisy = "ערר (מרכז) 1094-09-19 פדילה"
|
||||
assert _strip_invisibles(noisy).count("") == 0
|
||||
assert _docket_grounded("1094-09-19", noisy)
|
||||
|
||||
|
||||
def test_grounding_accepts_the_value_being_replaced():
|
||||
"""Normalising an uploader's citation string into a clean docket is the
|
||||
whole point of the rewrite — the digits come from there, not the text."""
|
||||
citation = "ערר (ועדות ערר - תכנון ובנייה מרכז) 1094-09-19 פדילה נ' טירה (נבו 4.12.2019)"
|
||||
assert _docket_grounded("1094-09-19", "", citation)
|
||||
assert not _docket_grounded("1094-09-14", "", citation)
|
||||
|
||||
|
||||
def test_two_and_three_part_dockets_both_ground():
|
||||
assert _docket_grounded("8031/21", "בהיטל השבחה 8031/21 נדון")
|
||||
assert _docket_grounded("85074-09-24", "בל\"מ 85074-09-24")
|
||||
|
||||
|
||||
def test_non_numeric_or_empty_never_grounds():
|
||||
assert not _docket_grounded("", HEADER)
|
||||
assert not _docket_grounded("ערר 1094", HEADER)
|
||||
assert not _docket_grounded("abc-de", HEADER)
|
||||
|
||||
|
||||
def test_absent_from_every_source_is_refused():
|
||||
"""The פדילה failure mode: text has no docket at all, so anything the
|
||||
model offers is ungrounded and must not reach the identity field."""
|
||||
body = "בפני: יו\"ר הוועדה: רונית אלפר, עו\"ד\nהעוררים: 1. פדילה אברהים"
|
||||
assert not _docket_grounded("1094-09-14", body)
|
||||
assert not _docket_grounded("1094-09-19", body)
|
||||
68
mcp-server/tests/test_relevance_scale.py
Normal file
68
mcp-server/tests/test_relevance_scale.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""`score` and `relevance` are different things — thresholds must use `relevance`.
|
||||
|
||||
Retrieval returns cosine similarities (~0.4-0.75) until the lexical leg returns
|
||||
rows; then `_merge_sem_lex` replaces `score` with an RRF value (~0.008-0.02).
|
||||
Both are legitimate *ranking* signals, but they are not on the same scale, so a
|
||||
caller comparing `score >= 0.45` rejected every hit the moment BM25 matched
|
||||
anything. That is how the citation-verification tab came to show no supporting
|
||||
precedent for a single argument, on every case.
|
||||
|
||||
`relevance` is the fix: always a cosine, or None when the row came from the
|
||||
lexical leg alone and no cosine was ever computed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services.case_citation_verification import _SUGGEST_FLOOR, _passes_floor
|
||||
from legal_mcp.services.hybrid_search import _merge_sem_lex
|
||||
|
||||
|
||||
def _sem(key: str, score: float) -> dict:
|
||||
return {"chunk_id": key, "case_law_id": "c1", "score": score, "relevance": score}
|
||||
|
||||
|
||||
def _lex(key: str, score: float) -> dict:
|
||||
# The lexical leg emits ts_rank_cd, never a cosine — so no `relevance`.
|
||||
return {"chunk_id": key, "case_law_id": "c1", "score": score}
|
||||
|
||||
|
||||
def test_fusion_replaces_score_but_preserves_the_cosine():
|
||||
"""The regression in one assertion."""
|
||||
merged = _merge_sem_lex([_sem("a", 0.73)], [_lex("a", 0.31)], limit=10)
|
||||
row = merged[0]
|
||||
assert row["score"] < 0.1, "fused score is an RRF value, not a cosine"
|
||||
assert row["relevance"] == pytest.approx(0.73), "the cosine must survive fusion"
|
||||
|
||||
|
||||
def test_lexical_only_row_has_no_fabricated_cosine():
|
||||
"""None means 'not measured' — not 'measured as irrelevant'."""
|
||||
merged = _merge_sem_lex([], [_lex("b", 0.31)], limit=10)
|
||||
assert merged[0]["relevance"] is None
|
||||
|
||||
|
||||
def test_semantic_only_row_keeps_its_cosine():
|
||||
merged = _merge_sem_lex([_sem("c", 0.62)], [], limit=10)
|
||||
assert merged[0]["relevance"] == pytest.approx(0.62)
|
||||
|
||||
|
||||
def test_floor_would_have_rejected_everything_on_the_fused_score():
|
||||
"""Guards the exact production symptom: fused scores are ~0.008, the floor
|
||||
is 0.45, so score-based filtering wipes the result set."""
|
||||
merged = _merge_sem_lex(
|
||||
[_sem(k, 0.70) for k in "abcd"], [_lex(k, 0.30) for k in "abcd"], limit=10)
|
||||
assert all(r["score"] < _SUGGEST_FLOOR for r in merged) # the bug
|
||||
assert all(_passes_floor(r) for r in merged) # the fix
|
||||
|
||||
|
||||
def test_floor_still_rejects_genuinely_weak_hits():
|
||||
"""The fix must not become 'accept everything'."""
|
||||
assert not _passes_floor({"relevance": 0.10})
|
||||
assert not _passes_floor({"relevance": _SUGGEST_FLOOR - 0.01})
|
||||
assert _passes_floor({"relevance": _SUGGEST_FLOOR})
|
||||
|
||||
|
||||
def test_lexical_only_hits_are_kept_deliberately():
|
||||
"""An exact docket/phrase match reaches the top by BM25 rank with no cosine.
|
||||
Dropping it would hide precisely what a chair searches for by name."""
|
||||
assert _passes_floor({"relevance": None})
|
||||
assert _passes_floor({}) # missing key behaves the same as None
|
||||
237
mcp-server/tests/test_research_md_chair_position.py
Normal file
237
mcp-server/tests/test_research_md_chair_position.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""Chair-position round-trip in analysis-and-research.md.
|
||||
|
||||
Regression cover for the reader/writer asymmetry that made "עמדת ועדת הערר"
|
||||
appear to save on threshold claims and then vanish on refresh: the writer
|
||||
matched the label anywhere on a line, the reader only at the start of one, and
|
||||
the analyst agent writes threshold claims as a bullet list
|
||||
("- **עמדת ועדת הערר:**") while it writes issues flush-left.
|
||||
|
||||
The invariant these tests pin down: whatever update_chair_position writes,
|
||||
parse() must read back — for every label form that appears in the corpus.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import research_md
|
||||
|
||||
|
||||
BULLETED = """# ניתוח משפטי — ערר 1069-04-26
|
||||
תאריך: 2026-08-02
|
||||
|
||||
## 5. טענות סף
|
||||
|
||||
### ס-1 — אי-מילוי תנאי ההפקדה
|
||||
- **טענה (claim):** התכנית אושרה מבלי שמולאו תנאי ההפקדה.
|
||||
- **שאלה משפטית:** האם הסטת התנאים פוגמת בחוקיות?
|
||||
- **עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
|
||||
|
||||
### ס-2 — זכות עמידה
|
||||
- **טענה (claim):** לעוררים אין אינטרס מוגן.
|
||||
- **עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
|
||||
|
||||
## 6. סוגיות להכרעה
|
||||
|
||||
### סוגיה 1: סמכות לפי 62א(א)(11)
|
||||
|
||||
**ניתוח:**
|
||||
- הכלל החל: ...
|
||||
|
||||
**עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def analysis_file(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "analysis-and-research.md"
|
||||
path.write_text(BULLETED, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _positions(path: Path) -> dict[str, str]:
|
||||
parsed = research_md.parse(path)
|
||||
items = parsed["threshold_claims"] + parsed["issues"]
|
||||
return {item["id"]: item["chair_position"] for item in items}
|
||||
|
||||
|
||||
# ── the reported bug ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bulleted_threshold_position_survives_a_reload(analysis_file: Path) -> None:
|
||||
"""The bug as chaim hit it: saved on a threshold claim, gone on refresh."""
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדתנו היא שהטענה נדחית.")
|
||||
|
||||
assert _positions(analysis_file)["threshold_1"] == "עמדתנו היא שהטענה נדחית."
|
||||
|
||||
|
||||
def test_flush_left_issue_position_still_round_trips(analysis_file: Path) -> None:
|
||||
"""The surface that already worked must keep working."""
|
||||
research_md.update_chair_position(analysis_file, "issue_1", "יש לקבל את הערר בנקודה זו.")
|
||||
|
||||
assert _positions(analysis_file)["issue_1"] == "יש לקבל את הערר בנקודה זו."
|
||||
|
||||
|
||||
def test_bulleted_threshold_fields_are_parsed_at_all(analysis_file: Path) -> None:
|
||||
"""Bulleted labels were invisible to the reader — the whole claim looked empty."""
|
||||
threshold = research_md.parse(analysis_file)["threshold_claims"]
|
||||
|
||||
labels = [f["label"] for f in threshold[0]["fields"]]
|
||||
assert "טענה (claim)" in labels
|
||||
assert "שאלה משפטית" in labels
|
||||
# the chair field is surfaced separately, never as a regular field
|
||||
assert not any(research_md._is_chair_label(label) for label in labels)
|
||||
|
||||
|
||||
# ── writer/reader agreement on content boundaries ───────────────
|
||||
|
||||
|
||||
def test_position_containing_markdown_is_not_truncated(analysis_file: Path) -> None:
|
||||
"""The old writer stopped the field at the first '*' and swallowed the rest."""
|
||||
text = 'העמדה כוללת **הדגשה** וגם קו --- באמצע\nושורה שנייה.'
|
||||
research_md.update_chair_position(analysis_file, "issue_1", text)
|
||||
|
||||
assert _positions(analysis_file)["issue_1"] == text
|
||||
|
||||
|
||||
def test_update_does_not_touch_sibling_subsections(analysis_file: Path) -> None:
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "ראשונה")
|
||||
research_md.update_chair_position(analysis_file, "threshold_2", "שנייה")
|
||||
|
||||
positions = _positions(analysis_file)
|
||||
assert positions["threshold_1"] == "ראשונה"
|
||||
assert positions["threshold_2"] == "שנייה"
|
||||
assert positions["issue_1"] == ""
|
||||
|
||||
|
||||
def test_closing_rule_and_bullet_marker_are_preserved(analysis_file: Path) -> None:
|
||||
"""An update must not restructure the file the analyst produced."""
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
|
||||
research_md.update_chair_position(analysis_file, "issue_1", "עמדה")
|
||||
content = analysis_file.read_text(encoding="utf-8")
|
||||
|
||||
assert "- **עמדת ועדת הערר:**" in content
|
||||
assert content.rstrip().endswith("---")
|
||||
|
||||
|
||||
def test_clearing_a_position_restores_the_placeholder(analysis_file: Path) -> None:
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "")
|
||||
|
||||
assert _positions(analysis_file)["threshold_1"] == ""
|
||||
assert research_md.CHAIR_POSITION_PLACEHOLDERS[0] in analysis_file.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_repeated_saves_do_not_accumulate_blocks(analysis_file: Path) -> None:
|
||||
for text in ("ראשון", "שני", "שלישי"):
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", text)
|
||||
|
||||
content = analysis_file.read_text(encoding="utf-8")
|
||||
assert content.count(f"**{research_md.CHAIR_POSITION_LABEL}:**") == 3
|
||||
assert _positions(analysis_file)["threshold_1"] == "שלישי"
|
||||
|
||||
|
||||
# ── decorated and duplicated labels ─────────────────────────────
|
||||
|
||||
|
||||
def test_decorated_label_is_updated_not_duplicated(tmp_path: Path) -> None:
|
||||
"""'עמדת ועדת הערר (הכוונת יו"ר 24.6)' appears in the corpus (8125-09-24)."""
|
||||
path = tmp_path / "a.md"
|
||||
path.write_text(
|
||||
"## 6. סוגיות להכרעה\n\n"
|
||||
"### סוגיה 1: כותרת\n\n"
|
||||
'**עמדת ועדת הערר (הכוונת יו"ר 24.6):** [ימולא ע"י יו"ר הוועדה]\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
research_md.update_chair_position(path, "issue_1", "עמדה מעודכנת")
|
||||
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert content.count(research_md.CHAIR_POSITION_LABEL) == 1
|
||||
assert _positions(path)["issue_1"] == "עמדה מעודכנת"
|
||||
|
||||
|
||||
def test_duplicate_labels_read_back_the_one_that_was_written(tmp_path: Path) -> None:
|
||||
"""An H3 covering several issues (1033-02-25) must not read a sibling's text."""
|
||||
path = tmp_path / "a.md"
|
||||
path.write_text(
|
||||
"## 6. סוגיות להכרעה\n\n"
|
||||
"### סוגיות 4–6: מקובצות\n\n"
|
||||
"**עמדת ועדת הערר:** ראשונה\n\n"
|
||||
"**עמדת ועדת הערר:** אחרונה\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
research_md.update_chair_position(path, "issue_1", "העמדה הנכונה")
|
||||
|
||||
assert _positions(path)["issue_1"] == "העמדה הנכונה"
|
||||
|
||||
|
||||
# ── read-after-write guard ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_update_returns_the_persisted_value(analysis_file: Path) -> None:
|
||||
result = research_md.update_chair_position(analysis_file, "threshold_1", " עמדה ")
|
||||
|
||||
assert result["saved"] is True
|
||||
assert result["position"] == "עמדה"
|
||||
|
||||
|
||||
def test_a_write_the_parser_cannot_read_back_is_reported_as_failure(
|
||||
analysis_file: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Regression guard: the bug hid behind a success response for months."""
|
||||
monkeypatch.setattr(
|
||||
research_md, "_stored_chair_position", lambda *_args: "משהו אחר"
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="לא נקראה בחזרה"):
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
|
||||
|
||||
|
||||
# ── downstream consumers see the recovered positions ────────────
|
||||
|
||||
|
||||
def test_chair_directions_count_bulleted_positions(analysis_file: Path) -> None:
|
||||
"""extract_chair_directions feeds legal-writer; it under-reported before."""
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
|
||||
|
||||
directions = research_md.extract_chair_directions(analysis_file)
|
||||
assert directions["filled_count"] == 1
|
||||
assert directions["status"] == "partial"
|
||||
filled = [t for t in directions["threshold_claims"] if t["direction"]]
|
||||
assert [t["id"] for t in filled] == ["threshold_1"]
|
||||
|
||||
|
||||
# ── the real corpus ─────────────────────────────────────────────
|
||||
|
||||
|
||||
CORPUS = sorted(
|
||||
Path("/home/chaim/legal-ai/data/cases").glob(
|
||||
"*/documents/research/analysis-and-research.md"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not CORPUS, reason="case corpus not present on this host")
|
||||
@pytest.mark.parametrize("source", CORPUS, ids=lambda p: p.parts[-4])
|
||||
def test_every_corpus_subsection_round_trips(source: Path, tmp_path: Path) -> None:
|
||||
sentinel = "עמדת-בדיקה — **הדגשה** ו---קו\nושורה שנייה."
|
||||
before = _positions(source)
|
||||
|
||||
for section_id in before:
|
||||
working = tmp_path / f"{section_id}.md"
|
||||
working.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
research_md.update_chair_position(working, section_id, sentinel)
|
||||
after = _positions(working)
|
||||
|
||||
assert after[section_id] == sentinel, f"{source.parts[-4]} {section_id}"
|
||||
untouched = {k: v for k, v in after.items() if k != section_id}
|
||||
assert untouched == {k: v for k, v in before.items() if k != section_id}
|
||||
67
mcp-server/tests/test_source_kind_selector.py
Normal file
67
mcp-server/tests/test_source_kind_selector.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""#232 — the source_kind selector must not silently hide a corpus.
|
||||
|
||||
`search_precedent_library` defaulted to source_kind='external_upload' all the
|
||||
way down the stack, so 104 appeals-committee decisions (29% of the corpus)
|
||||
were unreachable through the entry point the writing agents actually call.
|
||||
These tests pin the selector semantics; the end-to-end retrieval check lives
|
||||
in scripts/test_retrieval_by_name.py (needs a live DB).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services.db import _source_kind_clause
|
||||
|
||||
|
||||
def test_empty_selector_means_no_filter():
|
||||
"""'' and 'all' must produce no predicate — the whole corpus."""
|
||||
assert _source_kind_clause("") == ""
|
||||
assert _source_kind_clause("all") == ""
|
||||
assert _source_kind_clause(" ") == ""
|
||||
|
||||
|
||||
def test_named_kinds_produce_equality_predicate():
|
||||
assert _source_kind_clause("internal_committee", "cl.") == (
|
||||
"cl.source_kind = 'internal_committee'"
|
||||
)
|
||||
assert _source_kind_clause("external_upload") == "source_kind = 'external_upload'"
|
||||
|
||||
|
||||
def test_all_committees_expands_to_both_shapes():
|
||||
"""Committee decisions live under two historical shapes — cover both."""
|
||||
clause = _source_kind_clause("all_committees", "cl.")
|
||||
assert "cl.source_kind = 'internal_committee'" in clause
|
||||
assert "cl.source_type = 'appeals_committee'" in clause
|
||||
assert clause.startswith("(") and clause.endswith(")")
|
||||
|
||||
|
||||
def test_unknown_selector_raises_rather_than_reaching_sql():
|
||||
"""The value is f-string-interpolated, so the whitelist is the guard."""
|
||||
with pytest.raises(ValueError, match="source_kind"):
|
||||
_source_kind_clause("'; DROP TABLE case_law; --")
|
||||
with pytest.raises(ValueError):
|
||||
_source_kind_clause("internal")
|
||||
|
||||
|
||||
def test_default_of_the_search_entry_points_is_whole_corpus():
|
||||
"""A regression guard on the defaults themselves — this is the bug."""
|
||||
import inspect
|
||||
|
||||
from legal_mcp.services import hybrid_search, precedent_library
|
||||
from legal_mcp.services import db as db_mod
|
||||
from legal_mcp.tools import precedent_library as plib_tool
|
||||
|
||||
for fn in (
|
||||
db_mod.search_precedent_library_semantic,
|
||||
db_mod.search_precedent_library_lexical,
|
||||
db_mod.list_external_case_law,
|
||||
hybrid_search.search_precedent_library_hybrid,
|
||||
precedent_library.search_library,
|
||||
precedent_library.list_precedents,
|
||||
plib_tool.search_precedent_library,
|
||||
plib_tool.precedent_library_list,
|
||||
):
|
||||
default = inspect.signature(fn).parameters["source_kind"].default
|
||||
assert default == "", (
|
||||
f"{fn.__module__}.{fn.__qualname__} defaults source_kind to "
|
||||
f"{default!r} — that hides a corpus from every caller (#232)"
|
||||
)
|
||||
@@ -26,6 +26,7 @@
|
||||
| `test_retrieval_by_name.py` | python | בדיקת אחזור-לפי-שם (#52/RC-A) — מאמת ש`search_precedent_library`/`search_internal_decisions` מדרגים את ההחלטה עצמה (אגסי) מעל מי שמצטט אותה, + רגרסיות לשאילתות מהותיות. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/test_retrieval_by_name.py` (exit 0 = עבר). | ידני אחרי שינוי שכבת חיפוש |
|
||||
| `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth |
|
||||
| `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank |
|
||||
| `legal-mcp-http.config.cjs` | pm2/js | **שרת ה-MCP חשוף ב-streamable-http** (#231) — `python -m legal_mcp.server` עם `MCP_TRANSPORT=streamable-http`, bound **`127.0.0.1:8790`**, Bearer `MCP_HTTP_SHARED_SECRET` מ-`~/.legal-mcp-http.env` (מקור-אמת: Infisical → All Infrastructure / `main` / `/apps/legal-ai`, תג `credentials`). **למה:** סוכנים המונעים דרך Agent Client Protocol מקבלים את שרתי-ה-MCP שלהם מהלקוח בפתיחת הסשן, והערוץ הזה נושא שרתי HTTP בלבד — ל-stdio אין מסלול לשם, ומכאן שהסוכנים נותרו בלי 108 הכלים. **אינו מחליף את stdio:** כל סשן אינטראקטיבי ממשיך דרך `~/.claude.json`; אותו קוד, אותו מרשם-כלים, שתי תחבורות (G2). **אבטחה:** loopback בלבד (צר יותר מ-`10.0.1.1` של legal-chat-service — שום קונטיינר לא צריך MCP), והשרת **מסרב לעלות בלי טוקן** (`services/http_auth.py`), כך שתקלת-הגדרה לא יכולה לייצר מאזין לא-מאומת. מראָה לדפוס `legal-chat-service.config.cjs`. התקנה: `pm2 start scripts/legal-mcp-http.config.cjs && pm2 save`. בדיקה: POST ל-`/mcp` → 401 בלי טוקן, 200 עם. | pm2 (host-side) |
|
||||
| `legal-court-fetch-service.config.cjs` | pm2/js | **שירות-מארח Tier-1 לאחזור פסקי-דין מנט המשפט (X13)** — 2 apps: (א) `legal-court-fetch-xvfb` (Xvfb :99, צג-וירטואלי ל-Camoufox); (ב) `legal-court-fetch-service` (`python -m legal_mcp.court_fetch_service.server`, bound `10.0.1.1:8771`, Bearer `COURT_FETCH_SHARED_SECRET` מ-`~/.legal-court-fetch-service.env`, `DISPLAY=:99`). מריץ Camoufox דרך חבילת-הפייתון (in-process) כי הקונטיינר לא יכול דפדפן. תלות: `pip install -e "mcp-server[court-fetch]" && python -m camoufox fetch`. אחזור = ניווט→צופה→`GetImages`(X-Requested-With)→PDF, ללא CAPTCHA; כשל→`ok:false`→orchestrator מסלים ל-fallback אנושי. **אומת על עת"מ 46111-12-22 (34 עמ').** מראָה לדפוס `legal-chat-service.config.cjs`. ספ: `docs/spec/X13-court-fetch.md`. התקנה: `pm2 start scripts/legal-court-fetch-service.config.cjs && pm2 save`. בריאות: `curl http://10.0.1.1:8771/health`. | pm2 (host-side) |
|
||||
| `drain_court_fetch.py` | python | **ריקון תור-אחזור הפסיקה (X13)** — קורא ל-`court_fetch_orchestrator.drain_pending(limit)` שמוריד+קולט כל job ממתין שהיומונים מילאו, וקושר חזרה ליומון. מקומי בלבד (ingest = claude CLI). no-op מהיר כשהתור ריק. הרצה ידנית: `mcp-server/.venv/bin/python scripts/drain_court_fetch.py [limit]`. | דרך `legal-court-fetch-drain.config.cjs` (pm2 cron) |
|
||||
| `legal-court-fetch-drain.config.cjs` | pm2/js | **תזמון שעתי של `drain_court_fetch.py`** (cron `17 * * * *`, `COURT_FETCH_DRAIN_CRON` לעקיפה) — הופך את לולאת יומון→אחזור→קליטה ל-fully-autonomous. `autorestart:false` (one-shot per tick). דורש `legal-court-fetch-service` רץ. התקנה: `pm2 start scripts/legal-court-fetch-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
|
||||
@@ -98,6 +99,7 @@
|
||||
|--------|------|---------|-----------|
|
||||
| `spec-guard.sh` | bash | **PreToolUse hook לאכיפת "פרוטוקול כתיבת-קוד"** (CLAUDE.md §פרוטוקול כתיבת-קוד) — בכל Edit/Write/MultiEdit על נתיב-קוד (`web/`, `mcp-server/`, `web-ui/src/`, `scripts/`, `adapters/`) מזריק תזכורת ל-Claude לקרוא את `docs/spec/00-constitution.md`+ספ-התחום ולוודא קיום G1–G12 — לפני שכותבים. **+ leak-guard בזמן-אמת (G12):** על כתיבה ל-`mcp-server/src/*` בודק את התוכן-הנכתב (`new_string`/`content`) ומזהיר אם מוזרק מונח-Paperclip לשכבת-האינטליגנציה (לא-deduped). המקבילה האינטראקטיבית ל-INV-AG1. קלט JSON ב-stdin, פלט `hookSpecificOutput.additionalContext` (non-blocking, exit 0). Dedup פעם-בסשן לתזכורת-הספ. רשום ב-`.claude/settings.json`. | נקרא אוטומטית ע"י Claude Code (hook) |
|
||||
| `leak_guard.py` | python | **המאכף הקנוני של INV-G12 (שער-הפלטפורמה / docs/spec/X15 §4 / R4).** שני כללים קשיחים: (1) `mcp-server/src` ללא סמלי-Paperclip (allowlist מנומק לפי substring); (2) רק `web/agent_platform_port.py` (+ קבצי-המעטפת) מייבאים את לקוח-Paperclip. stdlib-בלבד (אין venv). `leak_guard.py` = סריקת-repo (exit 1 על הפרה); `leak_guard.py <file>...` = קבצים נתונים (ל-hook). משותף ל-spec-guard.sh (hook), ל-CI (`.gitea/workflows/leak-guard.yaml`) ול-`mcp-server/tests/test_platform_port_leak_guard.py`. | CI + hook + pytest |
|
||||
| `agent_tool_grants_guard.py` | python | **המאכף הקנוני של INV-AG3 (מפת-הרשאות הסוכנים / docs/spec/X4-agents.md §2א).** ה-frontmatter `tools:` של סוכן הוא **allow-list סגורה** — כלי הרשום בשרת-ה-MCP אך חסר ממנה אינו ניתן לקריאה, גם כשהשרת מחובר. ארבעה כללים קשיחים: (1) כל `mcp__legal-ai__X` המופיע ב-`web/` (delegation שיוצר issue לסוכן) מוענק לסוכן כלשהו; (2) כל `mcp__legal-ai__X` בגוף קובץ-סוכן מוענק ב-frontmatter של **אותו** קובץ; (3) אין הענקה לכלי שאינו רשום ב-`@mcp.tool`; (4) שם-כלי בגרשיים-הפוכים ללא תחילית — מוענק, או מסווג ב-`CONTRASTIVE_OK` עם נימוק (כולל בדיקת-התיישנות לסיווגים). מחריג קבצים שאינם סוכני-claude_local: `hermes-curator.md`, `legal-analyst-gemini-critique.md`, `HEARTBEAT.md`. **כלל 5 (host-only, `--check-bindings`):** allow-list נאכפת רק כשה-runtime בוחר את הסוכן (`--agent <name>`); בלעדיו אותו קובץ נמסר כ-`--append-system-prompt-file` — פרוזה, לא שער — וכל הכלים נשארים נגישים. הקשירה יושבת ב-DB של הפלטפורמה ולכן ה-CI לא רואה אותה; בלי הדגל השער **אומר זאת במפורש** במקום לרמוז על אכיפה שלא אימת. stdlib-בלבד. נבנה אחרי CMP-229 (2026-08-04) — `analyze_protocol` נרשם בשרת ב-2026-06-30 בלי הענקה, ו-#226 הורה למנתח להריץ אותו. CI: `.gitea/workflows/agent-tool-grants.yaml`. | CI |
|
||||
| `check_undefined_names.py` | python | **CI gate ל-undefined names (מחלקת ה-NameError).** מריץ pyflakes על `web`, `mcp-server/src`, `scripts` ומפיל build (exit 1) רק על "undefined name"/"may be undefined" — לא על imports-לא-בשימוש/f-strings (רעש). זו בדיוק מחלקת-הבאג של PR #249 (שינוי-שם תיק → 500): שם שמופנה אך לא מיובא/מוגדר, חבוי בתוך `background_tasks` עד זמן-ריצה. דורש pyflakes (ה-workflow מתקין ל-venv זמני). משותף ל-CI (`.gitea/workflows/lint.yaml`). | CI |
|
||||
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
||||
| `host_sync.sh` | bash | מסנכרן את עץ-המארח `~/legal-ai` ל-origin/main (ff-only) כדי שקוד-המארח (כותב/פאנלים/MCP שרצים מהעץ, לא בקונטיינר) יתעדכן אחרי merge; restart מדויק ל-chat/court-fetch/reaper רק כשקבציהם משתנים. בטוח: אף-פעם לא force; tasks.json הדירטי נשמר. סוגר את פער-פריסת-המארח (TaskMaster #160) | `* * * * *` (cron, flock) |
|
||||
|
||||
319
scripts/agent_tool_grants_guard.py
Executable file
319
scripts/agent_tool_grants_guard.py
Executable file
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""INV-AG3 guard — every MCP tool an agent is TOLD to run must be GRANTED to it.
|
||||
|
||||
The canonical checker for INV-AG3 (docs/spec/X4-agents.md §2א): a Claude-Code
|
||||
subagent's ``tools:`` frontmatter is a CLOSED allow-list. A tool that is
|
||||
registered on the MCP server but absent from that list is *not callable* by the
|
||||
agent, however well the server is connected.
|
||||
|
||||
Why this exists — the failure it is built to catch (2026-08-04):
|
||||
``analyze_protocol`` shipped on 2026-06-30 (24e3e2f) touching 9 files, none of
|
||||
them ``.claude/agents/*``. Later ``wake_analyst_for_protocol_analysis``
|
||||
(web/paperclip_client.py, #226) started writing "הרץ
|
||||
``mcp__legal-ai__analyze_protocol(...)``" straight into the analyst's issue.
|
||||
The analyst therefore received an explicit instruction to run a tool it was
|
||||
never granted, reported "tools exist on the connected server but aren't exposed
|
||||
as callable in this session", and burned two runs working around it via raw
|
||||
psql + a hand-written script. INV-AG3 already covered this on paper; its
|
||||
enforcement was deferred ("אכיפה אוטומטית עתידית"), so the drift went unnoticed
|
||||
for five weeks. This script is that deferred enforcement.
|
||||
|
||||
Three HARD rules:
|
||||
|
||||
1. **Backend delegation.** Every ``mcp__legal-ai__X`` named inside ``web/``
|
||||
(the backend telling an agent what to run) must be granted to at least one
|
||||
agent. This is the rule that catches the 2026-08-04 failure.
|
||||
|
||||
2. **Per-agent instructions.** Every ``mcp__legal-ai__X`` in an agent file's
|
||||
BODY must be granted in that same file's frontmatter. Prefixed mentions are
|
||||
imperative by convention ("הרץ `mcp__legal-ai__…`").
|
||||
|
||||
3. **No phantom grants.** Every granted tool must actually be registered on
|
||||
the MCP server — catches typos and tools deleted out from under an agent.
|
||||
|
||||
Plus one reviewed-exception rule:
|
||||
|
||||
4. **Bare tool names.** An agent body may name a tool in backticks without the
|
||||
``mcp__legal-ai__`` prefix (```get_legal_arguments```). Those are
|
||||
ambiguous: some are real requirements, others are deliberately contrastive
|
||||
("**לא** דרך `precedent_library_upload`"), a pointer at *another* agent's
|
||||
job, or a DB column that merely shares a tool's name. Each is classified
|
||||
once in ``CONTRASTIVE_OK`` below; anything new fails until reviewed.
|
||||
|
||||
And one host-only rule, opt-in via ``--check-bindings``:
|
||||
|
||||
5. **Bindings.** Rules 1–4 compare files to files, which says nothing about
|
||||
whether an allow-list is *enforced*. It is only enforced when the runtime
|
||||
selects that agent (``--agent <name>``); without the flag the same file is
|
||||
delivered as ``--append-system-prompt-file`` — prose, not a gate — and every
|
||||
tool stays reachable. Found on 2026-08-05: one agent declared 41 grants with
|
||||
no ``--agent`` flag, so the largest allow-list in the system was inert while
|
||||
this guard reported OK. The binding lives in the platform DB, so CI cannot
|
||||
see it; without the flag the guard now says so out loud instead of implying
|
||||
enforcement it never verified.
|
||||
|
||||
NOT AGENTS (no frontmatter by design — the adapter sends the file as a raw
|
||||
prompt, so YAML would leak into it): ``hermes-curator.md`` (deepseek_local),
|
||||
``legal-analyst-gemini-critique.md`` (gemini_local). ``HEARTBEAT.md`` is a
|
||||
shared checklist, not an agent. All three are skipped.
|
||||
|
||||
Usage:
|
||||
agent_tool_grants_guard.py # exit 1 on any violation
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
AGENTS_DIR = REPO / ".claude" / "agents"
|
||||
MCP_SRC = REPO / "mcp-server" / "src"
|
||||
BACKEND_DIR = REPO / "web"
|
||||
|
||||
# Files under .claude/agents/ that are not claude_local subagent definitions.
|
||||
NOT_AGENTS = {
|
||||
"HEARTBEAT.md",
|
||||
"hermes-curator.md",
|
||||
"legal-analyst-gemini-critique.md",
|
||||
}
|
||||
|
||||
# Bare (unprefixed) tool names in an agent body that are NOT requirements.
|
||||
# Each entry is (agent file, tool, why) — reviewed 2026-08-04. Adding to this
|
||||
# map is a deliberate act: it asserts "the agent is not being told to call this".
|
||||
CONTRASTIVE_OK = {
|
||||
("legal-analyst.md", "case_create"): "prose about the cases.practice_area CHECK constraint, not a call",
|
||||
("legal-analyst.md", "search_internal_decisions"): "names the filter surface when contrasting Axis A/B",
|
||||
("legal-ceo.md", "search_decisions"): "contrast — 'search_decisions = only Dafna' vs the granted search_internal_decisions",
|
||||
("legal-ceo.md", "precedent_library_upload"): "explicitly the forbidden path ('לא דרך …', citation guard rejects)",
|
||||
("legal-ceo.md", "document_update"): "describes the tagging chaim must fix, not a CEO call",
|
||||
("legal-proofreader.md", "extraction_status"): "the documents.extraction_status DB column — name collides with a tool",
|
||||
("legal-qa.md", "precedent_attach"): "explicitly the researcher's job ('דרך precedent_attach של ה-researcher')",
|
||||
("legal-writer.md", "revise_draft"): "the CEO calls it ('CEO יקרא ל-revise_draft'), not the writer",
|
||||
("legal-writer.md", "search_case_precedents"): "a do-not-confuse disambiguation note ('שונה! … לא לבלבל')",
|
||||
}
|
||||
|
||||
TOOL_RE = re.compile(r"mcp__legal-ai__(\w+)")
|
||||
REGISTER_RE = re.compile(r"@mcp\.tool\([^)]*\)\s*(?:async\s+)?def\s+(\w+)")
|
||||
# `tool_name(` or `tool_name` inside backticks.
|
||||
BARE_RE = re.compile(r"`(\w+)[(`]")
|
||||
|
||||
|
||||
def server_tools() -> set[str]:
|
||||
"""Tool names registered on the MCP server."""
|
||||
out: set[str] = set()
|
||||
for path in MCP_SRC.rglob("*.py"):
|
||||
out |= set(REGISTER_RE.findall(path.read_text(encoding="utf-8", errors="ignore")))
|
||||
return out
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[str, str]:
|
||||
"""Return (frontmatter, body). Empty frontmatter when the file has none."""
|
||||
if not text.startswith("---"):
|
||||
return "", text
|
||||
parts = text.split("---")
|
||||
if len(parts) < 3:
|
||||
return "", text
|
||||
return parts[1], "---".join(parts[2:])
|
||||
|
||||
|
||||
def agent_files() -> list[Path]:
|
||||
return sorted(p for p in AGENTS_DIR.glob("*.md") if p.name not in NOT_AGENTS)
|
||||
|
||||
|
||||
def check_bindings() -> list[tuple[str, str]]:
|
||||
"""Return [(agent file, why)] for agents whose allow-list nothing enforces.
|
||||
|
||||
A ``tools:`` list is only an allow-list when the runtime is told which agent
|
||||
to be. The local adapter enforces it under ``--agent <name>``; without that
|
||||
flag the very same file is delivered as ``--append-system-prompt-file``, i.e.
|
||||
prose the model may follow or ignore, and every tool stays reachable.
|
||||
|
||||
Found the hard way on 2026-08-05: one agent carried 41 grants and no
|
||||
``--agent`` flag, so the largest allow-list in the system was inert — and
|
||||
this guard had been reporting OK on it, because Rules 1–4 only ever compare
|
||||
files to files.
|
||||
|
||||
Host-only. The binding lives in the platform's database, which CI cannot
|
||||
reach, so this shells out to ``psql`` rather than adding a driver dependency
|
||||
that would break the stdlib-only property the CI path relies on. Returns []
|
||||
when the database is unreachable — an unreachable DB is "not checked", not
|
||||
"no violations", and the caller prints that distinction.
|
||||
"""
|
||||
|
||||
sql = (
|
||||
"select adapter_config->>'instructionsEntryFile', "
|
||||
"coalesce(adapter_config->>'extraArgs','') "
|
||||
"from agents where adapter_type='claude_local' "
|
||||
"and adapter_config->>'instructionsEntryFile' is not null;"
|
||||
)
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["psql", "-h", "localhost", "-p", "54329", "-U", "paperclip",
|
||||
"-d", "paperclip", "-X", "-A", "-t", "-F", "\t", "-c", sql],
|
||||
capture_output=True, text=True, timeout=20,
|
||||
env={**os.environ, "PGPASSWORD": os.environ.get("PGPASSWORD", "paperclip")},
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
if out.returncode != 0:
|
||||
return []
|
||||
|
||||
bad: dict[str, str] = {}
|
||||
for line in out.stdout.splitlines():
|
||||
if "\t" not in line:
|
||||
continue
|
||||
entry_file, extra = line.split("\t", 1)
|
||||
entry_file = entry_file.strip()
|
||||
if not entry_file or entry_file in NOT_AGENTS:
|
||||
continue
|
||||
want = entry_file[:-3] if entry_file.endswith(".md") else entry_file
|
||||
try:
|
||||
args = json.loads(extra) if extra.strip() else []
|
||||
except json.JSONDecodeError:
|
||||
args = []
|
||||
# Only a literal ["--agent", "<name>"] pair binds the allow-list.
|
||||
ok = any(
|
||||
a == "--agent" and i + 1 < len(args) and args[i + 1] == want
|
||||
for i, a in enumerate(args)
|
||||
)
|
||||
if not ok:
|
||||
bad[entry_file] = (
|
||||
"extraArgs is empty" if not args
|
||||
else f"extraArgs={extra.strip()} does not select '{want}'"
|
||||
)
|
||||
|
||||
# Only report agents that actually declare grants — an agent with no tools:
|
||||
# list has nothing to enforce and is not a finding.
|
||||
result = []
|
||||
for path in agent_files():
|
||||
if path.name in bad:
|
||||
fm, _ = split_frontmatter(path.read_text(encoding="utf-8", errors="ignore"))
|
||||
if TOOL_RE.findall(fm):
|
||||
result.append((path.name, bad[path.name]))
|
||||
return sorted(result)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
registered = server_tools()
|
||||
if not registered:
|
||||
print("agent-tool-grants: FAIL — no @mcp.tool registrations found; is the tree complete?")
|
||||
return 1
|
||||
|
||||
grants: dict[str, set[str]] = {}
|
||||
bodies: dict[str, str] = {}
|
||||
for path in agent_files():
|
||||
fm, body = split_frontmatter(path.read_text(encoding="utf-8", errors="ignore"))
|
||||
grants[path.name] = set(TOOL_RE.findall(fm))
|
||||
bodies[path.name] = body
|
||||
|
||||
all_granted: set[str] = set().union(*grants.values()) if grants else set()
|
||||
violations: list[str] = []
|
||||
|
||||
# Rule 1 — backend delegation must land on a granted tool.
|
||||
for path in sorted(BACKEND_DIR.rglob("*.py")):
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
for tool in sorted(set(TOOL_RE.findall(text))):
|
||||
if tool not in all_granted:
|
||||
rel = path.relative_to(REPO)
|
||||
violations.append(
|
||||
f"[1 backend] {rel} instructs an agent to run "
|
||||
f"mcp__legal-ai__{tool}, but NO agent grants it.\n"
|
||||
f" fix: add `- mcp__legal-ai__{tool}` to the tools: "
|
||||
f"frontmatter of the agent that receives that issue."
|
||||
)
|
||||
|
||||
# Rule 2 — a prefixed mention in an agent body is an instruction to that agent.
|
||||
for name, body in bodies.items():
|
||||
for tool in sorted(set(TOOL_RE.findall(body))):
|
||||
if tool not in grants[name]:
|
||||
violations.append(
|
||||
f"[2 instructions] .claude/agents/{name} tells the agent to run "
|
||||
f"mcp__legal-ai__{tool}, which its own tools: list omits.\n"
|
||||
f" fix: add `- mcp__legal-ai__{tool}` to that frontmatter."
|
||||
)
|
||||
|
||||
# Rule 3 — no grant may point at a tool the server does not register.
|
||||
for name, granted in grants.items():
|
||||
for tool in sorted(granted - registered):
|
||||
violations.append(
|
||||
f"[3 phantom] .claude/agents/{name} grants mcp__legal-ai__{tool}, "
|
||||
f"which is not registered on the MCP server.\n"
|
||||
f" fix: correct the name, or drop the grant if the tool was removed."
|
||||
)
|
||||
|
||||
# Rule 4 — every bare tool name is either granted or classified as contrastive.
|
||||
for name, body in bodies.items():
|
||||
bare = {m for m in BARE_RE.findall(body) if m in registered}
|
||||
for tool in sorted(bare - grants[name]):
|
||||
if (name, tool) in CONTRASTIVE_OK:
|
||||
continue
|
||||
violations.append(
|
||||
f"[4 bare name] .claude/agents/{name} mentions `{tool}` — a real MCP "
|
||||
f"tool it is not granted.\n"
|
||||
f" fix: grant it if the agent must call it, otherwise add "
|
||||
f"(\"{name}\", \"{tool}\") to CONTRASTIVE_OK with the reason."
|
||||
)
|
||||
|
||||
# Stale exceptions: a classification that no longer matches the text is noise.
|
||||
for (name, tool), _why in sorted(CONTRASTIVE_OK.items()):
|
||||
if name not in bodies:
|
||||
violations.append(
|
||||
f"[4 stale] CONTRASTIVE_OK names {name}, which is not an agent file."
|
||||
)
|
||||
elif tool not in {m for m in BARE_RE.findall(bodies[name])}:
|
||||
violations.append(
|
||||
f"[4 stale] CONTRASTIVE_OK ({name}, {tool}) no longer appears in that "
|
||||
f"file — drop the exception."
|
||||
)
|
||||
|
||||
# Rule 5 — a grant list only binds if the runtime actually selects that agent.
|
||||
unenforced = check_bindings() if "--check-bindings" in sys.argv else None
|
||||
if unenforced:
|
||||
for name, detail in unenforced:
|
||||
violations.append(
|
||||
f"[5 binding] .claude/agents/{name} declares a tools: allow-list, but "
|
||||
f"the runtime does not select that agent — {detail}.\n"
|
||||
f" The list is inert: it is delivered as prompt text only, so "
|
||||
f"every tool remains callable.\n"
|
||||
f" fix: set adapter_config.extraArgs to "
|
||||
f'["--agent", "{name[:-3]}"], or drop tools: and document the agent as '
|
||||
f"unrestricted. Not both."
|
||||
)
|
||||
|
||||
if violations:
|
||||
print(f"INV-AG3 agent-tool-grants guard: {len(violations)} violation(s)\n")
|
||||
for v in violations:
|
||||
print(f" ✗ {v}")
|
||||
print(
|
||||
"\ndocs/spec/X4-agents.md §2א INV-AG3 — the frontmatter tools: list is a "
|
||||
"CLOSED allow-list.\nA tool missing from it is not callable, no matter that "
|
||||
"the MCP server is connected."
|
||||
)
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"INV-AG3 agent-tool-grants guard: OK "
|
||||
f"({len(agent_files())} agents, {len(all_granted)} distinct grants, "
|
||||
f"{len(registered)} tools registered)"
|
||||
)
|
||||
if unenforced is None:
|
||||
# Say plainly what was NOT checked. A guard that prints a bare "OK" invites
|
||||
# the reader to conclude the allow-lists are enforced; this one has only
|
||||
# compared files to files. Enforcement is a runtime property (see Rule 5),
|
||||
# and on 2026-08-05 exactly one agent was found declaring 41 grants that
|
||||
# nothing enforces — while this guard reported OK.
|
||||
print(
|
||||
" note: file-level only. Whether each allow-list is actually ENFORCED "
|
||||
"depends on the\n runtime passing --agent <name>, which needs the platform "
|
||||
"DB — re-run with --check-bindings\n on the host to verify."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -166,17 +166,33 @@ def aggregate_cell(per_run: list[dict]) -> dict:
|
||||
"""Mean each metric across repeated generations of the same (case, block, effort)."""
|
||||
if not per_run:
|
||||
return {"distance": 1.0, "anti_pattern_total": 0.0, "change_percent": 100.0,
|
||||
"golden_ratio_deviation_pp": None, "n": 0}
|
||||
"golden_ratio_deviation_pp": None, "anti_by_pattern": {}, "n": 0}
|
||||
ratios = [r["golden_ratio_deviation_pp"] for r in per_run if r.get("golden_ratio_deviation_pp") is not None]
|
||||
return {
|
||||
"distance": round(mean(r["distance"] for r in per_run), 4),
|
||||
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in per_run), 2),
|
||||
"change_percent": round(mean(r["change_percent"] for r in per_run), 2),
|
||||
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
|
||||
"anti_by_pattern": _mean_by_pattern(per_run),
|
||||
"n": len(per_run),
|
||||
}
|
||||
|
||||
|
||||
def _mean_by_pattern(per_run: list[dict]) -> dict:
|
||||
"""Mean hits PER anti-pattern name across runs — the 'which rule broke' view.
|
||||
|
||||
A pattern absent from a run counts as 0 (count_anti_patterns omits zero-hit
|
||||
keys), so the mean is over ALL runs, not only the ones that tripped it.
|
||||
"""
|
||||
names: set[str] = set()
|
||||
for r in per_run:
|
||||
names |= set((r.get("anti_by_pattern") or {}).keys())
|
||||
return {
|
||||
name: round(mean((r.get("anti_by_pattern") or {}).get(name, 0) for r in per_run), 2)
|
||||
for name in sorted(names)
|
||||
}
|
||||
|
||||
|
||||
def _current_default(block_id: str) -> str | None:
|
||||
from legal_mcp.services.block_writer import BLOCK_CONFIG, DEFAULT_EFFORT
|
||||
cfg = BLOCK_CONFIG.get(block_id, {})
|
||||
@@ -420,13 +436,30 @@ async def _finals_for_calibration(case_filter: str | None) -> list[dict]:
|
||||
|
||||
|
||||
async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
|
||||
final_total_words: int, outcome: str, repeats: int) -> dict:
|
||||
"""Generate `block_id` at `effort` `repeats` times; score each vs the final section."""
|
||||
final_total_words: int, outcome: str, repeats: int,
|
||||
model: str | None = None, instructions: str = "") -> dict:
|
||||
"""Generate `block_id` at `effort` `repeats` times; score each vs the final section.
|
||||
|
||||
`model` (optional) A/Bs the generation model via write_block(model_override=…).
|
||||
None ⇒ the pinned GENERATION_MODEL, i.e. the production path unchanged.
|
||||
|
||||
`instructions` (optional) is appended to the block prompt for EVERY cell in
|
||||
the run — a prompt-variant A/B (e.g. an explicit formatting rule). It is
|
||||
applied to all models so the comparison stays a model comparison rather
|
||||
than silently becoming a prompt comparison.
|
||||
"""
|
||||
from legal_mcp.services import block_writer
|
||||
from legal_mcp.services.style_distance import block_distance_to_final
|
||||
runs: list[dict] = []
|
||||
models_used: list[str] = []
|
||||
for _ in range(repeats):
|
||||
res = await block_writer.write_block(case_id, block_id, effort_override=effort)
|
||||
res = await block_writer.write_block(
|
||||
case_id, block_id, instructions=instructions,
|
||||
effort_override=effort, model_override=model,
|
||||
)
|
||||
# Record what the CLI was actually asked to run, so a silent fallback to
|
||||
# a different build is visible in the report rather than mis-attributed.
|
||||
models_used.append(res.get("model_used") or "?")
|
||||
scored = block_distance_to_final(
|
||||
block_id, res.get("content", ""), final_section, outcome,
|
||||
section_target_total_words=final_total_words,
|
||||
@@ -434,6 +467,8 @@ async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
|
||||
runs.append(scored)
|
||||
agg = aggregate_cell(runs)
|
||||
agg["effort"] = effort
|
||||
agg["model"] = model
|
||||
agg["models_used"] = sorted(set(models_used))
|
||||
agg["runs"] = runs
|
||||
return agg
|
||||
|
||||
@@ -446,6 +481,7 @@ async def _run(args, ts: str) -> dict:
|
||||
|
||||
efforts = args.efforts
|
||||
blocks = args.blocks
|
||||
models = args.models
|
||||
finals = await _finals_for_calibration(args.case)
|
||||
|
||||
cases_meta = []
|
||||
@@ -468,11 +504,15 @@ async def _run(args, ts: str) -> dict:
|
||||
section = _BLOCK_TO_SECTION.get(block_id)
|
||||
plan[block_id] = [c for c in cases_meta if section and c["sections"].get(section)]
|
||||
|
||||
total_cells = sum(len(plan[b]) for b in blocks) * len(efforts) * args.repeats
|
||||
total_cells = sum(len(plan[b]) for b in blocks) * len(efforts) * args.repeats * len(models)
|
||||
grid_summary = {
|
||||
"n_finals": len(cases_meta),
|
||||
"finals": [c["case_number"] for c in cases_meta],
|
||||
"blocks": blocks, "efforts": efforts, "repeats": args.repeats,
|
||||
"models": models,
|
||||
# Provenance: a prompt-variant run is NOT comparable to a baseline run,
|
||||
# so the instruction text is recorded in the report, not just the shell.
|
||||
"instructions": getattr(args, "instructions", "") or "",
|
||||
"total_generations": total_cells,
|
||||
"per_block_n": {b: len(plan[b]) for b in blocks},
|
||||
}
|
||||
@@ -480,6 +520,27 @@ async def _run(args, ts: str) -> dict:
|
||||
if args.dry_run:
|
||||
return {"dry_run": True, "grid": grid_summary, "by_block": {}}
|
||||
|
||||
by_model: dict[str, dict] = {}
|
||||
for model in models:
|
||||
by_block = await _run_blocks_for_model(
|
||||
model, blocks, efforts, plan, args, ts, grid_summary, by_model, _BLOCK_TO_SECTION,
|
||||
)
|
||||
by_model[model] = by_block
|
||||
|
||||
# `by_block` stays the single-model shape (first model) so --rerank and the
|
||||
# existing per-block report path keep working unchanged (G2 — no second
|
||||
# result schema); multi-model runs additionally carry by_model.
|
||||
out = {"dry_run": False, "grid": grid_summary, "by_block": by_model[models[0]]}
|
||||
if len(models) > 1:
|
||||
out["by_model"] = by_model
|
||||
return out
|
||||
|
||||
|
||||
async def _run_blocks_for_model(model, blocks, efforts, plan, args, ts, grid_summary,
|
||||
by_model_so_far, _BLOCK_TO_SECTION) -> dict:
|
||||
"""The per-block × per-effort grid for ONE generation model."""
|
||||
from uuid import UUID
|
||||
|
||||
by_block: dict[str, dict] = {}
|
||||
for block_id in blocks:
|
||||
section = _BLOCK_TO_SECTION.get(block_id)
|
||||
@@ -497,11 +558,12 @@ async def _run(args, ts: str) -> dict:
|
||||
cell = await _score_cell(
|
||||
UUID(c["case_id"]), block_id, effort, final_section,
|
||||
c["final_total_words"], c["outcome"], args.repeats,
|
||||
model=model, instructions=getattr(args, "instructions", "") or "",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — harness must survive any cell failure
|
||||
logger.warning(
|
||||
"calibration cell skipped: case=%s block=%s effort=%s — %s",
|
||||
c["case_number"], block_id, effort, exc,
|
||||
"calibration cell skipped: case=%s block=%s effort=%s model=%s — %s",
|
||||
c["case_number"], block_id, effort, model, exc,
|
||||
)
|
||||
continue
|
||||
per_effort_runs[effort].append(cell)
|
||||
@@ -523,6 +585,7 @@ async def _run(args, ts: str) -> dict:
|
||||
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in rows), 2),
|
||||
"change_percent": round(mean(r["change_percent"] for r in rows), 2),
|
||||
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
|
||||
"anti_by_pattern": _mean_by_pattern(rows),
|
||||
"n": len(rows),
|
||||
})
|
||||
rec = recommend_effort(effort_rows)
|
||||
@@ -532,6 +595,11 @@ async def _run(args, ts: str) -> dict:
|
||||
"recommended": rec["effort"] if rec else None,
|
||||
"confidence": rec["confidence"] if rec else None,
|
||||
"confidence_margin": rec.get("confidence_margin") if rec else None,
|
||||
"model": model,
|
||||
# Model builds the CLI actually reported across this block's cells —
|
||||
# a mismatch vs `model` means a silent fallback, not a real A/B.
|
||||
"models_used": sorted({m for e in per_effort_runs.values()
|
||||
for cell in e for m in cell.get("models_used", [])}),
|
||||
"efforts": effort_rows,
|
||||
"per_case": per_case,
|
||||
}
|
||||
@@ -541,11 +609,15 @@ async def _run(args, ts: str) -> dict:
|
||||
# Blocks not yet done are simply absent from by_block; _write_report tolerates
|
||||
# partial results. main() does the final flush once the loop finishes.
|
||||
try:
|
||||
_write_report({"dry_run": False, "grid": grid_summary, "by_block": by_block}, ts)
|
||||
snap = {"dry_run": False, "grid": grid_summary, "by_block": by_block}
|
||||
if by_model_so_far or len(grid_summary.get("models", [])) > 1:
|
||||
snap["by_model"] = {**by_model_so_far, model: by_block}
|
||||
_write_report(snap, ts)
|
||||
except Exception as exc: # noqa: BLE001 — a write hiccup must not abort the run
|
||||
logger.warning("incremental report write failed after block=%s — %s", block_id, exc)
|
||||
logger.warning("incremental report write failed after block=%s model=%s — %s",
|
||||
block_id, model, exc)
|
||||
|
||||
return {"dry_run": False, "grid": grid_summary, "by_block": by_block}
|
||||
return by_block
|
||||
|
||||
|
||||
IL_TZ = ZoneInfo("Asia/Jerusalem")
|
||||
@@ -578,7 +650,10 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
|
||||
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",
|
||||
f"- בלוקים: {', '.join(g['blocks'])}",
|
||||
f"- efforts: {', '.join(g['efforts'])} · repeats/cell: {g['repeats']}",
|
||||
f"- models: {', '.join(m or 'pinned-default' for m in g.get('models', [None]))}",
|
||||
f"- סך ייצורי-מודל: {g['total_generations']}",
|
||||
(f"- ⚠️ **וריאנט-פרומפט** (לא בר-השוואה לריצת-בסיס): `{g['instructions']}`"
|
||||
if g.get("instructions") else "- וריאנט-פרומפט: — (פרומפט ייצור כפי-שהוא)"),
|
||||
"",
|
||||
]
|
||||
if result.get("dry_run"):
|
||||
@@ -613,6 +688,54 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
|
||||
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
|
||||
f"{r['change_percent']} | {ratio if ratio is not None else '—'} | {r['n']} |")
|
||||
lines.append("")
|
||||
by_model = result.get("by_model") or {}
|
||||
if len(by_model) > 1:
|
||||
lines += ["## השוואת-מודלים (אותו block, אותו effort, אותם סופיים)\n",
|
||||
"| block | effort | model | anti_total | change% | ratioΔpp | distance | n |",
|
||||
"|---|---|---|---|---|---|---|---|"]
|
||||
for b in g["blocks"]:
|
||||
for eff in g["efforts"]:
|
||||
rows = []
|
||||
for m, bb in by_model.items():
|
||||
for r in (bb.get(b) or {}).get("efforts", []):
|
||||
if r["effort"] == eff:
|
||||
rows.append((m, r))
|
||||
if len(rows) < 2:
|
||||
continue # nothing to compare for this cell — don't fake a row
|
||||
best = min(rows, key=lambda mr: (mr[1]["anti_pattern_total"],
|
||||
mr[1]["golden_ratio_deviation_pp"] or 0,
|
||||
mr[1]["distance"]))[0]
|
||||
for m, r in rows:
|
||||
ratio = r["golden_ratio_deviation_pp"]
|
||||
star = " ⭐" if m == best else ""
|
||||
lines.append(
|
||||
f"| {b} | {eff} | {m}{star} | {r['anti_pattern_total']} | "
|
||||
f"{r['change_percent']} | {ratio if ratio is not None else '—'} | "
|
||||
f"{r['distance']:.4f} | {r['n']} |")
|
||||
lines.append("")
|
||||
|
||||
# WHICH rule broke — a total alone can't tell you what to fix.
|
||||
bd_rows = [(b, eff, m, r) for b in g["blocks"] for eff in g["efforts"]
|
||||
for m, bb in by_model.items()
|
||||
for r in (bb.get(b) or {}).get("efforts", []) if r["effort"] == eff]
|
||||
if any(r.get("anti_by_pattern") for *_, r in bd_rows):
|
||||
names = sorted({n for *_, r in bd_rows for n in (r.get("anti_by_pattern") or {})})
|
||||
lines += ["### פילוח אנטי-דפוסים (איזה כלל הופר)\n",
|
||||
"| block | effort | model | " + " | ".join(names) + " |",
|
||||
"|---|---|---|" + "---|" * len(names)]
|
||||
for b, eff, m, r in bd_rows:
|
||||
cells = " | ".join(str((r.get("anti_by_pattern") or {}).get(n, 0)) for n in names)
|
||||
lines.append(f"| {b} | {eff} | {m} | {cells} |")
|
||||
lines.append("")
|
||||
# A silent CLI fallback would make the whole comparison meaningless — surface it.
|
||||
for m, bb in by_model.items():
|
||||
for b, bd in bb.items():
|
||||
used = bd.get("models_used") or []
|
||||
if used and any(not u.startswith(str(m)) for u in used):
|
||||
lines.append(f"> ⚠️ **{b} / {m}**: ה-CLI דיווח `{', '.join(used)}` — "
|
||||
"ייתכן fallback שקט; ההשוואה לתא זה אינה תקפה.\n")
|
||||
lines.append("")
|
||||
|
||||
lines.append("> דירוג-ההמלצה **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak). "
|
||||
"**change% מדווח-לא-מדורג** — מערבב סגנון עם שלמות-תוכן (07-learning §0.7), "
|
||||
"anti_total הוא הסיגנל הנקי-לסגנון. confidence=⚠️weak ⇒ הבחירה בתוך-הרעש "
|
||||
@@ -633,6 +756,12 @@ async def main() -> int:
|
||||
help="comma block ids to calibrate")
|
||||
ap.add_argument("--case", default=None, help="restrict to a single case_number")
|
||||
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
|
||||
ap.add_argument("--models", default="",
|
||||
help="comma generation-model ids to A/B (e.g. claude-opus-4-8,claude-opus-5). "
|
||||
"Empty (default) = the pinned GENERATION_MODEL, i.e. production unchanged.")
|
||||
ap.add_argument("--instructions", default="",
|
||||
help="extra prompt instruction appended to EVERY cell (prompt-variant A/B). "
|
||||
"Applied to all models — the run stays a model comparison. Recorded in the report.")
|
||||
args = ap.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
@@ -653,6 +782,9 @@ async def main() -> int:
|
||||
if bad_b:
|
||||
print(f"non-calibratable block(s): {bad_b}. valid: {VALID_BLOCKS}", file=sys.stderr)
|
||||
return 2
|
||||
# [None] = "use the pinned GENERATION_MODEL" — keeps the default run byte-identical
|
||||
# to the pre-#models behaviour instead of hard-coding the id in a second place (G2).
|
||||
args.models = [m.strip() for m in args.models.split(",") if m.strip()] or [None]
|
||||
|
||||
ts = _ts()
|
||||
result = await _run(args, ts)
|
||||
|
||||
96
scripts/legal-mcp-http.config.cjs
Normal file
96
scripts/legal-mcp-http.config.cjs
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* pm2 ecosystem entry for legal-mcp-http — the legal-ai MCP server exposed over
|
||||
* streamable-http (TaskMaster #231).
|
||||
*
|
||||
* Why it exists
|
||||
* Agents driven over the Agent Client Protocol get their MCP servers from the
|
||||
* *client* at session start, and that channel carries HTTP servers only. A
|
||||
* stdio server has no path into such a session, which is how platform-driven
|
||||
* agents ended up with none of the 108 tools. This service is the HTTP end
|
||||
* they can actually be pointed at.
|
||||
*
|
||||
* It does NOT replace the stdio path. Every interactive Claude Code session
|
||||
* still reaches the same server through the `legal-ai` entry in
|
||||
* ~/.claude.json, spawned per session. Same code, same tool registry, two
|
||||
* transports (G2) — this is a second *door*, not a second server.
|
||||
*
|
||||
* Security
|
||||
* The registry includes case_delete, precedent_library_delete, document_upload
|
||||
* and every block-writing tool, so an open port here is a delete-any-case
|
||||
* endpoint. Two defences, both required:
|
||||
* 1. Bind 127.0.0.1 — the platform runs on this host, so loopback suffices.
|
||||
* Deliberately narrower than legal-chat-service's 10.0.1.1: nothing in a
|
||||
* container needs to call MCP.
|
||||
* 2. Bearer token — MCP_HTTP_SHARED_SECRET, loaded below. The server
|
||||
* REFUSES TO START without it (services/http_auth.py), so a
|
||||
* misconfiguration cannot silently produce an unauthenticated listener.
|
||||
*
|
||||
* Secret
|
||||
* Source of truth: Infisical, project "All Infrastructure", env `main`,
|
||||
* /apps/legal-ai/MCP_HTTP_SHARED_SECRET (tag: credentials). The file read
|
||||
* below is a chmod-600 runtime copy, same arrangement as
|
||||
* legal-chat-service.config.cjs. Rotate in Infisical first, then refresh the
|
||||
* file and `pm2 restart legal-mcp-http`.
|
||||
*
|
||||
* Install (once):
|
||||
* pm2 start /home/chaim/legal-ai/scripts/legal-mcp-http.config.cjs
|
||||
* pm2 save
|
||||
*
|
||||
* Smoke test — expect 401 without the token, 200 with it:
|
||||
* curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8790/mcp \
|
||||
* -H 'Content-Type: application/json' \
|
||||
* -H 'Accept: application/json, text/event-stream' \
|
||||
* -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}'
|
||||
*
|
||||
* Update: pm2 restart legal-mcp-http --update-env
|
||||
* Stop: pm2 stop legal-mcp-http
|
||||
*/
|
||||
const fs = require("fs");
|
||||
|
||||
const ENV_FILE = "/home/chaim/.legal-mcp-http.env";
|
||||
const env = {
|
||||
HOME: "/home/chaim",
|
||||
PATH: "/home/chaim/.local/bin:/usr/local/bin:/usr/bin:/bin",
|
||||
PYTHONUNBUFFERED: "1",
|
||||
// Same DB/data wiring the stdio server gets from ~/.claude.json, so both
|
||||
// transports read exactly the same corpus.
|
||||
DOTENV_PATH: "/home/chaim/.env",
|
||||
DATA_DIR: "/home/chaim/legal-ai/data",
|
||||
MCP_TRANSPORT: "streamable-http",
|
||||
MCP_HTTP_HOST: "127.0.0.1",
|
||||
MCP_HTTP_PORT: "8790",
|
||||
};
|
||||
|
||||
try {
|
||||
const text = fs.readFileSync(ENV_FILE, "utf8");
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line || line.trim().startsWith("#")) continue;
|
||||
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (m) env[m[1]] = m[2];
|
||||
}
|
||||
} catch (e) {
|
||||
// Warn, but do not fabricate a token. The server's own gate turns a missing
|
||||
// secret into a refusal to boot, which is the outcome we want — pm2 will
|
||||
// surface it as a crash loop rather than serve unauthenticated traffic.
|
||||
console.error(`legal-mcp-http: failed to load ${ENV_FILE}: ${e.message}`);
|
||||
console.error("Service will refuse to start without MCP_HTTP_SHARED_SECRET.");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "legal-mcp-http",
|
||||
cwd: "/home/chaim/legal-ai/mcp-server",
|
||||
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
|
||||
args: "-m legal_mcp.server",
|
||||
env,
|
||||
restart_delay: 5000,
|
||||
// Low ceiling on purpose: if the token is missing the process exits
|
||||
// immediately, and we want pm2 to stop retrying and leave an obvious
|
||||
// errored entry rather than loop forever on a config mistake.
|
||||
max_restarts: 10,
|
||||
autorestart: true,
|
||||
max_memory_restart: "800M",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -18,6 +18,13 @@ import { useCasePrecedents } from "@/lib/api/precedents";
|
||||
* the main case page (X17 #3). Lives inside the "טיעונים ועמדות" tab above the
|
||||
* collapsible by-party aggregated arguments. The 12-block editor + citation
|
||||
* verification moved to their own top-level tabs; /compose was deleted.
|
||||
*
|
||||
* The threshold-claim / issue cards deliberately stack in a SINGLE column.
|
||||
* They were a two-column CSS grid, but grid rows share a height: expanding one
|
||||
* card grew its row and shoved every card below it down — in *both* columns.
|
||||
* A single column keeps the jump local to what sits underneath, and gives the
|
||||
* expanded body (fields + chair editor + supporting precedents) full width
|
||||
* instead of half. Do not reintroduce `lg:grid-cols-2` here (chair, 2026-08-04).
|
||||
*/
|
||||
|
||||
function ProseSection({ title, content }: { title: string; content?: string }) {
|
||||
@@ -203,7 +210,7 @@ export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
|
||||
{analysis.data.threshold_claims.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-2 items-start">
|
||||
<div className="space-y-3">
|
||||
{analysis.data.threshold_claims.map((tc) => (
|
||||
<SubsectionCard
|
||||
key={tc.id}
|
||||
@@ -225,7 +232,7 @@ export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
|
||||
{analysis.data.issues.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-2 items-start">
|
||||
<div className="space-y-3">
|
||||
{analysis.data.issues.map((iss) => (
|
||||
<SubsectionCard
|
||||
key={iss.id}
|
||||
|
||||
@@ -45,8 +45,10 @@ export function ChairEditor({
|
||||
if (trimmed === lastSaved.current.trim()) return;
|
||||
setState({ kind: "saving" });
|
||||
try {
|
||||
await mutate.mutateAsync({ sectionId, position: trimmed });
|
||||
lastSaved.current = trimmed;
|
||||
const res = await mutate.mutateAsync({ sectionId, position: trimmed });
|
||||
/* Track what the backend read back, not what we sent — "✓ נשמר" must
|
||||
mean "persisted and re-readable", or a later blur skips the save. */
|
||||
lastSaved.current = res?.position ?? trimmed;
|
||||
setState({ kind: "saved", at: new Date() });
|
||||
} catch (e) {
|
||||
setState({
|
||||
|
||||
@@ -59,20 +59,31 @@ export function useResearchAnalysis(caseNumber: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export type SaveChairPositionResult = {
|
||||
saved: boolean;
|
||||
section_id: string;
|
||||
/** What the backend read back out of the file after writing — the truth. */
|
||||
position: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
export function useSaveChairPosition(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (vars: { sectionId: string; position: string }) =>
|
||||
apiRequest<unknown>(
|
||||
apiRequest<SaveChairPositionResult>(
|
||||
`/api/cases/${caseNumber}/research/analysis/chair-position`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: { section_id: vars.sectionId, position: vars.position },
|
||||
},
|
||||
),
|
||||
onSuccess: (_res, vars) => {
|
||||
onSuccess: (res, vars) => {
|
||||
/* Locally patch the cached analysis so other consumers stay in sync
|
||||
without an immediate refetch that would steal focus from the editor. */
|
||||
without an immediate refetch that would steal focus from the editor.
|
||||
Cache the value the server read back, never the value we sent — the
|
||||
two diverged silently while the parser could not see bulleted fields. */
|
||||
const persisted = res?.position ?? vars.position;
|
||||
qc.setQueryData<ResearchAnalysis | undefined>(
|
||||
researchKeys.analysis(caseNumber ?? ""),
|
||||
(prev) => {
|
||||
@@ -80,7 +91,7 @@ export function useSaveChairPosition(caseNumber: string | undefined) {
|
||||
const patch = (arr?: ResearchSubsection[]) =>
|
||||
arr?.map((s) =>
|
||||
s.id === vars.sectionId
|
||||
? { ...s, chair_position: vars.position }
|
||||
? { ...s, chair_position: persisted }
|
||||
: s,
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -6992,7 +6992,7 @@ async def precedent_library_list(
|
||||
precedent_level: str = "",
|
||||
source_type: str = "",
|
||||
search: str = "",
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
):
|
||||
@@ -7020,6 +7020,7 @@ async def precedent_library_search(
|
||||
subject_tag: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
source_kind: str = "",
|
||||
):
|
||||
if not q or len(q.strip()) < 2:
|
||||
return {"items": [], "count": 0}
|
||||
@@ -7032,6 +7033,7 @@ async def precedent_library_search(
|
||||
subject_tag=subject_tag,
|
||||
limit=limit,
|
||||
include_halachot=include_halachot,
|
||||
source_kind=source_kind,
|
||||
)
|
||||
return {"items": results, "count": len(results)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user