Compare commits
11 Commits
worktree-2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f2ff4f1ec | |||
| 6c870ac691 | |||
| ff3a2f398c | |||
| 5a2a989e9b | |||
| df60636876 | |||
| d65c335a4a | |||
| dc203c77eb | |||
| 180f70bcc0 | |||
| 1544669c7a | |||
| 63387c0d7d | |||
| 70c28c87ef |
40
CLAUDE.md
40
CLAUDE.md
@@ -1,6 +1,6 @@
|
|||||||
# עוזר משפטי — Legal Decision Assistant
|
# עוזר משפטי — 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 נכון | **לפני כל עבודה על סוכנים** |
|
| [`.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 |
|
| [`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/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. **אל תיגע** בשינויים לא-מתויקים שאינם שלך בעץ הראשי — הם של סשן אחר. אם העץ הראשי על ענף זר — אל תתייק עליו.
|
6. **אל תיגע** בשינויים לא-מתויקים שאינם שלך בעץ הראשי — הם של סשן אחר. אם העץ הראשי על ענף זר — אל תתייק עליו.
|
||||||
|
|
||||||
> **בידוד-DB:** ה-worktree מבודד-קבצים בלבד — לא בידוד-repo ולא בידוד-DB. **אל תריץ migrations מ-2 worktrees במקביל** על Postgres המשותף (`localhost:5433`) — סכמה שאף סשן לא מצפה לה ([Run agents in parallel](https://code.claude.com/docs/en/agents)).
|
> **בידוד-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.md`
|
||||||
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/` — **חובה לעדכן את `scripts/SCRIPTS.md`** (תפקיד, סטטוס, החלפה).
|
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/` — **חובה לעדכן את `scripts/SCRIPTS.md`** (תפקיד, סטטוס, החלפה).
|
||||||
|
|
||||||
## ניהול משימות — TaskMaster AI
|
## ניהול משימות — Gitea Issues ⚠️
|
||||||
**תמיד** 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](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 ישנים.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -195,9 +195,33 @@ CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ניהול משימות — TaskMaster AI (פירוט)
|
## ניהול משימות — Gitea Issues (פירוט)
|
||||||
|
|
||||||
- קובץ המשימות הקנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (יחסי ל-project root, **לא** `~/.taskmaster/tasks/tasks.json`). מכיל את כל ה-tags של legal-ai (`master`, `legal-ai`).
|
> **TaskMaster AI הוצא משימוש ב-2026-08-05.** מקור-האמת לעבודה הוא
|
||||||
- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`
|
> [Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues).
|
||||||
- לפני התחלת עבודה → `next_task`; אחרי סיום → `update_task` עם status=done; משימה מורכבת → `expand_task`
|
> ה-workflow, מערכת התיוג ו-Session-Start Protocol מתועדים ב-[`CLAUDE.md`](../CLAUDE.md);
|
||||||
- **⚠️ מלכוד cwd ב-CLI:** הדגל `--tag` בוחר קבוצה לוגית *בתוך* הקובץ — הוא **לא** בוחר לאיזה `tasks.json` לכתוב. ה-CLI מאתר את הקובץ לפי ה-cwd. תמיד `cd ~/legal-ai` לפני `task-master add-task` או כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את `~/legal-ai/.taskmaster/tasks/tasks.json` ישירות.
|
> טבלת המיפוי 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 חדש
|
||||||
|
אם נדרשת עבודה.
|
||||||
|
|||||||
61
docs/taskmaster-to-gitea-migration.md
Normal file
61
docs/taskmaster-to-gitea-migration.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# מיגרציה: TaskMaster → Gitea Issues
|
||||||
|
|
||||||
|
**תאריך: 2026-08-05.** ניהול המשימות של legal-ai עבר מ-TaskMaster AI ל-[Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues), לפי המוסכמות של [`infra/project-template`](https://gitea.marcus-law.co.il/infra/project-template).
|
||||||
|
|
||||||
|
## מה השתנה
|
||||||
|
|
||||||
|
- **מקור-האמת לעבודה = Gitea Issues.** לא TaskMaster, לא `CLAUDE.md`, לא זיכרון.
|
||||||
|
- **`.taskmaster/tasks/tasks.json` הוקפא** — נשמר לקריאה-בלבד כארכיון היסטורי (166 משימות שהושלמו + 4 שבוטלו). אין לכתוב אליו ואין ליצור בו משימות חדשות.
|
||||||
|
- **26 המשימות הפתוחות** הועברו ל-issues **#433–#458**, כל אחת עם `type` / `priority` / `area` / `status`, וגוף במבנה *What & Why → הקשר → Acceptance Criteria*.
|
||||||
|
- **30 תוויות** נוצרו בריפו: 5 שכבות (`type`/`priority`/`status`/`size`/`area`). שכבות `type`/`priority`/`status`/`size` זהות לתבנית; שכבת `area` הותאמה לתחומי legal-ai.
|
||||||
|
|
||||||
|
## ⚠️ מלכודת מספור
|
||||||
|
|
||||||
|
ב-Gitea, issues ו-PRs חולקים מרחב-מספרים אחד. מספרי ה-issue של המיגרציה מתחילים ב-**#433** כי #1–#432 כבר תפוסים (2 issues ישנים + כל ה-PRs).
|
||||||
|
|
||||||
|
**לכן: כל הפניה `(#N)` בהודעת-commit מלפני 2026-08-05 מתייחסת למזהה TaskMaster — לא למספר issue.** למשל `fix(retrieval): ... (#232)` מפנה ל-TaskMaster #232, בעוד #232 ב-Gitea הוא PR ישן. הטבלה למטה היא הגשר.
|
||||||
|
|
||||||
|
## טבלת המיפוי
|
||||||
|
|
||||||
|
| TaskMaster | Gitea | תוויות | כותרת |
|
||||||
|
|---|---|---|---|
|
||||||
|
| #14 | [#433](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/433) | type:chore · priority:p3-low · status:deferred | Upgrade: speed up halacha+metadata extraction |
|
||||||
|
| #116 | [#434](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/434) | type:chore · priority:p3-low · status:deferred | P2 (אופציונלי) — resume תוך-פאנל: פירוק הפאנל ל-map per-item |
|
||||||
|
| #121 | [#435](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/435) | type:spike · priority:p1-high · status:needs-decision | מיפוי כל שערי-האישור-האנושי (חיים/דפנה) והכרעה פר-מקום: להחליף ב"קונצנזוס תלת-מודלי ללא-אדם"? |
|
||||||
|
| #124 | [#436](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/436) | type:feature · priority:p3-low · status:ready | קטלוג עלון חודשי "עו"ד על נדל"ן" — פיצול ל-N רשומות digest (design-open) |
|
||||||
|
| #125 | [#437](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/437) | type:chore · priority:p1-high · status:in-progress | אופרציונליזציה של לולאת רכישת-הסגנון — סגירת ממצאי אודיט #122 |
|
||||||
|
| #126 | [#438](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/438) | type:chore · priority:p2-normal · status:in-progress | ניקוי persona "Hermes" → Curator + כיבוי self-learning אינרטי בפרופילי ה-curator |
|
||||||
|
| #128 | [#439](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/439) | type:chore · priority:p2-normal · status:ready | X14 finalize: ניקוי-דיסק אחרי soak + replication off-site מתוזמן (SNSD) |
|
||||||
|
| #129 | [#440](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/440) | type:chore · priority:p2-normal · status:ready | INV-STG1 Phase 2: read-wire ה-pipeline ל-ensure_local → כתיבת-בלוב ל-S3 בלבד (ללא דיסק) |
|
||||||
|
| #147 | [#441](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/441) | type:bug · priority:p3-low | חילוץ-הלכות: אטומיות-מלאה ל-force re-extract (extract-then-swap / staging_run_id) |
|
||||||
|
| #150 | [#442](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/442) | type:feature · priority:p2-normal | משיכת פרטי-תב"ע אוטומטית למרשם-התכניות (V38) + עיון בתכניות-מאושרות |
|
||||||
|
| #154 | [#443](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/443) | type:feature · priority:p1-high | X11 Phase 2 — חיווט סמכות-תקדים מודעת-טיפול לסוכני-המחקר |
|
||||||
|
| #155 | [#444](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/444) | type:bug · priority:p1-high | באג עמידות: ריצת-heartbeat כפולה על אותו issue דורסת טיוטת-קובץ תוך כדי כתיבה |
|
||||||
|
| #163 | [#445](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/445) | type:bug · priority:p2-normal | חקירת אנומליית chunking — פסיקות עליון ארוכות מקבלות chunk יחיד → 0 הלכות |
|
||||||
|
| #209 | [#446](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/446) | type:bug · priority:p3-low | Plugin status-sync מכוון ל-issue-ילד ומחזיר done→in_progress |
|
||||||
|
| #217 | [#447](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/447) | type:chore · priority:p2-normal | בלוק-י: צמצום-הקשר ממוקד-רלוונטיות (איכות + מניעת-דחיסה) |
|
||||||
|
| #218 | [#448](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/448) | type:feature · priority:p1-high · status:in-progress | Escalation primitive מדורג-חומרה בשער-הפלטפורמה (השראת Gastown gt escalate) |
|
||||||
|
| #219 | [#449](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/449) | type:feature · priority:p1-high · status:in-progress | טלמטריה מובנית (OpenTelemetry) על שכבת-הסוכנים (השראת Gastown OTel) |
|
||||||
|
| #220 | [#450](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/450) | type:feature · priority:p2-normal · status:in-progress | Seance — תשאול סשן-קודם לצמצום re-work של blind-heartbeats (השראת Gastown gt seance) |
|
||||||
|
| #221 | [#451](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/451) | type:feature · priority:p2-normal · status:deferred | Molecules — פייפליין 12-הבלוקים כתבנית הצהרתית עם checkpoint-recovery (השראת Gastown formulas) |
|
||||||
|
| #222 | [#452](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/452) | type:feature · priority:p2-normal · status:in-progress | טקסונומיית-בריאות מפורשת לסוכן ב-dashboard (Zombie/Stalled/Working/Idle — השראת Gastown gt feed --problems) |
|
||||||
|
| #225 | [#453](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/453) | type:spike · priority:p2-normal · status:needs-decision | בית-אב לדוקטרינה-משפטית של דפנה (chair legal-doctrine home) — תכנון בלבד |
|
||||||
|
| #228 | [#454](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/454) | type:bug · priority:p1-high | תשובת-interaction/הערה על issue בבעלות-אדם לא מעירה את ה-CEO — לנתב דרך פרימיטיב CEO-child (#227) |
|
||||||
|
| #229 | [#455](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/455) | type:bug · priority:p1-high | block_writer שומר טקסט-שיחה של המודל לתוך תוכן הבלוק — נדרש שער-ניקוי בכתיבה |
|
||||||
|
| #230 | [#456](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/456) | type:bug · priority:p2-normal | extractor._text_quality_ok לא מזהה סדר-קריאה RTL משובש ב-PDF דיגיטלי |
|
||||||
|
| #231 | [#457](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/457) | type:bug · priority:p1-high · status:ready | סוכני Paperclip מנותקים משרת ה-MCP legal-ai — ACP הפך לברירת-מחדל ב-claude_local |
|
||||||
|
| #233 | [#458](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/458) | type:bug · priority:p1-high · status:ready | צבירת-הטיעונים מוחקת צד שלם בשקט — 310 טענות עוררים → 0 טיעונים, status ok |
|
||||||
|
|
||||||
|
## משימות שנסגרו בזמן המיגרציה (לא הועברו)
|
||||||
|
|
||||||
|
| TaskMaster | למה נסגרה |
|
||||||
|
|---|---|
|
||||||
|
| #223 | PR #393 מוזג — בחירת פרוטוקול לפי `protocol_scope`. אומת ב-1069-04-26. |
|
||||||
|
| #224 | PRs #392 + #403 מוזגו — `claims.party_name` במקור + פיצול משיבים. אומת ב-1069-04-26. |
|
||||||
|
| #232 | PR #431 מוזג — 104 החלטות ועדות-ערר היו מסוננות מהחיפוש (סינון בברירת-מחדל). מלכודת 3 נסגרה ב-PR #432. |
|
||||||
|
|
||||||
|
## מה עדיין דורש עבודה ידנית
|
||||||
|
|
||||||
|
- **`size:` לא הוקצה** לאף issue — TaskMaster לא החזיק הערכות-גודל, ולא רציתי להמציא אותן. לפי כלל התבנית, issue ללא AC+size אינו `status:ready`.
|
||||||
|
- **21 מתוך 26 issues ללא Acceptance Criteria** — הן הועברו מ-TaskMaster בלי `testStrategy`, וגוף ה-issue מסמן זאת במפורש. לפני לקיחת issue כזה — להגדיר AC, להקצות `size`, ולסמן `status:ready`.
|
||||||
|
- 5 שכן קיבלו `status:ready` (היה להן `testStrategy`): #436, #439, #440, #457, #458.
|
||||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import (
|
from legal_mcp.services import (
|
||||||
@@ -34,8 +35,132 @@ logger = logging.getLogger(__name__)
|
|||||||
_SUGGEST_PER_ISSUE = 4
|
_SUGGEST_PER_ISSUE = 4
|
||||||
_SUGGEST_FLOOR = 0.45
|
_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)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return {"status": "case_not_found", "case_number": case_number, "arguments": []}
|
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)
|
logger.warning("citation_verification search failed (%s): %s", title[:30], e)
|
||||||
# Resolve the authority breakdown for the hit set in one batched query.
|
# Resolve the authority breakdown for the hit set in one batched query.
|
||||||
clids = [UUID(str(h["case_law_id"])) for h in hits
|
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 {}
|
authority = await db.citation_authority(clids) if clids else {}
|
||||||
return hits, authority
|
return hits, authority
|
||||||
|
|
||||||
# Fan out the expensive per-argument retrieval concurrently — was a sequential
|
# Fan out the expensive per-argument retrieval, but BOUNDED — see
|
||||||
# waterfall (N args × Voyage embed + vector search each). gather preserves order.
|
# _MAX_CONCURRENT_LOOKUPS. gather preserves order, so the zip below still
|
||||||
fetched = await asyncio.gather(*(_fetch(a) for a in args)) if args else []
|
# 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] = []
|
out_args: list[dict] = []
|
||||||
n_verified = 0
|
n_verified = 0
|
||||||
@@ -104,7 +262,7 @@ async def build_view(case_number: str) -> dict:
|
|||||||
clid = str(h.get("case_law_id") or "")
|
clid = str(h.get("case_law_id") or "")
|
||||||
if not clid or clid in seen:
|
if not clid or clid in seen:
|
||||||
continue
|
continue
|
||||||
if float(h.get("score", 0) or 0) < _SUGGEST_FLOOR:
|
if not _passes_floor(h):
|
||||||
continue
|
continue
|
||||||
seen.add(clid)
|
seen.add(clid)
|
||||||
att = attached_by_arg.get(aid, {}).get(clid)
|
att = attached_by_arg.get(aid, {}).get(clid)
|
||||||
@@ -137,6 +295,11 @@ async def build_view(case_number: str) -> dict:
|
|||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"case_number": case_number,
|
"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,
|
"arguments": out_args,
|
||||||
"summary": {
|
"summary": {
|
||||||
"arguments_total": len(out_args),
|
"arguments_total": len(out_args),
|
||||||
|
|||||||
@@ -7979,6 +7979,14 @@ async def search_precedent_library_semantic(
|
|||||||
# Calibrated so the average (≈0.85) stays at +0.05 (legacy value).
|
# Calibrated so the average (≈0.85) stays at +0.05 (legacy value).
|
||||||
_conf = float(d.get("confidence") or 0.0)
|
_conf = float(d.get("confidence") or 0.0)
|
||||||
d["score"] = float(d["score"]) + max(_conf * 0.06, 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"
|
d["type"] = "halacha"
|
||||||
# authority is DERIVED from the source, never stored (INV-DM7)
|
# authority is DERIVED from the source, never stored (INV-DM7)
|
||||||
d["authority"] = halacha_quality.derive_authority(d.get("precedent_level"))
|
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:
|
if d.get("decision_date") is not None:
|
||||||
d["decision_date"] = d["decision_date"].isoformat()
|
d["decision_date"] = d["decision_date"].isoformat()
|
||||||
d["score"] = float(d["score"])
|
d["score"] = float(d["score"])
|
||||||
|
d["relevance"] = d["score"] # cosine anchor — see the halacha branch above
|
||||||
d["type"] = "passage"
|
d["type"] = "passage"
|
||||||
_maybe_swap_parent(d)
|
_maybe_swap_parent(d)
|
||||||
results.append(d)
|
results.append(d)
|
||||||
|
|||||||
@@ -303,6 +303,21 @@ def _merge_sem_lex(
|
|||||||
if key in lex_row_by_key else 0.0
|
if key in lex_row_by_key else 0.0
|
||||||
d["lex_rank"] = lex_rank or 0
|
d["lex_rank"] = lex_rank or 0
|
||||||
d["score"] = sem_term + lex_term
|
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.append(d)
|
||||||
|
|
||||||
merged.sort(key=lambda x: -float(x["score"]))
|
merged.sort(key=lambda x: -float(x["score"]))
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
|
|||||||
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
|
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
|
||||||
"proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY' → 'ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר' → 'בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.",
|
"proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY' → 'ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר' → 'בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.",
|
||||||
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
|
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
|
||||||
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.",
|
"case_number_clean": "מספר הערר/תיק **בדיוק כפי שמופיע בכותרת** — רק ספרות ומפרידים. שתי צורות קיימות ושתיהן חוקיות: דו-חלקית ('1062/24', '8031/21') ותלת-חלקית של ועדות ערר ('1094-09-19', '85074-09-24' — סידורי-חודש-שנה). **העתק את הספרות מהמסמך; אל תשלים, אל תנחש ואל תתקן ספרה.** ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. **אם המספר אינו מופיע בטקסט — מחרוזת ריקה** (הקוד דוחה ממילא מספר שאינו מעוגן בטקסט).",
|
||||||
"chair_name": "שם יו\\\"ר ההרכב של **ההחלטה הזו** — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. כמעט תמיד מופיע — בשני מקומות: (א) בכותרת/רובריקה בראש המסמך, ליד 'בפני:' / 'בהרכב:' / רשימת חברי הוועדה; (ב) בבלוק-החתימה בסוף ההחלטה, אחרי 'ההחלטה ניתנה' — שם מופיעים זה-לצד-זה מזכיר/ת הוועדה והיו\\\"ר (למשל בשתי עמודות: בצד אחד 'פלוני, עו\\\"ד / מזכיר ועדת הערר' ובצד השני 'אלמוני, עו\\\"ד / יו\\\"ר ועדת הערר'). **קח את השם שמעליו/לצדו כתוב 'יו\\\"ר' — לא את המזכיר/ה.** השאר שם פרטי+משפחה בלבד, בלי תוארים ('עו\\\"ד', 'אדריכל', 'עו\\\"ד דפנה תמיר'→'דפנה תמיר'). **אזהרה קריטית:** אל תיקח שם יו\\\"ר של פסק/החלטה אחרים ש**מצוטטים** בגוף ההחלטה (למשל 'כפי שנקבע ברשותה של יו\\\"ר פלונית בערר אחר...') — אלה תקדימים מצוטטים, לא היו\\\"ר של ההחלטה הנוכחית. אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
"chair_name": "שם יו\\\"ר ההרכב של **ההחלטה הזו** — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. כמעט תמיד מופיע — בשני מקומות: (א) בכותרת/רובריקה בראש המסמך, ליד 'בפני:' / 'בהרכב:' / רשימת חברי הוועדה; (ב) בבלוק-החתימה בסוף ההחלטה, אחרי 'ההחלטה ניתנה' — שם מופיעים זה-לצד-זה מזכיר/ת הוועדה והיו\\\"ר (למשל בשתי עמודות: בצד אחד 'פלוני, עו\\\"ד / מזכיר ועדת הערר' ובצד השני 'אלמוני, עו\\\"ד / יו\\\"ר ועדת הערר'). **קח את השם שמעליו/לצדו כתוב 'יו\\\"ר' — לא את המזכיר/ה.** השאר שם פרטי+משפחה בלבד, בלי תוארים ('עו\\\"ד', 'אדריכל', 'עו\\\"ד דפנה תמיר'→'דפנה תמיר'). **אזהרה קריטית:** אל תיקח שם יו\\\"ר של פסק/החלטה אחרים ש**מצוטטים** בגוף ההחלטה (למשל 'כפי שנקבע ברשותה של יו\\\"ר פלונית בערר אחר...') — אלה תקדימים מצוטטים, לא היו\\\"ר של ההחלטה הנוכחית. אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
||||||
"district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים' → 'ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו' → 'תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
"district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים' → 'ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו' → 'תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
||||||
"parties": "שמות הצדדים בשורה אחת בצורה 'עורר נ\\' משיב' — בדיוק כפי שמופיעים בכותרת/רובריקה. בלי הדגשה, בלי מספר-תיק, בלי תוארים מיותרים. למשל 'ישיבת חברת אהבת שלום נ\\' תאיה' או 'ראם חיים נ\\' הוועדה המקומית לתכנון ובניה ירושלים'. אם הצדדים אינם מופיעים בטקסט (למשל החלטה שמתחילה בגוף בלי רובריקה) — מחרוזת ריקה. **אל תמציא שמות.**",
|
"parties": "שמות הצדדים בשורה אחת בצורה 'עורר נ\\' משיב' — בדיוק כפי שמופיעים בכותרת/רובריקה. בלי הדגשה, בלי מספר-תיק, בלי תוארים מיותרים. למשל 'ישיבת חברת אהבת שלום נ\\' תאיה' או 'ראם חיים נ\\' הוועדה המקומית לתכנון ובניה ירושלים'. אם הצדדים אינם מופיעים בטקסט (למשל החלטה שמתחילה בגוף בלי רובריקה) — מחרוזת ריקה. **אל תמציא שמות.**",
|
||||||
@@ -242,6 +243,37 @@ def _is_clean_docket(s: str) -> bool:
|
|||||||
return bool(_DOCKET_RE.fullmatch((s or "").strip()))
|
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:
|
def _source_type_for_level(level: str) -> str:
|
||||||
"""Derive source_type from precedent_level — the library section is driven by
|
"""Derive source_type from precedent_level — the library section is driven by
|
||||||
source_type, so the two MUST agree (an LLM slip pairing
|
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)",
|
"already owned by another non-internal row (likely duplicate)",
|
||||||
cur_cn, cn_clean,
|
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:
|
else:
|
||||||
fields_to_update["case_number"] = cn_clean
|
fields_to_update["case_number"] = cn_clean
|
||||||
|
|
||||||
|
|||||||
125
mcp-server/tests/test_citation_view_cache.py
Normal file
125
mcp-server/tests/test_citation_view_cache.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""The citation view is cached by input fingerprint, not by a guessed TTL.
|
||||||
|
|
||||||
|
Assembling it costs 20+ seconds of Voyage embeds and vector searches. A TTL
|
||||||
|
would force a choice between staleness and cost: a chair who verifies a
|
||||||
|
precedent must see it on the very next load, and a timer cannot promise that.
|
||||||
|
Keying on a fingerprint of the inputs (arguments · attachments · corpus) makes
|
||||||
|
freshness a property of the data rather than of the clock.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from legal_mcp.services import case_citation_verification as ccv
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_cache():
|
||||||
|
ccv.invalidate()
|
||||||
|
yield
|
||||||
|
ccv.invalidate()
|
||||||
|
|
||||||
|
|
||||||
|
def _stub(monkeypatch, fingerprint: str, calls: list, *, complete: bool = True):
|
||||||
|
"""Point build_view at a fake case, a controllable fingerprint, and a
|
||||||
|
counted assembler, so these tests never touch the corpus."""
|
||||||
|
async def fake_case(_cn):
|
||||||
|
return {"id": "00000000-0000-0000-0000-000000000001"}
|
||||||
|
|
||||||
|
async def fake_fp(_cid):
|
||||||
|
return fingerprint
|
||||||
|
|
||||||
|
async def fake_build(cn):
|
||||||
|
calls.append(cn)
|
||||||
|
return {"status": "ok", "case_number": cn, "arguments": [],
|
||||||
|
"retrieval_complete": complete, "summary": {}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(ccv.db, "get_case_by_number", fake_case)
|
||||||
|
monkeypatch.setattr(ccv, "_inputs_fingerprint", fake_fp)
|
||||||
|
monkeypatch.setattr(ccv, "_build_view_uncached", fake_build)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_second_load_is_served_from_cache(monkeypatch):
|
||||||
|
calls: list = []
|
||||||
|
_stub(monkeypatch, "fp-1", calls)
|
||||||
|
first = await ccv.build_view("1069-04-26")
|
||||||
|
second = await ccv.build_view("1069-04-26")
|
||||||
|
assert first["cached"] is False
|
||||||
|
assert second["cached"] is True
|
||||||
|
assert len(calls) == 1, "the expensive assembly must run once"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_changed_inputs_invalidate_without_anyone_calling_invalidate(monkeypatch):
|
||||||
|
"""The chair verifies a precedent → fingerprint moves → next load rebuilds.
|
||||||
|
Correctness must not depend on a writer remembering to clear the cache."""
|
||||||
|
calls: list = []
|
||||||
|
_stub(monkeypatch, "fp-before", calls)
|
||||||
|
await ccv.build_view("1069-04-26")
|
||||||
|
|
||||||
|
_stub(monkeypatch, "fp-after", calls) # e.g. a new case_precedents row
|
||||||
|
again = await ccv.build_view("1069-04-26")
|
||||||
|
assert again["cached"] is False
|
||||||
|
assert len(calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_concurrent_loads_do_not_stampede(monkeypatch):
|
||||||
|
"""Reload-during-a-slow-load is how the original outage compounded into
|
||||||
|
Postgres deadlocks. The later readers must wait, not pile on."""
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
async def fake_case(_cn):
|
||||||
|
return {"id": "00000000-0000-0000-0000-000000000001"}
|
||||||
|
|
||||||
|
async def fake_fp(_cid):
|
||||||
|
return "fp-1"
|
||||||
|
|
||||||
|
async def slow_build(cn):
|
||||||
|
calls.append(cn)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return {"status": "ok", "case_number": cn, "arguments": [],
|
||||||
|
"retrieval_complete": True, "summary": {}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(ccv.db, "get_case_by_number", fake_case)
|
||||||
|
monkeypatch.setattr(ccv, "_inputs_fingerprint", fake_fp)
|
||||||
|
monkeypatch.setattr(ccv, "_build_view_uncached", slow_build)
|
||||||
|
|
||||||
|
await asyncio.gather(*(ccv.build_view("1069-04-26") for _ in range(6)))
|
||||||
|
assert len(calls) == 1, "six concurrent loads, one assembly"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_partial_view_expires_so_missing_suggestions_get_retried(monkeypatch):
|
||||||
|
"""A budget-exhausted view is cached to stop reload storms, but it must not
|
||||||
|
become permanent — the arguments with no suggestions deserve another try."""
|
||||||
|
calls: list = []
|
||||||
|
_stub(monkeypatch, "fp-1", calls, complete=False)
|
||||||
|
await ccv.build_view("1069-04-26")
|
||||||
|
_fp, expires_at, _view = ccv._view_cache["1069-04-26"]
|
||||||
|
assert expires_at is not None, "a partial view must carry an expiry"
|
||||||
|
|
||||||
|
monkeypatch.setattr(ccv.time, "monotonic", lambda: expires_at + 1)
|
||||||
|
after = await ccv.build_view("1069-04-26")
|
||||||
|
assert after["cached"] is False
|
||||||
|
assert len(calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_complete_view_does_not_expire_on_a_timer(monkeypatch):
|
||||||
|
calls: list = []
|
||||||
|
_stub(monkeypatch, "fp-1", calls, complete=True)
|
||||||
|
await ccv.build_view("1069-04-26")
|
||||||
|
_fp, expires_at, _view = ccv._view_cache["1069-04-26"]
|
||||||
|
assert expires_at is None, "freshness comes from the fingerprint, not a clock"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_use_cache_false_always_rebuilds(monkeypatch):
|
||||||
|
calls: list = []
|
||||||
|
_stub(monkeypatch, "fp-1", calls)
|
||||||
|
await ccv.build_view("1069-04-26")
|
||||||
|
await ccv.build_view("1069-04-26", use_cache=False)
|
||||||
|
assert len(calls) == 2
|
||||||
81
mcp-server/tests/test_citation_view_fanout.py
Normal file
81
mcp-server/tests/test_citation_view_fanout.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""The citation-verification view must not fan out without a bound.
|
||||||
|
|
||||||
|
`build_view` used to spawn one retrieval task per legal argument with a bare
|
||||||
|
`asyncio.gather`. Each task opens a Voyage embed call, and past ~8 in flight
|
||||||
|
Voyage throttles — so the unbounded version was *slower* than a bounded one
|
||||||
|
(32 arguments: 30.4s unbounded vs 22.1s at 8), and a 69-argument case blew past
|
||||||
|
the 30s proxy timeout and returned 500 while overlapping reloads piled
|
||||||
|
contention into Postgres deadlocks.
|
||||||
|
|
||||||
|
These tests pin the two properties that fix gave us: the fan-out is bounded,
|
||||||
|
and a timeout yields the results that DID land instead of nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from legal_mcp.services import case_citation_verification as ccv
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrency_bound_is_set_and_modest():
|
||||||
|
assert 1 <= ccv._MAX_CONCURRENT_LOOKUPS <= 16, (
|
||||||
|
"the bound exists to stay under Voyage's throttle point — a large value "
|
||||||
|
"reintroduces the stall this was added to fix"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrieval_budget_leaves_room_under_the_proxy_timeout():
|
||||||
|
"""The proxy gives up at 30s; we must cut ourselves off before that."""
|
||||||
|
assert 0 < ccv._RETRIEVAL_BUDGET_S < 30
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_semaphore_actually_caps_in_flight_work():
|
||||||
|
"""A semaphore of N never lets N+1 coroutines run the body at once."""
|
||||||
|
limit = ccv._MAX_CONCURRENT_LOOKUPS
|
||||||
|
sem = asyncio.Semaphore(limit)
|
||||||
|
in_flight = 0
|
||||||
|
peak = 0
|
||||||
|
|
||||||
|
async def worker():
|
||||||
|
nonlocal in_flight, peak
|
||||||
|
async with sem:
|
||||||
|
in_flight += 1
|
||||||
|
peak = max(peak, in_flight)
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
in_flight -= 1
|
||||||
|
|
||||||
|
await asyncio.gather(*(worker() for _ in range(limit * 4)))
|
||||||
|
assert peak <= limit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_timeout_harvests_finished_work_instead_of_discarding_it():
|
||||||
|
"""The regression: wait_for(gather(...)) cancels everything on timeout, so
|
||||||
|
one slow lookup threw away every result that had already succeeded. The
|
||||||
|
harvest pattern must keep them."""
|
||||||
|
|
||||||
|
async def quick(i):
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return i
|
||||||
|
|
||||||
|
async def never():
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
return "unreachable"
|
||||||
|
|
||||||
|
tasks = [asyncio.ensure_future(quick(i)) for i in range(5)]
|
||||||
|
tasks.append(asyncio.ensure_future(never()))
|
||||||
|
|
||||||
|
done, pending = await asyncio.wait(tasks, timeout=0.3)
|
||||||
|
for t in pending:
|
||||||
|
t.cancel()
|
||||||
|
await asyncio.gather(*pending, return_exceptions=True)
|
||||||
|
|
||||||
|
harvested = [
|
||||||
|
t.result() if (t in done and not t.cancelled() and t.exception() is None) else None
|
||||||
|
for t in tasks
|
||||||
|
]
|
||||||
|
assert harvested[:5] == [0, 1, 2, 3, 4], "finished work must survive the timeout"
|
||||||
|
assert harvested[5] is None, "the unfinished one is empty, not fabricated"
|
||||||
|
assert len(pending) == 1
|
||||||
65
mcp-server/tests/test_docket_grounding.py
Normal file
65
mcp-server/tests/test_docket_grounding.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""#232 trap 3 — a well-formed docket is not necessarily the right docket.
|
||||||
|
|
||||||
|
ערר (מרכז) 1094-09-19 (פדילה) was stored as ``1094-09-14``: shape-valid, so
|
||||||
|
``_is_clean_docket`` waved it through, but wrong — and case_number is the
|
||||||
|
identity field, so the row detached from every reference to the real case.
|
||||||
|
Grounding the digits in the source text is what shape validation cannot do.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from legal_mcp.services.precedent_metadata_extractor import (
|
||||||
|
_docket_grounded,
|
||||||
|
_is_clean_docket,
|
||||||
|
_strip_invisibles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
HEADER = "ערר (ועדות ערר - תכנון ובנייה מרכז) 1094-09-19 פדילה אברהים נ' הוועדה המקומית"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_regression_shape_valid_but_wrong_digit():
|
||||||
|
"""Both pass the shape check; only the real one is grounded."""
|
||||||
|
assert _is_clean_docket("1094-09-14")
|
||||||
|
assert _is_clean_docket("1094-09-19")
|
||||||
|
assert _docket_grounded("1094-09-19", HEADER)
|
||||||
|
assert not _docket_grounded("1094-09-14", HEADER)
|
||||||
|
|
||||||
|
|
||||||
|
def test_separator_and_spacing_are_tolerated():
|
||||||
|
"""A real docket must still match when the source writes it differently."""
|
||||||
|
assert _docket_grounded("1094-09-19", "בערר 1094/09/19 נקבע")
|
||||||
|
assert _docket_grounded("4768/22", "עת\"מ 4768-22 פלוני")
|
||||||
|
assert _docket_grounded("1132-09-24", "תיק 1132 - 09 - 24")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bidi_marks_inside_the_number_do_not_defeat_grounding():
|
||||||
|
"""Hebrew legal PDFs embed RLM/LRM between digits and separators."""
|
||||||
|
noisy = "ערר (מרכז) 1094-09-19 פדילה"
|
||||||
|
assert _strip_invisibles(noisy).count("") == 0
|
||||||
|
assert _docket_grounded("1094-09-19", noisy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grounding_accepts_the_value_being_replaced():
|
||||||
|
"""Normalising an uploader's citation string into a clean docket is the
|
||||||
|
whole point of the rewrite — the digits come from there, not the text."""
|
||||||
|
citation = "ערר (ועדות ערר - תכנון ובנייה מרכז) 1094-09-19 פדילה נ' טירה (נבו 4.12.2019)"
|
||||||
|
assert _docket_grounded("1094-09-19", "", citation)
|
||||||
|
assert not _docket_grounded("1094-09-14", "", citation)
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_and_three_part_dockets_both_ground():
|
||||||
|
assert _docket_grounded("8031/21", "בהיטל השבחה 8031/21 נדון")
|
||||||
|
assert _docket_grounded("85074-09-24", "בל\"מ 85074-09-24")
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_numeric_or_empty_never_grounds():
|
||||||
|
assert not _docket_grounded("", HEADER)
|
||||||
|
assert not _docket_grounded("ערר 1094", HEADER)
|
||||||
|
assert not _docket_grounded("abc-de", HEADER)
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_from_every_source_is_refused():
|
||||||
|
"""The פדילה failure mode: text has no docket at all, so anything the
|
||||||
|
model offers is ungrounded and must not reach the identity field."""
|
||||||
|
body = "בפני: יו\"ר הוועדה: רונית אלפר, עו\"ד\nהעוררים: 1. פדילה אברהים"
|
||||||
|
assert not _docket_grounded("1094-09-14", body)
|
||||||
|
assert not _docket_grounded("1094-09-19", body)
|
||||||
68
mcp-server/tests/test_relevance_scale.py
Normal file
68
mcp-server/tests/test_relevance_scale.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""`score` and `relevance` are different things — thresholds must use `relevance`.
|
||||||
|
|
||||||
|
Retrieval returns cosine similarities (~0.4-0.75) until the lexical leg returns
|
||||||
|
rows; then `_merge_sem_lex` replaces `score` with an RRF value (~0.008-0.02).
|
||||||
|
Both are legitimate *ranking* signals, but they are not on the same scale, so a
|
||||||
|
caller comparing `score >= 0.45` rejected every hit the moment BM25 matched
|
||||||
|
anything. That is how the citation-verification tab came to show no supporting
|
||||||
|
precedent for a single argument, on every case.
|
||||||
|
|
||||||
|
`relevance` is the fix: always a cosine, or None when the row came from the
|
||||||
|
lexical leg alone and no cosine was ever computed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from legal_mcp.services.case_citation_verification import _SUGGEST_FLOOR, _passes_floor
|
||||||
|
from legal_mcp.services.hybrid_search import _merge_sem_lex
|
||||||
|
|
||||||
|
|
||||||
|
def _sem(key: str, score: float) -> dict:
|
||||||
|
return {"chunk_id": key, "case_law_id": "c1", "score": score, "relevance": score}
|
||||||
|
|
||||||
|
|
||||||
|
def _lex(key: str, score: float) -> dict:
|
||||||
|
# The lexical leg emits ts_rank_cd, never a cosine — so no `relevance`.
|
||||||
|
return {"chunk_id": key, "case_law_id": "c1", "score": score}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fusion_replaces_score_but_preserves_the_cosine():
|
||||||
|
"""The regression in one assertion."""
|
||||||
|
merged = _merge_sem_lex([_sem("a", 0.73)], [_lex("a", 0.31)], limit=10)
|
||||||
|
row = merged[0]
|
||||||
|
assert row["score"] < 0.1, "fused score is an RRF value, not a cosine"
|
||||||
|
assert row["relevance"] == pytest.approx(0.73), "the cosine must survive fusion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lexical_only_row_has_no_fabricated_cosine():
|
||||||
|
"""None means 'not measured' — not 'measured as irrelevant'."""
|
||||||
|
merged = _merge_sem_lex([], [_lex("b", 0.31)], limit=10)
|
||||||
|
assert merged[0]["relevance"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_only_row_keeps_its_cosine():
|
||||||
|
merged = _merge_sem_lex([_sem("c", 0.62)], [], limit=10)
|
||||||
|
assert merged[0]["relevance"] == pytest.approx(0.62)
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_would_have_rejected_everything_on_the_fused_score():
|
||||||
|
"""Guards the exact production symptom: fused scores are ~0.008, the floor
|
||||||
|
is 0.45, so score-based filtering wipes the result set."""
|
||||||
|
merged = _merge_sem_lex(
|
||||||
|
[_sem(k, 0.70) for k in "abcd"], [_lex(k, 0.30) for k in "abcd"], limit=10)
|
||||||
|
assert all(r["score"] < _SUGGEST_FLOOR for r in merged) # the bug
|
||||||
|
assert all(_passes_floor(r) for r in merged) # the fix
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_still_rejects_genuinely_weak_hits():
|
||||||
|
"""The fix must not become 'accept everything'."""
|
||||||
|
assert not _passes_floor({"relevance": 0.10})
|
||||||
|
assert not _passes_floor({"relevance": _SUGGEST_FLOOR - 0.01})
|
||||||
|
assert _passes_floor({"relevance": _SUGGEST_FLOOR})
|
||||||
|
|
||||||
|
|
||||||
|
def test_lexical_only_hits_are_kept_deliberately():
|
||||||
|
"""An exact docket/phrase match reaches the top by BM25 rank with no cosine.
|
||||||
|
Dropping it would hide precisely what a chair searches for by name."""
|
||||||
|
assert _passes_floor({"relevance": None})
|
||||||
|
assert _passes_floor({}) # missing key behaves the same as None
|
||||||
Reference in New Issue
Block a user