Compare commits

...

47 Commits

Author SHA1 Message Date
95571e0f05 fix(ops): יישור MULTIMODAL_ENABLED בשרת ה-MCP + תיעוד דריפט האחסון שנותר
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 6s
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
Lint — undefined names / undefined-names (pull_request) Successful in 14s
הקונטיינר והשרת ה-HTTP הם שתי דלתות לאותו קורפוס, ולכן חייבים לדרג
אותו. הם לא עשו זאת: הקונטיינר רץ `MULTIMODAL_ENABLED=true`, בעוד שרת
ה-MCP נפל לברירת-המחדל `false` — `~/.env` אינו מגדיר את הדגל כלל. סוכן
ו-UI יכלו לענות על אותה שאלה מתוך קבוצות-תוצאות שונות, בלי שאיש ידע
(G2 — מסלול מקביל שמתפצל בשקט).

מה שונה
- `MULTIMODAL_ENABLED: "true"` בקונפיג ה-pm2 של legal-mcp-http.
- תיעוד השירות ב-operations-runbook (פורט, שער-Bearer, ו**האזהרה
  שהחשובה מכולן**: קוד ב-`mcp-server/` לא נכנס לתוקף עד restart — זהו
  תהליך ארוך-חיים, בניגוד ל-stdio שנטען בכל סשן. שלושה מיזוגים היום
  רצו על קוד ישן עד שהבחנתי).

מה **לא** שונה, ולמה
`STORAGE_BACKEND` נשאר לא-מוגדר. הקונטיינר רץ `s3`, השרת נופל
ל-`filesystem`, וזה דריפט אמיתי — סוכן שכותב דרך MCP כותב לדיסק בעוד
ה-web קורא מ-S3. אבל הפיכת הדגל דורשת `MINIO_ENDPOINT`/`ACCESS_KEY`/
`SECRET_KEY`, ואלה **אינם ב-Infisical** — רק ב-env של Coolify. בלעדיהם
`MINIO_ENDPOINT` נפתר לברירת-המחדל `http://minio:9000` (שם-רשת פנימי של
דוקר, לא נגיש מהמארח) וכל פעולת-בלוב נשברת.

יצירת סוד חדש מחייבת אישור חיים לשם/תיקייה/תגית, ולכן הדריפט מתועד
בבירור ב-runbook עם בדיוק מה שצריך כדי לסגור אותו — במקום להישאר
ידיעה שבעל-פה. MinIO **כן** נגיש מהמארח: `https://s3.nautilus.marcusgroup.org`
מחזיר 200 ב-`/minio/health/live` (אומת), כך שהחסם היחיד הוא הסודות.

