Compare commits

...

15 Commits

Author SHA1 Message Date
50e3fa4023 Merge pull request 'fix(ops): שרת ה-MCP עובר ל-S3 — סוגר את דריפט האחסון מול הקונטיינר' (#467) from worktree-mcp-s3 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 14:03:50 +00:00
adb9586055 fix(ops): שרת ה-MCP עובר ל-S3 — סוגר את דריפט האחסון מול הקונטיינר
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
הקונטיינר רץ `STORAGE_BACKEND=s3` ושרת ה-MCP נפל ל-`filesystem`. כלומר
מסמך שסוכן כתב דרך MCP נחת על הדיסק בעוד ה-web חיפש אותו ב-S3 — שני
מסלולים לאותו נכס, שמתפצלים בשקט (G2 / INV-STG1).

מה נדרש כדי לסגור, ומה נמצא בדרך
- **הסודות לא היו קיימים במקור-האמת.** `MINIO_ENDPOINT`/`ACCESS_KEY`/
  `SECRET_KEY` חיו רק ב-env של Coolify. נוצרו ב-Infisical תחת
  `/apps/legal-ai` (tag `credentials`, באישור חיים) ונטענים
  ל-`~/.legal-mcp-http.env` — אותו דפוס כמו MCP_HTTP_SHARED_SECRET.
  הערכים עברו ב-stdin דרך infisical-set.sh; לא ב-argv ולא בהיסטוריה.
- **`aioboto3` לא היה מותקן ב-venv המקומי.** מוצהר ב-pyproject אך חסר.
  מסלול ה-S3 מייבא אותו **עצלנית**, ולכן השירות היה עולה בהצלחה ונשבר
  רק בפעולת-הקובץ הראשונה — כשל שקט עד לשימוש. זו בדיוק הסיבה שהבדיקה
  רצה לפני ההפעלה ולא אחריה.

אימות מהמארח לפני ההפעלה: put/get/delete מלא מול
https://s3.nautilus.marcusgroup.org, וקריאת בלוב-ייצור קיים
(573KB) מתוך 2,283 אובייקטים בדלי.

ה-runbook עודכן: בלוק-האזהרה הוחלף בתיעוד המצב הסגור, כולל מלכודת
הייבוא-העצל ופקודת-אימות שאפשר להעתיק.

invariants: G2 (מסלול-אחסון אחד לשתי הדלתות) · INV-STG1 · §6
2026-08-05 14:03:15 +00:00
09c737bddf Merge pull request 'fix(ops): יישור MULTIMODAL_ENABLED בשרת ה-MCP + תיעוד דריפט האחסון שנותר' (#466) from worktree-mcp-env-align into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 7s
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 13s
2026-08-05 13:58:41 +00:00
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
12 changed files with 761 additions and 18 deletions

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

@@ -52,6 +52,41 @@
- **אפס עלות 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/` — להפעיל מחדש, אחרת הסוכנים ירוצו על קוד ישן בשקט.
> #### ✅ תצורת האחסון והאחזור מיושרת לקונטיינר (2026-08-05)
> `STORAGE_BACKEND=s3` ו-`MULTIMODAL_ENABLED=true` — זהים לקונטיינר, כך שמסמך
> שסוכן כותב דרך MCP הוא זה שה-web קורא, ושתי הדלתות מדרגות את אותו קורפוס
> באותה צורה (G2).
>
> **הסודות:** `MINIO_ENDPOINT` · `MINIO_ACCESS_KEY` · `MINIO_SECRET_KEY` נוצרו
> ב-Infisical תחת `/apps/legal-ai` (tag `credentials`), ונטענים ל-`~/.legal-mcp-http.env`
> (chmod 600) — אותו דפוס כמו `MCP_HTTP_SHARED_SECRET`. **רוטציה:** לעדכן
> ב-Infisical → למשוך מחדש לקובץ → `pm2 restart legal-mcp-http`.
>
> **⚠️ מלכודת — `aioboto3` מיובא עצלנית.** מסלול ה-S3 מייבא אותו רק בקריאה
> הראשונה, ולכן venv בלי החבילה **עולה בהצלחה** ונשבר רק בפעולת-הקובץ הראשונה
> — כשל שקט עד לשימוש. הוא מוצהר ב-`mcp-server/pyproject.toml`, אבל לא היה
> מותקן ב-venv המקומי. אחרי כל בנייה מחדש של ה-venv:
> `~/legal-ai/mcp-server/.venv/bin/pip install -e ~/legal-ai/mcp-server`.
>
> **אימות לפני הפעלה** (הרץ מהמארח, לא מהקונטיינר):
> ```bash
> cd ~/legal-ai/mcp-server && set -a && . ~/.legal-mcp-http.env && set +a
> STORAGE_BACKEND=s3 HOME=/home/chaim DOTENV_PATH=/home/chaim/.env \
> .venv/bin/python -c "import asyncio;from legal_mcp.services import storage
> async def m():
> st=storage.get_storage()
> print(len(await st.list_keys('', bucket=storage.Bucket.DOCUMENTS)))
> asyncio.run(m())"
> ```
> מצופה: מספר האובייקטים בדלי (היו 2,283 ב-2026-08-05). שגיאת
> `ModuleNotFoundError: aioboto3` = ה-venv חסר את התלות.
---
## מבנה תיקיות
@@ -195,9 +230,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,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

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

@@ -7979,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"))
@@ -7990,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)

View File

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

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

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

@@ -56,6 +56,26 @@ const env = {
// 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.
//
// Blob storage must match the container too, for the same reason: a document
// an agent writes through MCP has to be the one the web reads back. The MinIO
// credentials now live in Infisical (/apps/legal-ai) and are loaded from the
// runtime file below, exactly like MCP_HTTP_SHARED_SECRET.
//
// Verified from the host before enabling: put/get/delete round-trip against
// https://s3.nautilus.marcusgroup.org, plus a read of an existing production
// blob out of 2,283 objects. Note the S3 path imports aioboto3 LAZILY, so a
// missing dependency does not fail at boot — it fails on the first blob
// operation. It is declared in mcp-server/pyproject.toml; make sure the venv
// actually has it (`.venv/bin/pip install -e mcp-server`).
STORAGE_BACKEND: "s3",
MULTIMODAL_ENABLED: "true",
MCP_TRANSPORT: "streamable-http",
MCP_HTTP_HOST: "127.0.0.1",
MCP_HTTP_PORT: "8790",