invariants: G2 (שתי דלתות, דירוג אחד) · §6 (הדריפט הנותר מתועד, לא נבלע)
2026-08-05 13:58:08 +00:00
1f2ff4f1ec Merge pull request 'perf(citation-view): cache מבוסס-טביעה — 24.6 שנ׳ לטעינה חוזרת → 0.01 שנ׳' (#464) from worktree-citation-view-cache into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 4s
Build & Deploy / build-and-deploy (push) Successful in 1m38s
G12 Leak-Guard / leak-guard (push) Successful in 6s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-08-05 13:36:11 +00:00
6c870ac691 perf(citation-view): cache מבוסס-טביעה — 24.6 שנ' לטעינה חוזרת → 0.01 שנ'
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
הרכבת תצוגת אימות-הפסיקה עולה 20+ שניות של embeds וחיפושים וקטוריים,
והתוצאה משתנה רק כשמשתנים הקלטים שלה. עד עכשיו כל פתיחת-דף שילמה את
המחיר המלא מחדש, וכל רענון תוך-כדי-טעינה הוסיף הרצה מקבילה — כך העומס
המקורי התגלגל ל-deadlocks ב-Postgres.

למה טביעה ולא TTL: יו"ר שמאמת תקדים חייב לראות זאת בטעינה **הבאה**, לא
כשיפוג טיימר. `_inputs_fingerprint` הוא שאילתה אחת זולה שמסכמת בדיוק את
מה שהתצוגה נגזרת ממנו — טיעונים · שיוכי-פסיקה · גודל-הקורפוס. השתנה
משהו → הטביעה זזה → נבנה מחדש. **הנכונות אינה תלויה בכך שמישהו יזכור
לבטל** (G1): הבדיקה יושבת במקור-האמת ולא בזיכרון של כל כותב. `invalidate()`
קיים לנוחות, לא לתקינות.

מה שונה
- cache פר-תיק, מפתח = טביעת-הקלטים. תצוגה שלמה אינה פגה בטיימר.
- **מנעול פר-תיק נגד היצף** — שש טעינות מקבילות מריצות הרכבה אחת. זה
  בדיוק התרחיש שהפיל את המערכת.
- תצוגה **חלקית** (תקציב-האחזור נגמר) נשמרת ל-90 שנ' בלבד: מספיק כדי
  לעצור סופת-רענונים, לא מספיק כדי שההצעות החסרות ייתקעו לנצח.
- `cached: true/false` בתשובה, ו-`use_cache=False` למי שצריך רענון כפוי.

מדידה מול הקורפוס החי (8124-09-24):
    טעינה קרה ........ 24.6 שנ'   cached=false
    טעינה חוזרת ...... 0.01 שנ'   cached=true      ← פי 2,074
    5 מקבילות ........ 0.02 שנ'   כולן מה-cache
    אחרי invalidate .. 24.8 שנ'   cached=false

invariants: G1 (השער במקור-האמת, לא בכל כותב) · §6 (חלקי מסומן ופג,
לא נשמר כשלם)

טסטים: 6 חדשים (tests/test_citation_view_cache.py) — כולל היצף-מקבילי,
פקיעת תצוגה חלקית, וביטול-אוטומטי בלי קריאה מפורשת ל-invalidate.
537 עוברים.
2026-08-05 13:35:39 +00:00
ff3a2f398c Merge pull request 'fix(retrieval): סף מכויל-קוסינוס סינן פלט RRF — דף אימות-הפסיקה הציג אפס תקדימים' (#463) from worktree-relevance-scale into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 4s
Build & Deploy / build-and-deploy (push) Successful in 1m33s
G12 Leak-Guard / leak-guard (push) Successful in 6s
Lint — undefined names / undefined-names (push) Successful in 13s
2026-08-05 13:31:47 +00:00
5a2a989e9b fix(retrieval): סף מכויל-קוסינוס סינן פלט RRF — דף אימות-הפסיקה הציג אפס תקדימים
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
`hybrid_search._merge_sem_lex` דורס את `score` בערך RRF (~0.008–0.02) ברגע
שה-leg הלקסיקלי מחזיר שורות. `score` הוא אות-דירוג לגיטימי, אבל הוא מפסיק
להיות קוסינוס — ואילו `case_citation_verification._SUGGEST_FLOOR = 0.45`
כויל לקוסינוס. התוצאה: **כל שאילתה במונחים משפטיים נפוצים סוננה עד
האחרונה**, והדף הציג "אין תקדים תומך" לכל טיעון בכל תיק.

אותה פונקציה החזירה שתי סקאלות, תלוי בשאילתה:

    "המונח חזית הבניין יפורש..."   leg לקסיקלי ריק   →  0.63–0.71  (קוסינוס)
    "סמכות ועדה מקומית לפי 62א"    leg לקסיקלי מלא   →  0.016      (RRF)

מה שונה
- **עוגן קוסינוס במקור (G1):** `db.search_precedent_library_semantic` מסמן
  `relevance` לצד `score`. **לא** בפונקציה הלקסיקלית — שם `ts_rank_cd`,
  ולסמן אותו כ-relevance היה חוזר על אותה טעות בדיוק.
- **הפיוז'ן משמר:** `_merge_sem_lex` מעביר את הקוסינוס הלאה. שורה
  לקסיקלית-בלבד מקבלת `relevance = None` — "לא נמדד" אינו "נמדד כלא-רלוונטי".
- **הצרכן קורא `relevance`:** `_passes_floor()` במקום השוואה ל-`score`.
  שורות לקסיקליות-בלבד **נשמרות במודע** — הן הגיעו לצמרת בדירוג BM25 בתוך
  top-k זעיר, וסינונן היה מחביא בדיוק את התאמות-הביטוי ומספרי-התיק שהיו"ר
  מחפש בשם.

הדירוג לא השתנה: RRF ממשיך לקבוע סדר. רק הסינון עבר לסקאלה יציבה.

אימות מול הקורפוס החי:
  8124-09-24:  0 → **32 מתוך 32** טיעונים עם תקדים תומך (21.5 שנ', שלם)
  1069-04-26:  0 → **29 מתוך 69** (24.1 שנ', חלקי — תקציב הזמן)
  שורה סמנטית: score=0.0082 · relevance=0.7297

היומונים לא נפגעו ולא נגעתי בהם: `case_digest_radar` עובר דרך
`search_digests_semantic` — סמנטי טהור, בלי RRF, ולכן `min_score=0.45` שלו
מכויל נכון.

invariants: G1 (עוגן במקור, לא תיקון-סף בקריאה) · G2 (הגדרה אחת ל-relevance
לכל הצרכנים) · INV-AH (היעדר-מדידה אינו היעדר-רלוונטיות)

טסטים: 6 חדשים (tests/test_relevance_scale.py) — אחד מהם מוכיח את הבאג
ואת התיקון באותה שורה. 531 עוברים.
2026-08-05 13:31:12 +00:00
df60636876 Merge pull request 'fix(citation-view): פיזור בלתי-מוגבל הפיל את דף אימות-הפסיקה ב-500' (#462) from worktree-citation-view-perf into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 5s
Build & Deploy / build-and-deploy (push) Successful in 1m34s
G12 Leak-Guard / leak-guard (push) Successful in 7s
Lint — undefined names / undefined-names (push) Successful in 15s
2026-08-05 13:26:17 +00:00
d65c335a4a fix(citation-view): פיזור בלתי-מוגבל הפיל את דף אימות-הפסיקה ב-500
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 41s
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
פתיחת טאב "אימות פסיקה" ירתה `asyncio.gather` על **כל** טיעוני התיק בבת
אחת — טיעון אחד = קריאת embed ל-Voyage + חיפוש היברידי + שאילתת סמכות.
מעל ~8 קריאות במקביל Voyage מווסת, וכולן נתקעות יחד. הפיזור הבלתי-מוגבל
היה לא רק מסוכן אלא **איטי יותר**:

    חיפוש בודד ............  1.2 שנ'
    32 ללא מגבלה .......... 30.4 שנ'   ← פי 25 מחיפוש בודד
    32 עם מגבלת 8 ......... 22.1 שנ'   ← מבוקר = מהיר יותר

תיק עם 69 טיעונים (1069-04-26) חרג מ-timeout ה-30 שניות של הפרוקסי והחזיר
500; רענוני-דף חופפים הערימו תחרות עד `deadlock detected` ב-Postgres,
והאטו את כל המערכת.

מה שונה
- `_MAX_CONCURRENT_LOOKUPS = 8` — semaphore על הפיזור.
- `_RETRIEVAL_BUDGET_S = 22` — תקציב זמן שנגמר לפני הפרוקסי.
- בפקיעת התקציב **קוצרים את מה שהספיק** במקום לבטל הכל. במפורש *לא*
  `wait_for(gather(...))`: הוא מבטל כל משימה, כך שאיחור אחד היה זורק 30
  תוצאות שכבר הצליחו. משימות ממתינות מבוטלות ונאספות לפני שהמאגר
  מתפרק, אחרת נותרת `connection_lost` יתומה.
- `retrieval_complete: false` בתשובה — הצעות חסרות מסומנות ולא מוצגות
  כ"אין תקדים תומך" (§6). שתי הטענות שונות.

מדידה אחרי: 24 מתוך 32 טיעונים ו-26 מתוך 69 חוזרים עם הצעות, במקום 500.

invariants: §6 (חלקי מסומן, לא נבלע) · INV-AH (לא להציג היעדר-מידע
כהיעדר-תקדים)

⚠️ **נותר פתוח, נפרד:** גם כשהחיפוש מצליח `arguments_with_support=0` —
`hybrid_search.py:305` דורס את ציון הקוסינוס בציון RRF כשה-leg הלקסיקלי
מחזיר תוצאות, כך ש-`_SUGGEST_FLOOR=0.45` (מכויל לקוסינוס) מסנן הכל.
מטופל ב-PR נפרד.

טסטים: 4 חדשים (tests/test_citation_view_fanout.py) — הראשונים נועלים את
המגבלה והתקציב, האחרון משחזר בדיוק את הרגרסיה: קציר מול ביטול-הכל.
525 עוברים.
2026-08-05 13:25:41 +00:00
dc203c77eb Merge pull request 'docs(workflow): ניהול המשימות עובר מ-TaskMaster ל-Gitea Issues' (#459) from worktree-gitea-issues-sot into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 4s
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 7s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-08-05 11:05:33 +00:00
180f70bcc0 docs(workflow): ניהול המשימות עובר מ-TaskMaster ל-Gitea Issues
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 4s
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
מקור-האמת לעבודה הוא מעכשיו
https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues —
לא TaskMaster, לא CLAUDE.md, לא זיכרון. המוסכמות נלקחו מ-infra/project-template
כך ש-legal-ai מתיישר עם שאר פרויקטי המשרד.

מה בוצע
- 26 המשימות הפתוחות הועברו ל-issues #433–#458, כל אחת עם type/priority/
  area/status וגוף במבנה What & Why → הקשר → Acceptance Criteria.
- 30 תוויות נוצרו בריפו: type/priority/status/size זהות לתבנית; שכבת area
  הותאמה לתחומי legal-ai (agents/retrieval/extraction/writing/mcp/...).
- CLAUDE.md: הסעיף "ניהול משימות" נכתב מחדש — Session-Start Protocol,
  Issue-First, מערכת התיוג, ומלכודת המספור.
- operations-runbook.md: פירוט TaskMaster הוחלף בעבודה מול Gitea Issues API.
- docs/taskmaster-to-gitea-migration.md — טבלת מיפוי מלאה + מה נותר ידני.

מלכודת מספור שחשוב לדעת: ב-Gitea issues ו-PRs חולקים מרחב-מספרים, ולכן
ה-issues מתחילים ב-#433. **כל `(#N)` בהודעת-commit מלפני היום מתייחס למזהה
TaskMaster, לא ל-issue.** הטבלה במסמך המיגרציה היא הגשר.

.taskmaster/tasks/tasks.json נשאר לא-מתויק (כרגיל) ומוקפא מקומית
לקריאה-בלבד — 166 שהושלמו + 4 שבוטלו, לחפירה היסטורית בלבד.

לא הועבר אוטומטית: size לא הוקצה לאף issue, ו-21 מתוך 26 עדיין ללא
Acceptance Criteria (לא היה להן testStrategy) — מסומן במפורש בגוף כל issue.
2026-08-05 11:05:06 +00:00
1544669c7a Merge pull request 'fix(extraction): מספר-תיק שהומצא נכתב לשדה-הזהות — עיגון בטקסט לפני כתיבה (#232 מלכודת 3)' (#432) from worktree-docket-grounding into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 5s
Build & Deploy / build-and-deploy (push) Successful in 1m35s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-08-05 10:46:36 +00:00
63387c0d7d fix(extraction): מספר-תיק שהומצא נכתב לשדה-הזהות — עיגון בטקסט לפני כתיבה (#232 מלכודת 3)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
ערר (מרכז) 1094-09-19 (פדילה) נשמר כ-`1094-09-14` בעוד כל חמשת מסמכי
התיק גורסים ...-19. `_is_clean_docket` בדק **צורה בלבד**, ושתי הצורות
תקינות — כך שספרה שגויה אחת עברה את השער וניתקה את השורה מכל הפניה
לתיק האמיתי. `case_number` הוא שדה-זהות; זה לא שדה-תצוגה שאפשר לתקן
בקריאה.

שורש נוסף שהתגלה תוך כדי: ב-1094-09-19 המספר **אינו מופיע ב-full_text
כלל** (הטקסט מתחיל ב"בפני:"), כלומר המודל הפיק ספרות שאין להן עיגון
במקור — בדיוק מה ש-INV-AH בא למנוע.

מה שונה
- `_docket_grounded()` — כל קבוצת-ספרות של ה-docket חייבת להופיע, בסדר,
  בטקסט ההחלטה או בערך שהיא מחליפה. סובלני למפריד (`-` מול `/`), לרווחים
  סביבו, ולתווי-כיווניות (RLM/LRM) שנדחסים בתוך המספר ב-PDF עברי.
  המקורות לעולם אינם פלט-המודל עצמו — אחרת הבדיקה מעגלית.
- סירוב לכתוב מלווה `logger.warning` מפורש (§6) במקום להסתמך על הצורה.
- הפרומפט תוקן: הדוגמאות היו דו-חלקיות בלבד ('1062/24'), מה שהטה נגד
  docket תלת-חלקי של ועדות ערר. נוספו דוגמאות תלת-חלקיות והוראה מפורשת
  לא להשלים/לנחש/לתקן ספרה, ולהחזיר ריק כשהמספר אינו בטקסט.

אימות מול הקורפוס החי (386 שורות): מתוך 50 השורות שהנרמול חל עליהן
בפועל — **0 נחסמות**. הגארד חוסם רק ספרות שאין להן עיגון באף מקור.

invariants: INV-AH (quote-or-retract על שדה-זהות) · G1 (נרמול במקור) ·
§6 (סירוב מדווח, לא נבלע)

טסטים: 7 חדשים (tests/test_docket_grounding.py), הראשון שבהם משחזר בדיוק
את הרגרסיה — שתי הצורות עוברות את בדיקת-הצורה, רק הנכונה מעוגנת.
521 עוברים.
2026-08-05 10:45:22 +00:00
70c28c87ef Merge pull request 'fix(retrieval): 104 החלטות ועדות-ערר היו בלתי-נראות בחיפוש — סינון ולא דירוג (#232)' (#431) from worktree-232-internal-search into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m37s
G12 Leak-Guard / leak-guard (push) Successful in 8s
Lint — undefined names / undefined-names (push) Successful in 13s
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 5s
2026-08-05 10:33:22 +00:00
2f50a8cc79 fix(retrieval): 104 החלטות ועדות-ערר היו בלתי-נראות בחיפוש — סינון ולא דירוג (#232)
All checks were successful
Lint — undefined names / undefined-names (pull_request) Successful in 11s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 40s
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
`search_precedent_library` — הכלי שסוכני הכתיבה קוראים לו בפועל — לא חשף
`source_kind` כלל, וכל שכבה מתחתיו נפלה לברירת-מחדל `external_upload`.
התוצאה: `WHERE cl.source_kind = 'external_upload'` חתך 104 החלטות ועדות-ערר
(29% מהקורפוס) עוד לפני הדירוג. ערר (חיפה) 83/16 — האסמכתה הישירה ביותר
בקורפוס לטענת-סף ס-2 ב-1069-04-26 — לא הוחזרה גם בשאילתה כמעט-מילולית.

ההיפותזה שנרשמה ב-#232 (הטיית verified/cite_count מדירה החלטות חדשות)
**נבדקה ונפסלה**: כיסוי ה-verified כמעט זהה בשני הקורפוסים (55% מול 49%),
ותקרת-ההטיה 0.22 לא הסבירה פער מול התאמה כמעט-מילולית. הסיבה הייתה סינום
קשיח, לא דירוג.

מה שונה
- `_source_kind_clause()` — הגדרה אחת לאוצר-המילים של הסלקטור (G2), במקום
  שכפולו בכל אתר חיפוש/רשימה. `""`/`"all"` = כל הקורפוס; סלקטור לא-מוכר
  מרים ValueError במקום להגיע ל-SQL (הערך מוזרק ב-f-string, אז ה-whitelist
  הוא גם מה ששומר על זה בטוח).
- ברירת-המחדל בכל שרשרת-החיפוש והרשימה: `""` = הקורפוס כולו.
- `search_precedent_library` + `precedent_library_list` חושפים `source_kind`
  לסינון מפורש; `/api/precedent-library/search` מקבל אותו גם הוא כדי ש-UI
  ו-MCP לא יתפצלו.
- נרמול מפריד במספר-תיק: `83/16` ו-`83-16` מחזירים את אותה שורה.

אימות מול ה-DB החי — 4/4 החלטות ועדה חוזרות במקום **1** בשאילתה בלשון
הלכה מאושרת (83-16, 1029-18, 1085-23, 1094-09-19 פדילה). 83/16 עלתה
ל-0.711 מול 0.663 של 3213/97 שחסמה אותה קודם. רשימת-הקורפוס: 259 → 386.

invariants: G1 (נרמול במקור, לא תיקון-תסמין בקריאה) · G2 (הגדרה אחת
לסלקטור; UI ו-MCP על אותו מסלול) · §6 (סלקטור שגוי מתפוצץ, לא נבלע)

טסטים: 5 חדשים (tests/test_source_kind_selector.py), אחד מהם נועל את
ברירות-המחדל של 8 נקודות-הכניסה — זה בדיוק הבאג. 514 עוברים.
2026-08-05 10:32:27 +00:00
079a489f0e Merge pull request 'fix(aggregator): צד שלם נמחק בשקט — chunking לפי גודל + כשל שמדווח (#233)' (#430) from worktree-aggregator-chunking into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 5s
Build & Deploy / build-and-deploy (push) Successful in 1m37s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-08-05 10:08:15 +00:00
720057bc72 fix(aggregator): צד שלם נמחק בשקט — chunking לפי גודל + כשל שמדווח (#233)
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
בתיק 1069-04-26 נשלחו 310 טענות עוררים בקריאת-Claude אחת. הקריאה החזירה
לא-JSON, הקוד רשם warning והחזיר [], והפעולה דיווחה status=completed עם
אפס טיעונים לצד המרכזי בערר. 495 propositions_processed — כלומר "עיבדתי
הכל".

שתי תקלות מובחנות, שתיהן מתוקנות:

1. **הקריאה גדולה מדי.** הפיצול הפר-כתב-טענות שנוסף למשיבים (#224) הסתיר
   את זה במקרה — הוא שמר על קריאות קטנות — אבל עוררים וּועדה מדברים בקול
   אחד ואינם מפוצלים לעולם, כך שערר גדול יוצא בקריאה אחת ענקית. ההערה
   בקוד כבר תיעדה בדיוק את הכשל הזה אצל המשיבים; התיקון פשוט לא הוחל על
   העוררים. עכשיו כל צד מעל MAX_PROPS_PER_CALL=80 נצבר בכמה קריאות
   ומשורשר.

   הפיצול הוא trade-off ולא רווח חינם: כל chunk מקובץ בבידוד, ולכן צד
   שמתפצל עלול לקבל יותר טיעונים (וחופפים במקצת) מאשר במעבר יחיד. לאבד
   ליטיגנט שלם גרוע יותר, והחלופה — פרומפט קטן יותר לכל פרופוזיציה —
   הייתה מנוונת כל תיק כדי לתקן את הגדולים. הסדר נשמר בחיתוך, כי טענות
   מגיעות ממוינות לפי claim_index ושכנות שייכות בד"כ לאותו ראש-טיעון.

2. **הכשל נבלע.** החזרת [] על תשובה לא-רשימה אינה ניתנת להבחנה מ"לצד
   הזה אין טיעונים". עכשיו נזרקת AggregationFailed, הקורא רושם אותה
   ב-errors, והסטטוס יורד ל-completed_with_errors (כלל-הנדסה §6).
   ההודעה נוקבת בשם הצד ובמספר הפרופוזיציות, אחרת מפעיל שרואה
   completed_with_errors לא יודע איזה ליטיגנט נעלם.

מבחני רגרסיה: chunking לא מאבד ולא מסדר-מחדש (310→4 קריאות, רצף נשמר);
תשובה לא-רשימה זורקת ומזכירה את שם הצד. אם המבחן השני יחזור אי-פעם
לטעון == [] — באג הבליעה הוחזר.

invariants: כלל-הנדסה §6 — אין בליעה שקטה. G1 — תיקון במקור (גודל הקריאה)
ולא בקריאה. G2/G12 — לא נגועים; שני השערים ירוקים.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:58:48 +00:00
d8bb2c7c0c Merge pull request 'feat(guard): כלל-קשירה ל-INV-AG3 — השער דיווח תקין על allow-list שאיש אינו אוכף' (#429) from worktree-binding-check into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 6s
Build & Deploy / build-and-deploy (push) Successful in 11s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 13s
2026-08-05 08:56:58 +00:00
5f5b13c6a4 feat(guard): כלל-קשירה ל-INV-AG3 — השער דיווח "תקין" על allow-list שאיש אינו אוכף
All checks were successful
Lint — undefined names / undefined-names (pull_request) Successful in 12s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
כללים 1–4 משווים קבצים לקבצים, וזה לא אומר דבר על **אכיפה**. רשימת
tools: הופכת ל-allow-list רק כשה-runtime בוחר את הסוכן (--agent <name>);
בלעדיו אותו קובץ בדיוק נמסר כ---append-system-prompt-file — פרוזה שהמודל
רשאי לקיים או להתעלם ממנה — וכל 108 הכלים נשארים נגישים.

התגלה 2026-08-05: סוכן אחד נושא 41 הענקות **בלי** --agent. זו הרשימה
הגדולה במערכת, והשער דיווח עליה OK — כי הוא מעולם לא הסתכל על הקשירה.
שער שמדווח "תקין" על רשימה בלתי-נאכפת גרוע מהיעדר שער: הוא מייצר
ביטחון-שווא.

כלל 5 (host-only, --check-bindings): מוודא ש-extraArgs מכיל
["--agent", <name>] התואם ל-instructionsEntryFile. הקשירה יושבת ב-DB של
הפלטפורמה, שה-CI לא רואה, ולכן הבדיקה היא opt-in ומריצה psql בתת-תהליך
במקום להוסיף תלות-דרייבר שהייתה שוברת את תכונת ה-stdlib-בלבד שמסלול
ה-CI נשען עליה. DB בלתי-נגיש מחזיר [] — "לא נבדק", לא "אין הפרות".

ובמסלול ה-CI, השער אומר עכשיו במפורש מה **לא** נבדק, במקום להדפיס OK
חשוף שמזמין את הקורא להסיק שהרשימות נאכפות.

מדווח רק על סוכנים שבאמת מצהירים הענקות — סוכן בלי tools: אינו ממצא.

אומת: מסלול-CI ירוק; --check-bindings תופס את legal-ceo עם הנימוק
"extraArgs is empty" ומציע את שתי החלופות (להוסיף --agent, או למחוק
tools: ולתעד כבלתי-מוגבל) — במפורש לא שתיהן, כי זו הפרת G2.

invariants: INV-AG3 — מרחיב מקובץ לזמן-ריצה. G2 — מקיים; אין מפת-הרשאות
שנייה, רק אימות שהמפה הקיימת נאכפת.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:53:53 +00:00
b394177207 Merge pull request 'feat(ops): שירות pm2 legal-mcp-http (#231.3)' (#428) from worktree-mcp-http-service into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 5s
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-08-05 08:27:07 +00:00
3f48dc0e11 feat(ops): שירות pm2 legal-mcp-http — שרת ה-MCP חשוף ב-HTTP מאחורי שער Bearer (#231.3)
All checks were successful
Lint — undefined names / undefined-names (pull_request) Successful in 11s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 4s
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
סוכנים המונעים דרך Agent Client Protocol מקבלים את שרתי-ה-MCP שלהם מהלקוח
בפתיחת הסשן, והערוץ הזה נושא שרתי HTTP בלבד. ל-stdio אין מסלול לשם, ומכאן
שהסוכנים נותרו בלי 108 הכלים. זהו הקצה ה-HTTP שאפשר להפנות אותם אליו.

**אינו מחליף את stdio.** כל סשן אינטראקטיבי ממשיך להגיע לאותו שרת דרך
הרשומה legal-ai ב-~/.claude.json. אותו קוד, אותו מרשם-כלים, שתי תחבורות
(G2) — דלת שנייה, לא שרת שני.

אבטחה — שתי הגנות, שתיהן נדרשות:
1. bind ל-127.0.0.1. צר יותר מ-10.0.1.1 של legal-chat-service בכוונה: שום
   קונטיינר לא צריך לקרוא ל-MCP.
2. Bearer מ-MCP_HTTP_SHARED_SECRET. השרת מסרב לעלות בלי טוקן
   (services/http_auth.py), ולכן תקלת-הגדרה לא יכולה לייצר בשקט מאזין
   לא-מאומת — pm2 יציג crash במקום להגיש תעבורה פתוחה.

הסוד: מקור-אמת ב-Infisical (All Infrastructure / main / /apps/legal-ai,
תג credentials). ~/.legal-mcp-http.env הוא עותק-ריצה chmod 600, אותו סידור
כמו legal-chat-service.

max_restarts נמוך במתכוון: טוקן חסר מפיל את התהליך מיד, ועדיף ש-pm2 יפסיק
לנסות ויותיר רשומת errored גלויה מאשר ילולאה על טעות-הגדרה.

אומת על השירות החי:
  POST בלי Authorization → 401
  POST עם טוקן שגוי      → 401
  POST עם הטוקן הנכון    → 200
  אחרי pm2 restart       → 200
  pm2 save               → ✓

invariants: G2 — מקיים (תחבורה שנייה לאותו שרת, לא מימוש מקביל).
G12 — לא נגוע. INV-AG3 — לא נגוע.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:19:45 +00:00
c218864f36 Merge pull request 'docs: כתובת שרת-הסודות ומיקומי הסודות — התיעוד הצביע למופע שפורק' (#427) from worktree-secrets-docs into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 4s
Build & Deploy / build-and-deploy (push) Successful in 1m38s
G12 Leak-Guard / leak-guard (push) Successful in 6s
Lint — undefined names / undefined-names (push) Successful in 15s
2026-08-05 08:16:59 +00:00
c22935c008 Merge pull request 'feat(mcp): שער Bearer לתחבורת ה-HTTP (#231.2)' (#426) from worktree-mcp-http-auth into main
Some checks failed
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Has been cancelled
Build & Deploy / build-and-deploy (push) Has been cancelled
Lint — undefined names / undefined-names (push) Has been cancelled
G12 Leak-Guard / leak-guard (push) Has been cancelled
2026-08-05 08:16:58 +00:00
58a2f33187 docs: כתובת שרת-הסודות ומיקומי הסודות — התיעוד הצביע למופע שפורק
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
secret.dev.marcus-law.co.il מת: רשומת ה-*.dev נמחקה מ-NPM עם פירוק
192.168.10.206 ב-2026-08-02. גם הזהות הישנה מחזירה 401.

השרת החי: secret.marcus-law.co.il, פרויקט All Infrastructure
(2c462576-b125-4279-b0ec-7220dbf51ccf), env main. אין פרויקט legal-ai
נפרד — סודות האפליקציות הם תיקיות: /apps/legal-ai, /apps/paperclip,
/apps/paperclip-hermes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:15:33 +00:00
363485d025 refactor(mcp): MCP_HTTP_TOKEN → MCP_HTTP_SHARED_SECRET — זהה לשם ב-Infisical
All checks were successful
Lint — undefined names / undefined-names (pull_request) Successful in 11s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
הסוד נוצר ב-All Infrastructure / env main / /apps/legal-ai בשם
MCP_HTTP_SHARED_SECRET, מתויג credentials, לפי הדפוס של שני טוקני-הגשר
שכבר שם: COURT_FETCH_SHARED_SECRET ו-LEGAL_CHAT_SHARED_SECRET.

שם זהה בקוד וב-Infisical = אין מיפוי שצריך לזכור.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:15:32 +00:00
eebb193fd3 feat(mcp): שער Bearer לתחבורת ה-HTTP — מסרב לעלות בלי טוקן (#231.2)
All checks were successful
Lint — undefined names / undefined-names (pull_request) Successful in 11s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 4s
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
ב-stdio ההגנה היא הצינור עצמו: רק תהליך שכבר רץ כמשתמש הזה יכול לדבר עם
השרת. streamable-http מבטל את התכונה הזו לחלוטין — כל מי שמגיע ל-socket
יכול לקרוא לכל אחד מ-108 הכלים, ובמרשם יש case_delete,
precedent_library_delete, document_upload וכל כלי כתיבת-הבלוקים. מאזין
לא-מאומת הוא, הלכה למעשה, endpoint למחיקת תיקים.

מימוש דרך ה-TokenVerifier של ה-SDK ו-BearerAuthBackend שלו — לא middleware
משלנו. מסלול-אימות אחד, של המסגרת (G2).

ההחלטות שקובעות את בטיחות הפיצ'ר:
- **מסרב לעלות בלי טוקן.** MissingTokenError קטלנית. החלופה המפתה — לעלות
  ולרשום warning — מייצרת מאזין שנראה בריא ועונה על כל קריאה הרסנית.
  סירוב-אתחול הוא הכשל הבטוח (§6).
- הבנייה בזמן-import ולא בתוך main(): FastMCP מקבל token_verifier ו-auth
  כארגומנטי-בנאי, ולכן טוקן חסר חייב להיכשל *לפני* שה-listener קיים.
- **stdio לא נוגע.** דרישת טוקן שם הייתה שוברת כל סשן אינטראקטיבי בלי שום
  רווח אבטחתי — הגבול שם הוא הצינור.
- השוואה בזמן-קבוע (hmac.compare_digest); == נאיבי מדליף את הטוקן בייט-בייט
  לתוקף שמודד זמנים.
- verify_token מחזיר None ולא זורק — זה אות ה"דחייה" של הפרוטוקול ומניב 401
  נקי במקום 500 שנקרא ככשל-שרת.
- סף אורך 32 תווים; טוקן קצר נדחה באתחול ולא מתגלה מ-access log.
- הטוקן נקרא מ-env (שיאוכלס מ-Infisical), לא מוטמע, ולא נרשם ללוג.

אומת בהרצה חיה:
  HTTP בלי טוקן → סירוב לעלות, exit 1
  stdio בלי טוקן → עולה כרגיל, auth כבוי
  POST בלי Authorization        → 401
  POST עם טוקן שגוי             → 401
  POST עם הטוקן הנכון           → 200
  claude דרך HTTP+Bearer        → 108 כלים, mcp__legal-ai__case_get
  מופעי הטוקן בלוג              → 0

invariants: G2 — מקיים (מסלול-אימות יחיד, של ה-SDK). G12 — מקיים; המודול
נקי מסמלי-פלטפורמה, leak_guard ירוק. INV-AG3 — לא נגוע, השער ירוק.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 07:45:38 +00:00
6aac4a73f6 Merge pull request 'feat(mcp): תחבורת streamable-http לצד stdio (#231.1)' (#425) from worktree-mcp-http-transport into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 4s
Build & Deploy / build-and-deploy (push) Successful in 3m14s
G12 Leak-Guard / leak-guard (push) Successful in 6s
Lint — undefined names / undefined-names (push) Successful in 13s
2026-08-05 07:42:22 +00:00
81ea43a70a feat(mcp): תחבורת streamable-http לצד stdio — פותח את הדרך להזרקת השרת לסשני ACP (#231.1)
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 42s
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
סוכני הפלטפורמה מקבלים את שרתי ה-MCP שלהם מהלקוח בפתיחת הסשן, והערוץ
הזה מקבל שרתי HTTP בלבד: כל שרת מוזרק כ-
{type:"http", name, url, headers:[Bearer]}. לשרת stdio אין מסלול לשם
בכלל — ומכאן שכל 108 הכלים נעדרו מהסוכנים מאז שמנוע-ההרצה שלהם השתנה.

שרת אחד, שתי תחבורות — לא מימוש שני (G2). מרשם-הכלים, השירותים ובריכת
ה-DB משותפים מילולית; רק פרוטוקול-החוט משתנה, כך שכלי לא יכול להתקיים
בתחבורה אחת ולא באחרת.

- MCP_TRANSPORT בוחר תחבורה, ברירת-מחדל stdio. ערך לא-חוקי נכשל ברעש
  (SystemExit) ולא נופל בשקט חזרה ל-stdio — אחרת ה-listener נעדר בעוד
  שהכול "נראה" תקין (כלל-הנדסה §6).
- stdio נשאר ברירת-המחדל: כל סשן אינטראקטיבי מגיע דרכו מ-~/.claude.json.

מלכודת שהתגלתה בהרצה: אי-אפשר להסתמך על FASTMCP_HOST/FASTMCP_PORT.
ל-FastMCP.__init__ יש ברירות-מחדל מפורשות (host="127.0.0.1", port=8000)
שמועברות ל-Settings(**settings), ובפידנטיק ארגומנטים מפורשים גוברים על
env — כך ש-FASTMCP_PORT מתעלמים ממנו והשרת נקשר ל-8000 בכל מקרה. על
המכונה הזו 8000 תפוס, כך שזה נכשל ברעש במקרה ולא בזכות תכנון. לכן
MCP_HTTP_HOST/MCP_HTTP_PORT נקראים אצלנו ומועברים לבנאי.

ברירת-מחדל loopback (127.0.0.1:8790): התחבורה עדיין ללא אימות, ובמרשם
יש כלים הרסניים (case_delete, precedent_library_delete). שער ה-Bearer
הוא #231.2 והוא חייב לנחות לפני חשיפה מחוץ למארח.

אומת end-to-end: claude התחבר ל-http://127.0.0.1:8791/mcp וקיבל 108
כלים בשם mcp__legal-ai__case_get — זהה בדיוק ל-stdio. כלומר 66 ההענקות,
הוראות-הסוכנים, web/paperclip_client.py ושער INV-AG3 ממשיכים לעבוד.

invariants: G2 — מקיים (שרת אחד, שתי תחבורות, מרשם משותף). G12 — מקיים;
חיווט צד-הפלטפורמה נשאר מאחורי web/agent_platform_port.py, והמודול הזה
נותר נקי מסמלי-פלטפורמה (leak_guard ירוק). INV-AG3 — לא נגוע, השער ירוק.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 07:40:05 +00:00
b8f6226fb1 Merge pull request 'fix(agents): INV-AG3 — כלי שמורים לסוכן להריץ חייב להיות מוענק לו, ושער-CI שיאכוף' (#424) from worktree-agent-tool-grants into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 4s
Build & Deploy / build-and-deploy (push) Successful in 10s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-08-04 09:09:03 +00:00
20a1da0a0e fix(agents): INV-AG3 — כלי שמורים לסוכן להריץ חייב להיות מוענק לו, ושער-CI שיאכוף
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
ה-frontmatter `tools:` של סוכן claude_local הוא allow-list סגורה: כלי הרשום
בשרת-ה-MCP אך חסר ממנה אינו ניתן לקריאה, גם כשהשרת מחובר לחלוטין.

`analyze_protocol` נרשם בשרת ב-24e3e2f (2026-06-30) בקומיט שנגע ב-9 קבצים,
אף אחד מהם ב-.claude/agents/. אחר כך #226 הוסיף את
wake_analyst_for_protocol_analysis, שכותב לתוך ה-issue "הרץ
mcp__legal-ai__analyze_protocol(...)" — כלומר המערכת הורתה למנתח להריץ כלי
שמעולם לא הוענק לו. ב-CMP-229 (2026-08-04) המנתח דיווח נכונה ש"הכלים קיימים
בשרת אך אינם נחשפים לסשן", ועקף דרך psql ידני וסקריפט מקומי: ההרצה הראשונה
שיגרה אותו לרקע וסיימה את התור, קבוצת-התהליכים נהרגה, והניתוח אבד. רק ההרצה
השנייה (recovery) הצליחה.

INV-AG3 כיסה את זה בספ מ-2026-06-06, אבל האכיפה נדחתה ("אכיפה אוטומטית
עתידית — תת-פרויקט 5"), ולכן הדריפט חי חמישה שבועות.

הענקות שנוספו (כל אחת עם ההוראה המתאימה — לא הענקה עודפת):
- legal-analyst: analyze_protocol + get_protocol_analysis (+ סעיף "משימה
  על-פי-דרישה: ניתוח פרוטוקול-הדיון" — אף סוכן לא ידע שהיכולת קיימת),
  get_legal_arguments ו-get_appraiser_facts (קריאה-בחזרה אחרי כתיבה)
- legal-ceo: get_appraiser_facts (אימות שהחילוץ נחת)
- legal-qa: precedent_library_list — הוראותיו כבר אמרו "הרץ" אותו. אותו באג
  בדיוק, שהתגלה אגב הסריקה

שער-CI חדש `scripts/agent_tool_grants_guard.py` בדפוס leak_guard.py של G12,
ארבעה כללים קשיחים: (1) כל mcp__legal-ai__X ב-web/ מוענק לסוכן כלשהו · (2) כל
mcp__legal-ai__X בגוף קובץ-סוכן מוענק באותו קובץ · (3) אין הענקה לכלי לא-רשום
· (4) שם-כלי בגרשיים ללא תחילית — מוענק, או מסווג ב-CONTRASTIVE_OK עם נימוק
(9 סווגו: אזכור ניגודי, מטלת-סוכן-אחר, שם-עמודה מתנגש), עם בדיקת-התיישנות.
מוחרגים קבצים שאינם סוכני-claude_local (hermes-curator,
legal-analyst-gemini-critique — בלי frontmatter בכוונה; HEARTBEAT).

השער אומת שלילית: לפני התיקון החזיר בדיוק 3 הפרות (analyze_protocol,
precedent_library_list, get_legal_arguments) ואפס רעש; אחריו OK.

invariants: INV-AG3 (docs/spec/X4-agents.md §2א) — מקיים; האכיפה עברה מידנית
ל-CI. G2 — סוכן שאינו יכול לקרוא לכלי בונה מסלול מקביל (SQL ישיר/סקריפט),
וזה מה שנחסם כאן. G12 — leak-guard רץ נקי (רגרסיה).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 09:05:35 +00:00
90de9831c3 Merge pull request 'fix(ui): טענות-הסף והסוגיות בטור אחד — פתיחת כרטיס הקפיצה גם את הטור השני' (#423) from worktree-positions-single-column into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m42s
Lint — undefined names / undefined-names (push) Successful in 14s
G12 Leak-Guard / leak-guard (push) Successful in 8s
2026-08-04 08:20:29 +00:00
f91cb2a660 fix(ui): טענות-הסף והסוגיות בטור אחד — פתיחת כרטיס הקפיצה גם את הטור השני
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 49s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
כרטיסי טענות-הסף והסוגיות ב"עמדות וטענות" ישבו ב-CSS Grid דו-טורי
(`lg:grid-cols-2`). ב-Grid כל שורה חולקת גובה, ולכן פתיחת כרטיס בטור
אחד הגדילה את גובה השורה ודחפה מטה את כל מה שמתחת — בשני הטורים.

מעבר לטור אחד (`space-y-3`) מקומם את הקפיצה למה שמתחת לכרטיס שנפתח
בלבד, ונותן לתוכן הנפתח (שדות + עורך עמדת-היו״ר + פסיקה תומכת) רוחב
מלא במקום חצי — נוח בהרבה לעריכה בעברית.

זוג הכרטיסים התחתון (פסיקה מצורפת + ייצוא/עדכון הניתוח) נשאר דו-טורי;
הוא אינו נפתח ואינו סובל מהבעיה.

invariants: INV-IA1 (מקור-אמת יחיד) נשמר — שינוי פריסה בלבד, ללא נגיעה
בנתונים, ב-hooks או ב-API. G2/G10/G12 — לא רלוונטיים.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 08:19:51 +00:00
bf9559c61a Merge pull request 'fix(research): עמדת ועדת הערר בטענות-סף נשמרה אך לא נקראה בחזרה' (#422) from worktree-chair-position-bullet-parse into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m55s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 13s
2026-08-02 14:05:37 +00:00
fbdbc64366 fix(research): עמדת ועדת הערר בטענות-סף נשמרה אך לא נקראה בחזרה
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 37s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
הקורא והכותב של analysis-and-research.md לא הסכימו על מהו שדה:
FIELD_LABEL_RE דרש `**כותרת:**` בתחילת שורה, בעוד הכותב התאים את
התווית בכל מקום בשורה. האנליסט כותב טענות-סף כרשימה
(`- **עמדת ועדת הערר:**`) ואת הסוגיות בתחילת שורה — ולכן שמירה
בטענת-סף הצליחה (200 + "✓ נשמר"), אבל הקורא לא ראה את השדה כלל
והעמדה נעלמה ברענון. גם יתר שדות הטענה (טענה/תשובה/שאלה משפטית)
היו בלתי-נראים באותן טענות.

- הקורא מקבל תווית עם סמן-רשימה אופציונלי, כמו הכותב.
- הכותב עובר להשתמש באותה הגדרת-גבולות של הקורא (_chair_field_span)
  במקום regex משלו — `[^*]*?` הישן גם קטע עמדה שהכילה `**הדגשה**`.
  התווית והסמן נשמרים כפי שהם, וכך גם `---` הסוגר.
- תווית מעוטרת (`עמדת ועדת הערר (הכוונת יו"ר 24.6)`) מזוהה בהתאמת-רישא;
  קודם הכותב הוסיף בלוק כפול במקום לעדכן.
- שדה כפול באותו H3 (`### סוגיות 4–6`): הקורא לקח את האחרון והכותב את
  הראשון. שניהם לוקחים עכשיו את הראשון.
- read-after-write: שמירה שהפרסר לא קורא בחזרה מדווחת כשגיאה במקום
  "נשמר" ירוק, וה-UI שומר בקאש את מה שהשרת קרא — לא את מה ששלח.
- תבנית האנליסט (§5) קיבלה שלד מפורש לטענות-סף, זהה לזה של הסוגיות,
  כדי שקבצים חדשים לא ייווצרו במבנה החורג (נרמול-במקור, G1).

הרצת round-trip על כל הקורפוס: 24 מתוך 141 תת-סעיפים נכשלו לפני
התיקון (1017, 1019, 1027, 1033, 1043, 1069, 8124) — 0 אחריו. העמדות
שכבר נשמרו בקבצים הקיימים חוזרות להיקרא בלי מיגרציה.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:04:24 +00:00
08419e4434 Merge pull request 'fix(writer): כלל-הסגנון בסוף הפרומפט — חותך אנטי-דפוסים ב-72–93%' (#421) from worktree-anti-pattern-directive-position into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m34s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-28 11:53:57 +00:00
2ebaa82f85 Merge remote-tracking branch 'origin/main' into worktree-anti-pattern-directive-position
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
2026-07-28 11:53:23 +00:00
79d9ea55d8 Merge pull request 'feat(eval): ממד model×prompt ל-harness הכיול (#208) — A/B מודל-ייצור מול הסופיים' (#420) from worktree-opus5-model-calibration into main
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
G12 Leak-Guard / leak-guard (push) Has been cancelled
Lint — undefined names / undefined-names (push) Has been cancelled
2026-07-28 11:52:57 +00:00
86e66cc5bd feat(eval): פילוח אנטי-דפוסים per-ריצה — "איזה כלל הופר", לא רק כמה
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
`block_distance_to_final` החזיר `anti_pattern_total` בלבד. ריצת-כיול שמדווחת
"anti=4" אינה יכולה לומר מה לתקן — באבחון ה-A/B של 2026-07-28 נאלצנו להסיק
את הדפוס האשם מהקשר במקום למדוד אותו.

- `anti_by_pattern` (שם-דפוס → מספר-פגיעות) נוסף לתא-המדידה, מ-
  `count_anti_patterns` הקיים — אין ספירה מקבילה.
- `_mean_by_pattern` ממצע על **כל** הריצות: דפוס שלא נורה בריצה נספר כ-0
  ולא מושמט, אחרת הממוצע היה מוטה כלפי מעלה.
- הדוח מקבל טבלת "פילוח אנטי-דפוסים (איזה כלל הופר)" per block×effort×model.

invariants: INV-G8 (eval-harness) · G2 (מרונדר מ-count_anti_patterns/
ANTI_PATTERNS הקנוניים — מקור אחד).

אימות: self-test ALL PASS · 470 passed · בדיקת-שפיות ישירה —
טקסט עם 1 כותרת + 2 תבליטים + 1 פיצול-מיני מפולח נכון ל-
{markdown_headers:1, bullet_lists:2, inline_numbered_fragments:1}.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:17:35 +00:00
42ea1a7c58 fix(writer): כלל-הסגנון בסוף הפרומפט — הוא היה שם, במקום שבו הוא לא תופס
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 35s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
הרשימה הקנונית של lessons.ANTI_PATTERNS כבר הוזרקה לכותב, אבל בתו ~46,781
מתוך 46,950 של style_context — שהוא עצמו מקטע אחד מתוך ~12 בפרומפט. הטיוטות
המשיכו לפלוט בדיוק את מה שהיא אוסרת.

A/B מדוד מול הסופיים החתומים (9 תיקים, 60 ייצורים, 2026-07-28) הראה שאותו
כלל, בסוף הפרומפט, חותך anti_pattern_total ב-72–93%:

  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

וב-distance: −12%/−29% (4-8), −9%/−27% (5). זה שיפור גדול פי-3 מכל הבדל
שנמדד בין המודלים עצמם.

- `lessons.anti_pattern_directive()` — רינדור שני של אותה רשימה קנונית
  (מקור אחד, שתי תצוגות — לא שני כללים).
- מתווסף **אחרון** בשני מסלולי-הכתיבה: `write_block` (בתהליך) ו-
  `get_block_context` (סוכן legal-writer). אילו הוחל רק באחד, שני הכותבים
  היו נפרדים בסגנון (G2).
- **תיקון בליעה-שקטה (§6):** הרשימה הקנונית רונדרה בתוך לולאת-ה-overrides,
  כך שכשל-DB בקטגוריה מוקדמת (golden_ratios) הפיל את הלולאה והשמיט את
  אינווריאנטי-הסגנון כליל — עם אזהרה גנרית בלבד. עכשיו היא מרונדרת ללא
  תנאי, לפני כל קריאת-DB; הערות-היו"ר מתווספות מעליה.

invariants: G11 (תוכן משפטי — סגנון דפנה) · G2 (מקור-אמת יחיד לכלל, ושני
מסלולי-הכתיבה מיושרים) · §6 (אין בליעה שקטה).

בדיקות: 473 passed (3 חדשות — רינדור מלא, שני המסלולים, שרידות לכשל-DB).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:14:47 +00:00
10e05700cc feat(eval): --instructions ל-harness — A/B של וריאנט-פרומפט
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 34s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
מוחל על כל המודלים בריצה (אחרת השוואת-מודלים הופכת בשקט להשוואת-פרומפטים),
ונרשם ב-grid_summary + בכותרת הדוח כדי שריצת-וריאנט לא תושווה בטעות לבסיס.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 07:47:05 +00:00
11acdac337 feat(eval): ממד-מודל ל-harness הכיול (#208) — A/B של מודל-ייצור מול הסופיים
`calibrate_effort.py` כייל `effort` בלבד, על מודל נעוץ (GENERATION_MODEL).
כדי להשוות מודל-ייצור (opus-4-8 מול opus-5) מול הסופיים החתומים של דפנה
נדרש ממד שני — ללא מסלול-מדידה מקביל.

- `block_writer.write_block(model_override=…)` — אותו חוזה כמו
  `effort_override` הקיים (נוצר בדיוק ל-#208). מקבל את המזהה הבסיסי בלבד;
  אסקלציית ההקשר-1M (#216) מוחלת מעליו, כך ש-override לא מאבד בשקט את
  חלון ה-1M.
- `--models` ל-harness (ריק = המודל הנעוץ ⇒ ריצת ברירת-המחדל זהה לקודם).
- הדוח מקבל טבלת השוואת-מודלים (block × effort × model) ומסמן  לפי
  אותו דירוג style-clean (#213): anti_total → ratioΔ → distance.
- כל תא מתעד את `model_used` שה-CLI דיווח בפועל; אי-התאמה מסומנת כאזהרת
  fallback-שקט במקום להיזקף בטעות למודל המבוקש.

invariants: INV-G8 (eval-harness — מדידה, לא הרגשה) · G2 (אין מסלול מקביל:
משתמש ב-style_distance/learning_loop הקיימים ובשדה result הקיים
`model_used`) · §6 (אין בליעה שקטה — כשל-תא מדווח ומדולג).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 06:39:33 +00:00
4eb3312e9b Merge pull request 'refactor(legal-ceo): גיזום מסמך-ההכוונה לכותב — שיפוט במקום טופס' (#419) from ceo-prompt-trim into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-07-25 21:29:53 +00:00
574998021e refactor(legal-ceo): גיזום מסמך-ההכוונה לכותב — שיפוט במקום טופס
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 35s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
מיישם את ממצא ה-A/B (fable-5/opus-4-8 על תיק 1043-02-26): פרומפט
מגוזם-ומוכוון-שיפוט מפיק היסק משפטי חד יותר מתבנית נוקשה. משכתב את
תבנית מסמך-ההכוונה של ה-CEO לכותב בלבד — משמר את החוזה המלא (5 הרכיבים,
chair_directions בתגית, אילוצי-הסגנון) ומזריק את רמזי-השיפוט שהוכחו:
אדנים עצמאיים, מוקשי-עקביות-פנימית, מענה לצד המפסיד, הובלה בסוגיה
המכריעה. שאר מכונת-התזמור התפעולית (שערים, סטטוסים, API, MCP-race)
לא נגעה. net -27/+20.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 21:28:16 +00:00
cc4d757fce Merge pull request 'refactor(agents): גיזום כפילויות + rubric-קבלה משותף (context-engineering לדור-5)' (#418) from worktree-agent-prompts-trim into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 11s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-07-25 17:33:52 +00:00
253f8ff97b refactor(agents): גיזום כפילויות + rubric-קבלה משותף (context-engineering לדור-5)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
מיושר עם "context engineering for Claude 5 models" — הסוכנים רצים על opus-4-8/sonnet-5.

גיזום (רק כפילות חד-משמעית; טבלאות-סגנון/שער-אמינות/HEARTBEAT לא נגעו):
- 6 סוכנים: קריסת שכפולי HEARTBEAT §4ב/§4ג (כל סוכן הפנה כמקור-יחיד ואז שכפל).
- exporter: ציטוט "בג\"ץ 1234/21" מומצא בדוגמת-JSON → placeholder (סתירה לשער-האמינות).
- writer: רשימת-קריאה לבלוק-י כפולה מילה-במילה → הפניה לעותק הראשון.

מיני-פרויקט rubric (גניבת מודל Outcome מ-Managed Agents, במסמך משותף = בלי כפילות):
- docs/qa-acceptance-rubric.md — הגדרת "מוכן" אחת (חוסמים/אזהרות + severity).
- writer מכוון אליו מראש; qa מפנה אליו כמקור-האכיפה של 7 הבדיקות.

הערה: תבנית-הלוג של analyst היא ההגדרה הקנונית (לא כפילות) — נשמרה במכוון.
2026-07-25 17:33:32 +00:00
044aecb034 Merge pull request 'refactor(agents): מבנה-פרומפט — verdict מובנה ל-QA + תגית chair_directions' (#417) from worktree-agent-prompts-xml-structure into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-25 16:49:48 +00:00
1c7464c2ce refactor(agents): מבנה-פרומפט — verdict מובנה ל-QA, תגית chair_directions, ניקוי תבניות
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 35s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
שדרוג מבנה-פרומפט ממוקד (Anthropic prompt-engineering), רק היכן שיש ROI אמיתי —
הפרומפטים כבר בנויים היטב, אז לא בוצעה עטיפת-XML גורפת:

- legal-qa: דוח-בדיקה במבנה קבוע (טבלת pass/fail/חומרה + החלטת-ייצוא). markdown
  ולא XML — הדוח מוצג ליו"ר כהערת Paperclip.
- legal-ceo ↔ legal-writer (זוגי): ה-CEO עוטף עמדות-יו"ר מילוליות ב-<chair_directions>
  בהעברה לכותב; הכותב מונחה להתייחס אליהן כמחייבות (כמו chair_ruling מהכלי).
- legal-analyst: הבהרה ששתי הופעות "7א" הן אותו סעיף (הסרת תבניות חופפות).
- legal-writer: דוגמת-הפלט עטופה ב-<example_output> (מבדיל דוגמה מהוראה).

תגיות רק במקום שהמודל קורא (פרומפט/הקשר-מועבר), לא ב-output מוצג-למשתמש.
Deploy: קבצי-סוכנים נקראים מעץ-העבודה על ה-host; אחרי merge צריך git pull ב-~/legal-ai.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-25 16:49:27 +00:00
8bcf05df26 feat(agents-panel): 5 תיקוני יו"ר (#416)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m50s
G12 Leak-Guard / leak-guard (push) Successful in 6s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-23 12:30:40 +00:00
cb4aa703ec feat(agents-panel): היררכיה, סמן-כהושלם ידני, ובורר-יעד נגלל (5 תיקוני יו"ר)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 36s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
טאב הסוכנים בתיק — 5 תיקונים לבקשת היו"ר, מאושרים דרך שער-העיצוב
(Claude Design X17, כרטיס 18i4):

1. היררכיה — הרצה שנפתחה תחת השורש (למשל CMP-220 תחת CMP-189) מסומנת
   בתג "↳ <parent>" גם בציר-הזמן וגם בבורר-היעד (בהזחה). הנתון parent_id
   כבר הגיע מה-API — תצוגה בלבד.
2. סמן-כהושלם / בטל ידני — כל משימה פתוחה מקבלת פעולות בכותרת. פותר
   "אין אפשרות לסמן ידנית" (superseded runs נתקעו ב-in_review בלי מנגנון
   סגירה). endpoint חדש: POST /api/cases/{n}/agents/issue-status → דרך
   ה-Port (pc_set_issue_status), סגירה loop-safe ישירה ל-DB, בלי wakeup.
3. בורר-היעד נגלל — max-height + overflow פנימי, כך שהתיבה לא בורחת
   מגבולות העמוד. פותר "לא רואים את התחתית / לא מגיעים לפתיחת הליך".
4. "פתח הרצה חדשה" עלה לראש הרשימה, מעל "משימות פעילות", ומודגש.
5. קו-הפרדה בין משימות בציר-הזמן עבה יותר (border-b-2 rule).

G12: כל מגע-פלטפורמה עובר דרך agent_platform_port. אימות: lint + tsc +
next build עוברים.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:25:36 +00:00
46 changed files with 2760 additions and 215 deletions

View File

@@ -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)..." — כדי שהקורא ידע מראש מה לצפות.
@@ -234,7 +240,7 @@ search_precedent_library(
### 5ד. תיעוד מחייב — סעיף "שאילתות לקורפוסים" ב-`analysis-and-research.md`
ב-artifact הסופי, חובה להופיע סעיף חדש בשם **"7א. שאילתות לקורפוסים — log מלא"**, עם הפורמט הבא:
ב-artifact הסופי, חובה להופיע סעיף חדש בשם **"7א. שאילתות לקורפוסים — log מלא"**, עם הפורמט הבא. (זהו סעיף 7א שמופיע גם בתבנית-הפלט המלאה שבהמשך — **אותו סעיף אחד**, לא תבנית נפרדת.)
```markdown
## 7א. שאילתות לקורפוסים — log מלא
@@ -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".**
@@ -323,7 +352,7 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
### העֵר את העוזר המשפטי (CEO) — חובה!
wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג. **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג.
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** מוגדר אוטומטית ע"י Paperclip; ב-double-quotes bash מרחיב לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
## מבנה הפלט המלא — analysis-and-research.md
@@ -347,7 +376,18 @@ wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח מ
1. ...
## 5. טענות סף
[אם קיימות — כולל שאלות משפטיות + עמדת ועדת הערר לכל טענה]
[אם אין — "לא זוהו טענות סף." אם יש — טענה אחת לכל H3, במבנה הזה:]
### ס-1 — [כותרת הטענה] ([מי מעלה])
**טענה (claim):** ...
**תשובה (response):** ...
**שאלה משפטית:** ...
**עמדת ועדת הערר:**
[ימולא ע"י יו"ר הוועדה]
### ס-2 — ...
**תקן ביקורת:** [שיקול דעת עצמאי / בחינת תקינות השומה / אחר]

View File

@@ -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,44 +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")
### 4. הנחיות-היו"ר
העתק מילולי של עמדות-הוועדה מ-analysis-and-research.md (או "קרא get_chair_directions"), **עטוף ב-`<chair_directions>…</chair_directions>`** — טקסט מילולי בלבד בלי פרפרזה, כדי שהכותב לא ידרוס אותן.
### 5. הנחיות סגנון
- ניטרליות: בלוק ו = עובדות בלבד, בלי ציטוטים מצדדים
- ללא כפילות: בלוק י מפנה לבלוקים קודמים
- טענות מקוריות: בלוק ז = כתבי טענות מקוריים
- אורך מינימלי לדיון: 1,500 מילים לבלוק י
- פסיקה: חובה לצטט לפחות 3 תקדימים בדיון
ניטרליות (בלוק ו = עובדות בלבד, בלי ציטוטי-צדדים) · ללא כפילות (בלוק י מפנה לקודמים) · טענות מקוריות (בלוק ז) · דיון ≥ 1,500 מילים · ≥ 3 תקדימים בדיון.
```
---

View File

@@ -96,7 +96,7 @@ tools:
"id": "r1",
"type": "insert_after", // או insert_before, replace, delete
"anchor_bookmark": "block-yod", // מ-list_bookmarks
"content": "וכך נפסק בעניין פלוני. בבג\"ץ 1234/21 קבע השופט...",
"content": "<תוכן ההוספה — טקסט עברי נקי, בלי ציטוט מומצא>",
"style": "body", // או heading, quote
"reason": "הוספת פסק הלכה שחסר לפי בקשת יו\"ר"
}
@@ -128,7 +128,7 @@ tools:
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מייצא טיוטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מייצא טיוטה סיים [issue-id] בסטטוס [done/blocked]"`.
## כללים קריטיים

View File

@@ -96,5 +96,4 @@ tools:
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל / markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked` (`blocked` אם markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`.

View File

@@ -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
---
@@ -56,6 +57,8 @@ tools:
## 7 בדיקות
> **סף-הקבלה המשותף — `docs/qa-acceptance-rubric.md`:** 7 הבדיקות למטה הן האכיפה המפורטת שלו (מקור-אמת אחד; הכותב מכוון לאותו rubric). בדיווח — מפה כל `fail` לחוסם/אזהרה שם.
### 1. שלמות מבנית (structural_integrity)
- כל בלוקי חובה קיימים (ה עד יא)
- מספור רציף ללא קפיצות
@@ -232,14 +235,24 @@ new → processing → documents_ready → analyst_verified → research_complet
6. האם יש אנלוגיה לתקדים ללא הסבר מדיניות (למה הדמיון רלוונטי)?
### שלב 3: דיווח — חובה!
פרסם comment ב-Paperclip עם:
- תוצאת כל בדיקה (pass/fail)
- רשימת שגיאות מפורטת (אם יש)
- האם מותר לייצא (כל הקריטיים pass?)
- עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר)
פרסם comment ב-Paperclip **במבנה קבוע** (markdown — נקרא ע"י היו"ר וע"י ה-CEO), כדי שהפסיקה תהיה עקבית וניתנת-לפרסינג. מלא שורה לכל אחת מ-7 הבדיקות:
```markdown
## דוח בדיקת איכות — ערר {case_number}
| בדיקה | תוצאה | חומרה | ממצא (אם fail) |
|-------|:-----:|:-----:|-----------------|
| {שם הבדיקה} | pass / fail | critical / major / minor | {פירוט קצר} |
| ... שורה לכל 7 הבדיקות ... | | | |
**מותר לייצא:** כן / לא ← "לא" אם ולו בדיקה **critical** אחת ב-fail
**סטטוס חדש:** drafted (עבר) / qa_review (נכשל)
```
- עדכן סטטוס בהתאם: `drafted` (אם עבר) או `qa_review` (אם נכשל).
### סגור את ה-issue של עצמך + העֵר CEO — חובה!
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="בודק איכות סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="בודק איכות סיים [issue-id] בסטטוס [done/blocked]"`.

View File

@@ -410,7 +410,7 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="חוקר תקדימים סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="חוקר תקדימים סיים [issue-id] בסטטוס [done/blocked]"`.
## כללים
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים

View File

@@ -53,6 +53,8 @@ tools:
## לפני שאתה מתחיל — קרא!
> **המטרה שלך — `docs/qa-acceptance-rubric.md`:** זהו סף-הקבלה שה-QA יאכוף. כתוב כדי לעבור אותו מהפעם הראשונה, ולפני מסירה ל-CEO עבור על החוסמים ואשר שכל אחד מתקיים.
### חובה לפני כל כתיבה — נקודת ההתחלה:
0. **עץ ההחלטה: `docs/daphna-decision-tree.md`****כלי הפעולה היומיומי**. מאחד את כל המסמכים לתהליך אנליטי קצר: מהי הראיה הניצחת? איזה ארכיטקטורה? איזה מוד פתיחה? איזה אורך? **תמיד להתחיל כאן** — המסמך מצביע איזה מסמך אחר לקרוא לפי השאלה.
@@ -122,9 +124,11 @@ tools:
4. החזר את הטקסט ל-CEO (בקומנט או כ-return value) — **לא** שומר ב-DB
5. CEO יקרא ל-`revise_draft` עם הטקסט שלך
**דוגמה לפלט מצופה:**
**דוגמה לפלט מצופה** (עטופה בתגית כדי שלא תתבלבל עם הוראה):
> בבג"ץ 1234/21 [פלוני נ' הוועדה המחוזית] קבע בית המשפט העליון כי הוועדה המקומית מחויבת לשקול שיקולי Y גם בהיעדר התנגדות מפורשת. הלכה זו חלה ישירות על ענייננו: הוועדה המקומית לא בחנה את Y, ודי בכך כדי להחזיר את הדיון לוועדה.
<example_output>
בבג"ץ 1234/21 [פלוני נ' הוועדה המחוזית] קבע בית המשפט העליון כי הוועדה המקומית מחויבת לשקול שיקולי Y גם בהיעדר התנגדות מפורשת. הלכה זו חלה ישירות על ענייננו: הוועדה המקומית לא בחנה את Y, ודי בכך כדי להחזיר את הדיון לוועדה.
</example_output>
---
@@ -218,18 +222,13 @@ case_update(case_number, status="drafted")
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="כותב החלטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="כותב החלטה סיים [issue-id] בסטטוס [done/blocked]"`.
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
## בלוק י — דיון (הבלוק החשוב ביותר)
**קריאת חובה לפני כתיבה (5 מסמכים)**:
1. `docs/daphna-voice-fingerprint.md` — קבועים, פעלי "אנחנו", אנטי-דפוסים
2. `docs/daphna-precedent-network.md` — לכל סוגיה משפטית, איזה תקדם
3. `docs/daphna-architecture-by-outcome.md` — מבנה לפי תוצאה + עץ החלטה
4. `docs/daphna-acceptance-architecture.md` — **חובה אם תוצאה צפויה: קבלה (לא חלקית).** חמש תבניות מובחנות
5. `docs/voice-1130-25.md` — דוגמה עמוקה
> קרא קודם את **"חובה לפני בלוק י (חמישיית הקול)"** שלמעלה — חמשת מסמכי-הקול.
**עץ החלטה לבחירת ארכיטקטורה**:
1. מה התוצאה?
@@ -373,6 +372,8 @@ case_update(case_number, status="drafted")
### חובה: שימוש בעמדות יו"ר מ-`get_chair_directions`
> **מקור העמדות:** בדרך-כלל מ-`get_chair_directions` (שלב 1ג). אם ה-CEO **העתיק** עמדות-יו"ר לתוך ה-issue שלך, הן מגיעות עטופות ב-`<chair_directions>…</chair_directions>` — טקסט מילולי זה **מחייב אותך בדיוק כמו** `chair_ruling` מהכלי: אל תדרוס, אל תפרפרז, נסח בעדו.
עבור **כל טענת סף** ו**כל סוגיה** ב-direction_doc שבנית בשלב 1ג:
1. **פתח את הדיון במסקנה של דפנה** — למשל "**טענת הסף הראשונה נדחית**"

View 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

View File

@@ -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 ישנים.
---

View File

@@ -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` |
---
@@ -52,6 +52,28 @@
- **אפס עלות API** — claude CLI משתמש ב-claude.ai subscription של chaim. הנחת היסוד של `claude_session.py` (claude CLI מקומי בלבד) נשמרת.
- Coolify dependency: ה-Service Definition של legal-ai חייב להכיל `extra_hosts: host.docker.internal:host-gateway` (אחרת ה-proxy יקבל ConnectError).
### legal-mcp-http — שרת ה-MCP ב-HTTP (מאוגוסט 2026)
- פורט: `127.0.0.1:8790` (loopback בלבד) · קונפיג: [`scripts/legal-mcp-http.config.cjs`](../scripts/legal-mcp-http.config.cjs)
- **הדלת של סוכני הפלטפורמה** אל 108 כלי ה-MCP. סשן אינטראקטיבי מגיע לאותו שרת ב-stdio דרך `~/.claude.json`**אותו קוד, שתי תחבורות** (G2).
- שער Bearer (`MCP_HTTP_SHARED_SECRET`); השרת **מסרב לעלות** בלי טוקן. בדיקה: ללא טוקן → `401`, עם טוקן → `200`.
- **⚠️ שינוי קוד ב-`mcp-server/` לא נכנס לתוקף עד `pm2 restart legal-mcp-http`.** זה תהליך ארוך-חיים, בניגוד ל-stdio שנטען מחדש בכל סשן. אחרי מיזוג שנוגע ב-`mcp-server/` — להפעיל מחדש, אחרת הסוכנים ירוצו על קוד ישן בשקט.
> #### 🚨 דריפט תצורה פתוח — `STORAGE_BACKEND`
> הקונטיינר רץ `STORAGE_BACKEND=s3`; שרת ה-MCP נופל לברירת-המחדל `filesystem`, כי
> `~/.env` אינו מגדיר אותו. כלומר **סוכן שכותב קובץ דרך MCP כותב לדיסק, בעוד ה-web
> קורא מ-S3** — שני מסלולים שמתפצלים (G2 / INV-STG1).
>
> **למה זה עדיין לא תוקן:** ההעברה ל-s3 דורשת `MINIO_ENDPOINT` + `MINIO_ACCESS_KEY` +
> `MINIO_SECRET_KEY`, ואלה **אינם קיימים ב-Infisical** — רק ב-env של Coolify. הפיכת
> הדגל בלעדיהם תפתור את `MINIO_ENDPOINT` לברירת-המחדל `http://minio:9000` (שם-רשת
> פנימי של דוקר, לא נגיש מהמארח) ותשבור כל פעולת-בלוב.
>
> **מה שצריך כדי לסגור:** ליצור את שלושת המפתחות ב-Infisical תחת `/apps/legal-ai`
> (תגית `credentials`, באישור חיים — ראה כלל "Saving a NEW secret" ב-`~/CLAUDE.md`),
> לטעון אותם בקונפיג ה-pm2 כמו `MCP_HTTP_SHARED_SECRET`, ואז להוסיף
> `STORAGE_BACKEND: "s3"`. MinIO **כן** נגיש מהמארח: `https://s3.nautilus.marcusgroup.org`
> (אומת — `/minio/health/live` מחזיר 200).
---
## מבנה תיקיות
@@ -195,9 +217,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 חדש
אם נדרשת עבודה.

View File

@@ -0,0 +1,57 @@
# Rubric קבלה של החלטה — מקור-אמת אחד
מסמך זה הוא **הגדרת ה"מוכן"** של טיוטת החלטה: סף-הקבלה שהכותב **מכוון אליו מראש**
וה-QA **אוכף** כשער. שני הסוכנים מפנים לכאן — אין לשכפל את הקריטריונים בקבצי-הסוכן.
**זהו סף-הקבלה, לא הוראת-הכתיבה.** ה"איך" המלא חי במסמכי-הקול המופנים בכל שורה
(`daphna-voice-fingerprint.md`, `daphna-block-zayin-claims.md`,
`daphna-architecture-by-outcome.md`, `daphna-acceptance-architecture.md`,
`decision-methodology.md`, `anti-hallucination-gate.md`). כאן — רק המבחן.
> **לכותב:** לפני מסירה ל-CEO, עבור על החוסמים למטה ואשר שכל אחד מתקיים. טיוטה
> שלא עוברת חוסם אחד — תוחזר ב-`needs_revision`. כתוב כדי לעבור מהפעם הראשונה.
>
> **ל-QA:** אלה הקריטריונים ש-7 הבדיקות שלך אוכפות. `fail` על חוסם = `qa_review`;
> `fail` על אזהרה = דיווח לחיים, לא חוסם.
---
## חוסמי ייצוא (critical — כל אחד מכשיל את הטיוטה)
1. **שלמות מבנית** — בלוקים ה–יא קיימים; מספור רציף ללא קפיצות/כפילות; "להלן" בשימוש ראשון.
2. **רקע ניטרלי (בלוק ו)** — עובדות בלבד; **אפס** ציטוטים מצדדים; **אפס** מילות-שיפוט
("חריג", "בעייתי", "מגוחך", "פגום", "שערורייתי").
3. **כיסוי טענות** — כל טענה מהותית מבלוק ז קיבלה מענה בבלוק י (ישיר / קיבוץ / ציון שנבחנה).
טענות `[skip]` לא נספרות; `[bundle]` נבדקות כקבוצה. טענה מהותית לא-מסומנת שלא נענתה = כשל.
4. **מתודולוגיה** — לכל סוגיה מבנה סילוגיסטי (כלל+עובדות+מסקנה); ממצא עובדתי מופרד ממסקנה
משפטית; טענת הצד המפסיד הוצגה בחוזקתה (Steel-Man); איזון מפורש כשנדרש; בלי "נוסחאות ריקות";
ציטוטים בסנדוויץ' (הקדמה→ציטוט→ניתוח).
5. **קול דפנה**
- בלוק ז: כותרת **"תמצית טענות הצדדים"**; כותרת-משנה לכל צד; בלי רשימה ממוספרת בפסקה;
בלי מילות-הערכה; בלי גילוי-מסקנה; קול פעיל ("העורר טוען").
- בלוק י: כותרת **"דיון והכרעה"**; קול "אנחנו" תפקידי ("מצאנו", לא "הוועדה מוצאת");
דפוס **"אכן... אולם"** לטענות שנדחות; בלי מספור-פסקאות סדרתי; ציטוטי פסיקה במלואם (415 שורות);
"למעלה מן הצורך" לטיעונים מרכזיים; בלי תוצאת הכל-או-לא-כלום כשיש טענות מהותיות משני הצדדים.
- 8xxx עם הכרעה שמאית → ציטוט **בר"מ 3644/13** קיים.
6. **עיגון-מקור (INV-AH)** — כל אזכור פסיקה/חוק/הלכה/מספר מעוגן-מקור עם ציטוט; ציטוט חיצוני
תואם להלכה מאושרת בקורפוס (`search_precedent_library`). אזכור לא-מעוגן = הסר או הורד ל-`[ספקולציה]`.
7. **צירוף פסיקה ל-DB** — כל ציטוט בבלוק י (חיצוני/internal) רשום ב-`case_precedents` דרך
`precedent_attach`. ציטוט שקיים רק בטקסט ולא ב-DB = חוסם (ה-exporter ו-Hermes קוראים מה-DB).
8. **log שאילתות-קורפוס**`analysis-and-research.md` §7א ו-`precedent-research.md` §ז קיימים,
עם מינימום queries לקורפוס הסמכותי (`analyst ≥ טענות-סף + סוגיות`; `researcher ≥ סוגיות`)
ותיעוד negative-evidence (גם 0-results).
## אזהרות (warning — מדווח לחיים, לא חוסם)
- **משקלות בטווח:** ו 1540% · ז 2040% · י 3250% · יא 29%.
- **בלי כפילות:** בלוק י מפנה לבלוקים קודמים ("כאמור"), לא חוזר על עובדות/טענות.
- **מראה-מקום מלא בציטוטים:** מספר-תיק מלא + ערכאה + תאריך/`פורסם בנבו` (+ `page_reference` לציטוט ארוך).
## אם תוצאה = קבלה (מ-`daphna-acceptance-architecture.md`)
- התבנית הנבחרת (A/B/C/D/E) מתאימה לסיבת-הקבלה, ופורמט-הסיום תואם לתבנית
(A: "מתבטלת" + הודאת-צד-נגדי; B: "תיקבע לדיון"; C: "בכפוף לתיקונים" + הכרה בוועדה;
D: "דרישת התשלום בטלה"; E: "השומה תושב לתיקון").
---
> **מקור-אמת:** חומרת-הבדיקות המחייבת חיה ב-`.claude/agents/legal-qa.md` ("7 בדיקות" + טבלת-חומרה)
> וב-`docs/spec/05-qa-review.md`. Rubric זה הוא התמצית המשותפת — כשמשתנה שער QA, עדכן שם ואת התמצית כאן.

View File

@@ -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 מפעיל

View 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.

View File

@@ -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__":

View File

@@ -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.

View File

@@ -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 {}

View File

@@ -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),

View File

@@ -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]

View 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=[])

View File

@@ -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"]))

View File

@@ -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
7293% 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 = {

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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 "סוגיות 46") 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) ─────────

View File

@@ -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,
}

View File

@@ -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 החלטות ועדת-ערר")

View 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']}"

View 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)

View 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

View 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

View 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)

View 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

View 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"
"### סוגיות 46: מקובצות\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}

View 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)"
)

View File

@@ -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`+ספ-התחום ולוודא קיום G1G12 — לפני שכותבים. **+ 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) |

View 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 14 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 14 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())

View File

@@ -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)

View File

@@ -0,0 +1,110 @@
/**
* 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",
// Retrieval flags must match the container's, or the two doors into the same
// corpus rank differently (G2 — a parallel path that drifts). The container
// runs MULTIMODAL_ENABLED=true; without this the MCP door skipped the
// page-image merge entirely, so an agent and the web UI could answer the same
// question from different result sets. ~/.env sets neither, so both fell to
// the code defaults (false) — the drift was silent.
//
// NOT set here: STORAGE_BACKEND. The container runs `s3`, this service falls
// back to `filesystem`, and closing that gap needs MinIO credentials that do
// not exist in Infisical yet (only in Coolify's env). Flipping the flag
// without them would resolve MINIO_ENDPOINT to the in-cluster default
// `http://minio:9000` — unreachable from the host — and break every blob
// read. Tracked separately; see the note in docs/operations-runbook.md.
MULTIMODAL_ENABLED: "true",
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",
},
],
};

View File

@@ -10,6 +10,7 @@ import {
useSendComment,
useSubmitInteraction,
useDismissInteraction,
useSetIssueStatus,
} from "@/lib/api/agents";
import type {
Interaction,
@@ -772,31 +773,96 @@ function IssueGroup({
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const setStatus = useSetIssueStatus(caseNumber);
const closed = issue.status === "done" || issue.status === "cancelled";
// Show the parent run this issue hangs under (e.g. a "הרצה חדשה" nested under
// the live CEO root) so the hierarchy is visible, not flattened.
const parentIdentifier = issue.parent_id
? issueMap.get(issue.parent_id)
: undefined;
const close = (status: "done" | "cancelled") =>
setStatus.mutate(
{ issue_id: issue.id, status },
{
onSuccess: () =>
toast.success(
status === "done" ? "המשימה סומנה כהושלמה" : "המשימה בוטלה",
),
onError: () => toast.error("שגיאה בעדכון סטטוס המשימה"),
},
);
return (
<div className="border-b border-rule-soft last:border-b-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50 text-start"
>
<Badge
variant={closed ? "secondary" : "default"}
className="text-[10px] font-mono shrink-0"
<div className="border-b-2 border-rule last:border-b-0">
<div className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex-1 flex items-center gap-2 text-start min-w-0"
>
{issue.identifier}
</Badge>
<span className="text-[0.78rem] font-semibold text-navy truncate">
{shortIssueTitle(issue.title)}
</span>
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
{issueStatusLabel(issue.status)} · {items.length}
</span>
<ChevronDown
className={`w-4 h-4 ms-auto text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
<Badge
variant={closed ? "secondary" : "default"}
className="text-[10px] font-mono shrink-0"
>
{issue.identifier}
</Badge>
<span className="text-[0.78rem] font-semibold text-navy truncate">
{shortIssueTitle(issue.title)}
</span>
{parentIdentifier && (
<span
className="inline-flex items-center gap-0.5 text-[0.6rem] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parentIdentifier}`}
>
{parentIdentifier}
</span>
)}
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
{issueStatusLabel(issue.status)} · {items.length}
</span>
</button>
{!closed && (
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => close("done")}
disabled={setStatus.isPending}
className="h-7 px-2 text-[0.7rem] text-success border-success/40 hover:bg-success-bg"
title="סמן את המשימה כהושלמה"
>
{setStatus.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<CheckCircle2 className="w-3.5 h-3.5 me-1" />
)}
הושלם
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => close("cancelled")}
disabled={setStatus.isPending}
className="h-7 px-2 text-[0.7rem] text-ink-muted hover:text-danger"
title="בטל את המשימה"
>
בטל
</Button>
</div>
)}
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label={open ? "כווץ" : "הרחב"}
className="shrink-0"
>
<ChevronDown
className={`w-4 h-4 text-ink-faint transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
</div>
{open && (
<div className="pb-1">
{items.map((item) =>
@@ -878,6 +944,13 @@ function TargetSelector({
const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]);
const activeIssues = issues.filter((i) => isOpenStatus(i.status));
const closedIssues = issues.filter((i) => !isOpenStatus(i.status));
// id → identifier, to label a run with the parent it hangs under (↳ CMP-189).
const idToIdentifier = useMemo(
() => new Map(issues.map((i) => [i.id, i.identifier])),
[issues],
);
const parentTag = (i: PaperclipIssue) =>
i.parent_id ? idToIdentifier.get(i.parent_id) : undefined;
// The issue the pill currently represents (explicit pick, or the auto default).
const effectiveIssue =
@@ -925,7 +998,31 @@ function TargetSelector({
onClick={() => setOpen(false)}
aria-hidden
/>
<div className="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] bg-white border border-rule rounded-xl shadow-lg p-1.5">
<div className="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] max-h-[62vh] overflow-y-auto overscroll-contain bg-white border border-rule rounded-xl shadow-lg p-1.5">
{/* "פתח הרצה חדשה" is the FIRST, most-prominent option so it's always
visible without scrolling to the bottom of a long task list. */}
<button
type="button"
onClick={() => {
onChange({ kind: "new_run" });
setOpen(false);
}}
className="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-start bg-gold/10 border border-rule hover:border-gold/60"
>
<span className="w-6 h-6 rounded-md bg-navy text-white flex items-center justify-center shrink-0">
<Plus className="w-4 h-4" />
</span>
<span className="min-w-0">
<span className="block text-xs font-semibold text-navy">
פתח הרצה חדשה
</span>
<span className="block text-[10px] text-ink-muted">
ה-CEO ייצור משימה חדשה ויטפל בהוראה מאפס
</span>
</span>
</button>
<div className="h-px bg-rule-soft my-1.5 mx-1" />
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
משימות פעילות
</div>
@@ -933,6 +1030,7 @@ function TargetSelector({
const selected =
(target.kind === "issue" && target.id === i.id) ||
(target.kind === "auto" && defaultIssue?.id === i.id);
const parent = parentTag(i);
return (
<button
key={i.id}
@@ -941,12 +1039,20 @@ function TargetSelector({
onChange({ kind: "issue", id: i.id });
setOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${selected ? "bg-gold/10" : ""}`}
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${parent ? "ps-6" : ""} ${selected ? "bg-gold/10" : ""}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" />
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
{i.identifier}
</Badge>
{parent && (
<span
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parent}`}
>
{parent}
</span>
)}
<span className="text-xs text-ink-soft truncate flex-1">
{shortIssueTitle(i.title)}
</span>
@@ -968,50 +1074,39 @@ function TargetSelector({
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
משימות סגורות · שליחה אליהן לא תעיר סוכן
</div>
{closedIssues.map((i) => (
<div
key={i.id}
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed"
title="משימה סגורה — שליחה אליה לא תעיר סוכן"
>
<span className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" />
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
{i.identifier}
</Badge>
<span className="text-xs text-ink-faint truncate flex-1">
{shortIssueTitle(i.title)}
</span>
<span
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
{closedIssues.map((i) => {
const parent = parentTag(i);
return (
<div
key={i.id}
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed ${parent ? "ps-6" : ""}`}
title="משימה סגורה — שליחה אליה לא תעיר סוכן"
>
{issueStatusLabel(i.status)}
</span>
</div>
))}
<span className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" />
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
{i.identifier}
</Badge>
{parent && (
<span
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parent}`}
>
{parent}
</span>
)}
<span className="text-xs text-ink-faint truncate flex-1">
{shortIssueTitle(i.title)}
</span>
<span
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
>
{issueStatusLabel(i.status)}
</span>
</div>
);
})}
</>
)}
<div className="h-px bg-rule-soft my-1.5 mx-1" />
<button
type="button"
onClick={() => {
onChange({ kind: "new_run" });
setOpen(false);
}}
className="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-start hover:bg-sand-soft"
>
<span className="w-6 h-6 rounded-md bg-navy text-white flex items-center justify-center shrink-0">
<Plus className="w-4 h-4" />
</span>
<span className="min-w-0">
<span className="block text-xs font-semibold text-navy">
פתח הרצה חדשה
</span>
<span className="block text-[10px] text-ink-muted">
ה-CEO ייצור משימה חדשה ויטפל בהוראה מאפס
</span>
</span>
</button>
</div>
</>
)}

View File

@@ -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}

View File

@@ -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({

View File

@@ -208,6 +208,25 @@ export function useDismissInteraction(caseNumber: string | undefined) {
});
}
/** Manually close a board issue to done/cancelled — the chair tidying the agents
* board (e.g. superseded runs left in in_review). Backend does a loop-safe
* direct close and issues no wakeup; only the two terminal statuses are allowed. */
export function useSetIssueStatus(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { issue_id: string; status: "done" | "cancelled" }) =>
apiRequest<{ ok: boolean; id: string; identifier: string; status: string }>(
`/api/cases/${caseNumber}/agents/issue-status`,
{ method: "POST", body: vars },
),
onSuccess: () => {
if (caseNumber) {
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
}
},
});
}
export type AgentResetResult = {
ok: boolean;
reassigned_issues: { id: string; identifier: string }[];

View File

@@ -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 {

View File

@@ -68,6 +68,7 @@ from web.paperclip_client import (
reap_stale_interactions as _reap_stale_interactions,
reset_agent_session as _reset_agent_session,
reset_case_agents as _reset_case_agents,
set_issue_status as _set_issue_status,
wake_analyst_for_appraiser_facts as _wake_analyst_for_appraiser_facts,
wake_analyst_for_argument_aggregation as _wake_analyst_for_argument_aggregation,
wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis,
@@ -127,6 +128,12 @@ pc_cancel_interaction = instrument(
pc_escalate_issue = instrument(
"agent.escalated", keys=("issue_id", "severity", "company_id", "reason"),
)(_escalate_issue)
# Manual chair close of a board issue (done/cancelled) — the loop-safe primitive
# behind the agents-board "סמן כהושלם / בטל" actions. Countable per case in the
# same telemetry stream as the escalations/wakeups it sits beside.
pc_set_issue_status = instrument(
"issue.status_set", keys=("issue_id", "status", "company_id"),
)(_set_issue_status)
pc_reap_stale_interactions = instrument(
"interaction.reaped", result_keys=("cancelled",),
)(_reap_stale_interactions)
@@ -192,6 +199,7 @@ __all__ = [
"pc_cancel_interaction",
"pc_reap_stale_interactions",
"pc_escalate_issue",
"pc_set_issue_status",
# agent-run observability + control (live view + smart management)
"pc_get_agent_health",
"pc_get_recent_escalations",

View File

@@ -82,6 +82,7 @@ from web.agent_platform_port import (
pc_reset_agent_session,
pc_reset_case_agents,
pc_respond_to_interaction,
pc_set_issue_status,
pc_restore_project,
pc_wake_analyst_for_appraiser_facts,
pc_wake_analyst_for_argument_aggregation,
@@ -4851,6 +4852,29 @@ async def api_escalate_issue(case_number: str, req: EscalateRequest):
return result
class IssueStatusRequest(BaseModel):
issue_id: str
status: Literal["done", "cancelled"]
@app.post("/api/cases/{case_number}/agents/issue-status")
async def api_set_issue_status(case_number: str, req: IssueStatusRequest):
"""Manually close a board issue to done/cancelled from the chair UI.
Lets the chair tidy the agents board by hand — superseded "הרצה חדשה" runs
left in ``in_review`` had no manual close control and piled up. Only the two
terminal statuses are accepted; the loop-safe direct-DB close lives behind the
Port (``pc_set_issue_status``) and issues no wakeup.
"""
issues = await pc_get_case_issues(case_number)
if not any(i["id"] == req.issue_id for i in issues):
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
result = await pc_set_issue_status(req.issue_id, req.status)
if not result.get("ok"):
raise HTTPException(400, result.get("error", "עדכון הסטטוס נכשל"))
return result
# ── Settings: MCP Server Configuration ────────────────────────────
#
# Source of truth for legal-ai env vars is Coolify (see memory:
@@ -6968,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,
):
@@ -6996,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}
@@ -7008,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)}

View File

@@ -988,6 +988,70 @@ async def escalate_issue(
}
async def set_issue_status(
issue_id: str, status: str, company_id: str = "",
) -> dict:
"""Manually close a case issue to ``done`` or ``cancelled`` from the chair UI.
The chair needs to tidy the agents board by hand: superseded "הרצה חדשה" runs
that were replaced by a later run pile up in ``in_review`` with no way to close
them — there was no manual control, so they had to be closed out-of-band. This
is the loop-safe primitive behind the board's "סמן כהושלם / בטל" actions.
Only the two **terminal** statuses are accepted (``CLOSED_ISSUE_STATUSES``) —
this tidies the board, it does not drive the workflow (agents own the
todo/in_progress/in_review transitions). Direct-DB in one transaction,
mirroring :func:`escalate_issue`: bypasses Paperclip's disposition resolver
(whose multi-PATCH/``issue.released`` behaviour *causes* the recovery loops),
sets the matching close timestamp, clears any agent assignee so no recovery
sweep re-acts on it, and records an ``author_type='system'`` audit note (inert
w.r.t. the user-comment routing sweep, so it will not wake an agent). No wakeup.
"""
if status not in CLOSED_ISSUE_STATUSES:
return {
"ok": False,
"error": f"invalid status {status!r}; expected one of {tuple(CLOSED_ISSUE_STATUSES)}",
}
# status is validated against the frozenset above, so the interpolated column
# name is one of exactly two literals — no injection surface.
ts_col = "completed_at" if status == "done" else "cancelled_at"
note = (
"✓ סומן ידנית כהושלם ע\"י היו\"ר"
if status == "done"
else "✕ בוטל ידנית ע\"י היו\"ר"
)
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
async with conn.transaction():
row = await conn.fetchrow(
f"""UPDATE issues
SET status=$1, {ts_col}=now(),
assignee_agent_id=null, updated_at=now()
WHERE id=$2::uuid
RETURNING id, identifier, company_id""",
status, issue_id,
)
if not row:
return {"ok": False, "error": f"issue {issue_id} not found"}
cid = company_id or str(row["company_id"])
await conn.execute(
"""INSERT INTO issue_comments (id, company_id, issue_id, body, author_type)
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, 'system')""",
str(uuid.uuid4()), cid, issue_id, note,
)
finally:
await conn.close()
logger.info("Chair set issue %s%s", issue_id, status)
return {
"ok": True,
"id": str(row["id"]),
"identifier": row["identifier"],
"status": status,
}
async def respond_to_interaction(
issue_id: str, interaction_id: str, payload: dict,
) -> dict: