Compare commits
30 Commits
worktree-s
...
0990db7a3c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0990db7a3c | |||
| 955675eb1f | |||
| 8171572cdd | |||
| 9eaabffba4 | |||
| 90f3c472b5 | |||
| 638a542cf4 | |||
| 0e35060d3d | |||
| a0c1b74c55 | |||
| 7e7de485a4 | |||
| e62f39aabf | |||
| 632fe73857 | |||
| f60fdc2c6d | |||
| a07622659c | |||
| a1f491e9cc | |||
| 5aa3d4ed99 | |||
| b107654ee4 | |||
| 27911c5beb | |||
| 1a1757f29d | |||
| ac279220c4 | |||
| 9bd247c421 | |||
| b7b44f4453 | |||
| ab99cfa1d3 | |||
| e239915fd3 | |||
| 86f5797dbd | |||
| 25e0662ead | |||
| 6dbc9130b0 | |||
| 12313774a1 | |||
| 7d97ca25a2 | |||
| c7933b9de3 | |||
| 161d0d6ed6 |
@@ -223,12 +223,15 @@ new → proofread → documents_ready → analyst_verified → research_complete
|
|||||||
חיים העלה PDF פסיקה לתיק → ה-citation הוא:
|
חיים העלה PDF פסיקה לתיק → ה-citation הוא:
|
||||||
├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
|
├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
|
||||||
│ → internal_decision_upload (חובה chair_name + district)
|
│ → internal_decision_upload (חובה chair_name + district)
|
||||||
└── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
|
├── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
|
||||||
→ precedent_library_upload (external_upload)
|
│ → precedent_library_upload (external_upload)
|
||||||
|
└── PDF יומון "כל יום" (סיכום-משני של עפר טויסטר, עמוד אחד)
|
||||||
|
→ digest_upload (קורפוס-גילוי; לא קורפוס-ציטוט — X12)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
|
- **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
|
||||||
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
|
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
|
||||||
|
- **`digest_upload`** — ליומון "כל יום" בלבד (מקור-משני שמצביע על פסק; INV-DIG1/2). אינו מצוטט בהחלטה ואינו מחלץ הלכות. **אל** תעלה יומון דרך precedent/internal — ואל תעלה פסק-דין דרך digest.
|
||||||
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
|
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ tools:
|
|||||||
- mcp__legal-ai__precedent_list
|
- mcp__legal-ai__precedent_list
|
||||||
- mcp__legal-ai__search_case_precedents
|
- mcp__legal-ai__search_case_precedents
|
||||||
- mcp__legal-ai__search_precedent_library
|
- mcp__legal-ai__search_precedent_library
|
||||||
|
- mcp__legal-ai__search_digests
|
||||||
|
- mcp__legal-ai__digest_link
|
||||||
|
- mcp__legal-ai__digest_upload
|
||||||
- mcp__legal-ai__internal_decision_upload
|
- mcp__legal-ai__internal_decision_upload
|
||||||
- mcp__legal-ai__precedent_library_upload
|
- mcp__legal-ai__precedent_library_upload
|
||||||
- mcp__legal-ai__precedent_library_get
|
- mcp__legal-ai__precedent_library_get
|
||||||
@@ -193,6 +196,26 @@ mcp__legal-ai__internal_decision_upload(
|
|||||||
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
|
||||||
- `search_case_precedents` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
- `search_case_precedents` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
|
||||||
|
|
||||||
|
#### 2ב.0 — שכבת-גילוי: יומוני "כל יום" (`search_digests`) — מצפן, לפני האימות
|
||||||
|
|
||||||
|
לכל סוגיה מרכזית — הרץ `search_digests` כ**מצפן-מחקר (radar)**, **לא** כמקור-ציטוט. היומון הוא סיכום-משני (עפר טויסטר) של פסק-דין בודד, והוא מפנה אותך אל **הפסק המקורי**. אם נמצא יומון רלוונטי:
|
||||||
|
|
||||||
|
1. קרא את כותרת-ההלכה ואת ניתוח עפר-טויסטר **כרקע/orientation בלבד**.
|
||||||
|
2. חלץ את **מראה-המקום של הפסק המקורי** מהיומון (שדה `underlying_citation`, למשל `עת"מ 46111-12-22`).
|
||||||
|
3. **בדוק אם הפסק המקורי בקורפוס** — `search_precedent_library` **וגם** `search_internal_decisions` לפי פרוטוקול 2ב.4א (לפי קידומת-הציטוט; flowchart §8).
|
||||||
|
4. **אם נמצא** → אמת וצטט את הפסק המקורי כרגיל (`precedent_attach`), וקרא `digest_link(digest_id, case_law_id)` כדי לקשר את היומון לפסק.
|
||||||
|
5. **אם לא נמצא** → קרא `missing_precedent_create` על **הפסק המקורי** (לא על היומון), עם `notes="זוהה דרך יומון 'כל יום' מס' NNNN"`. היומון הוא הטריגר; הרשומה החסרה היא הפסק. (אם הפסק זמין — אפשר להעלותו דרך `precedent_library_upload`/`internal_decision_upload` ואז `digest_link`.)
|
||||||
|
|
||||||
|
⚠️ **היומון לעולם אינו מצוטט בהחלטה ואינו נרשם דרך `precedent_attach`** (INV-DIG1). הוא radar בלבד — מצביע, לא מקור. ראה [docs/spec/X12-digests-radar.md](../../docs/spec/X12-digests-radar.md).
|
||||||
|
|
||||||
|
```
|
||||||
|
search_digests(
|
||||||
|
query="...",
|
||||||
|
practice_area="betterment_levy", # rishuy_uvniya / betterment_levy / compensation_197
|
||||||
|
limit=10
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
|
||||||
|
|
||||||
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
|
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
|
||||||
@@ -310,6 +333,10 @@ mcp__legal-ai__missing_precedent_create(
|
|||||||
|
|
||||||
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
|
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
|
||||||
|
|
||||||
|
#### 2ב.6 — תיעוד סריקת היומונים — סעיף "ט" ב-`precedent-research.md`
|
||||||
|
|
||||||
|
הוסף סעיף נפרד `## ט. סריקת יומונים (radar — לא ציטוט)` שמתעד אילו יומונים נסרקו לכל סוגיה, אילו פסקי-דין מקוריים הם הצביעו עליהם, וסטטוס כל אחד: *בקורפוס (קושר) / נרשם כחסר / לא רלוונטי*. ציין מפורש: **רשומות אלה אינן ציטוטים** — הן עקבות-מחקר (radar). ה-writer וה-QA מתעלמים מהן כמקור-סמכות (INV-DIG1); הציטוט בהחלטה תמיד נשען על הפסק המקורי שבסעיפים ז/ח.
|
||||||
|
|
||||||
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
|
||||||
|
|
||||||
### שלב 3: מיפוי תכנית
|
### שלב 3: מיפוי תכנית
|
||||||
|
|||||||
@@ -978,7 +978,7 @@
|
|||||||
"legal-ai": {
|
"legal-ai": {
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": "1",
|
||||||
"title": "V7 schema: precedent library + halachot tables",
|
"title": "V7 schema: precedent library + halachot tables",
|
||||||
"description": "Add SCHEMA_V7_SQL to db.py: extend case_law with source_kind/document_id/extraction_status/halacha_extraction_status/practice_area (CHECK constraint for 3 areas)/appeal_subtype/headnote. Create precedent_chunks table with vector(1024). Create halachot table with vector(1024), review_status, practice_areas array. Add IVFFlat indexes. Register V7 in init_schema().",
|
"description": "Add SCHEMA_V7_SQL to db.py: extend case_law with source_kind/document_id/extraction_status/halacha_extraction_status/practice_area (CHECK constraint for 3 areas)/appeal_subtype/headnote. Create precedent_chunks table with vector(1024). Create halachot table with vector(1024), review_status, practice_areas array. Add IVFFlat indexes. Register V7 in init_schema().",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -990,7 +990,7 @@
|
|||||||
"updatedAt": "2026-05-03T08:17:59.928Z"
|
"updatedAt": "2026-05-03T08:17:59.928Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": "2",
|
||||||
"title": "Chunker: add court ruling section patterns",
|
"title": "Chunker: add court ruling section patterns",
|
||||||
"description": "Extend services/chunker.py SECTION_PATTERNS with 4 patterns for external court rulings: פסק דין→ruling, נימוקים→legal_analysis, סוף דבר→conclusion, העובדות הצריכות לעניין→facts",
|
"description": "Extend services/chunker.py SECTION_PATTERNS with 4 patterns for external court rulings: פסק דין→ruling, נימוקים→legal_analysis, סוף דבר→conclusion, העובדות הצריכות לעניין→facts",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -1004,7 +1004,7 @@
|
|||||||
"updatedAt": "2026-05-03T08:18:33.239Z"
|
"updatedAt": "2026-05-03T08:18:33.239Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
"id": "3",
|
||||||
"title": "Service: halacha_extractor.py",
|
"title": "Service: halacha_extractor.py",
|
||||||
"description": "New service that runs claude_session.query_json() over chunks where section_type IN (legal_analysis, ruling, conclusion). Concurrency=3, retry=1. Validates supporting_quote with substring check after Hebrew normalization. All halachot inserted with review_status=pending_review (no auto-publish). Embeds rule_statement+reasoning_summary via Voyage. Uses Hebrew prompt from plan appendix א. Idempotent on case_law_id.",
|
"description": "New service that runs claude_session.query_json() over chunks where section_type IN (legal_analysis, ruling, conclusion). Concurrency=3, retry=1. Validates supporting_quote with substring check after Hebrew normalization. All halachot inserted with review_status=pending_review (no auto-publish). Embeds rule_statement+reasoning_summary via Voyage. Uses Hebrew prompt from plan appendix א. Idempotent on case_law_id.",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -1019,7 +1019,7 @@
|
|||||||
"updatedAt": "2026-05-03T08:22:12.392Z"
|
"updatedAt": "2026-05-03T08:22:12.392Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
"id": "4",
|
||||||
"title": "Service: precedent_library.py orchestrator",
|
"title": "Service: precedent_library.py orchestrator",
|
||||||
"description": "New service with ingest_precedent(file_path, citation, court, decision_date, source_type, precedent_level, practice_area, appeal_subtype, subject_tags, case_name, task_id) that orchestrates: extract_text → proofread → INSERT case_law (source_kind=external_upload) → chunk → embed → store precedent_chunks → halacha_extractor.extract → embed halachot → publish progress. Plus delete_precedent (cascading), list_precedents(filters), get_precedent(id), search_library(query, filters, limit) merging chunks+approved-halachot ranked.",
|
"description": "New service with ingest_precedent(file_path, citation, court, decision_date, source_type, precedent_level, practice_area, appeal_subtype, subject_tags, case_name, task_id) that orchestrates: extract_text → proofread → INSERT case_law (source_kind=external_upload) → chunk → embed → store precedent_chunks → halacha_extractor.extract → embed halachot → publish progress. Plus delete_precedent (cascading), list_precedents(filters), get_precedent(id), search_library(query, filters, limit) merging chunks+approved-halachot ranked.",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -1035,7 +1035,7 @@
|
|||||||
"updatedAt": "2026-05-03T08:23:33.235Z"
|
"updatedAt": "2026-05-03T08:23:33.235Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 5,
|
"id": "5",
|
||||||
"title": "MCP tools: precedent_library + halacha_review",
|
"title": "MCP tools: precedent_library + halacha_review",
|
||||||
"description": "Create mcp-server/src/legal_mcp/tools/precedent_library.py with tools: precedent_library_upload, precedent_library_list, precedent_library_get, precedent_library_delete, precedent_extract_halachot, search_precedent_library (semantic, returns merged halachot+chunks), halacha_review (approve/reject). Register all in server.py. Do NOT modify existing precedent_search_library or search_decisions.",
|
"description": "Create mcp-server/src/legal_mcp/tools/precedent_library.py with tools: precedent_library_upload, precedent_library_list, precedent_library_get, precedent_library_delete, precedent_extract_halachot, search_precedent_library (semantic, returns merged halachot+chunks), halacha_review (approve/reject). Register all in server.py. Do NOT modify existing precedent_search_library or search_decisions.",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -1049,7 +1049,7 @@
|
|||||||
"updatedAt": "2026-05-03T08:25:07.439Z"
|
"updatedAt": "2026-05-03T08:25:07.439Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 6,
|
"id": "6",
|
||||||
"title": "FastAPI endpoints under /api/precedent-library",
|
"title": "FastAPI endpoints under /api/precedent-library",
|
||||||
"description": "Add to web/app.py: POST /api/precedent-library/upload (multipart), GET /api/precedent-library (filters), GET /api/precedent-library/{id}, PATCH /api/precedent-library/{id}, DELETE /api/precedent-library/{id}, POST /api/precedent-library/{id}/extract-halachot, GET /api/precedent-library/search, GET /api/halachot?status=pending_review, PATCH /api/halachot/{id}, GET /api/precedent-library/stats. Reuse existing /api/progress/{task_id} SSE.",
|
"description": "Add to web/app.py: POST /api/precedent-library/upload (multipart), GET /api/precedent-library (filters), GET /api/precedent-library/{id}, PATCH /api/precedent-library/{id}, DELETE /api/precedent-library/{id}, POST /api/precedent-library/{id}/extract-halachot, GET /api/precedent-library/search, GET /api/halachot?status=pending_review, PATCH /api/halachot/{id}, GET /api/precedent-library/stats. Reuse existing /api/progress/{task_id} SSE.",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -1063,7 +1063,7 @@
|
|||||||
"updatedAt": "2026-05-03T08:26:21.860Z"
|
"updatedAt": "2026-05-03T08:26:21.860Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 7,
|
"id": "7",
|
||||||
"title": "UI: /precedents page with 4 tabs",
|
"title": "UI: /precedents page with 4 tabs",
|
||||||
"description": "New web-ui/src/app/precedents/page.tsx with tabs: Library (table+filters+upload), Semantic Search, Pending Review (PRIMARY - bulk approval UX with J/K nav, A/R/E shortcuts, side-by-side rule_statement vs supporting_quote, badge count), Stats. New components in web-ui/src/components/precedents/: precedent-upload-sheet, precedent-list-table, precedent-search-panel, precedent-detail-panel, halacha-review-card. New hooks in web-ui/src/lib/api/precedent-library.ts. Add nav link in app-shell.tsx.",
|
"description": "New web-ui/src/app/precedents/page.tsx with tabs: Library (table+filters+upload), Semantic Search, Pending Review (PRIMARY - bulk approval UX with J/K nav, A/R/E shortcuts, side-by-side rule_statement vs supporting_quote, badge count), Stats. New components in web-ui/src/components/precedents/: precedent-upload-sheet, precedent-list-table, precedent-search-panel, precedent-detail-panel, halacha-review-card. New hooks in web-ui/src/lib/api/precedent-library.ts. Add nav link in app-shell.tsx.",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -1077,7 +1077,7 @@
|
|||||||
"updatedAt": "2026-05-03T08:34:00.548Z"
|
"updatedAt": "2026-05-03T08:34:00.548Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 8,
|
"id": "8",
|
||||||
"title": "Agent integration: legal-writer + 3 others",
|
"title": "Agent integration: legal-writer + 3 others",
|
||||||
"description": "Update .claude/agents/legal-writer.md (PRIMARY) — add mcp__legal-ai__search_precedent_library to tools and prompt section explaining when to use it for CREAC rule+explanation in block י. Update legal-researcher.md, legal-analyst.md, legal-ceo.md, legal-qa.md to add the tool. Update skills/decision/SKILL.md with section explaining the 3 corpora (style_corpus, case_precedents, precedent_library).",
|
"description": "Update .claude/agents/legal-writer.md (PRIMARY) — add mcp__legal-ai__search_precedent_library to tools and prompt section explaining when to use it for CREAC rule+explanation in block י. Update legal-researcher.md, legal-analyst.md, legal-ceo.md, legal-qa.md to add the tool. Update skills/decision/SKILL.md with section explaining the 3 corpora (style_corpus, case_precedents, precedent_library).",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -1091,7 +1091,7 @@
|
|||||||
"updatedAt": "2026-05-03T08:36:24.711Z"
|
"updatedAt": "2026-05-03T08:36:24.711Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 9,
|
"id": "9",
|
||||||
"title": "Service: precedent_metadata_extractor.py",
|
"title": "Service: precedent_metadata_extractor.py",
|
||||||
"description": "LLM-based extractor that auto-fills empty metadata fields after upload: short case_name (e.g. 'אהרון ברק' from long citation), summary (2-3 sentences), headnote, key_quote, subject_tags array, appeal_subtype. Reuses claude_session.query_json. Returns dict; caller decides which empty fields to merge (never overrides user values).",
|
"description": "LLM-based extractor that auto-fills empty metadata fields after upload: short case_name (e.g. 'אהרון ברק' from long citation), summary (2-3 sentences), headnote, key_quote, subject_tags array, appeal_subtype. Reuses claude_session.query_json. Returns dict; caller decides which empty fields to merge (never overrides user values).",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -1103,7 +1103,7 @@
|
|||||||
"updatedAt": "2026-05-03T10:19:15.105Z"
|
"updatedAt": "2026-05-03T10:19:15.105Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 10,
|
"id": "10",
|
||||||
"title": "Halacha extractor: dual mode (binding vs persuasive)",
|
"title": "Halacha extractor: dual mode (binding vs persuasive)",
|
||||||
"description": "Update halacha_extractor.py prompt to branch on is_binding: binding=true → strict halacha extraction (current). binding=false → extract reasoning principles, applications of established halachot, persuasive conclusions. New rule_types: 'application' (applying known rule to facts), 'persuasive' (committee's reasoning citable as authority). Schema unchanged (rule_type already TEXT).",
|
"description": "Update halacha_extractor.py prompt to branch on is_binding: binding=true → strict halacha extraction (current). binding=false → extract reasoning principles, applications of established halachot, persuasive conclusions. New rule_types: 'application' (applying known rule to facts), 'persuasive' (committee's reasoning citable as authority). Schema unchanged (rule_type already TEXT).",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -1115,7 +1115,7 @@
|
|||||||
"updatedAt": "2026-05-03T10:19:15.117Z"
|
"updatedAt": "2026-05-03T10:19:15.117Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 11,
|
"id": "11",
|
||||||
"title": "Ingest pipeline: add metadata extraction stage",
|
"title": "Ingest pipeline: add metadata extraction stage",
|
||||||
"description": "In services/precedent_library.py:ingest_precedent, after halacha extraction, run metadata_extractor and PATCH the case_law row with auto-filled fields (only those left empty by user). Publish progress 'extracting_metadata'.",
|
"description": "In services/precedent_library.py:ingest_precedent, after halacha extraction, run metadata_extractor and PATCH the case_law row with auto-filled fields (only those left empty by user). Publish progress 'extracting_metadata'.",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -1129,7 +1129,7 @@
|
|||||||
"updatedAt": "2026-05-03T10:19:15.128Z"
|
"updatedAt": "2026-05-03T10:19:15.128Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 12,
|
"id": "12",
|
||||||
"title": "UI: precedent edit sheet",
|
"title": "UI: precedent edit sheet",
|
||||||
"description": "Add edit button to library-list-panel rows that opens a Sheet with all editable fields (case_name, citation, court, date, practice_area, appeal_subtype, subject_tags, summary, headnote, key_quote, source_type, precedent_level, is_binding). Pre-populated from current values. Submit calls PATCH /api/precedent-library/{id} via useUpdatePrecedent. After save, invalidate library list query.",
|
"description": "Add edit button to library-list-panel rows that opens a Sheet with all editable fields (case_name, citation, court, date, practice_area, appeal_subtype, subject_tags, summary, headnote, key_quote, source_type, precedent_level, is_binding). Pre-populated from current values. Submit calls PATCH /api/precedent-library/{id} via useUpdatePrecedent. After save, invalidate library list query.",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -1141,7 +1141,7 @@
|
|||||||
"updatedAt": "2026-05-03T10:19:15.134Z"
|
"updatedAt": "2026-05-03T10:19:15.134Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 13,
|
"id": "13",
|
||||||
"title": "Test on 403-17: fix metadata + re-extract",
|
"title": "Test on 403-17: fix metadata + re-extract",
|
||||||
"description": "After deploy: PATCH 403-17 to set case_name='ערר 403/17', then trigger precedent_extract_halachot to test the dual-mode extraction on a non-binding committee decision.",
|
"description": "After deploy: PATCH 403-17 to set case_name='ערר 403/17', then trigger precedent_extract_halachot to test the dual-mode extraction on a non-binding committee decision.",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -1158,7 +1158,7 @@
|
|||||||
"updatedAt": "2026-05-26T10:38:07.071897Z"
|
"updatedAt": "2026-05-26T10:38:07.071897Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 14,
|
"id": "14",
|
||||||
"title": "Upgrade: speed up halacha+metadata extraction",
|
"title": "Upgrade: speed up halacha+metadata extraction",
|
||||||
"description": "Halacha extraction on long rulings is slow (5-15 min for typical court ruling, 30-50 min for a 207-chunk appeals committee decision). Root cause: each chunk spawns a separate `claude -p` subprocess (5-10 sec startup overhead each), Hebrew prompts on cold cache run 30-90 sec, and there's no prompt-cache sharing between chunks. Acceleration options to evaluate later when speed becomes a real blocker.\n\nOptions (each can be combined):\n\n1. Concurrency 3 -> 6 in halacha_extractor.CHUNK_CONCURRENCY. ~2x faster wall-clock. Cost: 6x ~300MB RSS = 1.8GB peak — verify on Nautilus headroom.\n\n2. Larger chunks 12K -> 18-25K chars (CHUNK_TARGET_CHARS in claims_extractor.py / halacha_extractor.py). Fewer waves. Risk: timeout on cold cache (currently 1800s ceiling), and may degrade extraction precision for very long sections.\n\n3. Anthropic SDK direct with 5-min ephemeral prompt caching on the static instruction prefix (already wired the parameter as system= in claude_session.query). Estimated 5-10x faster because cache reads are ~10% of cold cost. Costs ~$0.30-2 per long ruling on Sonnet 4.6. Chair previously rejected this path for ALL traffic ('we work only with claude session'). Compromise: SDK only for the precedent-library corpus build (static, one-time), claude session for live decision drafting (interactive, frequent).\n\n4. Two-tier prompt: a short 'classification' pass with claude -p deciding which chunks contain halachot, then deep extraction only on positive chunks. Could cut total LLM time by 40-60% on rulings with lots of factual chapters.\n\n5. Already implemented (Apr 3, 2026): skip non-extractable sections — only run on chunks where section_type IN (legal_analysis, ruling, conclusion); fallback to all chunks when chunker labels nothing. So that win is already banked.\n\nRe-evaluate when: a chair drops a 200K+ char ruling into the queue and the wait becomes painful, OR when the precedent-library has 50+ pending entries and bulk processing matters.",
|
"description": "Halacha extraction on long rulings is slow (5-15 min for typical court ruling, 30-50 min for a 207-chunk appeals committee decision). Root cause: each chunk spawns a separate `claude -p` subprocess (5-10 sec startup overhead each), Hebrew prompts on cold cache run 30-90 sec, and there's no prompt-cache sharing between chunks. Acceleration options to evaluate later when speed becomes a real blocker.\n\nOptions (each can be combined):\n\n1. Concurrency 3 -> 6 in halacha_extractor.CHUNK_CONCURRENCY. ~2x faster wall-clock. Cost: 6x ~300MB RSS = 1.8GB peak — verify on Nautilus headroom.\n\n2. Larger chunks 12K -> 18-25K chars (CHUNK_TARGET_CHARS in claims_extractor.py / halacha_extractor.py). Fewer waves. Risk: timeout on cold cache (currently 1800s ceiling), and may degrade extraction precision for very long sections.\n\n3. Anthropic SDK direct with 5-min ephemeral prompt caching on the static instruction prefix (already wired the parameter as system= in claude_session.query). Estimated 5-10x faster because cache reads are ~10% of cold cost. Costs ~$0.30-2 per long ruling on Sonnet 4.6. Chair previously rejected this path for ALL traffic ('we work only with claude session'). Compromise: SDK only for the precedent-library corpus build (static, one-time), claude session for live decision drafting (interactive, frequent).\n\n4. Two-tier prompt: a short 'classification' pass with claude -p deciding which chunks contain halachot, then deep extraction only on positive chunks. Could cut total LLM time by 40-60% on rulings with lots of factual chapters.\n\n5. Already implemented (Apr 3, 2026): skip non-extractable sections — only run on chunks where section_type IN (legal_analysis, ruling, conclusion); fallback to all chunks when chunker labels nothing. So that win is already banked.\n\nRe-evaluate when: a chair drops a 200K+ char ruling into the queue and the wait becomes painful, OR when the precedent-library has 50+ pending entries and bulk processing matters.",
|
||||||
"details": "נסקר 2026-06-03 — אין blocker נוכחי. הרצתי reindex ל-73 תקדימים + חילוצים מרובים בלי שמהירות הייתה כאב. YAGNI: לא מבצעים אופטימיזציה מוקדמת. נשאר deferred עם trigger ברור: פסק-דין 200K+ תווים שתוקע את התור, או 50+ פריטים ממתינים. ה-win הזול (concurrency 3→6) דורש אימות headroom של 1.8GB RSS ב-Nautilus לפני — לא עכשיו.",
|
"details": "נסקר 2026-06-03 — אין blocker נוכחי. הרצתי reindex ל-73 תקדימים + חילוצים מרובים בלי שמהירות הייתה כאב. YAGNI: לא מבצעים אופטימיזציה מוקדמת. נשאר deferred עם trigger ברור: פסק-דין 200K+ תווים שתוקע את התור, או 50+ פריטים ממתינים. ה-win הזול (concurrency 3→6) דורש אימות headroom של 1.8GB RSS ב-Nautilus לפני — לא עכשיו.",
|
||||||
@@ -1170,7 +1170,7 @@
|
|||||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 15,
|
"id": "15",
|
||||||
"title": "Multimodal — כיוונון MULTIMODAL_TEXT_WEIGHT (A/B) + הכרעה על backfill",
|
"title": "Multimodal — כיוונון MULTIMODAL_TEXT_WEIGHT (A/B) + הכרעה על backfill",
|
||||||
"description": "נסגר 2026-06-03 לאחר A/B אובייקטיבי על gold-set (86 שאילתות, eval_retrieval.py). הנחת היסוד התיישנה: multimodal כבר ברירת-מחדל בייצור (110 מסמכים מוטמעים אוטומטית בהעלאה), לא רק 2 תיקי A/B. ממצא: ה-weight 0.5 (ברירת-מחדל) היה mis-tuned — צד-התמונה כבד מדי וחתך recall של precedent_library (0.971→0.885). sweep 0.5→0.75: במשקל 0.65 multimodal מנצח את text-only בכל מדד ובכל corpus (R@5 0.994 מול 0.989; nDCG@5 0.960 מול 0.944; MRR 0.954 מול 0.936; precedent_library R@5 0.983, internal_decisions nDCG 0.978). כיסוי: 28/79 מסמכי gold-set מוטמעים multimodal (35% — אות אמיתי). דפנה אישרה.",
|
"description": "נסגר 2026-06-03 לאחר A/B אובייקטיבי על gold-set (86 שאילתות, eval_retrieval.py). הנחת היסוד התיישנה: multimodal כבר ברירת-מחדל בייצור (110 מסמכים מוטמעים אוטומטית בהעלאה), לא רק 2 תיקי A/B. ממצא: ה-weight 0.5 (ברירת-מחדל) היה mis-tuned — צד-התמונה כבד מדי וחתך recall של precedent_library (0.971→0.885). sweep 0.5→0.75: במשקל 0.65 multimodal מנצח את text-only בכל מדד ובכל corpus (R@5 0.994 מול 0.989; nDCG@5 0.960 מול 0.944; MRR 0.954 מול 0.936; precedent_library R@5 0.983, internal_decisions nDCG 0.978). כיסוי: 28/79 מסמכי gold-set מוטמעים multimodal (35% — אות אמיתי). דפנה אישרה.",
|
||||||
"details": "בוצע: MULTIMODAL_TEXT_WEIGHT=0.65 הוגדר ב-Coolify env של legal-ai (runtime) + redeploy; baseline (data/eval/baseline.json) עודכן לקונפיג 0.65. ה-backfill היקר ל-140 התיקים ה-legacy *לא* בוצע — אין הצדקת-אחזור לשאלות טקסט, וערך ה-image-answer לא נבדק. מומר ל-#80 (מותנה). ראיות: data/eval/eval-report-20260603T08*.md, project_multimodal_stage_c.",
|
"details": "בוצע: MULTIMODAL_TEXT_WEIGHT=0.65 הוגדר ב-Coolify env של legal-ai (runtime) + redeploy; baseline (data/eval/baseline.json) עודכן לקונפיג 0.65. ה-backfill היקר ל-140 התיקים ה-legacy *לא* בוצע — אין הצדקת-אחזור לשאלות טקסט, וערך ה-image-answer לא נבדק. מומר ל-#80 (מותנה). ראיות: data/eval/eval-report-20260603T08*.md, project_multimodal_stage_c.",
|
||||||
@@ -1182,7 +1182,7 @@
|
|||||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 16,
|
"id": "16",
|
||||||
"title": "[Paperclip Gap 1] runtime_config ריק — חסרים graceSec/cooldownSec/maxConcurrentRuns",
|
"title": "[Paperclip Gap 1] runtime_config ריק — חסרים graceSec/cooldownSec/maxConcurrentRuns",
|
||||||
"description": "runtime_config = '{}' לכל 14 הסוכנים. מסתבר שעיקר ההגדרות החשובות (timeoutSec=3600, maxTurnsPerRun=500) יושבות ב-adapter_config ולא ב-runtime_config — אז המצב פחות חמור. אבל graceSec/cooldownSec/maxConcurrentRuns עדיין חסרים.",
|
"description": "runtime_config = '{}' לכל 14 הסוכנים. מסתבר שעיקר ההגדרות החשובות (timeoutSec=3600, maxTurnsPerRun=500) יושבות ב-adapter_config ולא ב-runtime_config — אז המצב פחות חמור. אבל graceSec/cooldownSec/maxConcurrentRuns עדיין חסרים.",
|
||||||
"details": "תיקון לניתוח המקורי שגוי בעקבות בדיקה ב-DB:\n\nמה שכן יש לנו (ב-adapter_config, לא runtime_config):\n- timeoutSec: 3600 (לכל הסוכנים)\n- maxTurnsPerRun: 500 (לכל הסוכנים)\n- model + effort=high (לכל הסוכנים)\n- paperclipSkillSync.desiredSkills (5/7 סוכנים — חסר אצל הגהת מסמכים ומנתח משפטי)\n\nמה שבאמת חסר ב-runtime_config:\n- heartbeat.graceSec — זמן grace לפני SIGKILL אחרי timeout. מהקוד: Math.max(1, graceSec)*1000. אם לא מוגדר → 1ms grace. בעיה אם הסוכן נחתך באמצע commit ל-DB.\n- heartbeat.cooldownSec — default ביצירה חדשה: 10. אצלנו לא מוגדר.\n- heartbeat.maxConcurrentRuns — default מ-AGENT_DEFAULT_MAX_CONCURRENT_RUNS (כנראה 1).\n- heartbeat.wakeOnDemand — default=true בקוד. אצלנו לא מוגדר אבל בפועל true.\n- heartbeat.enabled — default=false (timer off). זה הרצוי אצלנו.\n\nפעולה (Phase 1):\n1. עדכון runtime_config של כל סוכן: { heartbeat: { graceSec: 60, cooldownSec: 10, maxConcurrentRuns: 1, wakeOnDemand: true } }\n2. בעיקר graceSec — בלעדיו commit באמצע יכול להיכשל\n3. cooldownSec=10 (זהה לdefault ב-UI ליצירת agent חדש)\n\nהשפעה: minimal — רוב המקרים עובדים עם defaults. graceSec הוא העיקר.",
|
"details": "תיקון לניתוח המקורי שגוי בעקבות בדיקה ב-DB:\n\nמה שכן יש לנו (ב-adapter_config, לא runtime_config):\n- timeoutSec: 3600 (לכל הסוכנים)\n- maxTurnsPerRun: 500 (לכל הסוכנים)\n- model + effort=high (לכל הסוכנים)\n- paperclipSkillSync.desiredSkills (5/7 סוכנים — חסר אצל הגהת מסמכים ומנתח משפטי)\n\nמה שבאמת חסר ב-runtime_config:\n- heartbeat.graceSec — זמן grace לפני SIGKILL אחרי timeout. מהקוד: Math.max(1, graceSec)*1000. אם לא מוגדר → 1ms grace. בעיה אם הסוכן נחתך באמצע commit ל-DB.\n- heartbeat.cooldownSec — default ביצירה חדשה: 10. אצלנו לא מוגדר.\n- heartbeat.maxConcurrentRuns — default מ-AGENT_DEFAULT_MAX_CONCURRENT_RUNS (כנראה 1).\n- heartbeat.wakeOnDemand — default=true בקוד. אצלנו לא מוגדר אבל בפועל true.\n- heartbeat.enabled — default=false (timer off). זה הרצוי אצלנו.\n\nפעולה (Phase 1):\n1. עדכון runtime_config של כל סוכן: { heartbeat: { graceSec: 60, cooldownSec: 10, maxConcurrentRuns: 1, wakeOnDemand: true } }\n2. בעיקר graceSec — בלעדיו commit באמצע יכול להיכשל\n3. cooldownSec=10 (זהה לdefault ב-UI ליצירת agent חדש)\n\nהשפעה: minimal — רוב המקרים עובדים עם defaults. graceSec הוא העיקר.",
|
||||||
@@ -1194,7 +1194,7 @@
|
|||||||
"updatedAt": "2026-05-04T07:47:02.008Z"
|
"updatedAt": "2026-05-04T07:47:02.008Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 17,
|
"id": "17",
|
||||||
"title": "[Paperclip Gap 2] תקציבים = 0 לכל הסוכנים — אין budget enforcement",
|
"title": "[Paperclip Gap 2] תקציבים = 0 לכל הסוכנים — אין budget enforcement",
|
||||||
"description": "budget_monthly_cents = 0 ו-spent_monthly_cents = 0 לכל 14 הסוכנים. Paperclip מציע cost control מובנה — אנחנו מתעלמים.",
|
"description": "budget_monthly_cents = 0 ו-spent_monthly_cents = 0 לכל 14 הסוכנים. Paperclip מציע cost control מובנה — אנחנו מתעלמים.",
|
||||||
"details": "ממצא: SELECT name, budget_monthly_cents, spent_monthly_cents FROM agents → הכל אפס.\n\nסיכון: לולאה חבויה יכולה לשרוף מאות $. אין auto-pause ב-80% spend (דפוס ש-CEO HEARTBEAT הרשמי מצפה לו).\n\nפעולה (Phase 3):\n1. מדידה: כמה כל סוכן באמת מוציא בחודש כיום (דרך לוגי claude-code, או Anthropic dashboard).\n2. הגדרת budget_monthly_cents סביר לכל סוכן (כותב Opus ≫ מנתח Sonnet).\n3. בדיקה שהמנגנון מפסיק כשמגיעים ל-100%.\n\nשאלה לחיים לפני ביצוע: באיזו רזולוציה למדוד? לפי Anthropic invoice, או לפי טוקנים בלוגים של claude_session?",
|
"details": "ממצא: SELECT name, budget_monthly_cents, spent_monthly_cents FROM agents → הכל אפס.\n\nסיכון: לולאה חבויה יכולה לשרוף מאות $. אין auto-pause ב-80% spend (דפוס ש-CEO HEARTBEAT הרשמי מצפה לו).\n\nפעולה (Phase 3):\n1. מדידה: כמה כל סוכן באמת מוציא בחודש כיום (דרך לוגי claude-code, או Anthropic dashboard).\n2. הגדרת budget_monthly_cents סביר לכל סוכן (כותב Opus ≫ מנתח Sonnet).\n3. בדיקה שהמנגנון מפסיק כשמגיעים ל-100%.\n\nשאלה לחיים לפני ביצוע: באיזו רזולוציה למדוד? לפי Anthropic invoice, או לפי טוקנים בלוגים של claude_session?",
|
||||||
@@ -1206,7 +1206,7 @@
|
|||||||
"updatedAt": "2026-05-04T10:18:08.046Z"
|
"updatedAt": "2026-05-04T10:18:08.046Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 18,
|
"id": "18",
|
||||||
"title": "[Paperclip Gap 3] חסר X-Paperclip-Run-Id header בקריאות API",
|
"title": "[Paperclip Gap 3] חסר X-Paperclip-Run-Id header בקריאות API",
|
||||||
"description": "ה-skill הרשמי קובע: 'You MUST include -H X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on ALL API requests that modify issues'. ב-HEARTBEAT.md שלנו אין זכר לכך.",
|
"description": "ה-skill הרשמי קובע: 'You MUST include -H X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on ALL API requests that modify issues'. ב-HEARTBEAT.md שלנו אין זכר לכך.",
|
||||||
"details": "ממצא: grep -n 'X-Paperclip-Run-Id' .claude/agents/ → 0 hits. כל curl ב-checkout/comments/PATCH issues — בלי הheader.\n\nסיכון: audit trail שבור. שאלה 'איזו ריצה שינתה את ה-issue X?' אין לה תשובה ב-DB.\n\nפעולה (Phase 1):\n1. עדכון .claude/agents/HEARTBEAT.md — דוגמאות ה-curl יכללו את הheader\n2. עדכון 6 קבצי הסוכנים (legal-ceo.md, legal-analyst.md, legal-researcher.md, legal-writer.md, legal-qa.md, legal-exporter.md) — כל מקום שיש curl POST/PATCH\n3. בדיקה שיש env var $PAPERCLIP_RUN_ID זמין בכל heartbeat",
|
"details": "ממצא: grep -n 'X-Paperclip-Run-Id' .claude/agents/ → 0 hits. כל curl ב-checkout/comments/PATCH issues — בלי הheader.\n\nסיכון: audit trail שבור. שאלה 'איזו ריצה שינתה את ה-issue X?' אין לה תשובה ב-DB.\n\nפעולה (Phase 1):\n1. עדכון .claude/agents/HEARTBEAT.md — דוגמאות ה-curl יכללו את הheader\n2. עדכון 6 קבצי הסוכנים (legal-ceo.md, legal-analyst.md, legal-researcher.md, legal-writer.md, legal-qa.md, legal-exporter.md) — כל מקום שיש curl POST/PATCH\n3. בדיקה שיש env var $PAPERCLIP_RUN_ID זמין בכל heartbeat",
|
||||||
@@ -1218,7 +1218,7 @@
|
|||||||
"updatedAt": "2026-05-04T08:49:44.646Z"
|
"updatedAt": "2026-05-04T08:49:44.646Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 19,
|
"id": "19",
|
||||||
"title": "[Paperclip Gap 4] לא משתמשים ב-/api/issues/{id}/interactions לאישורים",
|
"title": "[Paperclip Gap 4] לא משתמשים ב-/api/issues/{id}/interactions לאישורים",
|
||||||
"description": "Paperclip מציע API מובנה לאישור/שאלות (request_confirmation, ask_user_questions, suggest_tasks) עם idempotency keys ו-auto-wake. אנחנו עדיין כותבים 'חיים, מה לעשות?' כ-comment חופשי.",
|
"description": "Paperclip מציע API מובנה לאישור/שאלות (request_confirmation, ask_user_questions, suggest_tasks) עם idempotency keys ו-auto-wake. אנחנו עדיין כותבים 'חיים, מה לעשות?' כ-comment חופשי.",
|
||||||
"details": "סוגי interaction:\n- ask_user_questions — שאלות מובנות\n- request_confirmation — yes/no עם idempotency key (confirmation:{issueId}:plan:{revisionId})\n- suggest_tasks — הצעת עץ משימות\n- continuationPolicy: wake_assignee — wake אוטומטי על מענה\n- supersedeOnUserComment: true — בטל אם חיים עונה\n\nסיכון: אין UI מובנה לחיים (כפתורים), רק טקסט. אם הסוכן מתעורר פעמיים — שתי שאלות זהות.\n\nפעולה (Phase 2):\n1. בlegal-ceo.md — להחליף 'אם חיים לא הגדיר outcome: שאל בcomment' ב-request_confirmation\n2. בbrainstorm_directions — suggest_tasks במקום רשימת bullet\n3. בlegal-qa.md — request_confirmation לאישור export\n\nשאלה לחיים: האם תרצה לראות UI חדש או להישאר ב-Markdown comments?",
|
"details": "סוגי interaction:\n- ask_user_questions — שאלות מובנות\n- request_confirmation — yes/no עם idempotency key (confirmation:{issueId}:plan:{revisionId})\n- suggest_tasks — הצעת עץ משימות\n- continuationPolicy: wake_assignee — wake אוטומטי על מענה\n- supersedeOnUserComment: true — בטל אם חיים עונה\n\nסיכון: אין UI מובנה לחיים (כפתורים), רק טקסט. אם הסוכן מתעורר פעמיים — שתי שאלות זהות.\n\nפעולה (Phase 2):\n1. בlegal-ceo.md — להחליף 'אם חיים לא הגדיר outcome: שאל בcomment' ב-request_confirmation\n2. בbrainstorm_directions — suggest_tasks במקום רשימת bullet\n3. בlegal-qa.md — request_confirmation לאישור export\n\nשאלה לחיים: האם תרצה לראות UI חדש או להישאר ב-Markdown comments?",
|
||||||
@@ -1234,7 +1234,7 @@
|
|||||||
"updatedAt": "2026-05-04T11:18:59.050Z"
|
"updatedAt": "2026-05-04T11:18:59.050Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 20,
|
"id": "20",
|
||||||
"title": "[Paperclip Gap 5] לא משתמשים ב-PAPERCLIP_WAKE_PAYLOAD_JSON fast-path",
|
"title": "[Paperclip Gap 5] לא משתמשים ב-PAPERCLIP_WAKE_PAYLOAD_JSON fast-path",
|
||||||
"description": "בwake שמכוון ל-issue ספציפי, ה-env var מכיל כבר issue summary + comments חדשים דחוסים. ה-skill הרשמי אומר 'skip Steps 1-4 entirely'. שלנו תמיד fetcher גם ה-API.",
|
"description": "בwake שמכוון ל-issue ספציפי, ה-env var מכיל כבר issue summary + comments חדשים דחוסים. ה-skill הרשמי אומר 'skip Steps 1-4 entirely'. שלנו תמיד fetcher גם ה-API.",
|
||||||
"details": "ממצא: HEARTBEAT.md סעיפים 2-2c תמיד פונים ל-API גם אם ה-payload כבר מכיל את הכל.\n\nתועלת: חיסכון 3-4 קריאות API לכל ריצה. בwakeups תכופים (CEO על comments) — חיסכון ניכר.\n\nפעולה (Phase 2):\n1. הוספה ל-HEARTBEAT.md בראש הסעיפים: 'אם $PAPERCLIP_WAKE_PAYLOAD_JSON קיים — קרא אותו ראשון. רק אם fallbackFetchNeeded:true או חסר הקשר רחב — fetch'.\n2. דוגמה לפענוח JSON: jq עם key paths\n3. בדיקה איזה wake reasons בכלל מקבלים payload (כנראה comment-driven בלבד)",
|
"details": "ממצא: HEARTBEAT.md סעיפים 2-2c תמיד פונים ל-API גם אם ה-payload כבר מכיל את הכל.\n\nתועלת: חיסכון 3-4 קריאות API לכל ריצה. בwakeups תכופים (CEO על comments) — חיסכון ניכר.\n\nפעולה (Phase 2):\n1. הוספה ל-HEARTBEAT.md בראש הסעיפים: 'אם $PAPERCLIP_WAKE_PAYLOAD_JSON קיים — קרא אותו ראשון. רק אם fallbackFetchNeeded:true או חסר הקשר רחב — fetch'.\n2. דוגמה לפענוח JSON: jq עם key paths\n3. בדיקה איזה wake reasons בכלל מקבלים payload (כנראה comment-driven בלבד)",
|
||||||
@@ -1248,7 +1248,7 @@
|
|||||||
"updatedAt": "2026-05-04T09:15:46.339Z"
|
"updatedAt": "2026-05-04T09:15:46.339Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 21,
|
"id": "21",
|
||||||
"title": "[Paperclip Gap 6] שאילתות psql ישירות ל-issue_attachments — שובר אבסטרקציה",
|
"title": "[Paperclip Gap 6] שאילתות psql ישירות ל-issue_attachments — שובר אבסטרקציה",
|
||||||
"description": "HEARTBEAT.md סעיף 2c משתמש ב-psql ישיר ל-issue_attachments + assets. אם schema ישתנה (כפי שצפוי בעדכוני Paperclip) — כל הסוכנים נשברים.",
|
"description": "HEARTBEAT.md סעיף 2c משתמש ב-psql ישיר ל-issue_attachments + assets. אם schema ישתנה (כפי שצפוי בעדכוני Paperclip) — כל הסוכנים נשברים.",
|
||||||
"details": "ממצא: 6 קבצי סוכן + HEARTBEAT.md מכילים PGPASSWORD=paperclip psql ... FROM issue_attachments ia JOIN assets a.\n\nסיכון: breakage בעדכון Paperclip. כפילות לוגיקה (copy-paste בכל סוכן).\n\nפעולה (Phase 2):\n1. בדיקה אם קיים endpoint רשמי /api/issues/{id}/attachments (curl + grep ב-server/src/routes)\n2. אם כן — להחליף את כל ה-psql\n3. אם לא — להעביר את ה-psql למקום יחיד: helper ב-mcp-server (mcp__legal-ai__list_issue_attachments tool)\n4. אופציה ג: לפתוח issue ב-paperclipai/paperclip לבקש endpoint\n\nתלוי במחקר API.",
|
"details": "ממצא: 6 קבצי סוכן + HEARTBEAT.md מכילים PGPASSWORD=paperclip psql ... FROM issue_attachments ia JOIN assets a.\n\nסיכון: breakage בעדכון Paperclip. כפילות לוגיקה (copy-paste בכל סוכן).\n\nפעולה (Phase 2):\n1. בדיקה אם קיים endpoint רשמי /api/issues/{id}/attachments (curl + grep ב-server/src/routes)\n2. אם כן — להחליף את כל ה-psql\n3. אם לא — להעביר את ה-psql למקום יחיד: helper ב-mcp-server (mcp__legal-ai__list_issue_attachments tool)\n4. אופציה ג: לפתוח issue ב-paperclipai/paperclip לבקש endpoint\n\nתלוי במחקר API.",
|
||||||
@@ -1262,7 +1262,7 @@
|
|||||||
"updatedAt": "2026-05-04T09:28:18.058Z"
|
"updatedAt": "2026-05-04T09:28:18.058Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 22,
|
"id": "22",
|
||||||
"title": "[Paperclip Gap 7] לא משתמשים ב-/api/issues/{id}/heartbeat-context",
|
"title": "[Paperclip Gap 7] לא משתמשים ב-/api/issues/{id}/heartbeat-context",
|
||||||
"description": "Endpoint רשמי שמחזיר issue + ancestors + goal/project + comment cursor בקריאה אחת. אנחנו עושים 3 קריאות נפרדות.",
|
"description": "Endpoint רשמי שמחזיר issue + ancestors + goal/project + comment cursor בקריאה אחת. אנחנו עושים 3 קריאות נפרדות.",
|
||||||
"details": "ה-skill הרשמי: 'Prefer GET /api/issues/{issueId}/heartbeat-context first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.'\n\nשלנו: HEARTBEAT.md סעיפים 2 + 2b → שלוש קריאות (inbox-lite, issue, comments).\n\nפעולה (Phase 2):\n1. הוספת endpoint כצעד 6 ב-HEARTBEAT.md לפני 'Do the work'\n2. הסרת קריאות מיותרות שכבר ב-context\n3. שמירת comment cursor (after={last-seen-id}) לקריאות עוקבות",
|
"details": "ה-skill הרשמי: 'Prefer GET /api/issues/{issueId}/heartbeat-context first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.'\n\nשלנו: HEARTBEAT.md סעיפים 2 + 2b → שלוש קריאות (inbox-lite, issue, comments).\n\nפעולה (Phase 2):\n1. הוספת endpoint כצעד 6 ב-HEARTBEAT.md לפני 'Do the work'\n2. הסרת קריאות מיותרות שכבר ב-context\n3. שמירת comment cursor (after={last-seen-id}) לקריאות עוקבות",
|
||||||
@@ -1276,7 +1276,7 @@
|
|||||||
"updatedAt": "2026-05-04T09:28:14.247Z"
|
"updatedAt": "2026-05-04T09:28:14.247Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 23,
|
"id": "23",
|
||||||
"title": "[Paperclip Gap 8+11] HEARTBEAT.md ארוך + אין שימוש ב-skills של Paperclip",
|
"title": "[Paperclip Gap 8+11] HEARTBEAT.md ארוך + אין שימוש ב-skills של Paperclip",
|
||||||
"description": "HEARTBEAT.md שלנו 220 שורות (vs upstream 85). Paperclip מציע 8 skills מוכנים (paperclip, paperclip-create-agent, וכו') שאנחנו לא משתמשים באף אחד.",
|
"description": "HEARTBEAT.md שלנו 220 שורות (vs upstream 85). Paperclip מציע 8 skills מוכנים (paperclip, paperclip-create-agent, וכו') שאנחנו לא משתמשים באף אחד.",
|
||||||
"details": "תיקון לניתוח: מסתבר ש-CEO + 4 סוכנים אחרים כן משתמשים ב-paperclipSkillSync עם 4 paperclip skills (paperclip, paperclip-create-agent, paperclip-create-plugin, para-memory-files). חסר אצל: הגהת מסמכים ומנתח משפטי (skills_count=0).\n\nממצא: ls skills/ ב-paperclip repo → 8 skills. שלנו: 0 skills של Paperclip בשימוש.\n\nרלוונטיים לנו:\n- paperclip — API patterns + heartbeat checklist (יכול להחליף חלק מ-HEARTBEAT.md)\n- paperclip-create-agent — אם נוסיף סוכן\n- paperclip-create-plugin — לעדכוני plugin-legal-ai\n- paperclip-converting-plans-to-tasks — יכול להחליף brainstorm_directions\n- diagnose-why-work-stopped — לתחזוקה\n\nפעולה (Phase 3):\n1. קריאת skills/paperclip/SKILL.md מלא\n2. הזרקת skill לסביבת הסוכנים (כנראה דרך CLI: paperclipai agent local-cli)\n3. שכתוב HEARTBEAT.md לפי הדפוס: project-specific only, delegation לskill הרשמי לכלל ה-API\n4. יעד: ~120 שורות ב-HEARTBEAT.md שלנו\n\nשאלה לחיים: האם להזריק skills כסימלינקים ל-symlinks קיימים, או דרך paperclipai CLI?",
|
"details": "תיקון לניתוח: מסתבר ש-CEO + 4 סוכנים אחרים כן משתמשים ב-paperclipSkillSync עם 4 paperclip skills (paperclip, paperclip-create-agent, paperclip-create-plugin, para-memory-files). חסר אצל: הגהת מסמכים ומנתח משפטי (skills_count=0).\n\nממצא: ls skills/ ב-paperclip repo → 8 skills. שלנו: 0 skills של Paperclip בשימוש.\n\nרלוונטיים לנו:\n- paperclip — API patterns + heartbeat checklist (יכול להחליף חלק מ-HEARTBEAT.md)\n- paperclip-create-agent — אם נוסיף סוכן\n- paperclip-create-plugin — לעדכוני plugin-legal-ai\n- paperclip-converting-plans-to-tasks — יכול להחליף brainstorm_directions\n- diagnose-why-work-stopped — לתחזוקה\n\nפעולה (Phase 3):\n1. קריאת skills/paperclip/SKILL.md מלא\n2. הזרקת skill לסביבת הסוכנים (כנראה דרך CLI: paperclipai agent local-cli)\n3. שכתוב HEARTBEAT.md לפי הדפוס: project-specific only, delegation לskill הרשמי לכלל ה-API\n4. יעד: ~120 שורות ב-HEARTBEAT.md שלנו\n\nשאלה לחיים: האם להזריק skills כסימלינקים ל-symlinks קיימים, או דרך paperclipai CLI?",
|
||||||
@@ -1296,7 +1296,7 @@
|
|||||||
"updatedAt": "2026-05-04T16:44:27.553Z"
|
"updatedAt": "2026-05-04T16:44:27.553Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 24,
|
"id": "24",
|
||||||
"title": "[Paperclip Gap 9] לבדוק bootstrapPromptTemplate deprecated באף סוכן",
|
"title": "[Paperclip Gap 9] לבדוק bootstrapPromptTemplate deprecated באף סוכן",
|
||||||
"description": "מ-docs/agents-runtime.md: 'bootstrapPromptTemplate is deprecated... should be migrated to the managed instructions bundle system.' לבדוק האם adapter_config שלנו משתמש בזה.",
|
"description": "מ-docs/agents-runtime.md: 'bootstrapPromptTemplate is deprecated... should be migrated to the managed instructions bundle system.' לבדוק האם adapter_config שלנו משתמש בזה.",
|
||||||
"details": "פעולה (Phase 1):\n1. SELECT name, adapter_config->'promptTemplate' as pt, adapter_config->'bootstrapPromptTemplate' as bpt FROM agents WHERE adapter_type = 'claude_local';\n2. אם בשימוש אצל סוכן כלשהו — מיגרציה למבנה החדש\n3. ייעוד: לבדוק תיעוד managed instructions bundle ב-paperclip docs\n\nהערה: זה כנראה לא ישפיע אצלנו (אנחנו משתמשים ב-symlinks ל-AGENTS.md/HEARTBEAT.md ישירות) — אבל חובה לוודא.",
|
"details": "פעולה (Phase 1):\n1. SELECT name, adapter_config->'promptTemplate' as pt, adapter_config->'bootstrapPromptTemplate' as bpt FROM agents WHERE adapter_type = 'claude_local';\n2. אם בשימוש אצל סוכן כלשהו — מיגרציה למבנה החדש\n3. ייעוד: לבדוק תיעוד managed instructions bundle ב-paperclip docs\n\nהערה: זה כנראה לא ישפיע אצלנו (אנחנו משתמשים ב-symlinks ל-AGENTS.md/HEARTBEAT.md ישירות) — אבל חובה לוודא.",
|
||||||
@@ -1308,7 +1308,7 @@
|
|||||||
"updatedAt": "2026-05-04T08:19:27.766Z"
|
"updatedAt": "2026-05-04T08:19:27.766Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 25,
|
"id": "25",
|
||||||
"title": "[Paperclip Gap 10] סוכנים מוכפלים בין 2 חברות — אין סנכרון",
|
"title": "[Paperclip Gap 10] סוכנים מוכפלים בין 2 חברות — אין סנכרון",
|
||||||
"description": "14 שורות = 7 סוכנים × 2 חברות (1xxx, 8xxx). כל שינוי בהגדרות הסוכן צריך להיעשות פעמיים. אין מנגנון סנכרון או הורשה.",
|
"description": "14 שורות = 7 סוכנים × 2 חברות (1xxx, 8xxx). כל שינוי בהגדרות הסוכן צריך להיעשות פעמיים. אין מנגנון סנכרון או הורשה.",
|
||||||
"details": "ממצא: SELECT name, COUNT(*) FROM agents GROUP BY name → 2 לכל אחד.\n\nסיכון: drift בין החברות. שינוי runtime_config ל-CEO של 1xxx יכול לפספס את CEO של 8xxx.\n\nפעולה (Phase 3):\n1. בדיקה: האם Paperclip תומך ב-shared agents או chainOfCommand? (לקרוא docs/companies/)\n2. אם כן — מיגרציה למבנה משותף\n3. אם לא — סקריפט סנכרון: scripts/sync_agents_across_companies.py שמעתיק כל שינוי מחברה לחברה\n\nשאלה לחיים: בעתיד אם יהיו עוד סוגי ערר (10xxx?) — להוסיף עוד חברה או להשאיר 2?",
|
"details": "ממצא: SELECT name, COUNT(*) FROM agents GROUP BY name → 2 לכל אחד.\n\nסיכון: drift בין החברות. שינוי runtime_config ל-CEO של 1xxx יכול לפספס את CEO של 8xxx.\n\nפעולה (Phase 3):\n1. בדיקה: האם Paperclip תומך ב-shared agents או chainOfCommand? (לקרוא docs/companies/)\n2. אם כן — מיגרציה למבנה משותף\n3. אם לא — סקריפט סנכרון: scripts/sync_agents_across_companies.py שמעתיק כל שינוי מחברה לחברה\n\nשאלה לחיים: בעתיד אם יהיו עוד סוגי ערר (10xxx?) — להוסיף עוד חברה או להשאיר 2?",
|
||||||
@@ -1322,7 +1322,7 @@
|
|||||||
"updatedAt": "2026-05-04T09:52:14.263Z"
|
"updatedAt": "2026-05-04T09:52:14.263Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 26,
|
"id": "26",
|
||||||
"title": "[Paperclip Gap 12] עדכון @paperclipai/plugin-sdk + capabilities חדשות",
|
"title": "[Paperclip Gap 12] עדכון @paperclipai/plugin-sdk + capabilities חדשות",
|
||||||
"description": "ה-plugin שלנו: @paperclipai/plugin-sdk@^2026.325.0, apiVersion: 1, minimumHostVersion: 2026.325.0. ה-host: 2026.428.0. ייתכן capabilities חדשות (issue.interactions.create, וכו').",
|
"description": "ה-plugin שלנו: @paperclipai/plugin-sdk@^2026.325.0, apiVersion: 1, minimumHostVersion: 2026.325.0. ה-host: 2026.428.0. ייתכן capabilities חדשות (issue.interactions.create, וכו').",
|
||||||
"details": "פעולה (Phase 4 — אחרי שדרוג Paperclip stable):\n1. cd /home/chaim/plugin-legal-ai && npm view @paperclipai/plugin-sdk version\n2. אם חדשה: npm install @paperclipai/plugin-sdk@latest\n3. קריאת adapter-plugin.md המעודכן ב-paperclip repo\n4. בדיקה אם apiVersion: 2 קיים\n5. הוספת capabilities חדשות אם רלוונטי (בעיקר issue.interactions.create אחרי gap #4)\n6. npm run build && reinstall plugin\n\nתלוי בgap #19 (interactions API) — אם אנחנו רוצים שהplugin יוכל ליצור interactions, חייב capability חדש.",
|
"details": "פעולה (Phase 4 — אחרי שדרוג Paperclip stable):\n1. cd /home/chaim/plugin-legal-ai && npm view @paperclipai/plugin-sdk version\n2. אם חדשה: npm install @paperclipai/plugin-sdk@latest\n3. קריאת adapter-plugin.md המעודכן ב-paperclip repo\n4. בדיקה אם apiVersion: 2 קיים\n5. הוספת capabilities חדשות אם רלוונטי (בעיקר issue.interactions.create אחרי gap #4)\n6. npm run build && reinstall plugin\n\nתלוי בgap #19 (interactions API) — אם אנחנו רוצים שהplugin יוכל ליצור interactions, חייב capability חדש.",
|
||||||
@@ -1337,7 +1337,7 @@
|
|||||||
"updatedAt": "2026-05-26T12:19:16.180163Z"
|
"updatedAt": "2026-05-26T12:19:16.180163Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 27,
|
"id": "27",
|
||||||
"title": "[Paperclip Phase 4] שדרוג Paperclip לגרסה stable הבאה (לא 2026.428.0)",
|
"title": "[Paperclip Phase 4] שדרוג Paperclip לגרסה stable הבאה (לא 2026.428.0)",
|
||||||
"description": "כרגע אנחנו על 2026.428.0 — הגרסה היציבה האחרונה. כשיופיע stable חדש (כנראה 2026.5xx.x), לבצע שדרוג מבוקר.",
|
"description": "כרגע אנחנו על 2026.428.0 — הגרסה היציבה האחרונה. כשיופיע stable חדש (כנראה 2026.5xx.x), לבצע שדרוג מבוקר.",
|
||||||
"details": "טריגר: npm view paperclipai dist-tags.latest מחזיר משהו ≠ 2026.428.0.\n\nפעולה:\n1. קריאת releases/v2026.5xx.x.md ב-GitHub\n2. בדיקת שינויים שעלולים להשפיע (CUSTOMIZATIONS.md סעיפים: hebrew, RTL, plugin driver, heartbeat)\n3. גיבוי: pg_dump של paperclip DB + cp -r ~/.npm/_npx/43414d9b790239bb /tmp/\n4. pm2 stop paperclip\n5. rm -rf ~/.npm/_npx/43414d9b790239bb\n6. npx paperclipai@latest run (יוריד גרסה חדשה)\n7. הרצה מחדש: ~/.paperclip/hebrew/apply-hebrew.sh && ~/.paperclip/issue-link-fix/apply-issue-link-fix.sh\n8. pm2 restart paperclip\n9. בדיקה ב-pc.nautilus.marcusgroup.org: עברית + plugin פעיל + סוכן מתעורר על comment\n\nתלוי בלי dependencies (יכול להיות מבוצע בכל עת אחרי שיש stable חדש).",
|
"details": "טריגר: npm view paperclipai dist-tags.latest מחזיר משהו ≠ 2026.428.0.\n\nפעולה:\n1. קריאת releases/v2026.5xx.x.md ב-GitHub\n2. בדיקת שינויים שעלולים להשפיע (CUSTOMIZATIONS.md סעיפים: hebrew, RTL, plugin driver, heartbeat)\n3. גיבוי: pg_dump של paperclip DB + cp -r ~/.npm/_npx/43414d9b790239bb /tmp/\n4. pm2 stop paperclip\n5. rm -rf ~/.npm/_npx/43414d9b790239bb\n6. npx paperclipai@latest run (יוריד גרסה חדשה)\n7. הרצה מחדש: ~/.paperclip/hebrew/apply-hebrew.sh && ~/.paperclip/issue-link-fix/apply-issue-link-fix.sh\n8. pm2 restart paperclip\n9. בדיקה ב-pc.nautilus.marcusgroup.org: עברית + plugin פעיל + סוכן מתעורר על comment\n\nתלוי בלי dependencies (יכול להיות מבוצע בכל עת אחרי שיש stable חדש).",
|
||||||
@@ -1349,7 +1349,7 @@
|
|||||||
"updatedAt": "2026-05-26T12:19:16.180163Z"
|
"updatedAt": "2026-05-26T12:19:16.180163Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 28,
|
"id": "28",
|
||||||
"title": "[Paperclip Auxiliary] להפעיל skill-sync ל-2 סוכנים שפיספסו",
|
"title": "[Paperclip Auxiliary] להפעיל skill-sync ל-2 סוכנים שפיספסו",
|
||||||
"description": "הגהת מסמכים ומנתח משפטי לא קיבלו אף פעם revision מסוג skill-sync (לעומת 5 האחרים שכן). לבצע sync.",
|
"description": "הגהת מסמכים ומנתח משפטי לא קיבלו אף פעם revision מסוג skill-sync (לעומת 5 האחרים שכן). לבצע sync.",
|
||||||
"details": "ממצא: בדיקה ב-agent_config_revisions:\n- עוזר משפטי: 3 skill-sync revisions (יש 7 skills)\n- חוקר תקדימים: 3 (יש 5)\n- מייצא טיוטה: 5 (יש 5)\n- בודק איכות: 1 (יש 5)\n- כותב החלטה: 1 (יש 5)\n- הגהת מסמכים: 0 (יש 0) ❌\n- מנתח משפטי: 0 (יש 0) ❌\n\nאופציות:\n1. UI: agent settings → 'sync skills'\n2. API: POST /api/agents/{id}/skills-sync (לאתר)\n3. CLI: paperclipai agent skill-sync (לבדוק אם קיים)\n4. SQL ידני (לא מומלץ — דורף revision tracking)\n\nSkills להעתקה (לפי בודק איכות):\n- paperclipai/paperclip/paperclip\n- paperclipai/paperclip/paperclip-create-agent\n- paperclipai/paperclip/paperclip-create-plugin\n- paperclipai/paperclip/para-memory-files\n- (אופציונלי) local/eba6210d5a/legal-decision",
|
"details": "ממצא: בדיקה ב-agent_config_revisions:\n- עוזר משפטי: 3 skill-sync revisions (יש 7 skills)\n- חוקר תקדימים: 3 (יש 5)\n- מייצא טיוטה: 5 (יש 5)\n- בודק איכות: 1 (יש 5)\n- כותב החלטה: 1 (יש 5)\n- הגהת מסמכים: 0 (יש 0) ❌\n- מנתח משפטי: 0 (יש 0) ❌\n\nאופציות:\n1. UI: agent settings → 'sync skills'\n2. API: POST /api/agents/{id}/skills-sync (לאתר)\n3. CLI: paperclipai agent skill-sync (לבדוק אם קיים)\n4. SQL ידני (לא מומלץ — דורף revision tracking)\n\nSkills להעתקה (לפי בודק איכות):\n- paperclipai/paperclip/paperclip\n- paperclipai/paperclip/paperclip-create-agent\n- paperclipai/paperclip/paperclip-create-plugin\n- paperclipai/paperclip/para-memory-files\n- (אופציונלי) local/eba6210d5a/legal-decision",
|
||||||
@@ -1361,7 +1361,7 @@
|
|||||||
"updatedAt": "2026-05-04T09:46:32.092Z"
|
"updatedAt": "2026-05-04T09:46:32.092Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 29,
|
"id": "29",
|
||||||
"title": "[legal-ai UI] מסך הגדרות סוכנים — הצגה + עריכה + שמירה",
|
"title": "[legal-ai UI] מסך הגדרות סוכנים — הצגה + עריכה + שמירה",
|
||||||
"description": "מסך אדמין ב-legal-ai UI שמציג את כל הגדרות הסוכנים (model, timeout, runtime_config, skills, budget) ומאפשר עריכה ושמירה. מונע SQL ישיר.",
|
"description": "מסך אדמין ב-legal-ai UI שמציג את כל הגדרות הסוכנים (model, timeout, runtime_config, skills, budget) ומאפשר עריכה ושמירה. מונע SQL ישיר.",
|
||||||
"details": "מטרה: ממשק אדמין מרכזי במקום שעריכה תהיה רק ב-UI של Paperclip + SQL ישיר + CUSTOMIZATIONS.md.\n\nשדות (לכל סוכן × 2 חברות):\n1. adapter_config: model, effort, timeoutSec, maxTurnsPerRun, extraArgs[], paperclipSkillSync.desiredSkills[]\n2. runtime_config.heartbeat: graceSec, cooldownSec, wakeOnDemand, maxConcurrentRuns, enabled, intervalSec\n3. budget_monthly_cents (לקראת gap #2)\n4. status / pause_reason (קריאה + כפתור pause/resume)\n\nאופציות מימוש:\nA. עמוד חדש ב-legal-ai/web-ui (Next.js 16) — קורא Paperclip DB דרך FastAPI endpoint חדש (/api/admin/paperclip-agents)\nB. קריאה ל-Paperclip API (/api/companies/{id}/agents) — REST טהור, פחות שדות זמינים\nC. iframe ל-Paperclip UI — שטחי\n\nהמלצה: A. שולט מלא + ולידציה משפטית (timeoutSec >= 1800 כי OCR).\n\nתלוי ב: gap #25 (סוכנים מוכפלים) — אם נעבור לshared, המסך יתאים.\n\nשאלות פתוחות לחיים:\n- auth: מי יכול לגשת? (כיום אין auth ב-legal-ai)\n- bulk edit ל-2 חברות יחד או נפרד?\n- חשיפת skill marketplace (להוסיף/להוריד skills) או רק קריאה?",
|
"details": "מטרה: ממשק אדמין מרכזי במקום שעריכה תהיה רק ב-UI של Paperclip + SQL ישיר + CUSTOMIZATIONS.md.\n\nשדות (לכל סוכן × 2 חברות):\n1. adapter_config: model, effort, timeoutSec, maxTurnsPerRun, extraArgs[], paperclipSkillSync.desiredSkills[]\n2. runtime_config.heartbeat: graceSec, cooldownSec, wakeOnDemand, maxConcurrentRuns, enabled, intervalSec\n3. budget_monthly_cents (לקראת gap #2)\n4. status / pause_reason (קריאה + כפתור pause/resume)\n\nאופציות מימוש:\nA. עמוד חדש ב-legal-ai/web-ui (Next.js 16) — קורא Paperclip DB דרך FastAPI endpoint חדש (/api/admin/paperclip-agents)\nB. קריאה ל-Paperclip API (/api/companies/{id}/agents) — REST טהור, פחות שדות זמינים\nC. iframe ל-Paperclip UI — שטחי\n\nהמלצה: A. שולט מלא + ולידציה משפטית (timeoutSec >= 1800 כי OCR).\n\nתלוי ב: gap #25 (סוכנים מוכפלים) — אם נעבור לshared, המסך יתאים.\n\nשאלות פתוחות לחיים:\n- auth: מי יכול לגשת? (כיום אין auth ב-legal-ai)\n- bulk edit ל-2 חברות יחד או נפרד?\n- חשיפת skill marketplace (להוסיף/להוריד skills) או רק קריאה?",
|
||||||
@@ -1377,7 +1377,7 @@
|
|||||||
"updatedAt": "2026-05-04T17:29:25.686Z"
|
"updatedAt": "2026-05-04T17:29:25.686Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 30,
|
"id": "30",
|
||||||
"title": "תיקון 3 baגים בקטלוג (practice_area + source_kind + upload route)",
|
"title": "תיקון 3 baגים בקטלוג (practice_area + source_kind + upload route)",
|
||||||
"description": "CRITICAL: 3 sub-bugs. (א) יצירת תיקים מתייגת practice_area='appeals_committee' במקום rishuy_uvniya/betterment_levy/compensation_197 לפי קידומת מספר התיק (1xxx/8xxx/9xxx) — audit + migration לכל התיקים הקיימים + תיקון נתיב היצירה. (ב) כל החלטה של ועדת ערר שהועלתה ל-case_law מסומנת כ-source_kind='external_upload' במקום 'internal_committee' — audit ל-case_law עם case_number שמתחיל ב'ערר' → reclassify + מילוי chair_name + district רטרואקטיבית. (ג) POST /api/precedent-library/upload לא מבחין — תיקון: ניתוב לפי תחילית הציטוט (ערר/ועדות ערר → internal_committee, אחרת → external_upload).",
|
"description": "CRITICAL: 3 sub-bugs. (א) יצירת תיקים מתייגת practice_area='appeals_committee' במקום rishuy_uvniya/betterment_levy/compensation_197 לפי קידומת מספר התיק (1xxx/8xxx/9xxx) — audit + migration לכל התיקים הקיימים + תיקון נתיב היצירה. (ב) כל החלטה של ועדת ערר שהועלתה ל-case_law מסומנת כ-source_kind='external_upload' במקום 'internal_committee' — audit ל-case_law עם case_number שמתחיל ב'ערר' → reclassify + מילוי chair_name + district רטרואקטיבית. (ג) POST /api/precedent-library/upload לא מבחין — תיקון: ניתוב לפי תחילית הציטוט (ערר/ועדות ערר → internal_committee, אחרת → external_upload).",
|
||||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #1. Pre-requirement: השתמש במחיקה+rerun של halachot אחרי שינוי source_kind. השתמש בpattern של internal_decisions.py (dry_run+log_action).",
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #1. Pre-requirement: השתמש במחיקה+rerun של halachot אחרי שינוי source_kind. השתמש בpattern של internal_decisions.py (dry_run+log_action).",
|
||||||
@@ -1447,7 +1447,7 @@
|
|||||||
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 31,
|
"id": "31",
|
||||||
"title": "מיצוי chair_name + district בהעלאת ועדת ערר",
|
"title": "מיצוי chair_name + district בהעלאת ועדת ערר",
|
||||||
"description": "תוספת לטופס + חילוץ אוטומטי מהציטוט/text הפסיקה. רטרואקטיבי לכל הרשומות הקיימות עם source_kind='internal_committee' שהשדות בהן ריקים.",
|
"description": "תוספת לטופס + חילוץ אוטומטי מהציטוט/text הפסיקה. רטרואקטיבי לכל הרשומות הקיימות עם source_kind='internal_committee' שהשדות בהן ריקים.",
|
||||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #2. תלוי במשימה #30 (sub-bug ב).",
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #2. תלוי במשימה #30 (sub-bug ב).",
|
||||||
@@ -1483,7 +1483,7 @@
|
|||||||
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 32,
|
"id": "32",
|
||||||
"title": "UI — דף עריכת פסיקה ייפתח רחב-במרכז (לא צר-בצד)",
|
"title": "UI — דף עריכת פסיקה ייפתח רחב-במרכז (לא צר-בצד)",
|
||||||
"description": "חוסר נוחות בעריכה. שינוי ה-Dialog/Sheet ל-Modal רחב מרכזי. רלוונטי גם להוספת שדות chair_name + district מהמשימה #31.",
|
"description": "חוסר נוחות בעריכה. שינוי ה-Dialog/Sheet ל-Modal רחב מרכזי. רלוונטי גם להוספת שדות chair_name + district מהמשימה #31.",
|
||||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #3.",
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #3.",
|
||||||
@@ -1495,7 +1495,7 @@
|
|||||||
"updatedAt": "2026-05-26T10:38:07.071897Z"
|
"updatedAt": "2026-05-26T10:38:07.071897Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 33,
|
"id": "33",
|
||||||
"title": "UI — הסתרת עמודת 'שם' (case_name) בדף רשימת פסיקה",
|
"title": "UI — הסתרת עמודת 'שם' (case_name) בדף רשימת פסיקה",
|
||||||
"description": "רוב הערכים זהים למספר התיק. להסתיר את העמודה ב-UI, לשמור עמודה ב-DB לשימוש עתידי.",
|
"description": "רוב הערכים זהים למספר התיק. להסתיר את העמודה ב-UI, לשמור עמודה ב-DB לשימוש עתידי.",
|
||||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #4.",
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #4.",
|
||||||
@@ -1507,7 +1507,7 @@
|
|||||||
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 34,
|
"id": "34",
|
||||||
"title": "חילוץ ציטוטי-פנים מהחלטות דפנה (internal_committee + ירושלים)",
|
"title": "חילוץ ציטוטי-פנים מהחלטות דפנה (internal_committee + ירושלים)",
|
||||||
"description": "פאטרן: 'ונפנה להחלטות של ועדת ערר זו...', 'כפי שקבעתי בערר X', 'בדומה לעמדתי בהחלטה Y'. חילוץ אוטומטי + שמירה ב-precedent_internal_citations table שיאפשר ל-search_internal_decisions להחזיר גם את הפסיקה המוזכרת.",
|
"description": "פאטרן: 'ונפנה להחלטות של ועדת ערר זו...', 'כפי שקבעתי בערר X', 'בדומה לעמדתי בהחלטה Y'. חילוץ אוטומטי + שמירה ב-precedent_internal_citations table שיאפשר ל-search_internal_decisions להחזיר גם את הפסיקה המוזכרת.",
|
||||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #5. תלוי במשימה #30 (sub-bug ב) ובמשימה #31.",
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #5. תלוי במשימה #30 (sub-bug ב) ובמשימה #31.",
|
||||||
@@ -1522,7 +1522,7 @@
|
|||||||
"updatedAt": "2026-05-26T10:38:07.071897Z"
|
"updatedAt": "2026-05-26T10:38:07.071897Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 35,
|
"id": "35",
|
||||||
"title": "דף/דוח 'פסיקה חסרה בקורפוס' — פיצ'ר מלא",
|
"title": "דף/דוח 'פסיקה חסרה בקורפוס' — פיצ'ר מלא",
|
||||||
"description": "טבלת DB missing_precedents (id, citation, case_name, cited_in_case_id, cited_in_document_id, cited_by_party, legal_topic, legal_issue, claim_quote, status, linked_case_law_id, closed_at, created_at), API endpoints (POST/GET/upload/PATCH), MCP tools (missing_precedent_create/list/close), דף Next.js /missing-precedents, הוק אוטומטי במחקר ע\"י legal-researcher.",
|
"description": "טבלת DB missing_precedents (id, citation, case_name, cited_in_case_id, cited_in_document_id, cited_by_party, legal_topic, legal_issue, claim_quote, status, linked_case_law_id, closed_at, created_at), API endpoints (POST/GET/upload/PATCH), MCP tools (missing_precedent_create/list/close), דף Next.js /missing-precedents, הוק אוטומטי במחקר ע\"י legal-researcher.",
|
||||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #6.",
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #6.",
|
||||||
@@ -1571,7 +1571,7 @@
|
|||||||
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 36,
|
"id": "36",
|
||||||
"title": "כינוס פרופוזיציות לטיעונים משפטיים אמיתיים (de-dup/aggregation)",
|
"title": "כינוס פרופוזיציות לטיעונים משפטיים אמיתיים (de-dup/aggregation)",
|
||||||
"description": "extract_claims מחזיר ~60 פרופוזיציות לתיק, צריך לאגד ל-~10 טיעונים משפטיים אמיתיים. טבלה חדשה legal_arguments + טבלת קישור legal_argument_propositions (M:M ל-case_claims). LLM aggregation job (Hermes/DeepSeek). API + MCP + UI display שמציג 'X טיעונים משפטיים' במקום 'Y טענות'.",
|
"description": "extract_claims מחזיר ~60 פרופוזיציות לתיק, צריך לאגד ל-~10 טיעונים משפטיים אמיתיים. טבלה חדשה legal_arguments + טבלת קישור legal_argument_propositions (M:M ל-case_claims). LLM aggregation job (Hermes/DeepSeek). API + MCP + UI display שמציג 'X טיעונים משפטיים' במקום 'Y טענות'.",
|
||||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #7.",
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #7.",
|
||||||
@@ -1614,7 +1614,7 @@
|
|||||||
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 37,
|
"id": "37",
|
||||||
"title": "הפרדת תתי-סוגי בל\"מ לפי practice_area",
|
"title": "הפרדת תתי-סוגי בל\"מ לפי practice_area",
|
||||||
"description": "3 ערכי appeal_subtype חדשים: extension_request_building_permit (1xxx, ס'152 - 30 ימים), extension_request_betterment_levy (8xxx, ס'14 לתוספת ג' - 45 ימים), extension_request_compensation (9xxx, ס'198(ד) - 30 ימים). 3 templates מתודולוגיים נפרדים. אוטו-זיהוי מהsubject. UI badge + filter.",
|
"description": "3 ערכי appeal_subtype חדשים: extension_request_building_permit (1xxx, ס'152 - 30 ימים), extension_request_betterment_levy (8xxx, ס'14 לתוספת ג' - 45 ימים), extension_request_compensation (9xxx, ס'198(ד) - 30 ימים). 3 templates מתודולוגיים נפרדים. אוטו-זיהוי מהsubject. UI badge + filter.",
|
||||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #8. Pre-requirement: עדכון mcp-server/src/legal_mcp/services/practice_area.py APPEALS_COMMITTEE_SUBTYPES + עדכון web/paperclip_client.py mapping appeal_subtype → company.",
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #8. Pre-requirement: עדכון mcp-server/src/legal_mcp/services/practice_area.py APPEALS_COMMITTEE_SUBTYPES + עדכון web/paperclip_client.py mapping appeal_subtype → company.",
|
||||||
@@ -1657,7 +1657,7 @@
|
|||||||
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
"updatedAt": "2026-05-26T08:35:22.762800Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 38,
|
"id": "38",
|
||||||
"title": "שדרוג סוכני Paperclip להכרת השינויים מ-#30-#37",
|
"title": "שדרוג סוכני Paperclip להכרת השינויים מ-#30-#37",
|
||||||
"description": "עדכון 7 הגדרות סוכן (CEO/analyst/researcher/writer/QA/proofreader/exporter) + HEARTBEAT.md לזיהוי המבנים החדשים. בלי זה כל הפיצ'רים נשארים זמינים אבל הסוכנים לא יודעים להשתמש בהם. כולל הוספת research_complete כ-valid case_status.",
|
"description": "עדכון 7 הגדרות סוכן (CEO/analyst/researcher/writer/QA/proofreader/exporter) + HEARTBEAT.md לזיהוי המבנים החדשים. בלי זה כל הפיצ'רים נשארים זמינים אבל הסוכנים לא יודעים להשתמש בהם. כולל הוספת research_complete כ-valid case_status.",
|
||||||
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #9. תלוי במשימות #30-#37.",
|
"details": "ראה תוכנית /home/chaim/.claude/plans/3-glimmering-oasis.md חלק א משימה #9. תלוי במשימות #30-#37.",
|
||||||
@@ -1740,7 +1740,7 @@
|
|||||||
"updatedAt": "2026-05-26T07:41:47.880478Z"
|
"updatedAt": "2026-05-26T07:41:47.880478Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 39,
|
"id": "39",
|
||||||
"title": "[ROOT CAUSE] MCP tool חדש: internal_decision_upload",
|
"title": "[ROOT CAUSE] MCP tool חדש: internal_decision_upload",
|
||||||
"description": "הוספת @mcp.tool() עם chair_name+district חובה ו-source_kind='internal_committee' אוטומטי. סוגר את ה-root cause של Bug (ב) ב-#30. בלעדיו 44 רשומות חדשות יחזרו כ-external_upload תוך חודש.",
|
"description": "הוספת @mcp.tool() עם chair_name+district חובה ו-source_kind='internal_committee' אוטומטי. סוגר את ה-root cause של Bug (ב) ב-#30. בלעדיו 44 רשומות חדשות יחזרו כ-external_upload תוך חודש.",
|
||||||
"details": "מיקום: mcp-server/src/legal_mcp/tools/internal_decisions.py (אם לא קיים — ליצור). רישום ב-server.py סביב שורה 169 (ליד precedent_library_upload). הקריאה מנותבת ל-int_decisions_service.ingest_internal_decision (קיים ב-internal_decisions.py).",
|
"details": "מיקום: mcp-server/src/legal_mcp/tools/internal_decisions.py (אם לא קיים — ליצור). רישום ב-server.py סביב שורה 169 (ליד precedent_library_upload). הקריאה מנותבת ל-int_decisions_service.ingest_internal_decision (קיים ב-internal_decisions.py).",
|
||||||
@@ -1754,7 +1754,7 @@
|
|||||||
"updatedAt": "2026-05-26T07:41:37.260868Z"
|
"updatedAt": "2026-05-26T07:41:37.260868Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 40,
|
"id": "40",
|
||||||
"title": "[שלב B - ROI מיידי] הפעלת VOYAGE_RERANK_ENABLED=true ב-Coolify",
|
"title": "[שלב B - ROI מיידי] הפעלת VOYAGE_RERANK_ENABLED=true ב-Coolify",
|
||||||
"description": "Cross-encoder rerank-2 ממומש ב-mcp-server/src/legal_mcp/services/rerank.py אבל כבוי בייצור (default=false). POC הוכיח +4.5% mean@3 ו-+11.6% practical queries (latency +702ms acceptable לזרימה האסינכרונית). 5 דקות עבודה — env change ב-Coolify.",
|
"description": "Cross-encoder rerank-2 ממומש ב-mcp-server/src/legal_mcp/services/rerank.py אבל כבוי בייצור (default=false). POC הוכיח +4.5% mean@3 ו-+11.6% practical queries (latency +702ms acceptable לזרימה האסינכרונית). 5 דקות עבודה — env change ב-Coolify.",
|
||||||
"details": "mcp__coolify__env_vars set VOYAGE_RERANK_ENABLED=true. ראה web/mcp_env_catalog.py:71-72 לdescription. אופציה: rampup רק על search_precedent_library (לא על find_similar_cases — latency-sensitive).",
|
"details": "mcp__coolify__env_vars set VOYAGE_RERANK_ENABLED=true. ראה web/mcp_env_catalog.py:71-72 לdescription. אופציה: rampup רק על search_precedent_library (לא על find_similar_cases — latency-sensitive).",
|
||||||
@@ -1766,7 +1766,7 @@
|
|||||||
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 41,
|
"id": "41",
|
||||||
"title": "[שלב B] BM25/tsvector hybrid retrieval על precedent_chunks + halachot",
|
"title": "[שלב B] BM25/tsvector hybrid retrieval על precedent_chunks + halachot",
|
||||||
"description": "כיום כל החיפוש הוא 100% dense (cosine). ציטוטים מספריים ('עע\"מ 1461/20') נכשלים כי semantic לא מצליח בהם. הוספת tsvector GIN + RRF merge dense+lexical = +15-25% recall על ציטוטים — קריטי לאימות פסיקה ב-3-glimmering-oasis שלב 3.",
|
"description": "כיום כל החיפוש הוא 100% dense (cosine). ציטוטים מספריים ('עע\"מ 1461/20') נכשלים כי semantic לא מצליח בהם. הוספת tsvector GIN + RRF merge dense+lexical = +15-25% recall על ציטוטים — קריטי לאימות פסיקה ב-3-glimmering-oasis שלב 3.",
|
||||||
"details": "ALTER TABLE precedent_chunks ADD COLUMN content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED; CREATE INDEX ... USING gin (content_tsv). באותו אופן על halachot.rule_statement. ב-db.py:2357 (search_precedent_library_semantic) — להוסיף שאילתה מקבילה של websearch_to_tsquery → RRF merge עם cosine. אזהרה: postgres אינו תומך ב-'hebrew' config — simple config יעבוד אבל בלי stemming.",
|
"details": "ALTER TABLE precedent_chunks ADD COLUMN content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED; CREATE INDEX ... USING gin (content_tsv). באותו אופן על halachot.rule_statement. ב-db.py:2357 (search_precedent_library_semantic) — להוסיף שאילתה מקבילה של websearch_to_tsquery → RRF merge עם cosine. אזהרה: postgres אינו תומך ב-'hebrew' config — simple config יעבוד אבל בלי stemming.",
|
||||||
@@ -1780,7 +1780,7 @@
|
|||||||
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 42,
|
"id": "42",
|
||||||
"title": "[שלב B] Query expansion via Claude Haiku — 2-3 variants per query",
|
"title": "[שלב B] Query expansion via Claude Haiku — 2-3 variants per query",
|
||||||
"description": "שאילתות עם abbreviations משפטיות ('בל\"מ'/'בקשה להארכת מועד') חוטפות recall. LLM expansion: שאילתה → 2-3 variants → union retrieval. +10-15% recall.",
|
"description": "שאילתות עם abbreviations משפטיות ('בל\"מ'/'בקשה להארכת מועד') חוטפות recall. LLM expansion: שאילתה → 2-3 variants → union retrieval. +10-15% recall.",
|
||||||
"details": "בוטל 2026-06-03 — obviated. BM25_HYBRID_ENABLED=true כבר פעיל ותופס קיצורים לקסיקלית (בל\"מ כ-token). ב-gold-set (86 שאילתות) 0 שאילתות-קיצורים, ו-recall כללי ≈0.99 — אין gap נמדד. Query-expansion דרך LLM מוסיף latency+עלות לכל שאילתה ללא צורך מוכח (YAGNI). re-open trigger: אם eval ייעודי על שאילתות-קיצורים יראה recall<0.9.",
|
"details": "בוטל 2026-06-03 — obviated. BM25_HYBRID_ENABLED=true כבר פעיל ותופס קיצורים לקסיקלית (בל\"מ כ-token). ב-gold-set (86 שאילתות) 0 שאילתות-קיצורים, ו-recall כללי ≈0.99 — אין gap נמדד. Query-expansion דרך LLM מוסיף latency+עלות לכל שאילתה ללא צורך מוכח (YAGNI). re-open trigger: אם eval ייעודי על שאילתות-קיצורים יראה recall<0.9.",
|
||||||
@@ -1794,7 +1794,7 @@
|
|||||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 43,
|
"id": "43",
|
||||||
"title": "[שלב B] MMR / diversity penalty — limit 2 chunks per case_law_id",
|
"title": "[שלב B] MMR / diversity penalty — limit 2 chunks per case_law_id",
|
||||||
"description": "תוצאות חיפוש דומות מאוד זו לזו (אותה פסיקה, chunks סמוכים) — פסיקות חוזרות תופסות slots → diversity@10 נמוך. הוספת cap per case_law_id (2-3 max) או MMR אמיתי.",
|
"description": "תוצאות חיפוש דומות מאוד זו לזו (אותה פסיקה, chunks סמוכים) — פסיקות חוזרות תופסות slots → diversity@10 נמוך. הוספת cap per case_law_id (2-3 max) או MMR אמיתי.",
|
||||||
"details": "פתרון קל: SQL DISTINCT ON (case_law_id) + 2 בpost-processing. פתרון איכותי: MMR — לכל candidate, score = λ*relevance - (1-λ)*max_similarity_to_selected. λ=0.7 דיפולט.",
|
"details": "פתרון קל: SQL DISTINCT ON (case_law_id) + 2 בpost-processing. פתרון איכותי: MMR — לכל candidate, score = λ*relevance - (1-λ)*max_similarity_to_selected. λ=0.7 דיפולט.",
|
||||||
@@ -1808,7 +1808,7 @@
|
|||||||
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 44,
|
"id": "44",
|
||||||
"title": "[שלב B] HNSW migration (or lists=68 IVFFlat) + REINDEX",
|
"title": "[שלב B] HNSW migration (or lists=68 IVFFlat) + REINDEX",
|
||||||
"description": "IVFFlat lists=50 עם 4,595 vectors — sub-optimal. sqrt(4595)≈68. HNSW עדיף ל-recall (אבל יותר זיכרון). שיפור +3-5% recall@10.",
|
"description": "IVFFlat lists=50 עם 4,595 vectors — sub-optimal. sqrt(4595)≈68. HNSW עדיף ל-recall (אבל יותר זיכרון). שיפור +3-5% recall@10.",
|
||||||
"details": "אופציה 1: REINDEX עם lists=68 (פשוט, idempotent). אופציה 2: DROP+CREATE עם HNSW (m=16, ef_construction=64) — דורש pgvector ≥0.5 ובדיקת זמן build. בדוק SELECT extversion FROM pg_extension WHERE extname='vector'.",
|
"details": "אופציה 1: REINDEX עם lists=68 (פשוט, idempotent). אופציה 2: DROP+CREATE עם HNSW (m=16, ef_construction=64) — דורש pgvector ≥0.5 ובדיקת זמן build. בדוק SELECT extversion FROM pg_extension WHERE extname='vector'.",
|
||||||
@@ -1820,7 +1820,7 @@
|
|||||||
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 45,
|
"id": "45",
|
||||||
"title": "[שלב B] Halacha auto-approve sweep — בדיקת 219 pending + הורדת סף ל-0.78",
|
"title": "[שלב B] Halacha auto-approve sweep — בדיקת 219 pending + הורדת סף ל-0.78",
|
||||||
"description": "219 halachot pending review (17%) חסומות מ-search. אם dafna לא מסקר ידנית — הם מתבזבזים. dashboard batch + הורדת auto-approve threshold.",
|
"description": "219 halachot pending review (17%) חסומות מ-search. אם dafna לא מסקר ידנית — הם מתבזבזים. dashboard batch + הורדת auto-approve threshold.",
|
||||||
"details": "1. בדוק 20 דגימות אקראיות של pending — אם רובן ראויות לאישור, הורד HALACHA_AUTO_APPROVE_THRESHOLD מ-0.80 ל-0.78. 2. הוסף UI batch approval ב-/halachot עם filter pending+confidence>0.75. 3. one-shot SQL לאישור 200 halachot שעמדו בקריטריונים החדשים.",
|
"details": "1. בדוק 20 דגימות אקראיות של pending — אם רובן ראויות לאישור, הורד HALACHA_AUTO_APPROVE_THRESHOLD מ-0.80 ל-0.78. 2. הוסף UI batch approval ב-/halachot עם filter pending+confidence>0.75. 3. one-shot SQL לאישור 200 halachot שעמדו בקריטריונים החדשים.",
|
||||||
@@ -1832,7 +1832,7 @@
|
|||||||
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 46,
|
"id": "46",
|
||||||
"title": "[שלב B] Dynamic halacha boost — לפי query-rule similarity",
|
"title": "[שלב B] Dynamic halacha boost — לפי query-rule similarity",
|
||||||
"description": "כיום halacha boost = +0.05 קבוע. דינמי לפי query similarity ירוץ דייקנות (5% precision על שאילתות ספציפיות).",
|
"description": "כיום halacha boost = +0.05 קבוע. דינמי לפי query similarity ירוץ דייקנות (5% precision על שאילתות ספציפיות).",
|
||||||
"details": "ב-db.py:2479 — score = float(d['score']) + 0.05. החלף ב-boost = 0.10 * d['score'] (proportional). או — אם rerank ON, השתמש בrerank score כbaseline (אין צורך ב-boost כלל).",
|
"details": "ב-db.py:2479 — score = float(d['score']) + 0.05. החלף ב-boost = 0.10 * d['score'] (proportional). או — אם rerank ON, השתמש בrerank score כbaseline (אין צורך ב-boost כלל).",
|
||||||
@@ -1846,7 +1846,7 @@
|
|||||||
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
"updatedAt": "2026-05-26T08:08:27.953285Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 47,
|
"id": "47",
|
||||||
"title": "[שלב C - prevention] Audit script periodic: detect new external_upload עם case_number של ערר",
|
"title": "[שלב C - prevention] Audit script periodic: detect new external_upload עם case_number של ערר",
|
||||||
"description": "Drift detection: שגיאה דומה ל-Bug (ב) יכולה לחזור בעתיד. periodic check (יומי?) + alert ל-Slack/comment.",
|
"description": "Drift detection: שגיאה דומה ל-Bug (ב) יכולה לחזור בעתיד. periodic check (יומי?) + alert ל-Slack/comment.",
|
||||||
"details": "scripts/audit_corpus_consistency.py — בודק: 1. case_law WHERE source_kind='external_upload' AND case_number ~ '^ערר|^ARAR'. 2. case_law WHERE source_kind='internal_committee' AND chair_name IS NULL. הרצה דרך cron או scheduled task ב-Paperclip.",
|
"details": "scripts/audit_corpus_consistency.py — בודק: 1. case_law WHERE source_kind='external_upload' AND case_number ~ '^ערר|^ARAR'. 2. case_law WHERE source_kind='internal_committee' AND chair_name IS NULL. הרצה דרך cron או scheduled task ב-Paperclip.",
|
||||||
@@ -1861,7 +1861,7 @@
|
|||||||
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 48,
|
"id": "48",
|
||||||
"title": "[שלב C] Parent-doc retrieval (child=300, parent=1500 tokens)",
|
"title": "[שלב C] Parent-doc retrieval (child=300, parent=1500 tokens)",
|
||||||
"description": "chunk_size=600 חותך חלק מהלכות ארוכות. parent-doc: חיפוש על child קטן (300 tokens), החזרת parent גדול (1500 tokens) ל-LLM context.",
|
"description": "chunk_size=600 חותך חלק מהלכות ארוכות. parent-doc: חיפוש על child קטן (300 tokens), החזרת parent גדול (1500 tokens) ל-LLM context.",
|
||||||
"details": "מיגרציה DB: precedent_chunks.parent_chunk_id (FK self). chunking pipeline משתנה ל-2 רמות. retrieval: SELECT distinct parent_chunk WHERE child_chunk matches.",
|
"details": "מיגרציה DB: precedent_chunks.parent_chunk_id (FK self). chunking pipeline משתנה ל-2 רמות. retrieval: SELECT distinct parent_chunk WHERE child_chunk matches.",
|
||||||
@@ -1875,7 +1875,7 @@
|
|||||||
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 49,
|
"id": "49",
|
||||||
"title": "[שלב C] Multimodal backfill ל-77 רשומות שנותרו",
|
"title": "[שלב C] Multimodal backfill ל-77 רשומות שנותרו",
|
||||||
"description": "כיום 40/117 precedent_image_embeddings (34%). 77 רשומות נותרו ללא image embeddings. ערך נמוך כשהמסמכים digital-native, אבל קריטי לscanned PDFs.",
|
"description": "כיום 40/117 precedent_image_embeddings (34%). 77 רשומות נותרו ללא image embeddings. ערך נמוך כשהמסמכים digital-native, אבל קריטי לscanned PDFs.",
|
||||||
"details": "scripts/multimodal_backfill.py כבר קיים. להריץ עם batch size 10 כדי לא לדפוק את Voyage rate limits. אומדן: 77×~10K tokens = ~770K tokens ($10-15).",
|
"details": "scripts/multimodal_backfill.py כבר קיים. להריץ עם batch size 10 כדי לא לדפוק את Voyage rate limits. אומדן: 77×~10K tokens = ~770K tokens ($10-15).",
|
||||||
@@ -1887,7 +1887,7 @@
|
|||||||
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 50,
|
"id": "50",
|
||||||
"title": "[שלב C] Closed-loop retrieval feedback + ndcg dashboard",
|
"title": "[שלב C] Closed-loop retrieval feedback + ndcg dashboard",
|
||||||
"description": "אין tracking של 'what was retrieved → what writer cited'. בלי זה — אי אפשר לעדכן את ה-RAG בצורה מדודה לאורך זמן.",
|
"description": "אין tracking של 'what was retrieved → what writer cited'. בלי זה — אי אפשר לעדכן את ה-RAG בצורה מדודה לאורך זמן.",
|
||||||
"details": "טבלה חדשה retrieval_feedback (query, candidates_retrieved JSONB, cited_in_final_decision UUID[], created_at). hooks ב-writer לדווח. dashboard חודשי עם ndcg@10.",
|
"details": "טבלה חדשה retrieval_feedback (query, candidates_retrieved JSONB, cited_in_final_decision UUID[], created_at). hooks ב-writer לדווח. dashboard חודשי עם ndcg@10.",
|
||||||
@@ -1899,7 +1899,7 @@
|
|||||||
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 51,
|
"id": "51",
|
||||||
"title": "[שלב C] Halacha quality monitoring — confidence drift, alert",
|
"title": "[שלב C] Halacha quality monitoring — confidence drift, alert",
|
||||||
"description": "אם prompt או model משתנה — confidence distribution יכול לזוז. בלי monitoring — דרדור איכות עובר תחת הראדר.",
|
"description": "אם prompt או model משתנה — confidence distribution יכול לזוז. בלי monitoring — דרדור איכות עובר תחת הראדר.",
|
||||||
"details": "scheduled job: weekly mean confidence per practice_area. אם זז ביותר מ-0.05 — alert. dashboard ב-/halachot עם histogram.",
|
"details": "scheduled job: weekly mean confidence per practice_area. אם זז ביותר מ-0.05 — alert. dashboard ב-/halachot עם histogram.",
|
||||||
@@ -1911,7 +1911,7 @@
|
|||||||
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
"updatedAt": "2026-05-26T11:27:09.039154Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 52,
|
"id": "52",
|
||||||
"title": "[Retrieval RC-A] הוספת case_name + case_number ל-tsvector הלקסיקלי",
|
"title": "[Retrieval RC-A] הוספת case_name + case_number ל-tsvector הלקסיקלי",
|
||||||
"description": "השורש האמיתי לכך שסוכן לא מאתר החלטה לפי שם (אגסי). ה-tsvector הלקסיקלי (SCHEMA_V12_SQL ב-db.py) בנוי רק מ-precedent_chunks.content ומ-halachot rule/quote/reasoning — לא משם התיק/הצד או ממספר התיק. לכן שאילתת-שם מחזירה את מי שמצטט את ההחלטה, לא את ההחלטה עצמה. לשלב את case_law.case_name + case_number באינדקס הלקסיקלי (tsvector ייעודי על case_law או setweight) כך שחיפוש לפי שם יפגע ברשומה עצמה.",
|
"description": "השורש האמיתי לכך שסוכן לא מאתר החלטה לפי שם (אגסי). ה-tsvector הלקסיקלי (SCHEMA_V12_SQL ב-db.py) בנוי רק מ-precedent_chunks.content ומ-halachot rule/quote/reasoning — לא משם התיק/הצד או ממספר התיק. לכן שאילתת-שם מחזירה את מי שמצטט את ההחלטה, לא את ההחלטה עצמה. לשלב את case_law.case_name + case_number באינדקס הלקסיקלי (tsvector ייעודי על case_law או setweight) כך שחיפוש לפי שם יפגע ברשומה עצמה.",
|
||||||
"status": "done",
|
"status": "done",
|
||||||
@@ -1923,7 +1923,7 @@
|
|||||||
"updatedAt": "2026-05-30T11:05:36.307Z"
|
"updatedAt": "2026-05-30T11:05:36.307Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 53,
|
"id": "53",
|
||||||
"title": "[Retrieval RC-B] חיפוש/רשימה מאוחדים — לא לחתוך internal_committee",
|
"title": "[Retrieval RC-B] חיפוש/רשימה מאוחדים — לא לחתוך internal_committee",
|
||||||
"description": "החלטות ערר/בל\"מ שמועלות נשמרות source_kind='internal_committee'. precedent_library_list ברירת מחדל external_upload ומסתיר אותן; כלי ה-MCP precedent_library_list אפילו לא חושף פרמטר source_kind, כך שסוכן לעולם לא יכול לדפדף בהן. לחשוף source_kind/all_committees בכלי ה-MCP ובמידת הצורך לאחד את שכבת ה-list/search.",
|
"description": "החלטות ערר/בל\"מ שמועלות נשמרות source_kind='internal_committee'. precedent_library_list ברירת מחדל external_upload ומסתיר אותן; כלי ה-MCP precedent_library_list אפילו לא חושף פרמטר source_kind, כך שסוכן לעולם לא יכול לדפדף בהן. לחשוף source_kind/all_committees בכלי ה-MCP ובמידת הצורך לאחד את שכבת ה-list/search.",
|
||||||
"status": "done",
|
"status": "done",
|
||||||
@@ -1937,7 +1937,7 @@
|
|||||||
"updatedAt": "2026-05-30T11:09:44.511Z"
|
"updatedAt": "2026-05-30T11:09:44.511Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 54,
|
"id": "54",
|
||||||
"title": "[Retrieval RC-3] הנחיית סוכנים — איתור לפי שם + שני קורפוסים",
|
"title": "[Retrieval RC-3] הנחיית סוכנים — איתור לפי שם + שני קורפוסים",
|
||||||
"description": "לעדכן הנחיות legal-analyst/researcher/writer: לאיתור החלטה ספציפית לפי שם להוסיף מונחי תוכן או מספר תיק, ולחפש בשני הקורפוסים (search_internal_decisions + search_precedent_library) לפני שמסיקים 'לא קיים בקורפוס'. כולל יצירת missing_precedent רק אחרי חיפוש כפול.",
|
"description": "לעדכן הנחיות legal-analyst/researcher/writer: לאיתור החלטה ספציפית לפי שם להוסיף מונחי תוכן או מספר תיק, ולחפש בשני הקורפוסים (search_internal_decisions + search_precedent_library) לפני שמסיקים 'לא קיים בקורפוס'. כולל יצירת missing_precedent רק אחרי חיפוש כפול.",
|
||||||
"status": "done",
|
"status": "done",
|
||||||
@@ -1951,7 +1951,7 @@
|
|||||||
"updatedAt": "2026-05-30T11:12:44.727Z"
|
"updatedAt": "2026-05-30T11:12:44.727Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 55,
|
"id": "55",
|
||||||
"title": "[Retrieval RC-4] תיקון chunking — פרגמנטים זעירים",
|
"title": "[Retrieval RC-4] תיקון chunking — פרגמנטים זעירים",
|
||||||
"description": "בתוצאות החיפוש מופיעים chunks של מילה-שתיים ('דיון','דיון וב','סיכום ו') כתוצאות מובילות. מציפים תוצאות ומורידים דירוג תוכן אמיתי. לחקור את chunker.py (פיצול לפי כותרת-סעיף שיוצר chunks ריקים) ולתקן: מינימום אורך chunk / מיזוג כותרת לגוף.",
|
"description": "בתוצאות החיפוש מופיעים chunks של מילה-שתיים ('דיון','דיון וב','סיכום ו') כתוצאות מובילות. מציפים תוצאות ומורידים דירוג תוכן אמיתי. לחקור את chunker.py (פיצול לפי כותרת-סעיף שיוצר chunks ריקים) ולתקן: מינימום אורך chunk / מיזוג כותרת לגוף.",
|
||||||
"status": "done",
|
"status": "done",
|
||||||
@@ -1965,7 +1965,7 @@
|
|||||||
"updatedAt": "2026-05-30T11:19:23.923Z"
|
"updatedAt": "2026-05-30T11:19:23.923Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 56,
|
"id": "56",
|
||||||
"title": "[Retrieval finding] halacha_filters לא מסננים source_kind — דליפה חוצת-קורפוסים",
|
"title": "[Retrieval finding] halacha_filters לא מסננים source_kind — דליפה חוצת-קורפוסים",
|
||||||
"description": "התגלה תוך כדי משימה 53. ב-search_precedent_library_semantic וב-search_precedent_library_lexical (db.py): chunk_filters כוללים cl.source_kind=$sk אבל halacha_filters כוללים רק review_status. תוצאה: search_precedent_library(external) מחזיר גם הלכות internal_committee, ו-search_internal_decisions(internal) מחזיר גם הלכות external. אי-עקביות: chunks מסוננים, halachot לא. כרגע זה דווקא מסייע למציאוּת (לכן לא רגרסיה), אבל לא עקבי. דורש החלטת מדיניות: או לסנן halachot גם לפי source_kind (עקבי, אך 'מסתיר' שכבות), או להשאיר מאוחד במכוון + לתעד. אם משאירים מאוחד — לעדכן docstrings של שני הכלים שזה לא 'corpus נפרד'.",
|
"description": "התגלה תוך כדי משימה 53. ב-search_precedent_library_semantic וב-search_precedent_library_lexical (db.py): chunk_filters כוללים cl.source_kind=$sk אבל halacha_filters כוללים רק review_status. תוצאה: search_precedent_library(external) מחזיר גם הלכות internal_committee, ו-search_internal_decisions(internal) מחזיר גם הלכות external. אי-עקביות: chunks מסוננים, halachot לא. כרגע זה דווקא מסייע למציאוּת (לכן לא רגרסיה), אבל לא עקבי. דורש החלטת מדיניות: או לסנן halachot גם לפי source_kind (עקבי, אך 'מסתיר' שכבות), או להשאיר מאוחד במכוון + לתעד. אם משאירים מאוחד — לעדכן docstrings של שני הכלים שזה לא 'corpus נפרד'.",
|
||||||
"status": "cancelled",
|
"status": "cancelled",
|
||||||
@@ -1977,7 +1977,7 @@
|
|||||||
"updatedAt": "2026-05-30T11:09:30.257989+00:00"
|
"updatedAt": "2026-05-30T11:09:30.257989+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 57,
|
"id": "57",
|
||||||
"title": "[Retrieval #55 follow-up] re-chunk+re-embed של פסיקה שהוטמעה לפני תיקון ה-chunker",
|
"title": "[Retrieval #55 follow-up] re-chunk+re-embed של פסיקה שהוטמעה לפני תיקון ה-chunker",
|
||||||
"description": "משימה 55 תיקנה את ה-chunker (עיגון כותרות + מיזוג) ומסננת את 484 הפרגמנטים בזמן query. הרמדיאציה המלאה: re-chunk מ-full_text השמור (ללא re-OCR — תואם feedback_no_reocr_retrofit) + re-embed, כדי שהתוכן יהיה נכון ולא רק מוסתר. נדחה כי זו מיגרציית-נתונים עם עלות Voyage API על ~13+ תיקים — דורש אישור עלות מ-chaim לפני הרצה. לבדוק כמה תיקים מושפעים (יש להם chunk<50) ולהריץ בקבוצות.",
|
"description": "משימה 55 תיקנה את ה-chunker (עיגון כותרות + מיזוג) ומסננת את 484 הפרגמנטים בזמן query. הרמדיאציה המלאה: re-chunk מ-full_text השמור (ללא re-OCR — תואם feedback_no_reocr_retrofit) + re-embed, כדי שהתוכן יהיה נכון ולא רק מוסתר. נדחה כי זו מיגרציית-נתונים עם עלות Voyage API על ~13+ תיקים — דורש אישור עלות מ-chaim לפני הרצה. לבדוק כמה תיקים מושפעים (יש להם chunk<50) ולהריץ בקבוצות.",
|
||||||
"status": "done",
|
"status": "done",
|
||||||
@@ -1991,7 +1991,7 @@
|
|||||||
"updatedAt": "2026-06-03T07:56:21.688Z"
|
"updatedAt": "2026-06-03T07:56:21.688Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 58,
|
"id": "58",
|
||||||
"title": "[Case access] get_case_by_number שביר לפורמט — סוכן 'עיוור' למסמכי תיק",
|
"title": "[Case access] get_case_by_number שביר לפורמט — סוכן 'עיוור' למסמכי תיק",
|
||||||
"description": "דווח ע\"י chaim: סוכן כתב שחסרים מסמכי תיק כי document_list החזיר ריק, אך המסמכים קיימים. שורש: get_case_by_number (db.py) עושה 'WHERE case_number=$1' התאמה מדויקת בלבד. אומת — 8137-24 מחזיר 9 מסמכים, אבל 8137/24 / 'ערר 8137-24' / רווחים / zero-pad → 'תיק לא נמצא'. הסוכן מקבל את המספר בפורמט שונה (כותרת issue, לוכסן, תחילית ערר/בל\"מ) → התאמה נכשלת → 'אין מסמכים'. משפיע על כל הכלים מבוססי case_number (document_list, extract_references, search_case_documents, get_claims, draft, וכו'). תיקון: נורמליזציה (strip prefix לתחילת ספרה, trim, '/'→'-') + fallback בשאילתה. תיקון נקודה-אחת מתקן את כל הכלים.",
|
"description": "דווח ע\"י chaim: סוכן כתב שחסרים מסמכי תיק כי document_list החזיר ריק, אך המסמכים קיימים. שורש: get_case_by_number (db.py) עושה 'WHERE case_number=$1' התאמה מדויקת בלבד. אומת — 8137-24 מחזיר 9 מסמכים, אבל 8137/24 / 'ערר 8137-24' / רווחים / zero-pad → 'תיק לא נמצא'. הסוכן מקבל את המספר בפורמט שונה (כותרת issue, לוכסן, תחילית ערר/בל\"מ) → התאמה נכשלת → 'אין מסמכים'. משפיע על כל הכלים מבוססי case_number (document_list, extract_references, search_case_documents, get_claims, draft, וכו'). תיקון: נורמליזציה (strip prefix לתחילת ספרה, trim, '/'→'-') + fallback בשאילתה. תיקון נקודה-אחת מתקן את כל הכלים.",
|
||||||
"status": "done",
|
"status": "done",
|
||||||
@@ -2003,7 +2003,7 @@
|
|||||||
"updatedAt": "2026-05-30T11:54:34.291Z"
|
"updatedAt": "2026-05-30T11:54:34.291Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 59,
|
"id": "59",
|
||||||
"title": "[FU-1] איחוד מסלול ה-ingest למסלול קנוני אחד",
|
"title": "[FU-1] איחוד מסלול ה-ingest למסלול קנוני אחד",
|
||||||
"description": "מאחד את ingest_precedent ו-ingest_internal_decision למסלול קנוני יחיד; מבטל את האסימטריות.",
|
"description": "מאחד את ingest_precedent ו-ingest_internal_decision למסלול קנוני יחיד; מבטל את האסימטריות.",
|
||||||
"details": "מכסה GAP-01,02,04,05. מספק INV-ING1/ING3/G2/G4. severity: Critical. סוג: קוד. יסוד — FU-2/FU-3/FU-7 תלויים בו. מקור: docs/spec/gap-audit.md + 01-ingest.md.",
|
"details": "מכסה GAP-01,02,04,05. מספק INV-ING1/ING3/G2/G4. severity: Critical. סוג: קוד. יסוד — FU-2/FU-3/FU-7 תלויים בו. מקור: docs/spec/gap-audit.md + 01-ingest.md.",
|
||||||
@@ -2056,7 +2056,7 @@
|
|||||||
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 60,
|
"id": "60",
|
||||||
"title": "[FU-2a] ingest idempotent + נרמול-בכתיבה + searchable (pure-code)",
|
"title": "[FU-2a] ingest idempotent + נרמול-בכתיבה + searchable (pure-code)",
|
||||||
"description": "upsert ON CONFLICT על מפתח קנוני + נרמול case_number בכתיבה (type-aware) + דגל searchable מפורש. אפס מיגרציית-נתונים.",
|
"description": "upsert ON CONFLICT על מפתח קנוני + נרמול case_number בכתיבה (type-aware) + דגל searchable מפורש. אפס מיגרציית-נתונים.",
|
||||||
"details": "מכסה GAP-03,06,13. מספק INV-ING2/G3/G1/ID1/DM1. severity: Critical. סוג: pure-code (schema-additive). תלוי ב-FU-1 (#59). FU-2b (#67) מטפל ב-GAP-07/08 בנפרד.",
|
"details": "מכסה GAP-03,06,13. מספק INV-ING2/G3/G1/ID1/DM1. severity: Critical. סוג: pure-code (schema-additive). תלוי ב-FU-1 (#59). FU-2b (#67) מטפל ב-GAP-07/08 בנפרד.",
|
||||||
@@ -2101,7 +2101,7 @@
|
|||||||
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 61,
|
"id": "61",
|
||||||
"title": "[FU-3] re-index בשינוי תוכן",
|
"title": "[FU-3] re-index בשינוי תוכן",
|
||||||
"description": "embedding מתעדכן אוטומטית בשינוי תוכן (כיום trigger-dependent, לא GENERATED).",
|
"description": "embedding מתעדכן אוטומטית בשינוי תוכן (כיום trigger-dependent, לא GENERATED).",
|
||||||
"details": "מכסה GAP-09. מספק INV-DM3/G6. severity: High. סוג: קוד + מיגרציה (re-embed). תלוי ב-FU-1.",
|
"details": "מכסה GAP-09. מספק INV-DM3/G6. severity: High. סוג: קוד + מיגרציה (re-embed). תלוי ב-FU-1.",
|
||||||
@@ -2138,7 +2138,7 @@
|
|||||||
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 62,
|
"id": "62",
|
||||||
"title": "[FU-4] בידוד-קורפוס בכל מסלול query",
|
"title": "[FU-4] בידוד-קורפוס בכל מסלול query",
|
||||||
"description": "אכיפת source_kind בכל פילטר (כולל halacha_filters); חסימת חיפוש ללא תחום.",
|
"description": "אכיפת source_kind בכל פילטר (כולל halacha_filters); חסימת חיפוש ללא תחום.",
|
||||||
"details": "מכסה GAP-10,12. מספק INV-RET1/G5. severity: Critical. סוג: קוד. ללא תלות — דחוף (דליפה פעילה).",
|
"details": "מכסה GAP-10,12. מספק INV-RET1/G5. severity: Critical. סוג: קוד. ללא תלות — דחוף (דליפה פעילה).",
|
||||||
@@ -2173,7 +2173,7 @@
|
|||||||
"updatedAt": "2026-05-30T18:30:11.503Z"
|
"updatedAt": "2026-05-30T18:30:11.503Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 63,
|
"id": "63",
|
||||||
"title": "[FU-5] eval-harness + נראות backlog",
|
"title": "[FU-5] eval-harness + נראות backlog",
|
||||||
"description": "מדידת precision/recall על gold-set + חשיפת backlog הלכות בבדיקת-בריאות.",
|
"description": "מדידת precision/recall על gold-set + חשיפת backlog הלכות בבדיקת-בריאות.",
|
||||||
"details": "מכסה GAP-11,14. מספק INV-RET4/G8/QA1/G10. severity: High. סוג: קוד + החלטת-יו\"ר (בניית gold-set). תלוי ב-FU-2. | DONE 2026-05-31: Unit B (GAP-14) — halacha_backlog נחשף ב-metrics.get_dashboard + /api/system/diagnostics (גילה 178 pending_review מתוך 1552, הישן 3.5.26). Unit A (GAP-11) — scripts/eval_gold_bootstrap.py (citations+known_item) + scripts/eval_retrieval.py (P/R/MRR/nDCG@5,10, self-test, baseline+config). gold-set=77 known-item queries (citation-source ריק: 0 ציטוטים בהחלטות). baseline בייצור: R@10=0.987 MRR=0.837; ממצא: MULTIMODAL=true מוריד known-item recall קלות (relevant ל-#15). gold-set=provisional עד סקירת דפנה (chair-gate; הדומיין). spec: docs/superpowers/specs/2026-05-31-fu5-eval-harness-design.md",
|
"details": "מכסה GAP-11,14. מספק INV-RET4/G8/QA1/G10. severity: High. סוג: קוד + החלטת-יו\"ר (בניית gold-set). תלוי ב-FU-2. | DONE 2026-05-31: Unit B (GAP-14) — halacha_backlog נחשף ב-metrics.get_dashboard + /api/system/diagnostics (גילה 178 pending_review מתוך 1552, הישן 3.5.26). Unit A (GAP-11) — scripts/eval_gold_bootstrap.py (citations+known_item) + scripts/eval_retrieval.py (P/R/MRR/nDCG@5,10, self-test, baseline+config). gold-set=77 known-item queries (citation-source ריק: 0 ציטוטים בהחלטות). baseline בייצור: R@10=0.987 MRR=0.837; ממצא: MULTIMODAL=true מוריד known-item recall קלות (relevant ל-#15). gold-set=provisional עד סקירת דפנה (chair-gate; הדומיין). spec: docs/superpowers/specs/2026-05-31-fu5-eval-harness-design.md",
|
||||||
@@ -2210,7 +2210,7 @@
|
|||||||
"updatedAt": "2026-05-31T14:55:38.295Z"
|
"updatedAt": "2026-05-31T14:55:38.295Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 64,
|
"id": "64",
|
||||||
"title": "[FU-6] שערי-QA נאכפים בקוד",
|
"title": "[FU-6] שערי-QA נאכפים בקוד",
|
||||||
"description": "export חוסם בקוד על כשל-QA קריטי; תיקון neutral_background critical-but-passes.",
|
"description": "export חוסם בקוד על כשל-QA קריטי; תיקון neutral_background critical-but-passes.",
|
||||||
"details": "מכסה GAP-15,16. מספק INV-QA3/EX3/G10. severity: Critical. סוג: קוד. ללא תלות — מהיר.",
|
"details": "מכסה GAP-15,16. מספק INV-QA3/EX3/G10. severity: Critical. סוג: קוד. ללא תלות — מהיר.",
|
||||||
@@ -2245,7 +2245,7 @@
|
|||||||
"updatedAt": "2026-05-30T18:30:11.521Z"
|
"updatedAt": "2026-05-30T18:30:11.521Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 65,
|
"id": "65",
|
||||||
"title": "[FU-7] audit-trail + provenance",
|
"title": "[FU-7] audit-trail + provenance",
|
||||||
"description": "כתיבת audit_log בכל פעולה; קישור בלוק→קטעי-מקור; סנכרון DB אחרי עריכה; אימות citation→corpus.",
|
"description": "כתיבת audit_log בכל פעולה; קישור בלוק→קטעי-מקור; סנכרון DB אחרי עריכה; אימות citation→corpus.",
|
||||||
"details": "מכסה GAP-17,18,19,20. מספק INV-AUD1/2/3/EX1/G9. severity: High. סוג: קוד + backfill קל. תלוי ב-FU-1. (זרע לתת-פרויקט 3/audit-provenance.)",
|
"details": "מכסה GAP-17,18,19,20. מספק INV-AUD1/2/3/EX1/G9. severity: High. סוג: קוד + backfill קל. תלוי ב-FU-1. (זרע לתת-פרויקט 3/audit-provenance.)",
|
||||||
@@ -2300,7 +2300,7 @@
|
|||||||
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 66,
|
"id": "66",
|
||||||
"title": "[FU-8a] מחסומי-תהליך→קוד: enforce sync + Paperclip-access guard (pure-code)",
|
"title": "[FU-8a] מחסומי-תהליך→קוד: enforce sync + Paperclip-access guard (pure-code)",
|
||||||
"description": "אכיפת cross-company sync (--verify יוצא non-zero על drift; adapter_type mismatch = drift לא silent skip) + fitness-function שחוסם גישת-Paperclip לא-מאושרת (raw http / INSERT agent_wakeup_requests).",
|
"description": "אכיפת cross-company sync (--verify יוצא non-zero על drift; adapter_type mismatch = drift לא silent skip) + fitness-function שחוסם גישת-Paperclip לא-מאושרת (raw http / INSERT agent_wakeup_requests).",
|
||||||
"details": "מכסה GAP-21,22. מספק INV-MC1/INT1/INT3. severity: High. סוג: pure-code. GAP-23 (חיווט ספ→סוכנים) הופרד ל-#69 (משנה התנהגות-ייצור). | DONE 2026-05-31 PR#16: --verify drift-gate (exit≠0) + Paperclip-access fitness function. GAP-23→#69.",
|
"details": "מכסה GAP-21,22. מספק INV-MC1/INT1/INT3. severity: High. סוג: pure-code. GAP-23 (חיווט ספ→סוכנים) הופרד ל-#69 (משנה התנהגות-ייצור). | DONE 2026-05-31 PR#16: --verify drift-gate (exit≠0) + Paperclip-access fitness function. GAP-23→#69.",
|
||||||
@@ -2333,7 +2333,7 @@
|
|||||||
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 67,
|
"id": "67",
|
||||||
"title": "[FU-2b] תיאום מזהים קנוניים + ניקוי ציטוט-כמזהה (data-migration, chair)",
|
"title": "[FU-2b] תיאום מזהים קנוניים + ניקוי ציטוט-כמזהה (data-migration, chair)",
|
||||||
"description": "מיגרציה חד-פעמית של ~52+ רשומות case_law עם ציטוט-מלא ב-case_number → מספר-בסיס מנורמל; dedup (למשל 8047-23 כפול); הכרעת צורה קנונית per-record.",
|
"description": "מיגרציה חד-פעמית של ~52+ רשומות case_law עם ציטוט-מלא ב-case_number → מספר-בסיס מנורמל; dedup (למשל 8047-23 כפול); הכרעת צורה קנונית per-record.",
|
||||||
"details": "מכסה GAP-07,08. מספק INV-ID1/ID2/DM2. severity: High. סוג: DATA-MIGRATION + chair-decision (מספר רשמי per-record, with-month canonical). דורש: גיבוי, dry-run, סקירת-יו\"ר, reversibility. תלוי ב-FU-2a (#60, לצורך פונקציית הנרמול). מקור: בדיקת DB 2026-05-30 — internal_committee ~52/56 ציטוט-מלא, ≥1 dup (8047-23), 1 בלתי-פתיר (ערר אדלר/cited_only). | APPLIED 2026-05-31: 55 internal rows normalized to bare case_number; corrupted 8047 dup (מטודלה) deleted; חלוואני 1028-20 proc→בל\"מ. Backups in data/audit/fu2b-*. external→#68.",
|
"details": "מכסה GAP-07,08. מספק INV-ID1/ID2/DM2. severity: High. סוג: DATA-MIGRATION + chair-decision (מספר רשמי per-record, with-month canonical). דורש: גיבוי, dry-run, סקירת-יו\"ר, reversibility. תלוי ב-FU-2a (#60, לצורך פונקציית הנרמול). מקור: בדיקת DB 2026-05-30 — internal_committee ~52/56 ציטוט-מלא, ≥1 dup (8047-23), 1 בלתי-פתיר (ערר אדלר/cited_only). | APPLIED 2026-05-31: 55 internal rows normalized to bare case_number; corrupted 8047 dup (מטודלה) deleted; חלוואני 1028-20 proc→בל\"מ. Backups in data/audit/fu2b-*. external→#68.",
|
||||||
@@ -2367,7 +2367,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 68,
|
"id": "68",
|
||||||
"title": "[FU-2c] תיאום מזהי external_upload (case_number↔citation_formatted)",
|
"title": "[FU-2c] תיאום מזהי external_upload (case_number↔citation_formatted)",
|
||||||
"description": "פסיקה חיצונית: case_number מחזיק ציטוט מלא; citation_formatted לא תמיד תואם (נמצאה סתירה 25226-04-25 מול 1975/24). דורש קודם תיקון סתירות citation_formatted↔case_number, ואז הכרעה אם docket מחולץ הופך ל-case_number או שהציטוט נשאר המזהה.",
|
"description": "פסיקה חיצונית: case_number מחזיק ציטוט מלא; citation_formatted לא תמיד תואם (נמצאה סתירה 25226-04-25 מול 1975/24). דורש קודם תיקון סתירות citation_formatted↔case_number, ואז הכרעה אם docket מחולץ הופך ל-case_number או שהציטוט נשאר המזהה.",
|
||||||
"details": "מקור: בדיקת DB 2026-05-31 (FU-2b scoping). 22/24 external עם ציטוט ב-case_number; citation_formatted נוצר בנפרד (LLM) ולא אמין כ-ground truth. שונה מ-internal (שם 0 סתירות). דורש סקירת-יו\"ר פר-רשומה. severity: Medium. סוג: data-migration + chair. תלוי בהחלטה: האם זהות external = ציטוט (FU-1) או docket מנורמל (INV-ID2). מופרד מ-FU-2b לפי החלטת chaim 2026-05-31. | APPLIED 2026-05-31: chair decision Option A (designator+docket, '/' kept). 21 external_upload case_number normalized + 3 citation_formatted fixed (D=לויתן/קלמנוביץ consolidated→25226-04-25; 2×C empty-citation composed). אהוד שפר עע\"מ 317/10 deferred (cross-source dup w/ cited_only → #70). collision-guard: 0. Backups data/audit/fu2c-backup-20260531T140943Z.csv. cited_only(49)→#70.",
|
"details": "מקור: בדיקת DB 2026-05-31 (FU-2b scoping). 22/24 external עם ציטוט ב-case_number; citation_formatted נוצר בנפרד (LLM) ולא אמין כ-ground truth. שונה מ-internal (שם 0 סתירות). דורש סקירת-יו\"ר פר-רשומה. severity: Medium. סוג: data-migration + chair. תלוי בהחלטה: האם זהות external = ציטוט (FU-1) או docket מנורמל (INV-ID2). מופרד מ-FU-2b לפי החלטת chaim 2026-05-31. | APPLIED 2026-05-31: chair decision Option A (designator+docket, '/' kept). 21 external_upload case_number normalized + 3 citation_formatted fixed (D=לויתן/קלמנוביץ consolidated→25226-04-25; 2×C empty-citation composed). אהוד שפר עע\"מ 317/10 deferred (cross-source dup w/ cited_only → #70). collision-guard: 0. Backups data/audit/fu2c-backup-20260531T140943Z.csv. cited_only(49)→#70.",
|
||||||
@@ -2381,7 +2381,7 @@
|
|||||||
"updatedAt": "2026-05-31T14:11:37.689Z"
|
"updatedAt": "2026-05-31T14:11:37.689Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 69,
|
"id": "69",
|
||||||
"title": "[FU-8b] חיווט הספ לסוכני-Paperclip (GAP-23)",
|
"title": "[FU-8b] חיווט הספ לסוכני-Paperclip (GAP-23)",
|
||||||
"description": "HEARTBEAT/agent docs דורשים קריאת 00-constitution + ספ-תחום רלוונטי לפני פעולה. משנה התנהגות-סוכן בייצור; prereq לתת-פרויקט 5.",
|
"description": "HEARTBEAT/agent docs דורשים קריאת 00-constitution + ספ-תחום רלוונטי לפני פעולה. משנה התנהגות-סוכן בייצור; prereq לתת-פרויקט 5.",
|
||||||
"details": "מכסה GAP-23. מספק INV-AG1. severity: High. סוג: docs+chair-decision. דורש ספ יציב (קיים) + החלטה על שילוב בזרימת-הסוכנים. הופרד מ-FU-8a לפי החלטת chaim 2026-05-31 (GAP-21/22 = pure-code עכשיו).",
|
"details": "מכסה GAP-23. מספק INV-AG1. severity: High. סוג: docs+chair-decision. דורש ספ יציב (קיים) + החלטה על שילוב בזרימת-הסוכנים. הופרד מ-FU-8a לפי החלטת chaim 2026-05-31 (GAP-21/22 = pure-code עכשיו).",
|
||||||
@@ -2407,7 +2407,7 @@
|
|||||||
"updatedAt": "2026-05-31T16:01:42.032Z"
|
"updatedAt": "2026-05-31T16:01:42.032Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 70,
|
"id": "70",
|
||||||
"title": "[FU-2c-b] תיאום + dedup של cited_only (49 רשומות) + אהוד שפר cross-source",
|
"title": "[FU-2c-b] תיאום + dedup של cited_only (49 רשומות) + אהוד שפר cross-source",
|
||||||
"description": "המשך ל-FU-2c (#68). ה-dry-run של תיאום-המזהים החיצוני חשף 49 רשומות source_kind='cited_only' (הפניות-ציטוט שחולצו מהחלטות) שלא היו בהיקף #68. דורשות נרמול נפרד: צורות-ועדה כמו 'ערר 1093-19' (NNNN-NN) שה-extractor הנוכחי לא תופס (NO_DOCKET), 'בש\"א 2487-14', dups, ו-'ערר אדלר' בלתי-פתיר (ללא מספר). בנוסף: dedup חוצה-source של אהוד שפר — external_upload 'עע\"מ 317/10 אהוד שפר' מול cited_only קיים 'עע\"מ 317/10' (אותו תיק; ה-collision-guard מנע התנגשות ב-uq_case_law_external_number, ה-external_upload נשאר עם case_number מנופח עד הכרעה).",
|
"description": "המשך ל-FU-2c (#68). ה-dry-run של תיאום-המזהים החיצוני חשף 49 רשומות source_kind='cited_only' (הפניות-ציטוט שחולצו מהחלטות) שלא היו בהיקף #68. דורשות נרמול נפרד: צורות-ועדה כמו 'ערר 1093-19' (NNNN-NN) שה-extractor הנוכחי לא תופס (NO_DOCKET), 'בש\"א 2487-14', dups, ו-'ערר אדלר' בלתי-פתיר (ללא מספר). בנוסף: dedup חוצה-source של אהוד שפר — external_upload 'עע\"מ 317/10 אהוד שפר' מול cited_only קיים 'עע\"מ 317/10' (אותו תיק; ה-collision-guard מנע התנגשות ב-uq_case_law_external_number, ה-external_upload נשאר עם case_number מנופח עד הכרעה).",
|
||||||
"details": "[2026-06-03] נרמול מבוסס-מחקר (4 מקורות: ECLI work-level id, Akoma Ntoso FRBR Work/Manifestation, ELI canonical+alias, OpenCitations OMID + Christen data-matching). מדיניות: צורה קנונית אחת + alias; cited_only stub = אותו Work כמו ה-doc → merge על התאמה-מדויקת בלבד; un-resolvable = display+flag, לא למחוק; merge = re-point edges + dedup, שמרני (false-merge בגרף-ציטוט יקר). בוצע: 46 רשומות cited_only סווגו; 3 תיקונים מכניים-דטרמיניסטיים הוחלו (ערר \\n316/10→ערר 316/10; עע\"מ65/13→עע\"מ 65/13; עע\"מ9057/09→עע\"מ 9057/09). 0 malformed (whitespace/no-space) נותרו. **נותר לשיקול יו\"ר (לא ננחש, לפי המשמר)**: (1) 2 garbled — 'ערר 1078/0724' (4a38c202), 'ערר 1083/0724' (6682f9cb); (2) 'ערר אדלר' (863a7bf8) ללא docket → keep+flag; (3) combined 'ערר (ירושלים) 1078+1083/24' (e7f6fd06) → פיצול ל-1078/24+1083/24 מתנגש עם stub קיים 'ערר 1083/24' → entity-resolution ידני. תוספת קוד עתידית: טיפול '+' ב-citation_extractor. הדדאפ הקודם (shafer + stub cleanup) כבר הושלם. אלה chair-domain — לא הכרעת-מהנדס. [2026-06-03 סגירה]: בדיקת-קשתות חשפה ש-4 ה'דו-משמעיים' (+11 נוספים) הם stubs **יתומים מתים** — 0 קשתות בכל 5 מנגנוני-הציטוט, 0 full_text, 0 הלכות, 0 chunks/embeddings. כלומר ניקוי טכני, לא שיפוט-יו\"ר (OpenCitations שומר ישות חסרת-מזהה רק אם מצוטטת — אלה לא). נמחקו 15 יתומים (cited_only 46→31), גיבוי data/audit/fu2b-orphan-stub-cleanup-20260603T093741Z.json. 0 malformed/יתומים נותרו; כל 31 הנותרים מצוטטים. forward-edge ידוע (לא חוסם, ללא משימה): טיפול '+' בציטוט-משולב ב-citation_extractor אם יחזור בחילוץ עתידי. #70 done.",
|
"details": "[2026-06-03] נרמול מבוסס-מחקר (4 מקורות: ECLI work-level id, Akoma Ntoso FRBR Work/Manifestation, ELI canonical+alias, OpenCitations OMID + Christen data-matching). מדיניות: צורה קנונית אחת + alias; cited_only stub = אותו Work כמו ה-doc → merge על התאמה-מדויקת בלבד; un-resolvable = display+flag, לא למחוק; merge = re-point edges + dedup, שמרני (false-merge בגרף-ציטוט יקר). בוצע: 46 רשומות cited_only סווגו; 3 תיקונים מכניים-דטרמיניסטיים הוחלו (ערר \\n316/10→ערר 316/10; עע\"מ65/13→עע\"מ 65/13; עע\"מ9057/09→עע\"מ 9057/09). 0 malformed (whitespace/no-space) נותרו. **נותר לשיקול יו\"ר (לא ננחש, לפי המשמר)**: (1) 2 garbled — 'ערר 1078/0724' (4a38c202), 'ערר 1083/0724' (6682f9cb); (2) 'ערר אדלר' (863a7bf8) ללא docket → keep+flag; (3) combined 'ערר (ירושלים) 1078+1083/24' (e7f6fd06) → פיצול ל-1078/24+1083/24 מתנגש עם stub קיים 'ערר 1083/24' → entity-resolution ידני. תוספת קוד עתידית: טיפול '+' ב-citation_extractor. הדדאפ הקודם (shafer + stub cleanup) כבר הושלם. אלה chair-domain — לא הכרעת-מהנדס. [2026-06-03 סגירה]: בדיקת-קשתות חשפה ש-4 ה'דו-משמעיים' (+11 נוספים) הם stubs **יתומים מתים** — 0 קשתות בכל 5 מנגנוני-הציטוט, 0 full_text, 0 הלכות, 0 chunks/embeddings. כלומר ניקוי טכני, לא שיפוט-יו\"ר (OpenCitations שומר ישות חסרת-מזהה רק אם מצוטטת — אלה לא). נמחקו 15 יתומים (cited_only 46→31), גיבוי data/audit/fu2b-orphan-stub-cleanup-20260603T093741Z.json. 0 malformed/יתומים נותרו; כל 31 הנותרים מצוטטים. forward-edge ידוע (לא חוסם, ללא משימה): טיפול '+' בציטוט-משולב ב-citation_extractor אם יחזור בחילוץ עתידי. #70 done.",
|
||||||
@@ -2421,7 +2421,7 @@
|
|||||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 71,
|
"id": "71",
|
||||||
"title": "[FU-5 follow-up] כוונון עומק-אחזור/rerank — recall רב-תקדימי לסוגיות רחבות",
|
"title": "[FU-5 follow-up] כוונון עומק-אחזור/rerank — recall רב-תקדימי לסוגיות רחבות",
|
||||||
"description": "נפתר ע\"י תיקון ה-weight של #15 (multimodal 0.5→0.65). מדידה 2026-06-03: כל 11 השאילתות הרב-תקדים/יו\"ר מחזירות את כל התקדימים הרלוונטיים ב-top-10 (רובם top-6; גרוע ביותר rank 9). השאילתות החלשות מהבייסליין (S2 הבית-שמעוני@16, S4 ב.דייניש@15, S7@15, S8) כולן תוקנו. recall@10≈1.0.",
|
"description": "נפתר ע\"י תיקון ה-weight של #15 (multimodal 0.5→0.65). מדידה 2026-06-03: כל 11 השאילתות הרב-תקדים/יו\"ר מחזירות את כל התקדימים הרלוונטיים ב-top-10 (רובם top-6; גרוע ביותר rank 9). השאילתות החלשות מהבייסליין (S2 הבית-שמעוני@16, S4 ב.דייניש@15, S7@15, S8) כולן תוקנו. recall@10≈1.0.",
|
||||||
"details": "החלטה מבוססת-מדידה+מחקר (6 מקורות: Cormack RRF, Drowning-in-Documents 2411.11767, ReFIT, MMR, Elastic, Pinecone). המחקר המליץ להפעיל rerank (fetch_k=50,return_k=10); בדקתי אמפירית — VOYAGE_RERANK_ENABLED=true דווקא הזיק: nDCG@5 0.879 מול 0.960, MRR 0.867 מול 0.954, R@5 0.966 מול 0.994 (כל המדדים שליליים). הסיבה: recall כבר רווי, וה-cross-encoder הכללי מוריד את ההתאמה המדויקת ב-known-item. **המדידה גוברת על התיאוריה — לא מפעילים rerank, לא מעלים limit, RRF_K=60 נשאר.** אין שינוי-קוד נדרש.",
|
"details": "החלטה מבוססת-מדידה+מחקר (6 מקורות: Cormack RRF, Drowning-in-Documents 2411.11767, ReFIT, MMR, Elastic, Pinecone). המחקר המליץ להפעיל rerank (fetch_k=50,return_k=10); בדקתי אמפירית — VOYAGE_RERANK_ENABLED=true דווקא הזיק: nDCG@5 0.879 מול 0.960, MRR 0.867 מול 0.954, R@5 0.966 מול 0.994 (כל המדדים שליליים). הסיבה: recall כבר רווי, וה-cross-encoder הכללי מוריד את ההתאמה המדויקת ב-known-item. **המדידה גוברת על התיאוריה — לא מפעילים rerank, לא מעלים limit, RRF_K=60 נשאר.** אין שינוי-קוד נדרש.",
|
||||||
@@ -2435,7 +2435,7 @@
|
|||||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 72,
|
"id": "72",
|
||||||
"title": "[ops] MCP 'No such tool' תחת עומס חילוץ opus-4-8@xhigh — timeout ב-handshake",
|
"title": "[ops] MCP 'No such tool' תחת עומס חילוץ opus-4-8@xhigh — timeout ב-handshake",
|
||||||
"description": "בריצת CMPA-71 (חילוץ הלכות 9002-24, סוכן עוזר משפטי) שרת ה-legal-ai MCP לא נטען — כל קריאות mcp__legal-ai__* החזירו 'No such tool available' אחרי 3 ניסיונות+המתנות; הסוכן עשה fallback ל-.venv ישיר (לפי legal-ceo.md) והחילוץ הצליח על claude-opus-4-8@xhigh.",
|
"description": "בריצת CMPA-71 (חילוץ הלכות 9002-24, סוכן עוזר משפטי) שרת ה-legal-ai MCP לא נטען — כל קריאות mcp__legal-ai__* החזירו 'No such tool available' אחרי 3 ניסיונות+המתנות; הסוכן עשה fallback ל-.venv ישיר (לפי legal-ceo.md) והחילוץ הצליח על claude-opus-4-8@xhigh.",
|
||||||
"status": "done",
|
"status": "done",
|
||||||
@@ -2446,7 +2446,7 @@
|
|||||||
"subtasks": []
|
"subtasks": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 73,
|
"id": "73",
|
||||||
"title": "החלטת ועדת ערר: ברירת מחדל is_binding=false (יישור דוקטרינרי)",
|
"title": "החלטת ועדת ערר: ברירת מחדל is_binding=false (יישור דוקטרינרי)",
|
||||||
"description": "כשמעלים החלטת ועדת ערר דרך מסך העלאת הפסיקה (precedent-upload-sheet, isCommittee=true), הצ'קבוקס 'הלכה מחייבת' (is_binding) כברירת מחדל הוא true — כך שההלכות שמחולצות מהחלטה לא-מחייבת מתויגות rule_type='binding'. זה סותר את ההגדרה הדוקטרינרית שלנו (ועדת ערר = persuasive בלבד, לא binding כמו עליון/מנהלי). התיקון: כש-isCommittee=true ב-precedent-upload-sheet.tsx, להפוך את is_binding ל-false כברירת מחדל (או לנעול/להסתיר את הצ'קבוקס ולתייג אוטומטית persuasive). הערה חשובה: זהו תיקון יישור-דוקטרינרי בלבד — אין השפעה downstream על ranking/injection (rule_type הוא תווית תצוגה; השער הפונקציונלי האמיתי הוא review_status שדפנה שולטת בו ידנית). קבצים: web-ui/src/components/precedents/precedent-upload-sheet.tsx (useState isBinding שורה 47, isCommittee שורה 53); guard clause קיים ב-mcp-server/src/legal_mcp/services/halacha_extractor.py:229-235 שמוריד binding→persuasive רק כאשר is_binding=false.",
|
"description": "כשמעלים החלטת ועדת ערר דרך מסך העלאת הפסיקה (precedent-upload-sheet, isCommittee=true), הצ'קבוקס 'הלכה מחייבת' (is_binding) כברירת מחדל הוא true — כך שההלכות שמחולצות מהחלטה לא-מחייבת מתויגות rule_type='binding'. זה סותר את ההגדרה הדוקטרינרית שלנו (ועדת ערר = persuasive בלבד, לא binding כמו עליון/מנהלי). התיקון: כש-isCommittee=true ב-precedent-upload-sheet.tsx, להפוך את is_binding ל-false כברירת מחדל (או לנעול/להסתיר את הצ'קבוקס ולתייג אוטומטית persuasive). הערה חשובה: זהו תיקון יישור-דוקטרינרי בלבד — אין השפעה downstream על ranking/injection (rule_type הוא תווית תצוגה; השער הפונקציונלי האמיתי הוא review_status שדפנה שולטת בו ידנית). קבצים: web-ui/src/components/precedents/precedent-upload-sheet.tsx (useState isBinding שורה 47, isCommittee שורה 53); guard clause קיים ב-mcp-server/src/legal_mcp/services/halacha_extractor.py:229-235 שמוריד binding→persuasive רק כאשר is_binding=false.",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -2458,7 +2458,7 @@
|
|||||||
"updatedAt": "2026-05-31T20:41:04.160Z"
|
"updatedAt": "2026-05-31T20:41:04.160Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 74,
|
"id": "74",
|
||||||
"title": "ניקוי רטרואקטיבי: rule_type binding→persuasive להלכות ממקור ועדת ערר",
|
"title": "ניקוי רטרואקטיבי: rule_type binding→persuasive להלכות ממקור ועדת ערר",
|
||||||
"description": "המשך משימה #73 (PR #29 מנע binding חדש לועדת ערר מכאן והלאה). יש 82 הלכות קיימות ב-DB עם rule_type='binding' שמקורן (case_law) בהחלטת ועדת ערר — בסתירה לדוקטרינה (ועדת ערר = persuasive). פילוח: 75 approved + 7 pending_review. גישה #2 (שמרנית): לתקן רק את ה-binding ל-persuasive, ולהשאיר interpretive/procedural/application/obiter כמות שהם (תקינים גם לועדת ערר). הגדרת 'מקור ועדת ערר': case_law WHERE source_type='appeals_committee' OR precedent_level LIKE 'ועדת%' OR court LIKE '%ועדת%ערר%' OR court LIKE '%ועדות ערר%'. שאילתה: UPDATE halachot SET rule_type='persuasive' WHERE rule_type='binding' AND case_law_id IN (<committee case_law ids>). הערה: rule_type הוא תווית תצוגה בלבד — אין השפעה על ranking/injection (השער הפונקציונלי הוא review_status). DB: legal_ai על Postgres pgvector קונטיינר t84kegpjm5qrttd6nw7bgoxe (פורט 5433). ביצוע דרך docker exec עם trust מקומי. לגבות/לספור לפני ואחרי לאימות (צפוי: 82 שורות מושפעות, 0 binding ממקור ועדת ערר אחרי).",
|
"description": "המשך משימה #73 (PR #29 מנע binding חדש לועדת ערר מכאן והלאה). יש 82 הלכות קיימות ב-DB עם rule_type='binding' שמקורן (case_law) בהחלטת ועדת ערר — בסתירה לדוקטרינה (ועדת ערר = persuasive). פילוח: 75 approved + 7 pending_review. גישה #2 (שמרנית): לתקן רק את ה-binding ל-persuasive, ולהשאיר interpretive/procedural/application/obiter כמות שהם (תקינים גם לועדת ערר). הגדרת 'מקור ועדת ערר': case_law WHERE source_type='appeals_committee' OR precedent_level LIKE 'ועדת%' OR court LIKE '%ועדת%ערר%' OR court LIKE '%ועדות ערר%'. שאילתה: UPDATE halachot SET rule_type='persuasive' WHERE rule_type='binding' AND case_law_id IN (<committee case_law ids>). הערה: rule_type הוא תווית תצוגה בלבד — אין השפעה על ranking/injection (השער הפונקציונלי הוא review_status). DB: legal_ai על Postgres pgvector קונטיינר t84kegpjm5qrttd6nw7bgoxe (פורט 5433). ביצוע דרך docker exec עם trust מקומי. לגבות/לספור לפני ואחרי לאימות (צפוי: 82 שורות מושפעות, 0 binding ממקור ועדת ערר אחרי).",
|
||||||
"details": "",
|
"details": "",
|
||||||
@@ -2472,7 +2472,7 @@
|
|||||||
"updatedAt": "2026-05-31T20:49:28.894Z"
|
"updatedAt": "2026-05-31T20:49:28.894Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 75,
|
"id": "75",
|
||||||
"title": "[X11 Phase 2] חיווט אוטו-אישור מבוסס-ציטוט + backfill",
|
"title": "[X11 Phase 2] חיווט אוטו-אישור מבוסס-ציטוט + backfill",
|
||||||
"description": "Phase 2 של citation-corroboration (X11). Phase 1 (האות) מוזג ב-PR #27. דפנה אימתה את האות ואישרה הפעלה (2026-06-01). Phase 2: (1) חיווט אוטו-אישור — הלכה corroborated (≥2 ציטוטים חיוביים בלתי-תלויים, 0 שליליים) עוברת ל-review_status='approved' עם reviewer='corroborated (…judicial citations)' (INV-COR4/G10); (2) הדחת overruled — הלכה approved שקיבלה טיפול overruled בציטוט מאוחר חוזרת לשער-היו\"ר (INV-COR2); (3) backfill על 12 התקדימים (halachot+ציטוטים-נכנסים); (4) כלי-MCP write להרצת rebuild.",
|
"description": "Phase 2 של citation-corroboration (X11). Phase 1 (האות) מוזג ב-PR #27. דפנה אימתה את האות ואישרה הפעלה (2026-06-01). Phase 2: (1) חיווט אוטו-אישור — הלכה corroborated (≥2 ציטוטים חיוביים בלתי-תלויים, 0 שליליים) עוברת ל-review_status='approved' עם reviewer='corroborated (…judicial citations)' (INV-COR4/G10); (2) הדחת overruled — הלכה approved שקיבלה טיפול overruled בציטוט מאוחר חוזרת לשער-היו\"ר (INV-COR2); (3) backfill על 12 התקדימים (halachot+ציטוטים-נכנסים); (4) כלי-MCP write להרצת rebuild.",
|
||||||
"details": "דגל: HALACHA_CORROBORATION_AUTO_APPROVE (default true, env-tunable). פונקציית-הכרעה טהורה approval_action(agg, has_overruled)→'approve'/'demote'/None (unit-tested, INV-COR2/COR4). DB: approve_halacha_by_corroboration (רק על pending_review), demote_halacha_overruled (רק על approved→pending_review), list_corroboration_grouped, precedents_with_halachot_and_incoming_citations. שירות: reconcile_approvals מופעל בסוף build_for_precedent; build_all driver. backfill target=12 תקדימים (אומת 2026-06-01). נדחה ל-backlog (proposal-only, מסוכן-תוכן): enrichment של rule_statement, treatment-backfill ל-case_law_citations.citation_type. תוכנית: docs/superpowers/plans/2026-06-01-x11-citation-corroboration-phase2.md. spec: docs/spec/X11-citation-corroboration.md §4-6.",
|
"details": "דגל: HALACHA_CORROBORATION_AUTO_APPROVE (default true, env-tunable). פונקציית-הכרעה טהורה approval_action(agg, has_overruled)→'approve'/'demote'/None (unit-tested, INV-COR2/COR4). DB: approve_halacha_by_corroboration (רק על pending_review), demote_halacha_overruled (רק על approved→pending_review), list_corroboration_grouped, precedents_with_halachot_and_incoming_citations. שירות: reconcile_approvals מופעל בסוף build_for_precedent; build_all driver. backfill target=12 תקדימים (אומת 2026-06-01). נדחה ל-backlog (proposal-only, מסוכן-תוכן): enrichment של rule_statement, treatment-backfill ל-case_law_citations.citation_type. תוכנית: docs/superpowers/plans/2026-06-01-x11-citation-corroboration-phase2.md. spec: docs/spec/X11-citation-corroboration.md §4-6.",
|
||||||
@@ -2484,7 +2484,7 @@
|
|||||||
"updatedAt": "2026-06-01T04:43:40.474Z"
|
"updatedAt": "2026-06-01T04:43:40.474Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 76,
|
"id": "76",
|
||||||
"title": "תיקון כפתור \"צור משימה\" ב-Paperclip — מאופשר אך submit חוזר בשקט",
|
"title": "תיקון כפתור \"צור משימה\" ב-Paperclip — מאופשר אך submit חוזר בשקט",
|
||||||
"description": "בוטל 2026-06-03 — באג upstream של Paperclip, לא ניתן לתיקון בטוח אצלנו (ee=companyId; הכפתור מאופשר לפי כותרת בלבד אך submit דורש חברה שלא אותחלה). אומת ע\"י chaim שעובד מהקשרי-חברה רגילים. Workaround: לבחור חברה במודאל / לפתוח מתוך לוח. מסלול אמין: pc.sh POST /companies/{id}/issues. תיקון יסודי = upstream. #78 מסיר את הצורך בזרימת הפסיקה.",
|
"description": "בוטל 2026-06-03 — באג upstream של Paperclip, לא ניתן לתיקון בטוח אצלנו (ee=companyId; הכפתור מאופשר לפי כותרת בלבד אך submit דורש חברה שלא אותחלה). אומת ע\"י chaim שעובד מהקשרי-חברה רגילים. Workaround: לבחור חברה במודאל / לפתוח מתוך לוח. מסלול אמין: pc.sh POST /companies/{id}/issues. תיקון יסודי = upstream. #78 מסיר את הצורך בזרימת הפסיקה.",
|
||||||
"details": "אבחנה סופית מתוך הבאנדל (index-BWGhimVr.js): ה-submit הוא `function xi(){const je=m.current.trim();if(!ee||!je||He.isPending)return;...He.mutate({...companyId:ee...})}`. `je`=כותרת (קיים), `He`=mutation. ה-guard שנכשל הוא **`ee`**, ש-`ee` משמש כ-`projects.list(ee)` וכ-`companyId:ee` במוטציה — כלומר **`ee` = מזהה החברה**. השורש: הכפתור מאופשר לפי הכותרת בלבד (`disabled:!b`, b=כותרת), אבל ה-submit דורש גם חברה (`!ee`). כשהמודאל נפתח בהקשר שבו החברה לא אותחלה, המשתמש לוחץ כפתור 'מאופשר' וה-handler חוזר בשקט — בלי POST, בלי שגיאה. בחירת הסוכן (callback Ro) לא מגדירה את החברה — היא נקבעת רק דרך בורר חברה נפרד (pr/oe). ההזרקה שלנו (translate-he.js) זוכתה: reverseComments נוגע רק ב-[id^='comment-'], לא במודאל; isUserContent מדלג על contentEditable. **לא ניתן לתקן בבטחה דרך injection**: אי-אפשר לכתוב ל-state של React מבחוץ; shim שמגרד DOM ויוצר issue דרך API הוא שביר (צריך IDs מה-DOM) ועלול ליצור משימות פגומות — גרוע מהבאג. **Workaround**: לוודא שהחברה נבחרה במודאל (בורר החברה) לפני לחיצה על 'צור משימה'; או לפתוח 'משימה חדשה' מתוך הקשר חברה/לוח. מסלול אמין תמיד: API ישיר `pc.sh POST /companies/{id}/issues`. **תיקון יסודי = upstream Paperclip** (הכפתור צריך להיות disabled כשאין חברה, או החברה צריכה להיגזר מהלוח/סוכן הנבחר). הערה: #78 (חילוץ פסיקה אוטומטי) מסיר את הצורך במודאל הזה בזרימת חילוץ-הפסיקה; הזרימה הרגילה מניעה סוכנים דרך תגובות (CEO מנתב).",
|
"details": "אבחנה סופית מתוך הבאנדל (index-BWGhimVr.js): ה-submit הוא `function xi(){const je=m.current.trim();if(!ee||!je||He.isPending)return;...He.mutate({...companyId:ee...})}`. `je`=כותרת (קיים), `He`=mutation. ה-guard שנכשל הוא **`ee`**, ש-`ee` משמש כ-`projects.list(ee)` וכ-`companyId:ee` במוטציה — כלומר **`ee` = מזהה החברה**. השורש: הכפתור מאופשר לפי הכותרת בלבד (`disabled:!b`, b=כותרת), אבל ה-submit דורש גם חברה (`!ee`). כשהמודאל נפתח בהקשר שבו החברה לא אותחלה, המשתמש לוחץ כפתור 'מאופשר' וה-handler חוזר בשקט — בלי POST, בלי שגיאה. בחירת הסוכן (callback Ro) לא מגדירה את החברה — היא נקבעת רק דרך בורר חברה נפרד (pr/oe). ההזרקה שלנו (translate-he.js) זוכתה: reverseComments נוגע רק ב-[id^='comment-'], לא במודאל; isUserContent מדלג על contentEditable. **לא ניתן לתקן בבטחה דרך injection**: אי-אפשר לכתוב ל-state של React מבחוץ; shim שמגרד DOM ויוצר issue דרך API הוא שביר (צריך IDs מה-DOM) ועלול ליצור משימות פגומות — גרוע מהבאג. **Workaround**: לוודא שהחברה נבחרה במודאל (בורר החברה) לפני לחיצה על 'צור משימה'; או לפתוח 'משימה חדשה' מתוך הקשר חברה/לוח. מסלול אמין תמיד: API ישיר `pc.sh POST /companies/{id}/issues`. **תיקון יסודי = upstream Paperclip** (הכפתור צריך להיות disabled כשאין חברה, או החברה צריכה להיגזר מהלוח/סוכן הנבחר). הערה: #78 (חילוץ פסיקה אוטומטי) מסיר את הצורך במודאל הזה בזרימת חילוץ-הפסיקה; הזרימה הרגילה מניעה סוכנים דרך תגובות (CEO מנתב).",
|
||||||
@@ -2496,7 +2496,7 @@
|
|||||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 77,
|
"id": "77",
|
||||||
"title": "תיקון מיפוי שדות בהעלאת פסיקת ועדת-ערר — מראה-מקום נדחס ל-case_number; case_number לא ניתן לעריכה",
|
"title": "תיקון מיפוי שדות בהעלאת פסיקת ועדת-ערר — מראה-מקום נדחס ל-case_number; case_number לא ניתן לעריכה",
|
||||||
"description": "בטופס העלאת פסיקה (precedent-upload-sheet), עבור החלטות ועדת-ערר ה-frontend ממפה את שדה \"מראה המקום\" אל case_number (`case_number: citation.trim()`), כך שהמזהה הייחודי מקבל את המראה-מקום הארוך במקום מספר תיק נקי (למשל '8027-25'). בנוסף case_number כלל לא קיים ב-PrecedentUpdateRequest — אז מסך העריכה לא יכול לתקן אותו בדיעבד. citation_formatted נשאר ריק בהעלאה (מתמלא רק בחילוץ מטא). תוצאה ב-8027-25: case_number=מראה-מקום, case_name=מראה-מקום, מראה-מקום ריק עד החילוץ.",
|
"description": "בטופס העלאת פסיקה (precedent-upload-sheet), עבור החלטות ועדת-ערר ה-frontend ממפה את שדה \"מראה המקום\" אל case_number (`case_number: citation.trim()`), כך שהמזהה הייחודי מקבל את המראה-מקום הארוך במקום מספר תיק נקי (למשל '8027-25'). בנוסף case_number כלל לא קיים ב-PrecedentUpdateRequest — אז מסך העריכה לא יכול לתקן אותו בדיעבד. citation_formatted נשאר ריק בהעלאה (מתמלא רק בחילוץ מטא). תוצאה ב-8027-25: case_number=מראה-מקום, case_name=מראה-מקום, מראה-מקום ריק עד החילוץ.",
|
||||||
"details": "קבצים: web-ui/src/components/precedents/precedent-upload-sheet.tsx:121-123 (committee path ממפה citation→case_number); web/app.py:5147-5163 (PrecedentUpdateRequest חסר case_number); mcp-server/.../internal_decisions.py (id_field=case_number, display_name_fallback=case_number); precedent_metadata_extractor.py:247-253 (guard: case_name מתוקן רק אם ריק או ==case_number, לכן לא תיקן). תיקון מוצע: (1) בטופס committee — שדה נפרד \"מספר תיק (מזהה ייחודי)\" שממפה ל-case_number, ולמפות \"מראה המקום\" ל-citation (→citation_formatted), במקום לדחוס הכל ל-case_number; (2) להוסיף case_number ל-PrecedentUpdateRequest כדי שהעריכה תוכל לתקן בדיעבד (update_case_law כבר מתיר אותו); (3) להריץ `npm run api:types`. ראה כללי השם שהוגדרו: מזהה ייחודי = שם הקובץ/מספר תיק; מראה-מקום בשדה שלו; שם קצר = שם הצד.",
|
"details": "קבצים: web-ui/src/components/precedents/precedent-upload-sheet.tsx:121-123 (committee path ממפה citation→case_number); web/app.py:5147-5163 (PrecedentUpdateRequest חסר case_number); mcp-server/.../internal_decisions.py (id_field=case_number, display_name_fallback=case_number); precedent_metadata_extractor.py:247-253 (guard: case_name מתוקן רק אם ריק או ==case_number, לכן לא תיקן). תיקון מוצע: (1) בטופס committee — שדה נפרד \"מספר תיק (מזהה ייחודי)\" שממפה ל-case_number, ולמפות \"מראה המקום\" ל-citation (→citation_formatted), במקום לדחוס הכל ל-case_number; (2) להוסיף case_number ל-PrecedentUpdateRequest כדי שהעריכה תוכל לתקן בדיעבד (update_case_law כבר מתיר אותו); (3) להריץ `npm run api:types`. ראה כללי השם שהוגדרו: מזהה ייחודי = שם הקובץ/מספר תיק; מראה-מקום בשדה שלו; שם קצר = שם הצד.",
|
||||||
@@ -2508,7 +2508,7 @@
|
|||||||
"updatedAt": "2026-06-02T12:17:44.302Z"
|
"updatedAt": "2026-06-02T12:17:44.302Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 78,
|
"id": "78",
|
||||||
"title": "כשל שקט ב-wakeup לחילוץ פסיקה — חילוץ אוטומטי לא רץ והתיק נתקע ב-pending",
|
"title": "כשל שקט ב-wakeup לחילוץ פסיקה — חילוץ אוטומטי לא רץ והתיק נתקע ב-pending",
|
||||||
"description": "אחרי העלאת פסיקה, הבקאנד מסמן metadata+halacha כ-pending וקורא ל-pc_wake_for_precedent_extraction להעיר את ה-CEO. הקריאה נבלעת בשקט (try/except 'non-fatal') — אם PAPERCLIP_BOARD_API_KEY חסר מחזיר {ok:false,skipped:no_api_key}, או שה-wakeup API נכשל — והתוצאה: ה-CEO לא מתעורר, לא רץ חילוץ מטא ולא חילוץ הלכות, וה-UI מציג 'ממתין לחילוץ' לנצח. קרה ב-8027-25 (תוקן ידנית עם precedent_process_pending). זה גם הסיבה ששדות המטא (תקציר/headnote/תגיות/citation) היו ריקים.",
|
"description": "אחרי העלאת פסיקה, הבקאנד מסמן metadata+halacha כ-pending וקורא ל-pc_wake_for_precedent_extraction להעיר את ה-CEO. הקריאה נבלעת בשקט (try/except 'non-fatal') — אם PAPERCLIP_BOARD_API_KEY חסר מחזיר {ok:false,skipped:no_api_key}, או שה-wakeup API נכשל — והתוצאה: ה-CEO לא מתעורר, לא רץ חילוץ מטא ולא חילוץ הלכות, וה-UI מציג 'ממתין לחילוץ' לנצח. קרה ב-8027-25 (תוקן ידנית עם precedent_process_pending). זה גם הסיבה ששדות המטא (תקציר/headnote/תגיות/citation) היו ריקים.",
|
||||||
"details": "קבצים: web/app.py:5250-5262 (wakeup best-effort, exception נבלעת); web/paperclip_client.py:816-820 (skip שקט כש-no api key) ו-~905-907 (כשל API נבלע). תיקון מוצע: (1) להציף את הכשל למשתמש — סטטוס מובחן (extraction_wakeup_failed / 'ממתין-לעיבוד-ידני') ב-UI במקום 'pending' אילם; (2) fallback אוטומטי — אם ה-wakeup נכשל, או job מתוזמן (כמו sync-case-status) שמנקז את התור עם precedent_process_pending, או retry; (3) לאמת אם PAPERCLIP_BOARD_API_KEY מוגדר בקונטיינר (Coolify env) — אם לא, להוסיף. עיין reference: project_precedent_auto_extraction. לא לבלוע exceptions בשקט (feedback_silent_swallow).",
|
"details": "קבצים: web/app.py:5250-5262 (wakeup best-effort, exception נבלעת); web/paperclip_client.py:816-820 (skip שקט כש-no api key) ו-~905-907 (כשל API נבלע). תיקון מוצע: (1) להציף את הכשל למשתמש — סטטוס מובחן (extraction_wakeup_failed / 'ממתין-לעיבוד-ידני') ב-UI במקום 'pending' אילם; (2) fallback אוטומטי — אם ה-wakeup נכשל, או job מתוזמן (כמו sync-case-status) שמנקז את התור עם precedent_process_pending, או retry; (3) לאמת אם PAPERCLIP_BOARD_API_KEY מוגדר בקונטיינר (Coolify env) — אם לא, להוסיף. עיין reference: project_precedent_auto_extraction. לא לבלוע exceptions בשקט (feedback_silent_swallow).",
|
||||||
@@ -2520,7 +2520,7 @@
|
|||||||
"updatedAt": "2026-06-02T12:07:22.194Z"
|
"updatedAt": "2026-06-02T12:07:22.194Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 79,
|
"id": "79",
|
||||||
"title": "[#55 follow-up] chunker — כותרות-סעיף מבודדות נשארות chunks זעירים (<50)",
|
"title": "[#55 follow-up] chunker — כותרות-סעיף מבודדות נשארות chunks זעירים (<50)",
|
||||||
"description": "ה-chunker ההיררכי הנוכחי (אחרי תיקון #55) עדיין פולט מדי פעם chunk זעיר שהוא כותרת-סעיף בודדת שלא מוזגה קדימה לתוכן שאחריה. התגלה בסגירת #57 (re-chunk legacy): מתוך 73 תקדימים שעברו reindex, נשארו 4 chunks זעירים — כולם כותרות מבודדות: 'דיון' (ע\"א 5138/04, len=4), 'טענות המשיבים' (בג\"ץ 6525/15, len=13), 'העובדות וההליכים' (בר\"מ 2340/02, len=16), ושבר-ציטוט 'כלל התושבים\". (ע' 13 להחלטה)' (403-17, len=32). לא שאריות legacy — אלה פלט דטרמיניסטי של ה-chunker הנוכחי.",
|
"description": "ה-chunker ההיררכי הנוכחי (אחרי תיקון #55) עדיין פולט מדי פעם chunk זעיר שהוא כותרת-סעיף בודדת שלא מוזגה קדימה לתוכן שאחריה. התגלה בסגירת #57 (re-chunk legacy): מתוך 73 תקדימים שעברו reindex, נשארו 4 chunks זעירים — כולם כותרות מבודדות: 'דיון' (ע\"א 5138/04, len=4), 'טענות המשיבים' (בג\"ץ 6525/15, len=13), 'העובדות וההליכים' (בר\"מ 2340/02, len=16), ושבר-ציטוט 'כלל התושבים\". (ע' 13 להחלטה)' (403-17, len=32). לא שאריות legacy — אלה פלט דטרמיניסטי של ה-chunker הנוכחי.",
|
||||||
"details": "השפעה: נמוכה — chunks אלה כבר מסוננים ב-query-time (פילטר length>=50 שנוסף ב-#55), אז החיפוש לא מושפע; זו בעיית-איכות-chunking ולא בעיית-אחזור. מיקום: chunker ההיררכי ב-mcp-server (chunk_document_hierarchical / מדיניות מיזוג הכותרות שב-#55). התיקון הצפוי: כשכותרת/heading קצרה (<50, או section_type שמתחיל סעיף) נותרת כ-chunk עצמאי ללא גוף — למזג אותה קדימה אל ה-chunk הבא (anchor-forward), או אחורה אם אין הבא. לשים לב ל-section boundaries: 'דיון'/'טענות המשיבים' הן תחילת סעיף — המיזוג צריך לצרף את הכותרת לראש הסעיף שאחריה, לא לזנב הקודם. אימות: להריץ scripts/rechunk_legacy_precedents.py אחרי התיקון — אמור להגיע ל-0 chunks<50 (או רק שברי-ציטוט לגיטימיים נדירים). תלוי ב-#55. ראה גם feedback_no_reocr_retrofit (re-chunk מ-full_text בלבד).",
|
"details": "השפעה: נמוכה — chunks אלה כבר מסוננים ב-query-time (פילטר length>=50 שנוסף ב-#55), אז החיפוש לא מושפע; זו בעיית-איכות-chunking ולא בעיית-אחזור. מיקום: chunker ההיררכי ב-mcp-server (chunk_document_hierarchical / מדיניות מיזוג הכותרות שב-#55). התיקון הצפוי: כשכותרת/heading קצרה (<50, או section_type שמתחיל סעיף) נותרת כ-chunk עצמאי ללא גוף — למזג אותה קדימה אל ה-chunk הבא (anchor-forward), או אחורה אם אין הבא. לשים לב ל-section boundaries: 'דיון'/'טענות המשיבים' הן תחילת סעיף — המיזוג צריך לצרף את הכותרת לראש הסעיף שאחריה, לא לזנב הקודם. אימות: להריץ scripts/rechunk_legacy_precedents.py אחרי התיקון — אמור להגיע ל-0 chunks<50 (או רק שברי-ציטוט לגיטימיים נדירים). תלוי ב-#55. ראה גם feedback_no_reocr_retrofit (re-chunk מ-full_text בלבד).",
|
||||||
@@ -2534,7 +2534,7 @@
|
|||||||
"updatedAt": "2026-06-03T08:10:57.844Z"
|
"updatedAt": "2026-06-03T08:10:57.844Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 80,
|
"id": "80",
|
||||||
"title": "[#15 follow-up] בדיקת ערך image-answer ל-multimodal → הכרעה על backfill 140 legacy",
|
"title": "[#15 follow-up] בדיקת ערך image-answer ל-multimodal → הכרעה על backfill 140 legacy",
|
||||||
"description": "נסגר 2026-06-03 — ההנחה התבררה שגויה בכל מרכיב (full check). לא '140 מסמכים / 17,700 עמ' / שעתיים / אישור-עלות chaim + תיוג דפנה', אלא: מתוך 140 חסרי-image רק 65 PDF (השאר MD/DOCX — ה-pipeline מרנדר PDF בלבד), ובסך 704 עמ'. תיקי-השמאות (כל ערך ה-multimodal) כבר היו 8/12 מוטמעים — הפער היחיד היה תיק 8070-25 (4 מסמכי שמאות).",
|
"description": "נסגר 2026-06-03 — ההנחה התבררה שגויה בכל מרכיב (full check). לא '140 מסמכים / 17,700 עמ' / שעתיים / אישור-עלות chaim + תיוג דפנה', אלא: מתוך 140 חסרי-image רק 65 PDF (השאר MD/DOCX — ה-pipeline מרנדר PDF בלבד), ובסך 704 עמ'. תיקי-השמאות (כל ערך ה-multimodal) כבר היו 8/12 מוטמעים — הפער היחיד היה תיק 8070-25 (4 מסמכי שמאות).",
|
||||||
"details": "בוצע: backfill מקומי (multimodal_backfill.py 8070-25, voyage-multimodal-3, ~30 שניות) → כל 14 מסמכי 8070-25 הוטמעו. **כיסוי שמאות עכשיו 12/12 (100%)**. נותרו 51 PDF/649 עמ' ללא multimodal — כולם טקסטואליים (reference/response/appeal), ו-#15 הוכיח ש-multimodal לא עוזר (אף מדלל) על מסמכים טקסטואליים → **מושארים בכוונה** text-only; זו לא חוסר-עקביות אלא הקונפיג הנכון. אין צורך ב-gold-set/דפנה/אישור-עלות — העלות הייתה סנטים והערך הוכח ב-#15 לתיקי ועדה/שמאות. #80 done (טכני, לא human-gated).",
|
"details": "בוצע: backfill מקומי (multimodal_backfill.py 8070-25, voyage-multimodal-3, ~30 שניות) → כל 14 מסמכי 8070-25 הוטמעו. **כיסוי שמאות עכשיו 12/12 (100%)**. נותרו 51 PDF/649 עמ' ללא multimodal — כולם טקסטואליים (reference/response/appeal), ו-#15 הוכיח ש-multimodal לא עוזר (אף מדלל) על מסמכים טקסטואליים → **מושארים בכוונה** text-only; זו לא חוסר-עקביות אלא הקונפיג הנכון. אין צורך ב-gold-set/דפנה/אישור-עלות — העלות הייתה סנטים והערך הוכח ב-#15 לתיקי ועדה/שמאות. #80 done (טכני, לא human-gated).",
|
||||||
@@ -2548,7 +2548,7 @@
|
|||||||
"updatedAt": "2026-06-03T00:00:00.000Z"
|
"updatedAt": "2026-06-03T00:00:00.000Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 81,
|
"id": "81",
|
||||||
"title": "איכות חילוץ הלכות — לוודא שמה שמחולץ הוא הלכה אמיתית ולא ציטוט/אמרת-אגב",
|
"title": "איכות חילוץ הלכות — לוודא שמה שמחולץ הוא הלכה אמיתית ולא ציטוט/אמרת-אגב",
|
||||||
"description": "מנוע חילוץ ההלכות מפיק כיום פריטים שאינם 'הלכות' במובן המהותי: אמרות-אגב שהערכאה לא הכריעה בהן, יישומים ספציפיים-לתיק (rule_type=application), ציטוטים חתוכים, ופירוק-יתר (עד 351 'הלכות' מפסק אחד). המשימה: לחדד את ה-prompt ולהוסיף ולידטורים אוטומטיים כך שרק עיקרון משפטי בר-הכללה ובר-הסתמכות ייכנס למאגר. מבוססת מחקר מקצועי (ratio decidendi מול obiter dictum, holding-extraction בספרות legal-NLP, קריטריונים לאיכות rule_statement). תתי-המשימות יוגדרו לאחר המחקר.",
|
"description": "מנוע חילוץ ההלכות מפיק כיום פריטים שאינם 'הלכות' במובן המהותי: אמרות-אגב שהערכאה לא הכריעה בהן, יישומים ספציפיים-לתיק (rule_type=application), ציטוטים חתוכים, ופירוק-יתר (עד 351 'הלכות' מפסק אחד). המשימה: לחדד את ה-prompt ולהוסיף ולידטורים אוטומטיים כך שרק עיקרון משפטי בר-הכללה ובר-הסתמכות ייכנס למאגר. מבוססת מחקר מקצועי (ratio decidendi מול obiter dictum, holding-extraction בספרות legal-NLP, קריטריונים לאיכות rule_statement). תתי-המשימות יוגדרו לאחר המחקר.",
|
||||||
"details": "רקע מבצעי (ניקוי 2026-06-03): נמחקו 196 רשומות מתוך 1650 (165 כפילויות תוכן + 31 Tier B/C). גיבוי: data/audit/halacha-cleanup-backup-20260603T101747Z.sql ; מניפסט: data/audit/halacha-cleanup-manifest-20260603T101747Z.csv . קוד רלוונטי: mcp-server/src/legal_mcp/services/halacha_extractor.py (prompts BINDING/PERSUASIVE, _coerce_halacha, אימות ציטוט). תחומי בדיקה ידועים: (א) חסימת quote_verified=false מהתור; (ב) הוצאת rule_type=application מהגדרת 'הלכה'; (ג) שמירה על דחיית dicta שלא הוכרעו; (ד) תקרת כמות/גרנולריות לפסק; (ה) הגנה מפני ציטוט חתוך (truncation guard). מפרט מאומת: docs/halacha-strict-rubric.md (הרובריקה האגרסיבית שהנחיתה ניקוי קורפוס 1454→534 ב-2026-06-03, שפר 51→22). להטמיע אותה במחלץ + dedup-on-insert (#82).",
|
"details": "רקע מבצעי (ניקוי 2026-06-03): נמחקו 196 רשומות מתוך 1650 (165 כפילויות תוכן + 31 Tier B/C). גיבוי: data/audit/halacha-cleanup-backup-20260603T101747Z.sql ; מניפסט: data/audit/halacha-cleanup-manifest-20260603T101747Z.csv . קוד רלוונטי: mcp-server/src/legal_mcp/services/halacha_extractor.py (prompts BINDING/PERSUASIVE, _coerce_halacha, אימות ציטוט). תחומי בדיקה ידועים: (א) חסימת quote_verified=false מהתור; (ב) הוצאת rule_type=application מהגדרת 'הלכה'; (ג) שמירה על דחיית dicta שלא הוכרעו; (ד) תקרת כמות/גרנולריות לפסק; (ה) הגנה מפני ציטוט חתוך (truncation guard). מפרט מאומת: docs/halacha-strict-rubric.md (הרובריקה האגרסיבית שהנחיתה ניקוי קורפוס 1454→534 ב-2026-06-03, שפר 51→22). להטמיע אותה במחלץ + dedup-on-insert (#82).",
|
||||||
@@ -2648,7 +2648,7 @@
|
|||||||
"updatedAt": "2026-06-03T16:27:24.755Z"
|
"updatedAt": "2026-06-03T16:27:24.755Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 82,
|
"id": "82",
|
||||||
"title": "Dedup בזמן הכנסה — מניעת הלכות כמעט-זהות בכתיבה (semantic dedup on insert)",
|
"title": "Dedup בזמן הכנסה — מניעת הלכות כמעט-זהות בכתיבה (semantic dedup on insert)",
|
||||||
"description": "store_halachot_for_chunk מבצע INSERT עיוור ללא בדיקת כפילות, ולכן נצברו עשרות הלכות 'אותו עיקרון במילים אחרות'. המשימה: לפני שמירה, לבדוק דמיון סמנטי (embedding) מול הלכות קיימות באותו פסק ולדלג/למזג near-duplicates, וכן לזהות ציטוט-תומך זהה. מבוססת מחקר (ספי near-duplicate ב-embedding-IR, MinHash/LSH מול cosine, בחירת צורה קנונית, merge מול skip). תתי-המשימות יוגדרו לאחר המחקר.",
|
"description": "store_halachot_for_chunk מבצע INSERT עיוור ללא בדיקת כפילות, ולכן נצברו עשרות הלכות 'אותו עיקרון במילים אחרות'. המשימה: לפני שמירה, לבדוק דמיון סמנטי (embedding) מול הלכות קיימות באותו פסק ולדלג/למזג near-duplicates, וכן לזהות ציטוט-תומך זהה. מבוססת מחקר (ספי near-duplicate ב-embedding-IR, MinHash/LSH מול cosine, בחירת צורה קנונית, merge מול skip). תתי-המשימות יוגדרו לאחר המחקר.",
|
||||||
"details": "ממצא מהניקוי: סף cosine ≥0.90 תוך-פסק זיהה כפילויות אמיתיות בוודאות גבוהה (הרצועה 0.90-0.95 הייתה כמעט כולה 'אותו עיקרון בניסוח שונה'); ≥0.95 = ודאי. ציטוט-תומך זהה = איתות ודאי. להחליט: סף מבצעי, התנהגות (skip/merge/flag-for-review), ובחירת השורד (approved>pending, ביטחון גבוה, quote_verified). קוד: db.store_halachot_for_chunk; קיים idx_halachot_vec (pgvector).",
|
"details": "ממצא מהניקוי: סף cosine ≥0.90 תוך-פסק זיהה כפילויות אמיתיות בוודאות גבוהה (הרצועה 0.90-0.95 הייתה כמעט כולה 'אותו עיקרון בניסוח שונה'); ≥0.95 = ודאי. ציטוט-תומך זהה = איתות ודאי. להחליט: סף מבצעי, התנהגות (skip/merge/flag-for-review), ובחירת השורד (approved>pending, ביטחון גבוה, quote_verified). קוד: db.store_halachot_for_chunk; קיים idx_halachot_vec (pgvector).",
|
||||||
@@ -2736,7 +2736,7 @@
|
|||||||
"updatedAt": "2026-06-03T12:32:19.721Z"
|
"updatedAt": "2026-06-03T12:32:19.721Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 83,
|
"id": "83",
|
||||||
"title": "חוסן pipeline החילוץ — re-run אידמפוטנטי + אינדוקס תקין (תיקון באגים)",
|
"title": "חוסן pipeline החילוץ — re-run אידמפוטנטי + אינדוקס תקין (תיקון באגים)",
|
||||||
"description": "התגלו שני באגים: (1) halacha_index מוקצה per-chunk ולכן אינו ייחודי לפסק — שני עקרונות שונים מקבלים אותו מספר (לא כפילות, אך שובר dedup/מיון מבוסס-אינדקס); (2) חילוץ רץ פי-2/3 על אותו פסק (למשל 85026-17 שלוש ריצות תוך דקתיים) ומוסיף append במקום להחליף — ה-advisory lock לא מנע. המשימה: אינדוקס ייחודי לפסק, force=True שמוחק לפני re-extract, וחיזוק ה-lock/אידמפוטנטיות. מחקר קצר: דפוסי idempotency/exactly-once ב-pipelines.",
|
"description": "התגלו שני באגים: (1) halacha_index מוקצה per-chunk ולכן אינו ייחודי לפסק — שני עקרונות שונים מקבלים אותו מספר (לא כפילות, אך שובר dedup/מיון מבוסס-אינדקס); (2) חילוץ רץ פי-2/3 על אותו פסק (למשל 85026-17 שלוש ריצות תוך דקתיים) ומוסיף append במקום להחליף — ה-advisory lock לא מנע. המשימה: אינדוקס ייחודי לפסק, force=True שמוחק לפני re-extract, וחיזוק ה-lock/אידמפוטנטיות. מחקר קצר: דפוסי idempotency/exactly-once ב-pipelines.",
|
||||||
"details": "קוד: halacha_extractor.py (global advisory lock, per-chunk checkpoints ב-precedent_chunks.halacha_extracted_at, force flag), db.store_halachot_for_chunk (הקצאת halacha_index). לשקול unique constraint (case_law_id, halacha_index) אחרי תיקון ההקצאה.",
|
"details": "קוד: halacha_extractor.py (global advisory lock, per-chunk checkpoints ב-precedent_chunks.halacha_extracted_at, force flag), db.store_halachot_for_chunk (הקצאת halacha_index). לשקול unique constraint (case_law_id, halacha_index) אחרי תיקון ההקצאה.",
|
||||||
@@ -2822,7 +2822,7 @@
|
|||||||
"updatedAt": "2026-06-03T13:08:10.793Z"
|
"updatedAt": "2026-06-03T13:08:10.793Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 84,
|
"id": "84",
|
||||||
"title": "טריאז' תור אישור ההלכות — אישור יעיל ולא מתיש",
|
"title": "טריאז' תור אישור ההלכות — אישור יעיל ולא מתיש",
|
||||||
"description": "אישור ההלכות ידני ומתיש: קריאת עקרונות כמעט-זהים שוב ושוב, ללא תיעדוף או קיבוץ. המשימה: לייעל את חוויית האישור — מיון לפי ביטחון/corroboration, קיבוץ near-duplicates יחד, auto-defer/הסתרה של פריטים באיכות נמוכה, ופעולות batch (אישור/דחייה מרובים). מבוססת מחקר (human-in-the-loop review UX, active-learning prioritization, triage queues). תתי-המשימות לאחר המחקר.",
|
"description": "אישור ההלכות ידני ומתיש: קריאת עקרונות כמעט-זהים שוב ושוב, ללא תיעדוף או קיבוץ. המשימה: לייעל את חוויית האישור — מיון לפי ביטחון/corroboration, קיבוץ near-duplicates יחד, auto-defer/הסתרה של פריטים באיכות נמוכה, ופעולות batch (אישור/דחייה מרובים). מבוססת מחקר (human-in-the-loop review UX, active-learning prioritization, triage queues). תתי-המשימות לאחר המחקר.",
|
||||||
"details": "הקשר: הדחייה כמעט לא בשימוש (1/1650) — התור הוא 'אשר-או-השאר-תלוי'. כלים קיימים: halachot_pending, halacha_review (MCP), דף ביקורת ב-UI. לשלב עם פלט #81 (איכות) ו-#82 (dedup) כדי שהתור יציג רק מועמדים אמיתיים ומקובצים.",
|
"details": "הקשר: הדחייה כמעט לא בשימוש (1/1650) — התור הוא 'אשר-או-השאר-תלוי'. כלים קיימים: halachot_pending, halacha_review (MCP), דף ביקורת ב-UI. לשלב עם פלט #81 (איכות) ו-#82 (dedup) כדי שהתור יציג רק מועמדים אמיתיים ומקובצים.",
|
||||||
@@ -2918,18 +2918,19 @@
|
|||||||
"updatedAt": "2026-06-03T13:43:18.488Z"
|
"updatedAt": "2026-06-03T13:43:18.488Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 85,
|
"id": "85",
|
||||||
"title": "CEO MCP instance: nested claude -p exits 1 in write_interim_draft",
|
"title": "CEO MCP instance: nested claude -p exits 1 in write_interim_draft",
|
||||||
"description": "write_interim_draft נכשל לכל 5 הבלוקים מתוך session ה-CEO עם 'Claude CLI failed (exit 1): unknown error'. אומת: claude CLI תקין מ-bash (exit 0), PATH+HOME של תהליך ה-MCP תקינים (/home/chaim/.local/bin/claude), אין ANTHROPIC_API_KEY ב-.env, הבלוקים נכתבים סדרתית (לא concurrency). סוכני משנה (proofreader/analyst CMPA-73..76) הריצו claude -p בהצלחה באותו יום. ⇒ כשל ספציפי ל-MCP server instance של ה-CEO. עוקף: האצלה ל-writer agent. דרוש: לבדוק מדוע nested claude -p נכשל מ-instance זה (אולי session lock / env stale); שקול restart ל-CEO agent session.",
|
"description": "write_interim_draft נכשל לכל 5 הבלוקים מתוך session ה-CEO עם 'Claude CLI failed (exit 1): unknown error'. אומת: claude CLI תקין מ-bash (exit 0), PATH+HOME של תהליך ה-MCP תקינים (/home/chaim/.local/bin/claude), אין ANTHROPIC_API_KEY ב-.env, הבלוקים נכתבים סדרתית (לא concurrency). סוכני משנה (proofreader/analyst CMPA-73..76) הריצו claude -p בהצלחה באותו יום. ⇒ כשל ספציפי ל-MCP server instance של ה-CEO. עוקף: האצלה ל-writer agent. דרוש: לבדוק מדוע nested claude -p נכשל מ-instance זה (אולי session lock / env stale); שקול restart ל-CEO agent session.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:47.925Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 86,
|
"id": "86",
|
||||||
"title": "טיפול ב-preamble/רציו של נבו — anti-contamination + gold-set מהרציו",
|
"title": "טיפול ב-preamble/רציו של נבו — anti-contamination + gold-set מהרציו",
|
||||||
"description": "התגלה (2026-06-03) ש-`strip_nevo_preamble` קיים ומחווט ל-ingest, אבל ה-regex `_DECISION_START` מזהה רק פתיחות של ועדת ערר (בפנינו/הערר שבנדון/ועדת הערר לתכנון/רקע עובדתי/עסקינן) — ולא פסקי-דין שנפתחים ב'פסק-דין' (כמו בג\"ץ 1764/05). לכן בפסקי-דין מנבו — בדיוק אלה שיש להם מיני-רציו — ה-preamble/רציו **אינו נחתך**, דולף לצ'אנקים, ועלול לזהם את חילוץ ההלכות (המחלץ קורא את התשובון של נבו) ואת הקורפוס. במקביל — הרציו של נבו הוא gold-set אנושי-מקצועי חינמי לאמידת איכות החילוץ.",
|
"description": "התגלה (2026-06-03) ש-`strip_nevo_preamble` קיים ומחווט ל-ingest, אבל ה-regex `_DECISION_START` מזהה רק פתיחות של ועדת ערר (בפנינו/הערר שבנדון/ועדת הערר לתכנון/רקע עובדתי/עסקינן) — ולא פסקי-דין שנפתחים ב'פסק-דין' (כמו בג\"ץ 1764/05). לכן בפסקי-דין מנבו — בדיוק אלה שיש להם מיני-רציו — ה-preamble/רציו **אינו נחתך**, דולף לצ'אנקים, ועלול לזהם את חילוץ ההלכות (המחלץ קורא את התשובון של נבו) ואת הקורפוס. במקביל — הרציו של נבו הוא gold-set אנושי-מקצועי חינמי לאמידת איכות החילוץ.",
|
||||||
"details": "קוד: mcp-server/src/legal_mcp/services/extractor.py — `strip_nevo_preamble` (~367), `_NEVO_MARKERS` (ספרות:/חקיקה שאוזכרה:/מיני-רציו:/...), `_DECISION_START` (~361). מחווט ב-ingest.py:161 ו-documents.py:152. הוכחה: ב-1764/05 המיני-רציו שרד כ-chunk מסוג intro (לא נחתך) ורק במזל לא חולץ (intro לא ב-EXTRACTABLE_SECTIONS). השוואת benchmark שבוצעה ידנית על 1764/05: 14 הלכות שלנו כיסו 100% מ-4 הלכות-הרציו של נבו + 2 נוספות, בגרנולריות פי ~3.5 (קשור ל-#81.5).",
|
"details": "קוד: mcp-server/src/legal_mcp/services/extractor.py — `strip_nevo_preamble` (~367), `_NEVO_MARKERS` (ספרות:/חקיקה שאוזכרה:/מיני-רציו:/...), `_DECISION_START` (~361). מחווט ב-ingest.py:161 ו-documents.py:152. הוכחה: ב-1764/05 המיני-רציו שרד כ-chunk מסוג intro (לא נחתך) ורק במזל לא חולץ (intro לא ב-EXTRACTABLE_SECTIONS). השוואת benchmark שבוצעה ידנית על 1764/05: 14 הלכות שלנו כיסו 100% מ-4 הלכות-הרציו של נבו + 2 נוספות, בגרנולריות פי ~3.5 (קשור ל-#81.5).",
|
||||||
@@ -2977,204 +2978,218 @@
|
|||||||
"updatedAt": "2026-06-03T16:56:13.158Z"
|
"updatedAt": "2026-06-03T16:56:13.158Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 87,
|
"id": "87",
|
||||||
"title": "claims_coverage: להבחין בין טענות כתב-ערר לטענות תכתובת",
|
"title": "claims_coverage: להבחין בין טענות כתב-ערר לטענות תכתובת",
|
||||||
"description": "בדיקת claims_coverage מסמנת false-positives: טענות שעלו רק בתכתובות/תגובות בין הצדדים (לא בכתב הערר) מסומנות כ'לא נענו'. יש להבחין בין טענות מכתב הערר (חובה מענה) לטענות מתכתובת (לא חייבות מענה עצמאי, במיוחד כשהערר מתקבל במלואו). מקור: chair_feedback תיק 1033-25.",
|
"description": "בדיקת claims_coverage מסמנת false-positives: טענות שעלו רק בתכתובות/תגובות בין הצדדים (לא בכתב הערר) מסומנות כ'לא נענו'. יש להבחין בין טענות מכתב הערר (חובה מענה) לטענות מתכתובת (לא חייבות מענה עצמאי, במיוחד כשהערר מתקבל במלואו). מקור: chair_feedback תיק 1033-25.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:47.933Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 88,
|
"id": "88",
|
||||||
"title": "פער DB↔file: decision_blocks (DB) מול drafts/decision.md (disk)",
|
"title": "פער DB↔file: decision_blocks (DB) מול drafts/decision.md (disk)",
|
||||||
"description": "legal-writer מעדכן decision_blocks ב-DB אבל legal-qa קורא drafts/decision.md מהדיסק — שני מקורות אמת לא מסונכרנים גורמים ל-QA להיכשל פעמיים על אותה בעיה. נדרש: או כתיבה אטומית ל-DB+file יחד, או hook אוטומטי regenerate-draft אחרי כל עדכון בלוק. מקור: chair_feedback תיקים 8126-03-25, CMPA-62. ראה legal-decision-lessons.md #35.",
|
"description": "legal-writer מעדכן decision_blocks ב-DB אבל legal-qa קורא drafts/decision.md מהדיסק — שני מקורות אמת לא מסונכרנים גורמים ל-QA להיכשל פעמיים על אותה בעיה. נדרש: או כתיבה אטומית ל-DB+file יחד, או hook אוטומטי regenerate-draft אחרי כל עדכון בלוק. מקור: chair_feedback תיקים 8126-03-25, CMPA-62. ראה legal-decision-lessons.md #35.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:47.943Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 89,
|
"id": "89",
|
||||||
"title": "[רכישת-סגנון T0] הזרקת הפרופיל-המופשט ל-block_writer + מדיניות-העתקה",
|
"title": "[רכישת-סגנון T0] הזרקת הפרופיל-המופשט ל-block_writer + מדיניות-העתקה",
|
||||||
"description": "הלוֹבר הראשי. block_writer.py יטען voice-fingerprint+author-features+Copy-Paste Templates ל-{style_context} בכל בלוק. הוראת-מדיניות לפי סוג-תוכן: נוסחה/בוילרפלייט→מותר להעתיק, ניתוח ספציפי→הכלל והתאם, מהות מתיק אחר→אסור. פיצול {precedents_context} ל-{daphna_style_exemplars} (סגנון) ו-{case_law_citations} (פסיקה). קבצים: block_writer.py:205-260,710,795-815. MVP.",
|
"description": "הלוֹבר הראשי. block_writer.py יטען voice-fingerprint+author-features+Copy-Paste Templates ל-{style_context} בכל בלוק. הוראת-מדיניות לפי סוג-תוכן: נוסחה/בוילרפלייט→מותר להעתיק, ניתוח ספציפי→הכלל והתאם, מהות מתיק אחר→אסור. פיצול {precedents_context} ל-{daphna_style_exemplars} (סגנון) ו-{case_law_citations} (פסיקה). קבצים: block_writer.py:205-260,710,795-815. MVP.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:47.951Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 90,
|
"id": "90",
|
||||||
"title": "[רכישת-סגנון T1] Backfill decision_paragraphs+paragraph_embeddings מ-style_corpus",
|
"title": "[רכישת-סגנון T1] Backfill decision_paragraphs+paragraph_embeddings מ-style_corpus",
|
||||||
"description": "אכלוס כל 48 ההחלטות עם author='daphna' כדי שאחזור-הבלוק (search_similar_paragraphs) יחזיר פסקאות אמיתיות של דפנה. documents.py:186-215 + סקריפט חד-פעמי. תלוי: אין. MVP-enabler.",
|
"description": "אכלוס כל 48 ההחלטות עם author='daphna' כדי שאחזור-הבלוק (search_similar_paragraphs) יחזיר פסקאות אמיתיות של דפנה. documents.py:186-215 + סקריפט חד-פעמי. תלוי: אין. MVP-enabler.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:47.959Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 91,
|
"id": "91",
|
||||||
"title": "[רכישת-סגנון T2] הרחבת search_similar_paragraphs — סינון outcome+practice_area+block_type",
|
"title": "[רכישת-סגנון T2] הרחבת search_similar_paragraphs — סינון outcome+practice_area+block_type",
|
||||||
"description": "db.py:2243 — להוסיף סינון, להחזיר פסקה מלאה, להרחיב לבלוקים ז/ח (לא רק י). block_writer.py:710 מעביר outcome, 4→6 exemplars. תלוי: T1.",
|
"description": "db.py:2243 — להוסיף סינון, להחזיר פסקה מלאה, להרחיב לבלוקים ז/ח (לא רק י). block_writer.py:710 מעביר outcome, 4→6 exemplars. תלוי: T1.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:47.965Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 92,
|
"id": "92",
|
||||||
"title": "[רכישת-סגנון T3] דוגמאות contrastive + תיוג 'תבנית-קול בלבד'",
|
"title": "[רכישת-סגנון T3] דוגמאות contrastive + תיוג 'תבנית-קול בלבד'",
|
||||||
"description": "להחזיר גם 'במה דפנה שונה' לא רק דומה (author-features+contrastive, arxiv 2504.08745). תלוי: T2,T0.",
|
"description": "להחזיר גם 'במה דפנה שונה' לא רק דומה (author-features+contrastive, arxiv 2504.08745). תלוי: T2,T0.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:47.973Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 93,
|
"id": "93",
|
||||||
"title": "[רכישת-סגנון T4] חיבור learning_loop ל-mark-final דרך ה-curator",
|
"title": "[רכישת-סגנון T4] חיבור learning_loop ל-mark-final דרך ה-curator",
|
||||||
"description": "mark-final מסמן+מעיר; curator מריץ ingest_final_version (claude_session לא בקונטיינר). app.py:3217-3283, paperclip_client.py, hermes-curator.md. תלוי: אין. MVP.",
|
"description": "mark-final מסמן+מעיר; curator מריץ ingest_final_version (claude_session לא בקונטיינר). app.py:3217-3283, paperclip_client.py, hermes-curator.md. תלוי: אין. MVP.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:47.982Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 94,
|
"id": "94",
|
||||||
"title": "[רכישת-סגנון T5] פנקס-התאמה draft_final_pairs + snapshot ב-mark-final (INV-LRN4)",
|
"title": "[רכישת-סגנון T5] פנקס-התאמה draft_final_pairs + snapshot ב-mark-final (INV-LRN4)",
|
||||||
"description": "טבלה draft_final_pairs(case_id,draft_text,final_text,diff_stats,status,created_at). snapshot של הטיוטה ברגע mark-final (אחרת diff מזוהם). זו 'רשימת ההחלטות' של כלל-העל + ground-truth ל-T7. תלוי: T4. MVP.",
|
"description": "טבלה draft_final_pairs(case_id,draft_text,final_text,diff_stats,status,created_at). snapshot של הטיוטה ברגע mark-final (אחרת diff מזוהם). זו 'רשימת ההחלטות' של כלל-העל + ground-truth ל-T7. תלוי: T4. MVP.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:47.991Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 95,
|
"id": "95",
|
||||||
"title": "[רכישת-סגנון T6] פנקס-התאמה ב-UI + קטגוריה במרכז-אישורים",
|
"title": "[רכישת-סגנון T6] פנקס-התאמה ב-UI + קטגוריה במרכז-אישורים",
|
||||||
"description": "רשימת כל ההחלטות + סטטוס (draft_done/final_received/analyzed/lessons_folded). מרכז-אישורים: קטגוריה 'ממתינות להשוואה מול סופי'. תלוי: T5.",
|
"description": "רשימת כל ההחלטות + סטטוס (draft_done/final_received/analyzed/lessons_folded). מרכז-אישורים: קטגוריה 'ממתינות להשוואה מול סופי'. תלוי: T5.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:47.998Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 96,
|
"id": "96",
|
||||||
"title": "[רכישת-סגנון T7] מדד מרחק-סגנון (style_distance.py)",
|
"title": "[רכישת-סגנון T7] מדד מרחק-סגנון (style_distance.py)",
|
||||||
"description": "golden_ratio_adherence + anti_pattern_hits + draft_to_final_diff (ללא LLM). lessons.py יקבל ANTI_PATTERNS. חשיפה דרך get_metrics/tool. תלוי: T5. MVP.",
|
"description": "golden_ratio_adherence + anti_pattern_hits + draft_to_final_diff (ללא LLM). lessons.py יקבל ANTI_PATTERNS. חשיפה דרך get_metrics/tool. תלוי: T5. MVP.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:48.007Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 97,
|
"id": "97",
|
||||||
"title": "[רכישת-סגנון T8] הסרת LIMIT 20 ב-style_analyzer (כיסוי 48/48)",
|
"title": "[רכישת-סגנון T8] הסרת LIMIT 20 ב-style_analyzer (כיסוי 48/48)",
|
||||||
"description": "style_analyzer.py:124 — LIMIT 20→פרמטר/הסרה. מזין author-features של T0. תלוי: אין.",
|
"description": "style_analyzer.py:124 — LIMIT 20→פרמטר/הסרה. מזין author-features של T0. תלוי: אין.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:48.015Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 98,
|
"id": "98",
|
||||||
"title": "[רכישת-סגנון T9] תיקון-המספור: ביטול אנטי-דפוס + מספור-אוטומטי בייצוא",
|
"title": "[רכישת-סגנון T9] תיקון-המספור: ביטול אנטי-דפוס + מספור-אוטומטי בייצוא",
|
||||||
"description": "ביטול 'אסור רשימה ממוספרת' (voice-fingerprint 3.1 — שגוי, ההחלטה ממוספרת תמיד). ייצוא DOCX יחיל מספור-אוטומטי של Word (skills/dafna-decision-template); הכותב יפסיק להזריק מספרים ידניים. בדיקה: האם הייצוא כבר ממספר אוטומטית. תלוי: אין.",
|
"description": "ביטול 'אסור רשימה ממוספרת' (voice-fingerprint 3.1 — שגוי, ההחלטה ממוספרת תמיד). ייצוא DOCX יחיל מספור-אוטומטי של Word (skills/dafna-decision-template); הכותב יפסיק להזריק מספרים ידניים. בדיקה: האם הייצוא כבר ממספר אוטומטית. תלוי: אין.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:48.022Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 99,
|
"id": "99",
|
||||||
"title": "[רכישת-סגנון T10] get_style_guide דינמי — golden-ratios נמדדים מקורפוס",
|
"title": "[רכישת-סגנון T10] get_style_guide דינמי — golden-ratios נמדדים מקורפוס",
|
||||||
"description": "drafting.py:68 — golden-ratios נמדדים מהקורפוס לצד הקבועים, סימון פער. תלוי: T1,T8.",
|
"description": "drafting.py:68 — golden-ratios נמדדים מהקורפוס לצד הקבועים, סימון פער. תלוי: T1,T8.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:48.028Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 100,
|
"id": "100",
|
||||||
"title": "[רכישת-סגנון T11] regen API types + deploy",
|
"title": "[רכישת-סגנון T11] regen API types + deploy",
|
||||||
"description": "npm run api:types ב-web-ui אם נוסף tool/endpoint; commit+push+Coolify deploy; MCP restart מקומי. תלוי: כל משימה שמשנה endpoint/tool.",
|
"description": "npm run api:types ב-web-ui אם נוסף tool/endpoint; commit+push+Coolify deploy; MCP restart מקומי. תלוי: כל משימה שמשנה endpoint/tool.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:48.038Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 101,
|
"id": "101",
|
||||||
"title": "[רכישת-סגנון T12] /methodology — קטגוריות profile חדשות",
|
"title": "[רכישת-סגנון T12] /methodology — קטגוריות profile חדשות",
|
||||||
"description": "להוסיף ל-CRUD הגנרי (/api/methodology/{category}) קטגוריות: transition_phrases, anti_patterns, voice_invariants (קבועי voice-fingerprint). + טאבים ב-web-ui/src/app/methodology. תלוי: אין.",
|
"description": "להוסיף ל-CRUD הגנרי (/api/methodology/{category}) קטגוריות: transition_phrases, anti_patterns, voice_invariants (קבועי voice-fingerprint). + טאבים ב-web-ui/src/app/methodology. תלוי: אין.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:48.044Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 102,
|
"id": "102",
|
||||||
"title": "[רכישת-סגנון T13] /training — טאבי learning חדשים",
|
"title": "[רכישת-סגנון T13] /training — טאבי learning חדשים",
|
||||||
"description": "טאב מדד-מרחק (מגמת T7), טאב פנקס-התאמה (T6), חיווט 'השוואה' ל-draft_final_pairs, פורטרט 'נמדד מול יעד'. תלוי: T5,T7.",
|
"description": "טאב מדד-מרחק (מגמת T7), טאב פנקס-התאמה (T6), חיווט 'השוואה' ל-draft_final_pairs, פורטרט 'נמדד מול יעד'. תלוי: T5,T7.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:48.051Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 103,
|
"id": "103",
|
||||||
"title": "[רכישת-סגנון T14] אישור הצעות-distillation של ה-curator → כתיבה לפרופיל",
|
"title": "[רכישת-סגנון T14] אישור הצעות-distillation של ה-curator → כתיבה לפרופיל",
|
||||||
"description": "משטח אישור הצעות ה-curator (שער INV-G10) שכותב ל-methodology/voice-fingerprint. /training טאב הסוכן. תלוי: T4.",
|
"description": "משטח אישור הצעות ה-curator (שער INV-G10) שכותב ל-methodology/voice-fingerprint. /training טאב הסוכן. תלוי: T4.",
|
||||||
"details": "",
|
"details": "",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-06-06T21:02:48.060Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-06-03T16:56:13.158Z",
|
"lastModified": "2026-06-06T21:02:48.060Z",
|
||||||
"taskCount": 86,
|
"taskCount": 103,
|
||||||
"completedCount": 77,
|
"completedCount": 95,
|
||||||
"tags": [
|
"tags": [
|
||||||
"legal-ai"
|
"legal-ai"
|
||||||
],
|
]
|
||||||
"created": "2026-06-06T12:53:14.496Z",
|
|
||||||
"description": "Tasks for legal-ai context",
|
|
||||||
"updated": "2026-06-06T15:58:42.555Z"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,3 +155,78 @@ CEO צריך להעביר את ה-issue ל-`in_review` (לא `in_progress`) כש
|
|||||||
### סטטוס
|
### סטטוס
|
||||||
|
|
||||||
- **תיקון בכל הסקייל** (CLAUDE.md זיכרון: `reference_paperclip_wakeup.md`)
|
- **תיקון בכל הסקייל** (CLAUDE.md זיכרון: `reference_paperclip_wakeup.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. מחיקת npx cache → crash-loop בהפעלה (השרת מנצח את הפאטצ')
|
||||||
|
|
||||||
|
### מה קורה
|
||||||
|
|
||||||
|
Paperclip מופעל דרך `exec npx -y paperclipai@<version> run` ב-[start-paperclip.sh](../../.paperclip/scripts/start-paperclip.sh). npx **עושה reuse** ל-cache שכבר חולץ (`~/.npm/_npx/<hash>/node_modules/@paperclipai/server/`) — הוא **לא** מחלץ מחדש בכל הפעלה. כל עוד ה-cache קיים, הפאטצ'ים שהוחלו עליו פעם אחת נשמרים על פני ריסטארטים.
|
||||||
|
|
||||||
|
הבעיה מתחילה כש-ה-cache **נמחק** (`npm cache clean`, prune, או ניקוי ידני) בזמן שהתהליך רץ. אז נוצרות שתי תקלות נפרדות:
|
||||||
|
|
||||||
|
1. **התהליך הישן ממשיך "online" אבל שבור** — המודולים של node כבר טעונים בזיכרון, אז `/api/health` עדיין מחזיר 200, אבל `GET /` קורא את `ui-dist/index.html` **מהדיסק בכל בקשה** (`readFileSync`) → `ENOENT` → **HTTP 500** (`{"error":"Internal server error"}`). גם ה-URL הציבורי `pc.nautilus...` מחזיר 500.
|
||||||
|
2. **בריסטארט נכנסים ל-crash-loop** — npx מחלץ עותק **טרי ולא-מתוקן**. השרת מריץ `assertCloudDatabaseContract()` (ראה patch §4 ב-start script) שמסרב ל-embedded PG במצב authenticated/public → **קורס מיד**, לפני שלולאת-הרקע (5/20/60ש') מספיקה להחיל את פאטץ' ה-bypass. כל ריסטארט מחלץ-וקורס מחדש ⇒ עשרות ריסטארטים, שום דבר לא מאזין על 3100.
|
||||||
|
|
||||||
|
### ראיה אמפירית — 06/06/26
|
||||||
|
|
||||||
|
```
|
||||||
|
# התהליך הישן: online 5D אבל GET / נכשל
|
||||||
|
GET / 500 — ENOENT: no such file or directory,
|
||||||
|
open '.../@paperclipai/server/ui-dist/index.html'
|
||||||
|
/api/health → 200 # שורד כי לא קורא קבצים
|
||||||
|
|
||||||
|
# אחרי restart: crash-loop
|
||||||
|
pm2 describe paperclip → status: "waiting restart", restarts: 36, nothing on :3100
|
||||||
|
ERROR log → "Paperclip server failed to start.
|
||||||
|
authenticated public deployments require DATABASE_URL ...;
|
||||||
|
refusing embedded PostgreSQL fallback"
|
||||||
|
```
|
||||||
|
|
||||||
|
הורדת החבילה איטית (~30ש', native builds) — מה שמחמיר את ה-loop: `min_uptime` של PM2 קוטע את ה-npx **באמצע ההורדה** לפני שהוא מסיים לחלץ, כך שה-cache לעולם לא מתמלא.
|
||||||
|
|
||||||
|
### ההשפעה על הצינור שלנו
|
||||||
|
|
||||||
|
Paperclip מושבת לגמרי — ה-UI לא עולה לאף משתמש, וכל סוכני Paperclip (14 הסוכנים) לא יכולים לרוץ כי הם חולקים את התהליך הזה.
|
||||||
|
|
||||||
|
### תיקון — שער סינכרוני לפני הפעלת השרת
|
||||||
|
|
||||||
|
**שורש הבעיה:** פאטץ' ה-cloud-db-bypass חייב להיות על הדיסק **לפני** שהשרת רץ; לולאת-הרקע מאוחרת מדי. ב-[start-paperclip.sh](../../.paperclip/scripts/start-paperclip.sh) נוספה `ensure_patched_before_run()` (06/06/26) שרצה סינכרונית לפני `exec`:
|
||||||
|
|
||||||
|
1. בודקת אם `@paperclipai/server/ui-dist/index.html` קיים ב-cache (ראה "מלכודות בדרך" — זה הסמן הנכון, לא `dist/index.js`).
|
||||||
|
2. אם לא — מריצה `npx -y paperclipai@<version> --help`. זה מאלץ את npx **לחלץ את כל החבילה** (כולל `ui-dist/`) כדי להריץ את ה-CLI, שמדפיס help ו**יוצא לבד ב-exit 0** — **לא** מפעיל שרת ולא תופס את 3100 (אומת). אין תהליך-רקע, אין שרת לא-מתוקן מוקדם, ואין מה להרוג.
|
||||||
|
3. מחילה את **כל** הפאטצ'ים (כולל bypass) על ה-cache המחולץ — עם guard שלא מפיל את ה-wrapper אם patch נכשל.
|
||||||
|
4. רק אז `exec npx ... run` — npx עושה reuse ל-cache המתוקן והשרת עולה נקי.
|
||||||
|
|
||||||
|
לולאת-הרקע (post-exec) נשמרה כרשת-ביטחון idempotent.
|
||||||
|
|
||||||
|
**אומת מקצה-לקצה (06/06/26):** מחיקת ה-cache בכוונה + `pm2 restart` → השער חילץ אוטומטית דרך `--help` (~64ש'), תיקן, והשרת עלה ל-200 ב-~72ש'. מונה הריסטארטים של PM2 **לא זז** (אפס crash-loop).
|
||||||
|
|
||||||
|
> **מלכודות שהתגלו בדרך (גרסה ראשונה של הפיקס נכשלה):**
|
||||||
|
> 1. **סמן חילוץ שגוי** — `dist/index.js` נכתב ~שניות **לפני** `ui-dist/`. שער שממתין ל-`dist` ומריץ מיד → ui-dist עדיין חסר → 500. הסמן הנכון הוא `ui-dist/index.html` (הקובץ האחרון, וגם זה שגרם ל-500 המקורי).
|
||||||
|
> 2. **`set -e` + patch כושל** — אם `apply-hebrew.sh` רץ בלי ui-dist הוא מחזיר שגיאה, ותחת `set -e` ה-wrapper מת → crash-loop חדש. הפתרון: `apply_all_patches || echo WARNING`.
|
||||||
|
> 3. **`pkill -f "paperclipai@..."` תופס את עצמו** — מחרוזת הדפוס מופיעה ב-command line של ה-shell שמריץ את ה-pkill, אז הוא הורג את עצמו (exit 144). זו הסיבה שגישת spawn-`run`-then-`pkill` ננטשה לטובת `--help` שיוצא לבד. אם בכל זאת צריך להרוג — לפי PID (`kill $PID; pkill -P $PID`), לא לפי `-f`.
|
||||||
|
|
||||||
|
**שחזור** — עם הפיקס פרוס, מספיק `pm2 restart paperclip` וה-`ensure_patched_before_run()` מתאושש לבד. אם צריך לעשות זאת ידנית (fix אחר, דיבוג):
|
||||||
|
```bash
|
||||||
|
pm2 stop paperclip # לעצור loop אם קיים
|
||||||
|
export PATH=/home/chaim/.nvm/versions/node/v24.14.0/bin:$PATH
|
||||||
|
npx -y paperclipai@2026.529.0 --help >/dev/null 2>&1 # חילוץ נקי שיוצא לבד (לא מפעיל שרת)
|
||||||
|
find ~/.npm/_npx -path "*@paperclipai/server/ui-dist/index.html" -type f # לאמת חילוץ מלא
|
||||||
|
# להחיל פאטצ'ים על ה-cache, ובמיוחד ה-bypass:
|
||||||
|
bash ~/.paperclip/hermes-patches/apply-cloud-db-bypass.sh
|
||||||
|
bash ~/.paperclip/hebrew/apply-hebrew.sh
|
||||||
|
bash ~/.paperclip/hermes-patches/apply-hermes-fixes.sh
|
||||||
|
bash ~/.paperclip/hermes-patches/apply-deepseek-reaper-fix.sh
|
||||||
|
grep -q HEBREW_PATCH_BYPASS_CLOUD_DB \
|
||||||
|
~/.npm/_npx/*/node_modules/@paperclipai/server/dist/index.js && echo "BYPASS OK"
|
||||||
|
pm2 start paperclip && pm2 save # reuse ל-cache המתוקן
|
||||||
|
```
|
||||||
|
> אל תשתמש ב-`pkill -f "paperclipai@..."` / `-f "@paperclipai/server"` — הדפוס תופס את ה-shell של עצמך (exit 144). אם חייבים להרוג תהליך — לפי PID.
|
||||||
|
|
||||||
|
### סטטוס
|
||||||
|
|
||||||
|
- **תוקן ב-start script** ע"י `ensure_patched_before_run()` (06/06/26) — שער סינכרוני שמחלץ+מתקן לפני exec.
|
||||||
|
- **הערה מטעה תוקנה**: ההערה הישנה בראש ה-script טענה ש-`npx run` מחלץ-מחדש בכל הפעלה (לכן הסתמכו על לולאת-הרקע בלבד) — זה לא נכון, npx עושה reuse ל-cache תקין; הסכנה היא cache **מחוק**.
|
||||||
|
- **לקח כללי**: כל patch שה-target שלו הוא assert בזמן-startup חייב להיות מוחל לפני `exec`, לא בלולאת-רקע.
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
|
|||||||
|
|
||||||
## 7. אינדקס הספ
|
## 7. אינדקס הספ
|
||||||
|
|
||||||
> הערה: כל קבצי הספ (00, 01–07, X1–X10) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
> הערה: כל קבצי הספ (00, 01–07, X1–X12) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
|
||||||
|
|
||||||
| קובץ | תפקיד | אוכף invariants |
|
| קובץ | תפקיד | אוכף invariants |
|
||||||
|------|--------|-----------------|
|
|------|--------|-----------------|
|
||||||
@@ -250,6 +250,8 @@ Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוט
|
|||||||
| [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) | חוזה 71 כלי-ה-MCP: envelope · שמות · idempotency · extract/get-symmetry · שלמות-הרשאות | G2, G3, G10 |
|
| [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) | חוזה 71 כלי-ה-MCP: envelope · שמות · idempotency · extract/get-symmetry · שלמות-הרשאות | G2, G3, G10 |
|
||||||
| [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) | env-catalog SSoT · מקור-config יחיד (Coolify) · ללא hardcode · secrets · drift | G2, G4, G9 |
|
| [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) | env-catalog SSoT · מקור-config יחיד (Coolify) · ללא hardcode · secrets · drift | G2, G4, G9 |
|
||||||
| [X11-citation-corroboration.md](X11-citation-corroboration.md) | citator פנימי — תיקוף הלכות בטיפול-שיפוטי מצטבר · תיקון-G10 מבוקר · סף-corroboration · התאמה-להלכה | G9, G10 |
|
| [X11-citation-corroboration.md](X11-citation-corroboration.md) | citator פנימי — תיקוף הלכות בטיפול-שיפוטי מצטבר · תיקון-G10 מבוקר · סף-corroboration · התאמה-להלכה | G9, G10 |
|
||||||
|
| [X12-digests-radar.md](X12-digests-radar.md) | יומונים כשכבת-גילוי (radar) — מקור-משני המצביע על הפסק המקורי · לא קורפוס-ציטוט רביעי · לא מצוטט/לא מחלץ-הלכות | G2, G4, G9 |
|
||||||
|
| [X13-court-fetch.md](X13-court-fetch.md) | אחזור-פסיקה אוטומטי מנט המשפט — 3 שכבות (עליון/מנהלי/skip) · שירות-מארח · reCAPTCHA · שער-אנושי | G2, G3, G4, G5, G9, G10 |
|
||||||
|
|
||||||
> **X6–X10 (מחזור-2):** מכסים את 8 משטחי-האפליקציה שמחוץ לצינור-הליבה (אינטגרציה, web-ui, מילוי-שדות,
|
> **X6–X10 (מחזור-2):** מכסים את 8 משטחי-האפליקציה שמחוץ לצינור-הליבה (אינטגרציה, web-ui, מילוי-שדות,
|
||||||
> אחסון-ניתוחים, כלי-MCP, deploy/env). הממצאים ב-[gap-audit.md](gap-audit.md) (GAP-24..62 → FU-9..15)
|
> אחסון-ניתוחים, כלי-MCP, deploy/env). הממצאים ב-[gap-audit.md](gap-audit.md) (GAP-24..62 → FU-9..15)
|
||||||
|
|||||||
@@ -35,6 +35,13 @@
|
|||||||
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
|
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
|
||||||
ושם נולדת ההפרה ב-§5.
|
ושם נולדת ההפרה ב-§5.
|
||||||
|
|
||||||
|
> **שכבת-גילוי — יומונים, לא קורפוס-ציטוט.** מעל 3 הקורפוסים יושבת שכבת-radar נפרדת: **יומונים**
|
||||||
|
> (סיכומי עפר-טויסטר), בטבלה פיזית נפרדת `digests` עם כלי `search_digests`. היומון הוא **מקור משני
|
||||||
|
> המצביע** על הפסק המקורי — **אינו** קורפוס-ציטוט רביעי, **אינו** עקיב-בפלט ([INV-RET5](#inv-ret5-כל-span-מוחזר-עקיב-למקורו)),
|
||||||
|
> ו**אינו** נוגע ב-`case_law`/`document_chunks`. ההפרדה כאן **פיזית** (טבלה נפרדת), לא תנאי-סינון —
|
||||||
|
> ולכן [INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query) מתקיים טריוויאלית. מלא ב-
|
||||||
|
> [X12-digests-radar.md](X12-digests-radar.md) (INV-DIG1–DIG3).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. עיצוב ה-hybrid retrieval
|
## 2. עיצוב ה-hybrid retrieval
|
||||||
@@ -176,3 +183,4 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
|
|||||||
- [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם.
|
- [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם.
|
||||||
- [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable.
|
- [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable.
|
||||||
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5).
|
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5).
|
||||||
|
- [X12-digests-radar.md](X12-digests-radar.md) — שכבת-הגילוי (יומונים) שמעל הקורפוסים — מצביעה, לא מצוטטת.
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
|
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
|
||||||
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
|
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
|
||||||
|
|
||||||
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X10 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
מבנה: 00 חוקה · 01–07 מחזור-חיים · X1–X13 חוצי-שלבים. ראה אינדקס מלא בחוקה.
|
||||||
- X1–X5: מזהים · רב-חברתי · אינטגרציה+deploy · סוכנים · audit.
|
- X1–X5: מזהים · רב-חברתי · אינטגרציה+deploy · סוכנים · audit.
|
||||||
- X6–X10 (מחזור-2, 8 משטחי-האפליקציה): חוזה UI↔API · לקוח-Paperclip · מילוי-שדות · חוזה כלי-MCP · deploy/env/secrets.
|
- X6–X10 (מחזור-2, 8 משטחי-האפליקציה): חוזה UI↔API · לקוח-Paperclip · מילוי-שדות · חוזה כלי-MCP · deploy/env/secrets.
|
||||||
|
- X11–X13 (הרחבות-תחום): citator פנימי (תיקוף-הלכות) · יומונים כשכבת-גילוי (radar) · אחזור-פסיקה אוטומטי מנט המשפט (שירות).
|
||||||
|
|
||||||
מפות-ממצאים: [gap-audit.md](gap-audit.md) (GAP-01..62 → FU-1..15; מחזור-1 ✅ הושלם, מחזור-2 פתוח) · [ui-audit.md](ui-audit.md) (ביקורת 13 דפי-UI).
|
מפות-ממצאים: [gap-audit.md](gap-audit.md) (GAP-01..62 → FU-1..15; מחזור-1 ✅ הושלם, מחזור-2 פתוח) · [ui-audit.md](ui-audit.md) (ביקורת 13 דפי-UI).
|
||||||
בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md
|
בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md
|
||||||
|
|||||||
163
docs/spec/X12-digests-radar.md
Normal file
163
docs/spec/X12-digests-radar.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# X12 — יומונים כשכבת-גילוי (Digests Radar)
|
||||||
|
|
||||||
|
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md). הוא מגדיר **שכבת-גילוי (discovery/radar)**
|
||||||
|
מעל קורפוסי-הפסיקה: קליטה וחיפוש של **יומונים** — סיכומי-עמוד-אחד של משרד עפר טויסטר ("כל יום —
|
||||||
|
היומון לענייני תכנון ובנייה") על פסק-דין/החלטה בודדים. היומון הוא **מקור משני** המצביע על פסק-הדין
|
||||||
|
המקורי; הוא **אינו** נכנס לאף אחד מ-3 קורפוסי-הציטוט, **אינו** מצוטט בהחלטה, ו**אינו** מחלץ הלכות.
|
||||||
|
הוא נשען על [INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
(אין מסלול מקביל), [INV-G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)
|
||||||
|
(שלמות + אין בליעה שקטה) ו-[INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
|
||||||
|
(עקיבוּת-מקור), ומובחן מ-3 הקורפוסים של [03-retrieval.md](03-retrieval.md).
|
||||||
|
|
||||||
|
> **TARGET, לא תיאור-מצב.** התת-מערכת כולה היא יעד — אין כיום טבלת `digests`, כלי-`digest_*`,
|
||||||
|
> ולא אינטגרציית-חוקר. כל רכיב מסומן מפורשות כ-audit-finding לבנייה (§6). כל טענה על הקוד `file:line`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. הרעיון — radar, לא קורפוס-ציטוט
|
||||||
|
|
||||||
|
חיים מקבל כמעט יומית מייל עם **יומון**: PDF של עמוד אחד שמסכם פסק-דין/החלטה בודדים בתחום
|
||||||
|
רישוי-ובנייה / היטל-השבחה / פיצויים(ס'197). היומון אינו הטקסט המשפטי המקורי — הוא **ניתוח של צד
|
||||||
|
שלישי** (עפר טויסטר), הנושא הבהרה מודפסת: *"האמור הוא מידע ראשוני בלבד ואין הוא תחליף לייעוץ
|
||||||
|
משפטי"*. במונחי-מחקר-משפטי זהו **מקור משני (secondary authority)**: כלי-איתור והכוונה, לא סמכות
|
||||||
|
שמצטטים בהחלטה.
|
||||||
|
|
||||||
|
הערך שלו עצום דווקא כ-**radar**: כל יומון הוא *headnote + תג-נושא כתובים-מראש בידי מומחה*, המצביע
|
||||||
|
על פסק-דין מקורי. כשמנסחים החלטה, `search_digests` מחזיר את היומון הרלוונטי → החוקר קורא את ניתוח
|
||||||
|
טויסטר **כרקע** → מחלץ את מראה-המקום של פסק-הדין המקורי → מביא את הפסק עצמו לקורפוס-הפסיקה הקיים
|
||||||
|
(הזמינות גבוהה) → ומצטט **משם**. היומון מצביע; הציטוט תמיד נשען על המקור.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. מה היומון מכיל
|
||||||
|
|
||||||
|
מבנה קבוע (אומת מול הקבצים ב-`data/precedents/incoming/`, יומון 5158/5159/5160/5163):
|
||||||
|
|
||||||
|
| רכיב | דוגמה | תפקיד |
|
||||||
|
|------|-------|-------|
|
||||||
|
| מספר-יומון + תאריך-גיליון | `יומון מס' 5163 7 ביוני 2026` | מפתח-upsert + `digest_date` |
|
||||||
|
| תג-מושג | `"שיקול הדעת המצומצם"` | ציר-נושא לחיפוש |
|
||||||
|
| כותרת-הלכה | `ביהמ"ש - שיקול דעת הוועדה המחוזית אינו מצומצם...` | הסיכום בשורה |
|
||||||
|
| גוף-ניתוח (1–2 עמ') | ניתוח עפר-טויסטר | רקע + מקור-embedding |
|
||||||
|
| מראה-מקום בתחתית | `עת"מ 46111-12-22 יכין-אפק... ניתן 3.6.26... שופטת: יעל טויסטר ישראלי` | **השדה הקריטי** — הגשר לפסק המקורי |
|
||||||
|
|
||||||
|
`underlying_date` (מתן הפסק) שונה מ-`digest_date` (גיליון היומון) — מקור-באגים נפוץ; חילוץ-המטא-דאטה
|
||||||
|
מבחין ביניהם מפורשות.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. למה זה לא קורפוס-ציטוט רביעי (הקושיה המרכזית — G2)
|
||||||
|
|
||||||
|
[03-retrieval.md §1](03-retrieval.md#1-שלושת-הקורפוסים-וכלי-החיפוש) מגדיר 3 **קורפוסי-ציטוט**:
|
||||||
|
מסמכי-תיק+סגנון-דפנה, פסיקה-חיצונית, החלטות-ועדה. השאלה: האם יומונים = רביעי, ובכך הפרת
|
||||||
|
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)?
|
||||||
|
|
||||||
|
**לא — בתנאי המסגור הנכון.** G2 אוסר *מסלול מקביל ליכולת קיימת*. יומונים אינם עוד-מסלול-לאחזור-
|
||||||
|
פסיקה אלא **bounded context נפרד**: ישות נפרדת (`digests`, לא `case_law`), מטרה נפרדת (הצבעה ולא
|
||||||
|
ציטוט), וחוזה נפרד. ההבחנה הקנונית: 3 הקורפוסים הם **עקיבים-בפלט** (כל ציטוט בהחלטה חוזר אליהם —
|
||||||
|
[INV-RET5](03-retrieval.md#inv-ret5-כל-span-מוחזר-עקיב-למקורו)/[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)).
|
||||||
|
היומון **לעולם אינו עקיב-אליו בפלט** (INV-DIG1) — ולכן אינו קורפוס-ציטוט רביעי, אלא שכבה
|
||||||
|
**מקדימה** לקורפוסים. הפרדת-הקורפוס מ-[INV-RET1](03-retrieval.md#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)
|
||||||
|
מתקיימת אוטומטית: `search_digests` שואל **רק** את `digests`, ואף כלי-חיפוש-פסיקה אינו נוגע בה
|
||||||
|
(הפרדה פיזית בטבלה, לא תנאי-סינון).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. המנגנון (TARGET)
|
||||||
|
|
||||||
|
```
|
||||||
|
קליטה (מסלול קצר עצמאי — INV-DIG2):
|
||||||
|
יומון PDF → extract_text → content_hash (idempotent, INV-G3)
|
||||||
|
→ חילוץ-LLM: תג-מושג / כותרת-הלכה / תקציר / מראה-מקום / שני-תאריכים / תחום / תגיות
|
||||||
|
→ INSERT digests → embedding יחיד (תג+כותרת+תקציר+ניתוח) לחיפוש סמנטי בלבד
|
||||||
|
→ try_autolink(underlying_citation → case_law) [INV-DIG3]
|
||||||
|
⚠ ללא precedent_chunks, ללא halacha-extraction, ללא precedent metadata-extractor.
|
||||||
|
|
||||||
|
חיפוש + שימוש (radar — INV-DIG1):
|
||||||
|
legal-researcher: search_digests(סוגיה)
|
||||||
|
→ קורא ניתוח טויסטר + כותרת-הלכה = רקע/orientation בלבד
|
||||||
|
→ מחלץ את מראה-המקום של הפסק המקורי
|
||||||
|
→ הפסק בקורפוס? כן → אמת+צטט כרגיל (precedent_attach) + digest_link
|
||||||
|
לא → missing_precedent_create על *הפסק המקורי*
|
||||||
|
(notes="זוהה דרך יומון מס' NNNN") [INV-DIG3]
|
||||||
|
→ היומון לעולם אינו נרשם דרך precedent_attach ואינו supporting_quote. [INV-DIG1]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Invariants של התחום
|
||||||
|
|
||||||
|
### INV-DIG1: היומון מצביע, לא מצוטט
|
||||||
|
**כלל:** רשומת-`digest` לעולם אינה משמשת כ-`supporting_quote`/provenance בפלט-החלטה; כל ציטוט
|
||||||
|
בהחלטה נגזר מקורפוס-ציטוט (`case_law`/`document_chunks`). היומון הוא מקור משני — כלי-איתור,
|
||||||
|
לא סמכות-מצוטטת. החוקר רושם אותו כ-radar (סעיף-דוח נפרד), לא דרך `precedent_attach`.
|
||||||
|
**מקור-סמכות:** היו"ר + ההבהרה המודפסת ביומון ("מידע ראשוני בלבד... אינו תחליף לייעוץ משפטי") —
|
||||||
|
invariant תוכן-משפטי/תפעולי, **קשור** ל-[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
|
||||||
|
**מקורות (פתוחים, להבחנת מקור-ראשוני↔משני):** Georgetown Law Library — *Secondary Sources research
|
||||||
|
guide* (*"secondary sources... are not the law"*) · Amy E. Sloan, *Basic Legal Research: Tools and
|
||||||
|
Strategies* — primary vs. persuasive/secondary authority · *The Bluebook: A Uniform System of
|
||||||
|
Citation* — סיווג סמכות-ראשונית מול משנית | סטטוס: verified
|
||||||
|
**אכיפה:** היעדר FK מ-`decision_blocks`/ציטוטים ל-`digests`; ולידציית-QA ([05-qa-review.md](05-qa-review.md))
|
||||||
|
שדוחה ציטוט שמקורו digest; הוראת-חוקר מפורשת ([X4-agents.md](X4-agents.md), `legal-researcher.md`).
|
||||||
|
**הפרה ידועה:** — (תת-מערכת חדשה)
|
||||||
|
|
||||||
|
### INV-DIG2: מסלול-קליטה נפרד-בכוונה — לא מסלול-פסיקה מקביל
|
||||||
|
**כלל:** קליטת-יומון היא **bounded context נפרד**, ואינה עוברת ב-precedent pipeline
|
||||||
|
([01-ingest.md](01-ingest.md)): אין `precedent_chunks`, אין halacha-extraction, אין
|
||||||
|
precedent-metadata-extractor. מסלול קצר עצמאי (`digest_library.ingest_digest`) הבונה
|
||||||
|
embedding-יחיד לחיפוש סמנטי בלבד. הצהרה זו היא מה ש**מונע** הפרת-G2 — היומון אינו ישות-אחות
|
||||||
|
של `case_law` ואינו מתפצל ממסלולו.
|
||||||
|
**מקורות:** Eric Evans, *Domain-Driven Design* (2003) — Bounded Context (הקשרים שונים = מודלים
|
||||||
|
מובחנים) · Martin Kleppmann, *DDIA* (2017) — system-of-record מובחן מ-derived/index data · Martin
|
||||||
|
Fowler — Bounded Context / Canonical Data Model | סטטוס: verified
|
||||||
|
**אכיפה:** טבלה פיזית נפרדת `digests`; `ingest_digest` עושה reuse לשירותים אטומיים בלבד
|
||||||
|
(`extractor.extract_text`, `embeddings.embed_texts`) ולא ל-`ingest.ingest_document`; ביקורת-
|
||||||
|
ארכיטקטורה. אוכף את [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||||
|
+ כלל-הנדסה "סימטריה" (§6).
|
||||||
|
**הפרה ידועה:** — (תת-מערכת חדשה)
|
||||||
|
|
||||||
|
### INV-DIG3: קישור-לפסק-המקורי הוא הגשר — חוסר-קישור הוא פער גלוי
|
||||||
|
**כלל:** לכל `digest` שדה `linked_case_law_id` (FK ל-`case_law`, nullable). כשהפסק המקורי בקורפוס —
|
||||||
|
היומון מקושר אליו (אוטומטית בקליטה לפי מראה-המקום, או ידנית ב-`digest_link`). כל עוד אינו בקורפוס,
|
||||||
|
הקישור ריק ו**הפער מוצף** דרך `missing_precedent_create` על הפסק המקורי — לא נבלע בשקט.
|
||||||
|
**מקורות:** E.F. Codd — referential integrity (foreign keys, CACM 13(6), 1970) · ISO 8000 —
|
||||||
|
completeness (פער-ידע מתועד) · DAMA-DMBOK2 — data linkage / lineage | סטטוס: verified
|
||||||
|
**אכיפה:** שדה-FK `digests.linked_case_law_id` + `try_autolink` בקליטה + כלי `digest_link`/
|
||||||
|
`digest_relink`; חוסר-קישור → `missing_precedent_create` (כלל-הנדסה "אין בליעה שקטה", §6). אוכף את
|
||||||
|
[G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) +
|
||||||
|
[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
|
||||||
|
**הפרה ידועה:** — (תת-מערכת חדשה)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. מצב קיים מול יעד — audit-findings
|
||||||
|
|
||||||
|
התת-מערכת כולה TARGET; אין כיום מימוש. רכיבים לבנייה:
|
||||||
|
|
||||||
|
- **טבלת `digests` + פונקציות-DB** — לא קיימות. יעד: `SCHEMA_V30` ב-`db.py` (טבלה + ivfflat/GIN/FTS
|
||||||
|
אינדקסים + UNIQUE חלקי על `yomon_number`/`content_hash` ל-idempotent) + `create_digest`/`search_digests`/
|
||||||
|
`link_digest_to_case_law` (§4, INV-DIG2/DIG3).
|
||||||
|
- **שירות + חילוץ-LLM** — `services/digest_library.py` + `services/digest_metadata_extractor.py`
|
||||||
|
לא קיימים. החילוץ נשען על `claude_session` (local-only — ייבוא lazy בתוך `ingest_digest` בלבד,
|
||||||
|
לא רץ בקונטיינר; תואם [claude_session local-only]).
|
||||||
|
- **כלי-MCP `digest_*`** — לא קיימים. יעד: `tools/digests.py` + רישום ב-`server.py`, מעטפת-envelope
|
||||||
|
אחידה לפי [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) (`search_digests` מובחן בשם מ-6 כלי-
|
||||||
|
החיפוש הקיימים — INV-TOOL2).
|
||||||
|
- **אינטגרציית-חוקר** — `legal-researcher.md` ללא `search_digests`/`digest_link` ב-`tools:` וללא שלב-
|
||||||
|
radar. יעד: שלב סריקת-יומונים לפני האימות + סעיף-דוח נפרד "radar — לא ציטוט" (INV-DIG1).
|
||||||
|
- **UI** — אין דף `/digests`. יעד: דף נפרד (לא כרטיסייה ב-`/precedents`, לשמור גבול סמכותי/משני),
|
||||||
|
אחרי `npm run api:types` ([X6-ui-api-contract.md](X6-ui-api-contract.md)).
|
||||||
|
- **אוטומציית-קליטה (Gmail) + עלון-חודשי רב-נושאי** — שלב עתידי; שלב-1 ידני (drop ל-
|
||||||
|
`data/digests/incoming/` → `scripts/ingest_digests_batch.py`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. הפניות-אחיות
|
||||||
|
|
||||||
|
- [00-constitution.md](00-constitution.md) — G2 (אין מסלול מקביל), G4 (שלמות/אין-בליעה), G9 (עקיבוּת).
|
||||||
|
- [03-retrieval.md](03-retrieval.md) — 3 קורפוסי-הציטוט שהיומון מובחן מהם (§3); הפרדת-קורפוס.
|
||||||
|
- [01-ingest.md](01-ingest.md) — צינור-הפסיקה הקנוני שהיומון **אינו** עובר בו (INV-DIG2).
|
||||||
|
- [02-data-model.md](02-data-model.md) — `case_law` (יעד-הקישור של `linked_case_law_id`).
|
||||||
|
- [05-qa-review.md](05-qa-review.md) — שער-QA שדוחה ציטוט שמקורו digest (INV-DIG1).
|
||||||
|
- [X4-agents.md](X4-agents.md) — סוכן החוקר שצורך את ה-radar.
|
||||||
|
- [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) — חוזה כלי-ה-`digest_*`.
|
||||||
151
docs/spec/X13-court-fetch.md
Normal file
151
docs/spec/X13-court-fetch.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# X13 — אחזור-פסיקה אוטומטי מנט המשפט (Court Verdict Fetch)
|
||||||
|
|
||||||
|
> כפוף ל-[חוקת המערכת](00-constitution.md). תת-מערכת **שירות** (לא קורפוס) שמורידה פסקי-דין
|
||||||
|
> ציבוריים של בתי-משפט ומזרימה אותם ל**צינור-הקליטה הקנוני** של ספריית-הפסיקה. אחות-מושגית
|
||||||
|
> ל-[X12 — Digests Radar](X12-digests-radar.md) (הטריגר העיקרי) ול-[01-ingest](01-ingest.md)
|
||||||
|
> (היעד). אינה קורפוס רביעי ואינה מסלול-ingest מקביל.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. ייעוד והקשר
|
||||||
|
|
||||||
|
יומון (digest) מצביע על פסק-דין נושא (`underlying_citation`, למשל `עת"מ 46111-12-22`). כשהפסק
|
||||||
|
אינו בקורפוס, המערכת **מאחזרת אותו אוטומטית** ממקור ציבורי, מחלצת טקסט, וקולטת אותו דרך
|
||||||
|
`precedent_library_upload` → `ingest_precedent`. כך הופך פסק-דין מ"מצוטט-בלבד" ל"שמיש לחיפוש
|
||||||
|
וחילוץ-הלכות".
|
||||||
|
|
||||||
|
**הבחנת-מקור קריטית:** רק **פסקי-דין של בתי-משפט** ניתנים לאחזור ציבורי. **החלטות ועדת-ערר**
|
||||||
|
אינן זמינות ציבורית (נדרש נבו) — מסומנות כפער ולא נשלחות לאחזור.
|
||||||
|
|
||||||
|
**שתי דרכי-מקור ציבוריות:**
|
||||||
|
- **עליון** (עע"מ/בג"ץ/ע"א/רע"א/בר"מ/דנ"א) → `supremedecisions.court.gov.il` — הורדה ישירה (httpx), ללא CAPTCHA.
|
||||||
|
- **מנהלי/מחוזי/שלום** (עת"מ/עמ"נ/...) → מציג-התיקים של **נט המשפט** — ASP.NET WebForms
|
||||||
|
(`__doPostBack`/VIEWSTATE), anti-bot של F5, reCAPTCHA על החיפוש הציבורי, מסמכים כ-S3 cleared URLs.
|
||||||
|
מחייב **דפדפן-אמת** (host-side), ולכן שירות-מארח ב-pm2 (כדפוס `legal-chat-service`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ארכיטקטורה — שלוש שכבות (tiered)
|
||||||
|
|
||||||
|
```
|
||||||
|
underlying_citation → [classifier] → tier ∈ {supreme, admin, skip}
|
||||||
|
skip(ערר/בל"מ) → missing_precedent (נבו ידני) — לא אחזור
|
||||||
|
supreme → Tier 0: httpx בקונטיינר → supremedecisions — אוטונומי מלא
|
||||||
|
admin → Tier 1: legal-court-fetch-service (host/pm2) — אוטונומי-first
|
||||||
|
→ Camoufox stealth browser → external-search → reCAPTCHA(audio/Whisper)
|
||||||
|
→ download cleared PDF
|
||||||
|
→ Tier 2 fallback: VNC ידני / missing_precedent + התראה — שער-אנושי
|
||||||
|
(כל ה-tiers) → precedent_library_upload(source_type=court_ruling) → ingest_precedent
|
||||||
|
→ chunks+embeddings+halachot(pending) → relink digest / close gap
|
||||||
|
```
|
||||||
|
|
||||||
|
מצב-העבודה מנוהל בטבלת-תור `court_fetch_jobs` (idempotent, נצפה, retryable).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Invariants
|
||||||
|
|
||||||
|
### INV-CF1: מסלול-קליטה יחיד — אין ingest מקביל
|
||||||
|
**כלל:** כל ה-tiers מתנקזים ל**צינור-הקליטה הקנוני היחיד** (`precedent_library_upload` →
|
||||||
|
`ingest_precedent`). המאחזר מספק קובץ+מטא בלבד; אסור לו לכתוב `case_law`/`precedent_chunks`/
|
||||||
|
`halachot` ישירות או לשכפל לוגיקת-chunking/embedding.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G2](00-constitution.md#inv-g2) (מקור-אמת יחיד, אין מסלול מקביל) על תת-מערכת זו.
|
||||||
|
**אכיפה:** האורקסטרטור קורא רק ל-API/שירות-הקליטה הקיים; ביקורת-ארכיטקטורה ב-PR.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF2: אין בליעה שקטה — כל אחזור נצפה
|
||||||
|
**כלל:** לכל פסק-דין שזוהה לאחזור יש רשומת-job עם סטטוס סופי מפורש
|
||||||
|
(`done`/`failed`/`manual`). כישלון-אחזור **לעולם אינו נבלע** — הוא מסומן ומועלה (Tier 2),
|
||||||
|
לא נזרק בשקט. `except: pass` אסור.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G4](00-constitution.md#inv-g4) וכלל-ההנדסה "אין בליעה שקטה" (§6).
|
||||||
|
**אכיפה:** טבלת `court_fetch_jobs` (status+error+attempts) + לוג-warning בכל כישלון + Tier-2 gate.
|
||||||
|
**הפרה ידועה:** הפער הקיים ב-X12 — `try_autolink` שנכשל מחזיר `None` בשקט (יתוקן ע"י טריגר זה).
|
||||||
|
|
||||||
|
### INV-CF3: אוטונומי-first, שער-אנושי חובה ב-fallback
|
||||||
|
**כלל:** האחזור מנסה אוטונומית; אך כש-N נסיונות נכשלים, **שער-אנושי** (VNC לפתרון-CAPTCHA
|
||||||
|
חי / סימון missing_precedent + התראה) הוא **חובה, לא רשות**. המערכת אינה "מוותרת" ואינה
|
||||||
|
"מסתירה" — היא מסלימה לאדם.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G10](00-constitution.md#inv-g10) (המערכת מסייעת; שערים אנושיים = invariant).
|
||||||
|
**אכיפה:** מונה-נסיונות בטבלת-התור + מעבר אוטומטי ל-status=`manual` עם נתיב-פעולה ל-chaim.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF4: אחזור-אחראי (politeness) — סדרתי, מרווח, חתימה-אמיתית
|
||||||
|
**כלל:** האחזור מאתר-ממשלתי הוא **אחראי**: סדרתי (לא מקבילי), עם cooldown בין בקשות,
|
||||||
|
כיבוד-`robots`/תנאי-שימוש, ו-rate מתון. אסור flooding/parallel-hammering שעלול לחסום IP
|
||||||
|
או להעמיס על שירות ציבורי.
|
||||||
|
**מקורות:** RFC 9309 (*Robots Exclusion Protocol*, IETF 2022) · Google Search Central —
|
||||||
|
*Crawler / crawl-rate guidance* · OWASP — *Automated Threat Handbook* (OAT-021 Denial of
|
||||||
|
Service / responsible automation) | סטטוס: verified
|
||||||
|
**אכיפה:** האורקסטרטור והשירות אוכפים serial + `INTER_FETCH_COOLDOWN_SEC`; Camoufox מספק
|
||||||
|
חתימת-דפדפן אמיתית (לא spoof-חמדני). מראה לדפוס-התור ב-[`precedent_library.py`](../../mcp-server/src/legal_mcp/services/precedent_library.py).
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF5: אחזור idempotent
|
||||||
|
**כלל:** אחזור הוא **idempotent** — מפתח-job דטרמיניסטי לפי `case_number` מנורמל. אחזור
|
||||||
|
חוזר של אותו תיק אינו יוצר job כפול ואינו קולט פסק-דין פעמיים (upsert על המפתח הקנוני).
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G3](00-constitution.md#inv-g3) (ingest idempotent) ו-[G1](00-constitution.md#inv-g1) (מזהה מנורמל בכתיבה).
|
||||||
|
**אכיפה:** אילוץ-ייחודיות על `court_fetch_jobs.case_number_norm`; הקליטה עצמה idempotent דרך `ingest_precedent`.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF6: שער-סיווג מקור — רק פסקי-דין של בתי-משפט
|
||||||
|
**כלל:** רק ציטוט שסווג כ**פסק-דין של בית-משפט** נשלח לאחזור. **ועדת-ערר (ערר/בל"מ) לעולם
|
||||||
|
אינה נשלחת לאחזור-ציבורי** (נדרש נבו) — היא מסומנת `missing_precedent` בלבד. הפריט הנקלט
|
||||||
|
נושא `source_type=court_ruling`, `source_kind=external_upload`, `precedent_level` לפי הערכאה.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G5](00-constitution.md#inv-g5) (metadata מלא + הפרדת-קורפוס)
|
||||||
|
ותואם את הבחנת-המקור ב-[01-ingest](01-ingest.md) (`court_ruling` מול `appeals_committee`).
|
||||||
|
**אכיפה:** המסווג מחזיר `tier=skip` ל-ערר/בל"מ; הקליטה אוכפת `source_type`.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
### INV-CF7: עקיבוּת-מקור + גבול-ToS
|
||||||
|
**כלל:** כל אחזור נושם **provenance** מלא (`source_url`, tier, זמן, מזהה-job) ב-audit-trail.
|
||||||
|
האחזור מוגבל ל**מסמכים ציבוריים** הזמינים ללא הזדהות (smart-card); אופי המערכת הוא
|
||||||
|
**הורדה-בסיוע** (עם שער-אנושי), לא בוט-סמוי לעקיפת בקרת-גישה.
|
||||||
|
**מקור-סמכות:** פרויקטלי-תפעולי — מיישם את [G9](00-constitution.md#inv-g9) (עקיבוּת + audit-trail);
|
||||||
|
גבול-ה-ToS מועלה ליו"ר (חיים) כשיקול-מדיניות (עיקרון-עבודה 4: המשתמש הוא הסמכות).
|
||||||
|
**אכיפה:** `source_url`+tier נשמרים על `case_law`/`court_fetch_jobs`; שער-אנושי שומר על אופי בסיוע.
|
||||||
|
**הפרה ידועה:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. מודל-נתונים — `court_fetch_jobs`
|
||||||
|
|
||||||
|
| עמודה | טיפוס | תפקיד |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `id` | UUID PK | מזהה-job |
|
||||||
|
| `case_number_norm` | TEXT UNIQUE | מפתח-idempotency קנוני (INV-CF5) |
|
||||||
|
| `citation_raw` | TEXT | הציטוט המקורי כפי שזוהה |
|
||||||
|
| `tier` | TEXT | `supreme` \| `admin` \| `skip` |
|
||||||
|
| `court` | TEXT | ערכאה שזוהתה |
|
||||||
|
| `status` | TEXT | `pending` \| `running` \| `done` \| `failed` \| `manual` |
|
||||||
|
| `attempts` | INT | מונה-נסיונות (ל-Tier 2 gate, INV-CF3) |
|
||||||
|
| `error` | TEXT | הודעת-כישלון אחרונה (INV-CF2) |
|
||||||
|
| `case_law_id` | UUID FK | הפסק שנקלט (NULL עד done) |
|
||||||
|
| `digest_id` | UUID FK | היומון-מקור (NULL לאד-הוק) |
|
||||||
|
| `source_url` | TEXT | provenance (INV-CF7) |
|
||||||
|
| `created_at` / `updated_at` | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. רכיבי-מימוש (מיפוי לקוד)
|
||||||
|
|
||||||
|
| רכיב | קובץ | מקור-תבנית / שימוש-חוזר |
|
||||||
|
|------|------|------------------------|
|
||||||
|
| מסווג | `mcp-server/.../services/court_citation.py` | regex מ-`citation_extractor.py:67-132` |
|
||||||
|
| Tier 0 | `services/court_fetch_supreme.py` | httpx; דפוס-cooldown מ-`precedent_library.py:176-186` |
|
||||||
|
| Tier 1 שירות | `mcp-server/.../court_fetch_service/server.py` | שכפול `chat_service/server.py` (aiohttp+Bearer+bind 10.0.1.1) |
|
||||||
|
| Camoufox client | `court_fetch_service/camofox_client.py` | חיקוי `~/.hermes/.../browser_camofox.py` |
|
||||||
|
| reCAPTCHA audio | `court_fetch_service/recaptcha_audio.py` | faster-whisper מקומי |
|
||||||
|
| proxy בקונטיינר | `web/court_fetch_proxy.py` | שכפול `web/chat_proxy.py` |
|
||||||
|
| pm2 | `scripts/legal-court-fetch-service.config.cjs` | שכפול `legal-chat-service.config.cjs` |
|
||||||
|
| אורקסטרטור+תור | `services/court_fetch_orchestrator.py` + `db.py` (SCHEMA_Vxx) | דפוס-תור קיים |
|
||||||
|
| כלי-MCP | `tools/court_fetch.py` (`court_verdict_fetch`) | חוזה-envelope [X9](X9-mcp-tool-contract.md) |
|
||||||
|
| טריגר | `services/digest_library.py` (`try_autolink` fail-path) | X12 |
|
||||||
|
| סוד | `COURT_FETCH_SHARED_SECRET` (Infisical + Coolify) | דפוס `LEGAL_CHAT_SHARED_SECRET`, [X10](X10-deploy-env-secrets.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. סיכונים (R&D — לעקוב)
|
||||||
|
- reCAPTCHA נלחם פעיל בפותרי-אודיו → שיעור-כישלון אפשרי גבוה → Tier 2 הוא קו-ההגנה (INV-CF3).
|
||||||
|
- F5/anti-bot עלול לחסום IP → politeness סדרתי + Camoufox (INV-CF4).
|
||||||
|
- שבירות מול שינויי-אתר → ריכוז selectors במקום אחד + בדיקות-עשן תקופתיות.
|
||||||
|
- גבול-ToS על אתר .gov → INV-CF7 + שיקול-יו"ר.
|
||||||
@@ -162,6 +162,13 @@ HALACHA_DEDUP_COSINE = float(os.environ.get("HALACHA_DEDUP_COSINE", "0.93"))
|
|||||||
# dropping a possibly-distinct principle unreviewed. 0.83 from the same cleanup.
|
# dropping a possibly-distinct principle unreviewed. 0.83 from the same cleanup.
|
||||||
HALACHA_DEDUP_BAND_COSINE = float(os.environ.get("HALACHA_DEDUP_BAND_COSINE", "0.83"))
|
HALACHA_DEDUP_BAND_COSINE = float(os.environ.get("HALACHA_DEDUP_BAND_COSINE", "0.83"))
|
||||||
|
|
||||||
|
# Halacha review-queue clustering (#84.2) — when the review queue is requested
|
||||||
|
# with cluster=true, halachot of the SAME precedent whose rule-embeddings are
|
||||||
|
# within this cosine are grouped into ONE review card (canonical + variants), so
|
||||||
|
# the chair judges near-identical principles once instead of repeatedly. Display
|
||||||
|
# only — never merges/deletes. 0.90 = "same principle, reworded".
|
||||||
|
HALACHA_CLUSTER_COSINE = float(os.environ.get("HALACHA_CLUSTER_COSINE", "0.90"))
|
||||||
|
|
||||||
# Halacha NLI entailment validator (#81.3) — after extraction, a claude_session
|
# Halacha NLI entailment validator (#81.3) — after extraction, a claude_session
|
||||||
# judge checks each halacha's rule_statement is entailed by its supporting_quote.
|
# judge checks each halacha's rule_statement is entailed by its supporting_quote.
|
||||||
# Non-entailed (neutral/contradiction) → quality flag 'nli_unsupported' that
|
# Non-entailed (neutral/contradiction) → quality flag 'nli_unsupported' that
|
||||||
|
|||||||
7
mcp-server/src/legal_mcp/court_fetch_service/__init__.py
Normal file
7
mcp-server/src/legal_mcp/court_fetch_service/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""Host-side Tier-1 verdict fetch service (X13).
|
||||||
|
|
||||||
|
Runs on the host under pm2 (it needs a real browser, which the legal-ai
|
||||||
|
container can't run). Drives a Camoufox stealth browser against נט המשפט to
|
||||||
|
download administrative/district-court verdicts the Supreme portal (Tier 0)
|
||||||
|
doesn't carry. See docs/spec/X13-court-fetch.md.
|
||||||
|
"""
|
||||||
148
mcp-server/src/legal_mcp/court_fetch_service/camofox_client.py
Normal file
148
mcp-server/src/legal_mcp/court_fetch_service/camofox_client.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Camoufox-browser client + נט-המשפט navigation flow (X13, Tier 1).
|
||||||
|
|
||||||
|
Open-source, zero-API-cost stealth browsing: a self-hosted ``camofox-browser``
|
||||||
|
REST server (``jo-inc/camofox-browser``, wrapping Camoufox — a Firefox fork
|
||||||
|
with C++ fingerprint spoofing) drives a real browser. We talk to it over the
|
||||||
|
same REST surface the Hermes agent uses (``~/.hermes/.../browser_camofox.py``):
|
||||||
|
|
||||||
|
POST /tabs → {tab_id}
|
||||||
|
POST /tabs/{tab}/navigate {url}
|
||||||
|
GET /tabs/{tab}/snapshot → accessibility tree w/ element refs
|
||||||
|
POST /tabs/{tab}/click {ref}
|
||||||
|
POST /tabs/{tab}/type {ref,text}
|
||||||
|
GET /tabs/{tab}/screenshot
|
||||||
|
DELETE /sessions/{user}
|
||||||
|
|
||||||
|
Set ``CAMOFOX_URL`` (e.g. ``http://127.0.0.1:9377``) to enable. The server's
|
||||||
|
``/health`` exposes a VNC URL — that's the human-fallback surface (INV-CF3):
|
||||||
|
when the autonomous reCAPTCHA solve fails, the chair opens the VNC and solves
|
||||||
|
it live, and this flow continues.
|
||||||
|
|
||||||
|
⚠ CALIBRATION: the נט-המשפט external-case-search is an ASP.NET WebForms app
|
||||||
|
behind an F5 WAF + reCAPTCHA. The element selectors and step sequence below
|
||||||
|
are the *documented plan* of the flow; they must be calibrated against the
|
||||||
|
live snapshot on first run (the site rate-limited static probing during
|
||||||
|
development). Every step that can't find its target **raises** a clear Hebrew
|
||||||
|
reason (INV-CF2 — no silent success-with-garbage) so the orchestrator escalates
|
||||||
|
to the Tier-2 human fallback rather than returning an empty/wrong file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# נט המשפט public entry points (discovered from the homepage __doPostBack menu).
|
||||||
|
NGCS_HOME = "https://www.court.gov.il/ngcs.web.site/homepage.aspx"
|
||||||
|
|
||||||
|
CAMOFOX_URL = os.environ.get("CAMOFOX_URL", "").rstrip("/")
|
||||||
|
_TIMEOUT = float(os.environ.get("COURT_FETCH_BROWSER_TIMEOUT_S", "60"))
|
||||||
|
|
||||||
|
|
||||||
|
class CamofoxUnavailable(RuntimeError):
|
||||||
|
"""camofox-browser isn't configured/reachable."""
|
||||||
|
|
||||||
|
|
||||||
|
class NgcsFlowError(RuntimeError):
|
||||||
|
"""A step in the נט-המשפט flow failed (selector/CAPTCHA/navigation)."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_enabled() -> bool:
|
||||||
|
return bool(CAMOFOX_URL)
|
||||||
|
|
||||||
|
|
||||||
|
async def health() -> dict:
|
||||||
|
"""Probe camofox-browser; surfaces the VNC URL for the human fallback."""
|
||||||
|
if not CAMOFOX_URL:
|
||||||
|
raise CamofoxUnavailable("CAMOFOX_URL is not set")
|
||||||
|
async with httpx.AsyncClient(timeout=10) as c:
|
||||||
|
r = await c.get(f"{CAMOFOX_URL}/health")
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
class _Browser:
|
||||||
|
"""Thin async wrapper over the camofox-browser REST surface."""
|
||||||
|
|
||||||
|
def __init__(self, client: httpx.AsyncClient, tab_id: str, user_id: str):
|
||||||
|
self._c = client
|
||||||
|
self.tab = tab_id
|
||||||
|
self.user = user_id
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def open(cls, client: httpx.AsyncClient) -> "_Browser":
|
||||||
|
r = await client.post(f"{CAMOFOX_URL}/tabs", json={})
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
return cls(client, data["tab_id"], data.get("user_id", data["tab_id"]))
|
||||||
|
|
||||||
|
async def navigate(self, url: str) -> None:
|
||||||
|
r = await self._c.post(f"{CAMOFOX_URL}/tabs/{self.tab}/navigate", json={"url": url})
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
async def snapshot(self) -> dict:
|
||||||
|
r = await self._c.get(f"{CAMOFOX_URL}/tabs/{self.tab}/snapshot")
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
async def click(self, ref: str) -> dict:
|
||||||
|
r = await self._c.post(f"{CAMOFOX_URL}/tabs/{self.tab}/click", json={"ref": ref})
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
async def type(self, ref: str, text: str) -> None:
|
||||||
|
r = await self._c.post(
|
||||||
|
f"{CAMOFOX_URL}/tabs/{self.tab}/type", json={"ref": ref, "text": text}
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
try:
|
||||||
|
await self._c.delete(f"{CAMOFOX_URL}/sessions/{self.user}")
|
||||||
|
except httpx.HTTPError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_admin_verdict(
|
||||||
|
*, file_number: str, month: str, year: str, case_number: str, court: str
|
||||||
|
) -> dict:
|
||||||
|
"""Drive נט המשפט to download an admin/district verdict PDF.
|
||||||
|
|
||||||
|
Returns ``{content: bytes, filename: str, source_url: str, court: str}``.
|
||||||
|
Raises ``CamofoxUnavailable`` / ``NgcsFlowError`` on failure.
|
||||||
|
|
||||||
|
The flow (to be calibrated against the live snapshot):
|
||||||
|
1. Open the homepage; trigger "חיפוש תיקים חיצוני" (btnExternalSearchCases).
|
||||||
|
2. Fill the case-number / month / year fields.
|
||||||
|
3. Solve the reCAPTCHA via the audio challenge (recaptcha_audio); on
|
||||||
|
repeated failure, surface the VNC URL for a human solve (INV-CF3).
|
||||||
|
4. Submit; open the matched case; locate the verdict ("פסק דין") document.
|
||||||
|
5. Download the cleared PDF (served via S3 pre-signed URL) and return bytes.
|
||||||
|
"""
|
||||||
|
if not CAMOFOX_URL:
|
||||||
|
raise CamofoxUnavailable(
|
||||||
|
"שירות-הדפדפן (camofox-browser) אינו מוגדר — הגדר CAMOFOX_URL "
|
||||||
|
"והפעל את jo-inc/camofox-browser. ראה docs/spec/X13-court-fetch.md."
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
br = await _Browser.open(client)
|
||||||
|
try:
|
||||||
|
await br.navigate(NGCS_HOME)
|
||||||
|
snap = await br.snapshot()
|
||||||
|
_ = snap # calibration anchor: locate btnExternalSearchCases here.
|
||||||
|
|
||||||
|
# The concrete selector/CAPTCHA/download steps require live
|
||||||
|
# calibration with camofox running. Until calibrated we fail
|
||||||
|
# loudly so the orchestrator escalates to the human fallback
|
||||||
|
# (INV-CF2/CF3) rather than pretending success.
|
||||||
|
raise NgcsFlowError(
|
||||||
|
"זרימת נט-המשפט (Tier 1) ממתינה לכיול מול snapshot חי של "
|
||||||
|
"camofox-browser — בקשת-אחזור מוסלמת ל-fallback אנושי (VNC/ידני)."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await br.close()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Open-source reCAPTCHA v2 audio-challenge solver (X13, Tier 1).
|
||||||
|
|
||||||
|
Pure open-source, zero-API-cost: switch the reCAPTCHA widget to its **audio**
|
||||||
|
challenge, download the mp3, transcribe it with a **local Whisper** model
|
||||||
|
(``faster-whisper``), and submit the transcript. This is the well-known
|
||||||
|
"Buster"-style technique. It is intentionally a *best-effort* solver —
|
||||||
|
reCAPTCHA actively fights audio solving, so a non-trivial failure rate is
|
||||||
|
expected and handled by the Tier-2 human fallback (INV-CF3), never hidden.
|
||||||
|
|
||||||
|
Model is loaded lazily and cached; ``WHISPER_MODEL`` (default ``small``) and
|
||||||
|
``WHISPER_DEVICE`` (default ``cpu``) tune it. The dependency is optional — if
|
||||||
|
``faster-whisper`` isn't installed, ``transcribe_audio`` raises a clear error
|
||||||
|
so the caller falls back to a human solve rather than crashing the service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_WHISPER_MODEL_NAME = os.environ.get("WHISPER_MODEL", "small")
|
||||||
|
_WHISPER_DEVICE = os.environ.get("WHISPER_DEVICE", "cpu")
|
||||||
|
_model = None
|
||||||
|
|
||||||
|
|
||||||
|
class AudioSolveUnavailable(RuntimeError):
|
||||||
|
"""faster-whisper isn't installed — cannot solve audio locally."""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_model():
|
||||||
|
global _model
|
||||||
|
if _model is not None:
|
||||||
|
return _model
|
||||||
|
try:
|
||||||
|
from faster_whisper import WhisperModel # type: ignore
|
||||||
|
except ImportError as e:
|
||||||
|
raise AudioSolveUnavailable(
|
||||||
|
"faster-whisper אינו מותקן — לא ניתן לפתור reCAPTCHA אודיו מקומית. "
|
||||||
|
"התקן `pip install faster-whisper` או הסתמך על fallback אנושי (VNC)."
|
||||||
|
) from e
|
||||||
|
logger.info("loading whisper model %s on %s", _WHISPER_MODEL_NAME, _WHISPER_DEVICE)
|
||||||
|
_model = WhisperModel(
|
||||||
|
_WHISPER_MODEL_NAME, device=_WHISPER_DEVICE, compute_type="int8"
|
||||||
|
)
|
||||||
|
return _model
|
||||||
|
|
||||||
|
|
||||||
|
async def download_audio(audio_url: str) -> bytes:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as c:
|
||||||
|
r = await c.get(audio_url)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.content
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_audio(mp3_bytes: bytes) -> str:
|
||||||
|
"""Transcribe a reCAPTCHA audio clip to its (English) digit/word phrase.
|
||||||
|
|
||||||
|
Raises ``AudioSolveUnavailable`` if the local model isn't installed.
|
||||||
|
"""
|
||||||
|
model = _get_model()
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=True) as f:
|
||||||
|
f.write(mp3_bytes)
|
||||||
|
f.flush()
|
||||||
|
# reCAPTCHA audio is English regardless of page locale.
|
||||||
|
segments, _info = model.transcribe(f.name, language="en")
|
||||||
|
text = " ".join(seg.text for seg in segments).strip()
|
||||||
|
# Normalise: reCAPTCHA expects the bare phrase, lower-case, no punctuation.
|
||||||
|
cleaned = "".join(ch for ch in text.lower() if ch.isalnum() or ch.isspace())
|
||||||
|
return " ".join(cleaned.split())
|
||||||
|
|
||||||
|
|
||||||
|
async def solve_from_audio_url(audio_url: str) -> str:
|
||||||
|
"""Convenience: download + transcribe an audio-challenge URL."""
|
||||||
|
mp3 = await download_audio(audio_url)
|
||||||
|
return transcribe_audio(mp3)
|
||||||
145
mcp-server/src/legal_mcp/court_fetch_service/server.py
Normal file
145
mcp-server/src/legal_mcp/court_fetch_service/server.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""Host-side HTTP bridge for Tier-1 verdict fetching (X13).
|
||||||
|
|
||||||
|
Mirrors ``legal_mcp.chat_service.server`` — the proven host-side pattern: an
|
||||||
|
aiohttp app, bound to the docker bridge gateway, Bearer-auth, that does the one
|
||||||
|
thing the container can't (here: drive a real browser against נט המשפט).
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /fetch body {file_number, month, year, case_number, court}
|
||||||
|
→ {ok, content_b64, filename, source_url, court, reason}
|
||||||
|
REQUIRES Authorization: Bearer <COURT_FETCH_SHARED_SECRET>.
|
||||||
|
GET /health liveness (no auth); reports camofox + VNC URL if available.
|
||||||
|
|
||||||
|
Run with pm2:
|
||||||
|
pm2 start scripts/legal-court-fetch-service.config.cjs
|
||||||
|
|
||||||
|
Security posture (identical rationale to legal-chat-service):
|
||||||
|
1. Bind defaults to ``10.0.1.1`` (docker0 bridge gateway) — reachable from
|
||||||
|
the host + containers on docker bridges, invisible to outside networks.
|
||||||
|
2. ``/fetch`` requires a Bearer token (constant-time compare); the service
|
||||||
|
refuses to start without ``COURT_FETCH_SHARED_SECRET`` set.
|
||||||
|
3. ``/health`` is unauthenticated and spawns nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
_pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
if _pkg_root not in sys.path:
|
||||||
|
sys.path.insert(0, _pkg_root)
|
||||||
|
|
||||||
|
from legal_mcp.court_fetch_service import camofox_client # noqa: E402
|
||||||
|
|
||||||
|
logger = logging.getLogger("legal_court_fetch_service")
|
||||||
|
|
||||||
|
_SHARED_SECRET: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
async def health(request: web.Request) -> web.Response:
|
||||||
|
info = {"ok": True, "service": "legal-court-fetch-service",
|
||||||
|
"camofox_enabled": camofox_client.is_enabled()}
|
||||||
|
if camofox_client.is_enabled():
|
||||||
|
try:
|
||||||
|
info["camofox"] = await camofox_client.health()
|
||||||
|
except Exception as e: # health must never throw
|
||||||
|
info["camofox_error"] = str(e)
|
||||||
|
return web.json_response(info)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_bearer(request: web.Request) -> web.Response | None:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
expected = "Bearer " + _SHARED_SECRET
|
||||||
|
if not auth or not hmac.compare_digest(auth, expected):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "unauthorized: missing or invalid Bearer token"}, status=401
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch(request: web.Request) -> web.Response:
|
||||||
|
unauth = _check_bearer(request)
|
||||||
|
if unauth is not None:
|
||||||
|
return unauth
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.json_response({"error": "invalid JSON body"}, status=400)
|
||||||
|
|
||||||
|
required = ("file_number", "month", "year")
|
||||||
|
if not all(body.get(k) for k in required):
|
||||||
|
return web.json_response(
|
||||||
|
{"ok": False, "reason": f"missing one of {required}"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await camofox_client.fetch_admin_verdict(
|
||||||
|
file_number=str(body["file_number"]),
|
||||||
|
month=str(body["month"]),
|
||||||
|
year=str(body["year"]),
|
||||||
|
case_number=str(body.get("case_number", "")),
|
||||||
|
court=str(body.get("court", "")),
|
||||||
|
)
|
||||||
|
return web.json_response({
|
||||||
|
"ok": True,
|
||||||
|
"content_b64": base64.b64encode(result["content"]).decode("ascii"),
|
||||||
|
"filename": result.get("filename", ""),
|
||||||
|
"source_url": result.get("source_url", ""),
|
||||||
|
"court": result.get("court", ""),
|
||||||
|
})
|
||||||
|
except (camofox_client.CamofoxUnavailable, camofox_client.NgcsFlowError) as e:
|
||||||
|
# Expected, recoverable failure → orchestrator escalates (INV-CF3).
|
||||||
|
return web.json_response({"ok": False, "reason": str(e)}, status=200)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.exception("fetch failed")
|
||||||
|
return web.json_response({"ok": False, "reason": f"unexpected: {e}"}, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
def build_app() -> web.Application:
|
||||||
|
app = web.Application(client_max_size=64 * 1024 * 1024)
|
||||||
|
app.router.add_get("/health", health)
|
||||||
|
app.router.add_post("/fetch", fetch)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="legal-court-fetch-service")
|
||||||
|
parser.add_argument("--port", type=int, default=8771)
|
||||||
|
parser.add_argument("--host", default="10.0.1.1",
|
||||||
|
help="bind address; default = docker0 bridge gateway")
|
||||||
|
parser.add_argument("--log-level", default="INFO")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=args.log_level.upper(),
|
||||||
|
format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
secret = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
|
||||||
|
if not secret:
|
||||||
|
logger.error(
|
||||||
|
"COURT_FETCH_SHARED_SECRET is empty; refusing to start. Set it in "
|
||||||
|
"/home/chaim/.legal-court-fetch-service.env (loaded by pm2) and "
|
||||||
|
"mirror it as a Coolify env var on the legal-ai app."
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
if len(secret) < 24:
|
||||||
|
logger.error("COURT_FETCH_SHARED_SECRET too short (>=32 chars expected).")
|
||||||
|
return 2
|
||||||
|
global _SHARED_SECRET
|
||||||
|
_SHARED_SECRET = secret
|
||||||
|
|
||||||
|
app = build_app()
|
||||||
|
logger.info("legal-court-fetch-service listening on %s:%d", args.host, args.port)
|
||||||
|
web.run_app(app, host=args.host, port=args.port, print=lambda _m: None)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -58,6 +58,8 @@ from legal_mcp.tools import ( # noqa: E402
|
|||||||
missing_precedents as mp_tools,
|
missing_precedents as mp_tools,
|
||||||
citations as cit_tools,
|
citations as cit_tools,
|
||||||
training_enrichment as train_tools,
|
training_enrichment as train_tools,
|
||||||
|
digests as digest_tools,
|
||||||
|
court_fetch as cf_tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -340,6 +342,75 @@ async def search_precedent_library(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Digests radar (X12) — secondary discovery layer; NOT a citation corpus.
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_upload(
|
||||||
|
file_path: str,
|
||||||
|
yomon_number: str = "",
|
||||||
|
digest_date: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""העלאת יומון ("כל יום") לקורפוס-הגילוי (radar) + חילוץ מטא-דאטה אוטומטי. היומון הוא מקור-משני המצביע על הפסק המקורי — אינו מצוטט בהחלטה ואינו מחלץ הלכות (INV-DIG1/2). practice_area: rishuy_uvniya / betterment_levy / compensation_197."""
|
||||||
|
return await digest_tools.digest_upload(
|
||||||
|
file_path, yomon_number, digest_date, practice_area,
|
||||||
|
appeal_subtype, subject_tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_list(
|
||||||
|
practice_area: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
linked: bool | None = None,
|
||||||
|
search: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> str:
|
||||||
|
"""רשימת יומונים בקורפוס-הגילוי, עם פילטרים. linked=false → יומונים שהפסק המקורי שלהם עוד לא נקלט לספריית הפסיקה (פער-ידע גלוי, INV-DIG3)."""
|
||||||
|
return await digest_tools.digest_list(
|
||||||
|
practice_area, concept_tag, linked, search, _clamp_limit(limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_get(digest_id: str) -> str:
|
||||||
|
"""יומון ספציפי לפי מזהה (כולל מראה-מקום, ניתוח, וקישור לפסק המקורי אם קיים)."""
|
||||||
|
return await digest_tools.digest_get(digest_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_link(digest_id: str, case_law_id: str) -> str:
|
||||||
|
"""קישור ידני של יומון לפסק הדין המקורי בספריית הפסיקה (INV-DIG3). idempotent."""
|
||||||
|
return await digest_tools.digest_link(digest_id, case_law_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_relink(digest_id: str) -> str:
|
||||||
|
"""ניסיון-קישור מחדש: בודק אם פסק הדין המקורי של היומון כבר בספרייה ומקשר אוטומטית."""
|
||||||
|
return await digest_tools.digest_relink(digest_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_delete(digest_id: str) -> str:
|
||||||
|
"""מחיקת יומון מקורפוס-הגילוי."""
|
||||||
|
return await digest_tools.digest_delete(digest_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_digests(
|
||||||
|
query: str,
|
||||||
|
practice_area: str = "",
|
||||||
|
subject_tag: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> str:
|
||||||
|
"""חיפוש סמנטי בקורפוס-הגילוי (יומוני "כל יום") — מצפן-מחקר (radar). מחזיר את היומון הרלוונטי + מראה-המקום של הפסק המקורי. ⚠️ היומון אינו מצוטט בהחלטה (INV-DIG1) — הצטט מהפסק המקורי דרך search_precedent_library. החוקר משתמש בזה בשלב 2ב.0 לפני האימות."""
|
||||||
|
return await digest_tools.search_digests(
|
||||||
|
query, practice_area, subject_tag, concept_tag, _clamp_limit(limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def halacha_review(
|
async def halacha_review(
|
||||||
halacha_id: str,
|
halacha_id: str,
|
||||||
@@ -895,6 +966,22 @@ async def missing_precedent_close(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Court verdict auto-fetch (X13) ────────────────────────────────
|
||||||
|
@mcp.tool()
|
||||||
|
async def court_verdict_fetch(citation: str) -> str:
|
||||||
|
"""אחזור אוטומטי של פסק-דין בית-משפט מנט המשפט/פורטל-העליון וקליטה לקורפוס.
|
||||||
|
|
||||||
|
מסווג את הציטוט (עליון→Tier0 / מנהלי→Tier1 / ערר→skip), מוריד וקולט דרך
|
||||||
|
צינור-הקליטה הקנוני. דוגמה: 'עת"מ 46111-12-22'. כלי מקומי בלבד."""
|
||||||
|
return await cf_tools.court_verdict_fetch(citation)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def court_fetch_status(case_number: str = "", status_filter: str = "") -> str:
|
||||||
|
"""סטטוס תור-אחזור הפסיקה. case_number לפריט יחיד, או status_filter (pending/failed/manual/done)."""
|
||||||
|
return await cf_tools.court_fetch_status(case_number, status_filter)
|
||||||
|
|
||||||
|
|
||||||
# ── Internal citations graph (TaskMaster #34) ─────────────────────
|
# ── Internal citations graph (TaskMaster #34) ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
204
mcp-server/src/legal_mcp/services/court_citation.py
Normal file
204
mcp-server/src/legal_mcp/services/court_citation.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""Court-citation classifier for the auto-fetch subsystem (X13).
|
||||||
|
|
||||||
|
Given a raw citation string (typically a digest's ``underlying_citation``,
|
||||||
|
e.g. ``עת"מ 46111-12-22 יכין-אפק נ' הוועדה המחוזית``), decide:
|
||||||
|
|
||||||
|
* **which tier** can fetch it (``supreme`` | ``admin`` | ``skip``), and
|
||||||
|
* the **canonical case number** plus, for נט המשפט, the
|
||||||
|
(file, month, year) triple the public case-search form needs.
|
||||||
|
|
||||||
|
Tier mapping (INV-CF6 — only court rulings are auto-fetched; ועדת-ערר is
|
||||||
|
never sent to a public fetch, it needs Nevo):
|
||||||
|
|
||||||
|
* ``supreme`` — Supreme Court prefixes (עע"מ/בג"ץ/ע"א/רע"א/דנ"א/בר"מ/בש"א).
|
||||||
|
Fetched directly from ``supremedecisions.court.gov.il`` (Tier 0, no CAPTCHA).
|
||||||
|
* ``admin`` — district / administrative-court prefixes (עת"מ/עמ"נ/…) and
|
||||||
|
the bare נט-המשפט "filed" format ``NNNNN-MM-YY``. Fetched via the
|
||||||
|
host-side stealth browser against נט המשפט (Tier 1).
|
||||||
|
* ``skip`` — ועדת-ערר (ערר/בל"מ). Not publicly fetchable → missing_precedent.
|
||||||
|
|
||||||
|
Regex families intentionally mirror ``citation_extractor.py`` (the canonical
|
||||||
|
prefix/number patterns) so the two stay in sync — we reuse ``_NUM_RX`` shape
|
||||||
|
and ``_normalize_case_number`` semantics rather than inventing a parallel
|
||||||
|
parser (INV-CF1 / engineering "symmetry" rule).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# Canonical number core, identical shape to citation_extractor._NUM_RX:
|
||||||
|
# 3-5 digits, optional separator + 2-4 digits, optional third group
|
||||||
|
# (the NNNNN-MM-YY "filed" format — 46111-12-22 = file 46111, month 12, yr 22).
|
||||||
|
_NUM_RX = r"\d{1,5}(?:[-/]\d{1,4}(?:[-/]\d{2,4})?)?"
|
||||||
|
|
||||||
|
# Hebrew gershayim: straight (") or curly (״).
|
||||||
|
_Q = r"[\"״]"
|
||||||
|
|
||||||
|
# Optional leading one-letter Hebrew preposition/conjunction (ב/ל/ה/ו/כ/מ/ש)
|
||||||
|
# attached to the prefix — e.g. "בערר", "וערר", "כפי שקבעתי בערר". Anchored by
|
||||||
|
# a lookbehind that forbids a *preceding* Hebrew letter, so we don't match a
|
||||||
|
# prefix buried inside a longer word. Regex backtracking lets the preposition
|
||||||
|
# match empty when the prefix itself starts with one of these letters (בג"ץ).
|
||||||
|
_LEAD = r"(?<![א-ת])(?:[בלהוכמש])?"
|
||||||
|
|
||||||
|
# Supreme Court prefixes → Tier 0 (supremedecisions public download API).
|
||||||
|
_SUPREME_PREFIXES = [
|
||||||
|
rf"עע{_Q}מ", # ערעור מנהלי (לעליון)
|
||||||
|
rf"בג{_Q}ץ", # בג"ץ
|
||||||
|
rf"בג{_Q}צ", # variant spelling
|
||||||
|
rf"דנג{_Q}ץ", # דיון נוסף בג"ץ
|
||||||
|
rf"ע{_Q}א", # ערעור אזרחי
|
||||||
|
rf"רע{_Q}א", # רשות ערעור אזרחי
|
||||||
|
rf"דנ{_Q}א", # דיון נוסף אזרחי
|
||||||
|
rf"בר{_Q}מ", # בקשת רשות ערעור מנהלי (עליון)
|
||||||
|
rf"בש{_Q}א", # בקשת רשות … (עליון)
|
||||||
|
]
|
||||||
|
|
||||||
|
# District / administrative-court prefixes → Tier 1 (נט המשפט case viewer).
|
||||||
|
_ADMIN_PREFIXES = [
|
||||||
|
rf"עת{_Q}מ", # עתירה מנהלית (בימ"ש לעניינים מנהליים)
|
||||||
|
rf"עמ{_Q}נ", # ערעור מנהלי (מחוזי)
|
||||||
|
rf"ת{_Q}א", # תביעה אזרחית (מחוזי/שלום)
|
||||||
|
rf"ה{_Q}פ", # המרצת פתיחה
|
||||||
|
]
|
||||||
|
|
||||||
|
# Appeals-committee → skip (needs Nevo; never auto-fetched).
|
||||||
|
_SKIP_PREFIXES = [
|
||||||
|
rf"ערר",
|
||||||
|
rf"בל{_Q}מ",
|
||||||
|
]
|
||||||
|
|
||||||
|
_SUPREME_RX = re.compile(
|
||||||
|
_LEAD + r"(" + "|".join(_SUPREME_PREFIXES) + r")\s*(" + _NUM_RX + r")",
|
||||||
|
re.UNICODE,
|
||||||
|
)
|
||||||
|
_ADMIN_RX = re.compile(
|
||||||
|
_LEAD + r"(" + "|".join(_ADMIN_PREFIXES) + r")\s*(" + _NUM_RX + r")",
|
||||||
|
re.UNICODE,
|
||||||
|
)
|
||||||
|
_SKIP_RX = re.compile(
|
||||||
|
_LEAD + r"(" + "|".join(_SKIP_PREFIXES) + r")" + r"(?:\s*\([^)\n]{0,80}\))?\s*(" + _NUM_RX + r")",
|
||||||
|
re.UNICODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bare נט-המשפט filed format with no prefix: 46111-12-22 (5/4-digit file,
|
||||||
|
# 1-2 digit month, 2-4 digit year). Used when a digest gives just the number.
|
||||||
|
_BARE_FILED_RX = re.compile(r"(?<!\d)(\d{1,5})-(\d{1,2})-(\d{2,4})(?!\d)", re.UNICODE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CourtCitation:
|
||||||
|
"""Result of classifying a citation for auto-fetch routing."""
|
||||||
|
|
||||||
|
tier: str # "supreme" | "admin" | "skip" | "unknown"
|
||||||
|
court_prefix: str # e.g. 'עת"מ', or "" for bare/unknown
|
||||||
|
case_number_raw: str # the matched number as written, e.g. "46111-12-22"
|
||||||
|
case_number_norm: str # canonical: slashes→dashes, digits/sep only
|
||||||
|
# נט-המשפט form fields (only when the filed format NNNNN-MM-YY is present):
|
||||||
|
file_number: str | None = None
|
||||||
|
month: str | None = None
|
||||||
|
year: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fetchable(self) -> bool:
|
||||||
|
return self.tier in ("supreme", "admin")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_case_number(raw: str) -> str:
|
||||||
|
"""Canonicalize a case number for idempotency keys / matching.
|
||||||
|
|
||||||
|
Mirrors ``citation_extractor._normalize_case_number``: strip everything
|
||||||
|
but digits and separators, unify ``/`` → ``-``. Display value is never
|
||||||
|
derived from this.
|
||||||
|
"""
|
||||||
|
cleaned = re.sub(r"[^\d/\-]", "", raw or "")
|
||||||
|
return cleaned.replace("/", "-").strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_filed(num_norm: str) -> tuple[str, str, str] | None:
|
||||||
|
"""Split a normalized NNNNN-MM-YY number into (file, month, year).
|
||||||
|
|
||||||
|
Only the three-group "filed" format yields a נט-המשפט triple; two-group
|
||||||
|
formats (1234-22 / 1234/22) are Supreme-style serials and return None.
|
||||||
|
"""
|
||||||
|
m = _BARE_FILED_RX.fullmatch(num_norm)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
file_no, month, year = m.group(1), m.group(2), m.group(3)
|
||||||
|
# Plausibility: month 1-12, year 2-4 digits. Reject implausible months
|
||||||
|
# (avoids mis-reading a 2-group serial that slipped through).
|
||||||
|
if not (1 <= int(month) <= 12):
|
||||||
|
return None
|
||||||
|
return file_no, month, year
|
||||||
|
|
||||||
|
|
||||||
|
def classify(citation: str) -> CourtCitation:
|
||||||
|
"""Classify a raw citation string into a fetch tier + parsed number.
|
||||||
|
|
||||||
|
Resolution order: ועדת-ערר (skip) is checked FIRST so an "ערר" prefix is
|
||||||
|
never mis-routed to a court tier; then Supreme prefixes; then admin
|
||||||
|
prefixes; then a bare filed number defaults to ``admin`` (נט המשפט is the
|
||||||
|
only public source for prefix-less district/שלום numbers).
|
||||||
|
"""
|
||||||
|
text = (citation or "").strip()
|
||||||
|
if not text:
|
||||||
|
return CourtCitation("unknown", "", "", "")
|
||||||
|
|
||||||
|
# 1. ועדת-ערר → skip (must win over any court match).
|
||||||
|
m = _SKIP_RX.search(text)
|
||||||
|
if m:
|
||||||
|
raw = m.group(2)
|
||||||
|
return CourtCitation(
|
||||||
|
tier="skip",
|
||||||
|
court_prefix=m.group(1),
|
||||||
|
case_number_raw=raw,
|
||||||
|
case_number_norm=normalize_case_number(raw),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Supreme Court prefix → Tier 0.
|
||||||
|
m = _SUPREME_RX.search(text)
|
||||||
|
if m:
|
||||||
|
raw = m.group(2)
|
||||||
|
return CourtCitation(
|
||||||
|
tier="supreme",
|
||||||
|
court_prefix=m.group(1),
|
||||||
|
case_number_raw=raw,
|
||||||
|
case_number_norm=normalize_case_number(raw),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. District / admin prefix → Tier 1.
|
||||||
|
m = _ADMIN_RX.search(text)
|
||||||
|
if m:
|
||||||
|
raw = m.group(2)
|
||||||
|
norm = normalize_case_number(raw)
|
||||||
|
filed = _split_filed(norm)
|
||||||
|
return CourtCitation(
|
||||||
|
tier="admin",
|
||||||
|
court_prefix=m.group(1),
|
||||||
|
case_number_raw=raw,
|
||||||
|
case_number_norm=norm,
|
||||||
|
file_number=filed[0] if filed else None,
|
||||||
|
month=filed[1] if filed else None,
|
||||||
|
year=filed[2] if filed else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Bare filed number (no prefix) → default admin (נט המשפט).
|
||||||
|
m = _BARE_FILED_RX.search(text)
|
||||||
|
if m:
|
||||||
|
raw = m.group(0)
|
||||||
|
norm = normalize_case_number(raw)
|
||||||
|
filed = _split_filed(norm)
|
||||||
|
if filed:
|
||||||
|
return CourtCitation(
|
||||||
|
tier="admin",
|
||||||
|
court_prefix="",
|
||||||
|
case_number_raw=raw,
|
||||||
|
case_number_norm=norm,
|
||||||
|
file_number=filed[0],
|
||||||
|
month=filed[1],
|
||||||
|
year=filed[2],
|
||||||
|
)
|
||||||
|
|
||||||
|
return CourtCitation("unknown", "", "", "")
|
||||||
241
mcp-server/src/legal_mcp/services/court_fetch_orchestrator.py
Normal file
241
mcp-server/src/legal_mcp/services/court_fetch_orchestrator.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""X13 orchestrator — classify → fetch → ingest → record.
|
||||||
|
|
||||||
|
The single entry point (`fetch_and_ingest`) wires the three tiers to the
|
||||||
|
**canonical** precedent-ingest pipeline (INV-CF1 — no parallel ingest path)
|
||||||
|
and keeps the `court_fetch_jobs` row honest at every step (INV-CF2 — a job
|
||||||
|
always ends in an explicit terminal state, never a silent drop).
|
||||||
|
|
||||||
|
Tier routing (from `court_citation.classify`):
|
||||||
|
* ``skip`` — ועדת-ערר → never fetched; logged as a missing_precedent gap.
|
||||||
|
* ``supreme`` — Tier 0, in-process httpx (`court_fetch_supreme`).
|
||||||
|
* ``admin`` — Tier 1, the host-side stealth-browser service over loopback.
|
||||||
|
|
||||||
|
Fallback (INV-CF3): after ``MAX_AUTONOMOUS_ATTEMPTS`` autonomous failures the
|
||||||
|
job flips to ``manual`` and a missing_precedent row is opened so the chair
|
||||||
|
sees the gap and can solve the CAPTCHA live (VNC) or drop the file manually.
|
||||||
|
|
||||||
|
This module runs **in the local MCP server only** — `ingest_precedent` drives
|
||||||
|
halacha extraction via the local ``claude`` CLI (see `claude_session.py`). It
|
||||||
|
is invoked from the `court_verdict_fetch` MCP tool, not from the container.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from legal_mcp.services import court_citation, db
|
||||||
|
from legal_mcp.services.court_fetch_supreme import (
|
||||||
|
SupremeFetchError,
|
||||||
|
fetch_supreme_verdict,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# After this many autonomous failures, stop auto-retrying and escalate to a
|
||||||
|
# human (INV-CF3). Kept low — the .gov site shouldn't be hammered (INV-CF4).
|
||||||
|
MAX_AUTONOMOUS_ATTEMPTS = int(os.environ.get("COURT_FETCH_MAX_ATTEMPTS", "2"))
|
||||||
|
|
||||||
|
# The host-side Tier-1 browser service (pm2). The MCP server runs on the host,
|
||||||
|
# so it reaches the service over loopback directly (the container bridge in
|
||||||
|
# web/court_fetch_proxy.py is a separate, optional entry point).
|
||||||
|
COURT_FETCH_SERVICE_URL = os.environ.get(
|
||||||
|
"COURT_FETCH_SERVICE_URL", "http://127.0.0.1:8771"
|
||||||
|
)
|
||||||
|
_SHARED_SECRET = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
|
||||||
|
_TIER1_TIMEOUT_S = float(os.environ.get("COURT_FETCH_TIER1_TIMEOUT_S", "300"))
|
||||||
|
|
||||||
|
# Provenance level by tier — Supreme rulings are binding; admin-court verdicts
|
||||||
|
# are administrative (set is_binding conservatively True, chair can downgrade).
|
||||||
|
_LEVEL_BY_TIER = {"supreme": "עליון", "admin": "מנהלי"}
|
||||||
|
|
||||||
|
|
||||||
|
class _Tier1Unavailable(RuntimeError):
|
||||||
|
"""The host browser service is not reachable / not configured."""
|
||||||
|
|
||||||
|
|
||||||
|
async def _ingest_bytes(
|
||||||
|
*, content: bytes, filename: str, citation: str, tier: str,
|
||||||
|
court: str, source_url: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Stage bytes to a temp file and run the canonical ingest (INV-CF1)."""
|
||||||
|
from legal_mcp.services import precedent_library
|
||||||
|
|
||||||
|
suffix = Path(filename).suffix or ".pdf"
|
||||||
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
prefix="court_fetch_", suffix=suffix, delete=False
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
tmp.write(content)
|
||||||
|
tmp.flush()
|
||||||
|
tmp.close()
|
||||||
|
result = await precedent_library.ingest_precedent(
|
||||||
|
file_path=tmp.name,
|
||||||
|
citation=citation,
|
||||||
|
court=court,
|
||||||
|
source_type="court_ruling", # INV-CF6
|
||||||
|
precedent_level=_LEVEL_BY_TIER.get(tier, ""),
|
||||||
|
is_binding=True,
|
||||||
|
)
|
||||||
|
# Stamp provenance on the new case_law row (INV-CF7).
|
||||||
|
case_law_id = result.get("case_law_id")
|
||||||
|
if case_law_id and source_url:
|
||||||
|
try:
|
||||||
|
await db.update_case_law(
|
||||||
|
UUID(str(case_law_id)), source_url=source_url
|
||||||
|
)
|
||||||
|
except Exception: # provenance is best-effort, never blocks ingest
|
||||||
|
logger.warning("could not stamp source_url on %s", case_law_id)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_tier1_admin(cit: court_citation.CourtCitation) -> dict:
|
||||||
|
"""Call the host-side browser service to fetch an admin-court verdict.
|
||||||
|
|
||||||
|
Returns the service's JSON: ``{ok, content_b64, filename, source_url,
|
||||||
|
court, reason}``. Raises ``_Tier1Unavailable`` if the service can't be
|
||||||
|
reached, ``SupremeFetchError``-style RuntimeError on a fetch failure the
|
||||||
|
service reports.
|
||||||
|
"""
|
||||||
|
if not (cit.file_number and cit.month and cit.year):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"מספר-תיק {cit.case_number_norm} אינו בפורמט נט-המשפט (תיק-חודש-שנה)"
|
||||||
|
)
|
||||||
|
headers = {"Authorization": f"Bearer {_SHARED_SECRET}"} if _SHARED_SECRET else {}
|
||||||
|
payload = {
|
||||||
|
"file_number": cit.file_number,
|
||||||
|
"month": cit.month,
|
||||||
|
"year": cit.year,
|
||||||
|
"case_number": cit.case_number_norm,
|
||||||
|
"court": cit.court_prefix,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIER1_TIMEOUT_S) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{COURT_FETCH_SERVICE_URL}/fetch", json=payload, headers=headers
|
||||||
|
)
|
||||||
|
except httpx.ConnectError as e:
|
||||||
|
raise _Tier1Unavailable(
|
||||||
|
f"שירות-האחזור (legal-court-fetch-service) אינו זמין ב-"
|
||||||
|
f"{COURT_FETCH_SERVICE_URL}: {e}"
|
||||||
|
) from e
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"שירות-האחזור החזיר {resp.status_code}: {resp.text[:200]}")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_and_ingest(
|
||||||
|
citation: str, *, digest_id: UUID | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""Classify a citation, fetch the verdict, ingest it, and record the job.
|
||||||
|
|
||||||
|
Idempotent on the canonical case number (INV-CF5): a case already fetched
|
||||||
|
(job ``done``) is returned without re-fetching.
|
||||||
|
"""
|
||||||
|
cit = court_citation.classify(citation)
|
||||||
|
|
||||||
|
# ── skip: ועדת-ערר — never auto-fetched (INV-CF6). Surface as a gap. ──
|
||||||
|
if cit.tier == "skip":
|
||||||
|
await _open_gap(citation, reason="ועדת-ערר — לא ניתן לאחזור ציבורי (נדרש נבו)")
|
||||||
|
return {"status": "skipped", "tier": "skip", "citation": citation,
|
||||||
|
"reason": "appeals_committee — needs Nevo"}
|
||||||
|
if cit.tier == "unknown" or not cit.case_number_norm:
|
||||||
|
return {"status": "unrecognized", "citation": citation}
|
||||||
|
|
||||||
|
# ── idempotent job row ──
|
||||||
|
job = await db.court_fetch_job_upsert(
|
||||||
|
case_number_norm=cit.case_number_norm,
|
||||||
|
citation_raw=citation,
|
||||||
|
tier=cit.tier,
|
||||||
|
court=cit.court_prefix,
|
||||||
|
digest_id=digest_id,
|
||||||
|
)
|
||||||
|
if job.get("status") == "done":
|
||||||
|
return {"status": "already_done", "job": job}
|
||||||
|
if job.get("status") == "manual":
|
||||||
|
return {"status": "awaiting_manual", "job": job}
|
||||||
|
|
||||||
|
job_id = UUID(str(job["id"]))
|
||||||
|
await db.court_fetch_job_update(job_id, status="running", bump_attempts=True)
|
||||||
|
|
||||||
|
# ── fetch ──
|
||||||
|
try:
|
||||||
|
if cit.tier == "supreme":
|
||||||
|
fetched = await fetch_supreme_verdict(
|
||||||
|
citation=citation, case_number_norm=cit.case_number_norm
|
||||||
|
)
|
||||||
|
content, filename = fetched.content, fetched.filename
|
||||||
|
source_url, court = fetched.source_url, fetched.court
|
||||||
|
else: # admin → Tier 1
|
||||||
|
res = await _fetch_tier1_admin(cit)
|
||||||
|
if not res.get("ok"):
|
||||||
|
raise RuntimeError(res.get("reason") or "אחזור נכשל")
|
||||||
|
import base64
|
||||||
|
content = base64.b64decode(res["content_b64"])
|
||||||
|
filename = res.get("filename") or f"{cit.case_number_norm}.pdf"
|
||||||
|
source_url = res.get("source_url", "")
|
||||||
|
court = res.get("court") or cit.court_prefix
|
||||||
|
except (_Tier1Unavailable, SupremeFetchError, RuntimeError) as e:
|
||||||
|
return await _record_failure(job_id, cit, citation, str(e))
|
||||||
|
|
||||||
|
# ── ingest into the canonical pipeline (INV-CF1) ──
|
||||||
|
try:
|
||||||
|
result = await _ingest_bytes(
|
||||||
|
content=content, filename=filename, citation=citation,
|
||||||
|
tier=cit.tier, court=court, source_url=source_url,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 — recorded, never swallowed (INV-CF2)
|
||||||
|
logger.exception("ingest failed for %s", cit.case_number_norm)
|
||||||
|
return await _record_failure(job_id, cit, citation, f"קליטה נכשלה: {e}")
|
||||||
|
|
||||||
|
case_law_id = result.get("case_law_id")
|
||||||
|
await db.court_fetch_job_update(
|
||||||
|
job_id, status="done",
|
||||||
|
case_law_id=UUID(str(case_law_id)) if case_law_id else None,
|
||||||
|
source_url=source_url, error="",
|
||||||
|
)
|
||||||
|
return {"status": "done", "tier": cit.tier, "case_law_id": case_law_id,
|
||||||
|
"citation": citation, "source_url": source_url, "ingest": result}
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_failure(
|
||||||
|
job_id: UUID, cit: court_citation.CourtCitation, citation: str, err: str
|
||||||
|
) -> dict:
|
||||||
|
"""Record a fetch/ingest failure; escalate to manual after N attempts (INV-CF3)."""
|
||||||
|
job = await db.court_fetch_job_get(cit.case_number_norm)
|
||||||
|
attempts = (job or {}).get("attempts", 1)
|
||||||
|
if attempts >= MAX_AUTONOMOUS_ATTEMPTS:
|
||||||
|
await db.court_fetch_job_update(job_id, status="manual", error=err)
|
||||||
|
await _open_gap(
|
||||||
|
citation,
|
||||||
|
reason=f"אחזור אוטונומי נכשל ({attempts} נסיונות) — נדרשת הורדה ידנית. {err}",
|
||||||
|
)
|
||||||
|
logger.warning("court fetch escalated to manual: %s — %s", citation, err)
|
||||||
|
return {"status": "manual", "citation": citation, "error": err,
|
||||||
|
"attempts": attempts}
|
||||||
|
await db.court_fetch_job_update(job_id, status="failed", error=err)
|
||||||
|
logger.warning("court fetch failed (will retry): %s — %s", citation, err)
|
||||||
|
return {"status": "failed", "citation": citation, "error": err,
|
||||||
|
"attempts": attempts}
|
||||||
|
|
||||||
|
|
||||||
|
async def _open_gap(citation: str, *, reason: str) -> None:
|
||||||
|
"""Open a missing_precedent gap so the chair sees it (INV-CF2/CF3).
|
||||||
|
|
||||||
|
Best-effort + de-duplicated by the missing_precedents layer; a failure
|
||||||
|
here is logged, never raised (it must not mask the original outcome).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db.create_missing_precedent(citation=citation, notes=reason)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("could not open missing_precedent for %s", citation)
|
||||||
181
mcp-server/src/legal_mcp/services/court_fetch_supreme.py
Normal file
181
mcp-server/src/legal_mcp/services/court_fetch_supreme.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""Tier 0 — Supreme Court verdict fetcher (X13).
|
||||||
|
|
||||||
|
Pulls a published Supreme Court verdict PDF from the **public** decisions
|
||||||
|
portal ``supremedecisions.court.gov.il`` — no smart-card, no CAPTCHA. The
|
||||||
|
portal is an AngularJS SPA backed by a small JSON API (reverse-engineered
|
||||||
|
from ``/Scripts/app/config.js`` + the search/results controllers):
|
||||||
|
|
||||||
|
POST Home/SearchVerdicts body {"document": <query>, "lan": 1} → result list
|
||||||
|
GET Home/GetCasesYearNum ?... (year + number lookup) → case + docs
|
||||||
|
GET Home/Download?path=<path>&fileName=<file>&type=4 → the PDF bytes
|
||||||
|
|
||||||
|
Two things matter for getting a 200 instead of an F5 connection-reset
|
||||||
|
(verified empirically 2026-06-07):
|
||||||
|
* a **complete** browser header set — UA + Accept + Accept-Language. A bare
|
||||||
|
UA alone gets reset.
|
||||||
|
* **politeness** (INV-CF4): one request at a time, a cooldown between them,
|
||||||
|
a Referer of the portal root. We never parallelise or hammer.
|
||||||
|
|
||||||
|
Honesty / scope: the *result→download* field mapping (where ``path`` and
|
||||||
|
``fileName`` live in the SearchVerdicts JSON) is derived from the client code,
|
||||||
|
not yet confirmed against a live JSON response (the live site rate-limited
|
||||||
|
probing during development). ``fetch_supreme_verdict`` therefore validates the
|
||||||
|
response shape and **raises** on anything unexpected (INV-CF2 — no silent
|
||||||
|
swallow) so the orchestrator can record the failure and fall back, rather than
|
||||||
|
returning a wrong/empty file. The first live run is the validation pass; see
|
||||||
|
the X13 verification section.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BASE = "https://supremedecisions.court.gov.il"
|
||||||
|
|
||||||
|
# A complete, browser-like header set. Empirically required to pass the F5
|
||||||
|
# WAF (a bare User-Agent gets a TCP reset).
|
||||||
|
_HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Accept-Language": "he-IL,he;q=0.9,en;q=0.8",
|
||||||
|
"Referer": _BASE + "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Politeness knobs (INV-CF4). Serial only — never run these concurrently.
|
||||||
|
_REQUEST_TIMEOUT_S = float(os.environ.get("COURT_FETCH_HTTP_TIMEOUT_S", "30"))
|
||||||
|
_INTER_REQUEST_COOLDOWN_S = float(os.environ.get("COURT_FETCH_COOLDOWN_S", "2"))
|
||||||
|
|
||||||
|
# type=4 → PDF in the portal's Download endpoint (from resultsControler.js).
|
||||||
|
_DOC_TYPE_PDF = "4"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FetchedVerdict:
|
||||||
|
"""A downloaded verdict file held in memory, ready for ingest."""
|
||||||
|
|
||||||
|
content: bytes
|
||||||
|
filename: str
|
||||||
|
source_url: str
|
||||||
|
court: str = "בית המשפט העליון"
|
||||||
|
|
||||||
|
|
||||||
|
class SupremeFetchError(RuntimeError):
|
||||||
|
"""Raised when the public portal returns an unexpected shape / no document.
|
||||||
|
|
||||||
|
Carries a human-readable Hebrew reason so the orchestrator can persist it
|
||||||
|
on the job row (INV-CF2) and decide on fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _get(client: httpx.AsyncClient, path: str, **kwargs) -> httpx.Response:
|
||||||
|
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
|
||||||
|
resp = await client.get(f"{_BASE}/{path.lstrip('/')}", **kwargs)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
async def _post(client: httpx.AsyncClient, path: str, json: dict) -> httpx.Response:
|
||||||
|
await asyncio.sleep(_INTER_REQUEST_COOLDOWN_S)
|
||||||
|
resp = await client.post(f"{_BASE}/{path.lstrip('/')}", json=json)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_doc_ref(results: object) -> tuple[str, str] | None:
|
||||||
|
"""Pull (path, fileName) of the first verdict document from a results blob.
|
||||||
|
|
||||||
|
The SearchVerdicts/GetCasesYearNum responses nest documents under varying
|
||||||
|
keys across the portal's endpoints. We probe the known shapes defensively
|
||||||
|
and return the first (path, fileName) pair found; ``None`` if none.
|
||||||
|
"""
|
||||||
|
def walk(node):
|
||||||
|
if isinstance(node, dict):
|
||||||
|
# A document node carries both a path and a file name.
|
||||||
|
path = node.get("Path") or node.get("path")
|
||||||
|
fname = node.get("FileName") or node.get("fileName") or node.get("Filename")
|
||||||
|
if path and fname:
|
||||||
|
yield (str(path), str(fname))
|
||||||
|
for v in node.values():
|
||||||
|
yield from walk(v)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for v in node:
|
||||||
|
yield from walk(v)
|
||||||
|
|
||||||
|
for pair in walk(results):
|
||||||
|
return pair
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_supreme_verdict(
|
||||||
|
*, citation: str, case_number_norm: str
|
||||||
|
) -> FetchedVerdict:
|
||||||
|
"""Fetch a Supreme Court verdict PDF by citation. Raises on failure.
|
||||||
|
|
||||||
|
Flow: full-text search for the citation → locate the verdict document's
|
||||||
|
(path, fileName) → download the PDF. Serial + cooled-down throughout.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
http2=True,
|
||||||
|
headers=_HEADERS,
|
||||||
|
timeout=_REQUEST_TIMEOUT_S,
|
||||||
|
follow_redirects=True,
|
||||||
|
) as client:
|
||||||
|
# 1. Search. The portal's quick-search posts {document, lan}; lan=1=Hebrew.
|
||||||
|
try:
|
||||||
|
search = await _post(
|
||||||
|
client, "Home/SearchVerdicts",
|
||||||
|
json={"document": citation, "lan": 1},
|
||||||
|
)
|
||||||
|
results = search.json()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise SupremeFetchError(
|
||||||
|
f"חיפוש בפורטל העליון נכשל עבור {citation}: {e}"
|
||||||
|
) from e
|
||||||
|
except ValueError as e: # non-JSON body
|
||||||
|
raise SupremeFetchError(
|
||||||
|
f"תשובת-חיפוש לא-JSON מהפורטל עבור {citation}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
ref = _extract_doc_ref(results)
|
||||||
|
if not ref:
|
||||||
|
raise SupremeFetchError(
|
||||||
|
f"לא נמצא מסמך-פסק עבור {citation} בפורטל העליון "
|
||||||
|
f"(ייתכן שאינו פורסם או שמבנה-התשובה השתנה)."
|
||||||
|
)
|
||||||
|
path, fname = ref
|
||||||
|
|
||||||
|
# 2. Download the PDF.
|
||||||
|
try:
|
||||||
|
dl = await _get(
|
||||||
|
client, "Home/Download",
|
||||||
|
params={"path": path, "fileName": fname, "type": _DOC_TYPE_PDF},
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise SupremeFetchError(
|
||||||
|
f"הורדת PDF נכשלה עבור {citation} (path={path}): {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
content = dl.content
|
||||||
|
ctype = dl.headers.get("content-type", "")
|
||||||
|
if not content or ("pdf" not in ctype.lower() and not content[:4] == b"%PDF"):
|
||||||
|
raise SupremeFetchError(
|
||||||
|
f"הקובץ שהתקבל עבור {citation} אינו PDF תקין (content-type={ctype})."
|
||||||
|
)
|
||||||
|
|
||||||
|
source_url = (
|
||||||
|
f"{_BASE}/Home/Download?path={path}&fileName={fname}&type={_DOC_TYPE_PDF}"
|
||||||
|
)
|
||||||
|
safe_name = fname if fname.lower().endswith(".pdf") else f"{case_number_norm}.pdf"
|
||||||
|
return FetchedVerdict(
|
||||||
|
content=content, filename=safe_name, source_url=source_url,
|
||||||
|
)
|
||||||
@@ -1232,6 +1232,155 @@ CREATE INDEX IF NOT EXISTS idx_style_exemplars_section ON style_exemplars(sectio
|
|||||||
CREATE INDEX IF NOT EXISTS idx_style_exemplars_decision ON style_exemplars(decision_number, source);
|
CREATE INDEX IF NOT EXISTS idx_style_exemplars_decision ON style_exemplars(decision_number, source);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
SCHEMA_V28_SQL = """
|
||||||
|
-- equivalent_halachot (#84.2 follow-up): halacha-level PARALLEL-AUTHORITY links.
|
||||||
|
-- Distinct from halacha_citation_corroboration (X11): that records an actual
|
||||||
|
-- citation of a halacha by a later decision; this records that two halachot of
|
||||||
|
-- DIFFERENT precedents state the same legal principle INDEPENDENTLY (no citation
|
||||||
|
-- between them). Symmetric and non-directional — stored with halacha_a < halacha_b
|
||||||
|
-- so each pair is unique and self-links are impossible. Never merges/deletes the
|
||||||
|
-- halachot; it only relates them so the chair sees a principle recurs across
|
||||||
|
-- committees (a real-but-non-citation signal the citator must not fabricate).
|
||||||
|
CREATE TABLE IF NOT EXISTS equivalent_halachot (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
halacha_a UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
|
||||||
|
halacha_b UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
|
||||||
|
cosine NUMERIC(4,3) DEFAULT 0,
|
||||||
|
note TEXT DEFAULT '',
|
||||||
|
created_by TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
CHECK (halacha_a < halacha_b),
|
||||||
|
UNIQUE (halacha_a, halacha_b)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_equiv_halacha_a ON equivalent_halachot(halacha_a);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_equiv_halacha_b ON equivalent_halachot(halacha_b);
|
||||||
|
"""
|
||||||
|
|
||||||
|
SCHEMA_V29_SQL = """
|
||||||
|
-- halacha_goldset (#81.7/#81.8): a human-tagged evaluation set. A stratified
|
||||||
|
-- sample of halachot the chair/Dafna labels (is_holding / correct_type /
|
||||||
|
-- quote_complete) so we can measure the extraction validators' precision/recall
|
||||||
|
-- and recalibrate the auto-approve threshold. The tags are the ground truth —
|
||||||
|
-- they MUST be human (no AI pre-fill) to avoid circular bias.
|
||||||
|
CREATE TABLE IF NOT EXISTS halacha_goldset (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
halacha_id UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
|
||||||
|
batch TEXT NOT NULL DEFAULT 'default',
|
||||||
|
is_holding BOOLEAN, -- NULL until tagged
|
||||||
|
correct_type TEXT DEFAULT '', -- binding | interpretive | obiter | application | ''
|
||||||
|
quote_complete BOOLEAN,
|
||||||
|
tagged_by TEXT DEFAULT '',
|
||||||
|
tagged_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
UNIQUE (halacha_id, batch)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_goldset_batch ON halacha_goldset(batch);
|
||||||
|
|
||||||
|
-- AI second-opinion (a QA aid, NOT ground truth): an INDEPENDENT local-LLM
|
||||||
|
-- judgment shown beside the human tag so the chair can spot disagreements and
|
||||||
|
-- reconsider. Independent of the rule-based validators that #81.8 measures, so
|
||||||
|
-- no circularity. Generated locally (claude_session); never auto-applied.
|
||||||
|
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_is_holding BOOLEAN;
|
||||||
|
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_correct_type TEXT DEFAULT '';
|
||||||
|
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_rationale TEXT DEFAULT '';
|
||||||
|
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_generated_at TIMESTAMPTZ;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_V30_SQL = """
|
||||||
|
-- digests (X12): Ofer Toister daily "כל יום" one-pagers. A SECONDARY,
|
||||||
|
-- discovery-layer ("radar") source — NOT authoritative law. Kept in its OWN
|
||||||
|
-- table (never case_law) so it cannot pollute the precedent corpus, never
|
||||||
|
-- enters the halacha pipeline (INV-DIG2), and is never cited directly in a
|
||||||
|
-- decision (INV-DIG1). Its only job is to point the researcher at the
|
||||||
|
-- UNDERLYING ruling, which is ingested separately into case_law and cited from
|
||||||
|
-- there. linked_case_law_id is the bridge (INV-DIG3): filled once the
|
||||||
|
-- underlying ruling is in the library; NULL = an open knowledge gap.
|
||||||
|
CREATE TABLE IF NOT EXISTS digests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
yomon_number TEXT NOT NULL DEFAULT '', -- "5163"
|
||||||
|
digest_date DATE, -- date of the yomon ISSUE
|
||||||
|
publication TEXT NOT NULL DEFAULT 'כל יום',
|
||||||
|
source_firm TEXT NOT NULL DEFAULT 'עפר טויסטר, עורכי דין',
|
||||||
|
concept_tag TEXT NOT NULL DEFAULT '', -- "שיקול הדעת המצומצם"
|
||||||
|
headline_holding TEXT NOT NULL DEFAULT '', -- bold subtitle = the holding
|
||||||
|
analysis_text TEXT NOT NULL DEFAULT '', -- the 1-2 page body (raw text)
|
||||||
|
summary TEXT NOT NULL DEFAULT '', -- 2-3 sentence LLM summary
|
||||||
|
underlying_citation TEXT NOT NULL DEFAULT '', -- 'עת"מ 46111-12-22 יכין-אפק...'
|
||||||
|
underlying_court TEXT NOT NULL DEFAULT '',
|
||||||
|
underlying_date DATE, -- date the RULING was given (≠ digest_date)
|
||||||
|
underlying_judge TEXT NOT NULL DEFAULT '',
|
||||||
|
practice_area TEXT NOT NULL DEFAULT '', -- rishuy_uvniya/betterment_levy/compensation_197
|
||||||
|
appeal_subtype TEXT NOT NULL DEFAULT '',
|
||||||
|
subject_tags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
linked_case_law_id UUID REFERENCES case_law(id) ON DELETE SET NULL,
|
||||||
|
embedding vector(1024), -- single vector of concept+headline+summary+analysis
|
||||||
|
source_document_path TEXT NOT NULL DEFAULT '', -- staged PDF path (rel to DATA_DIR)
|
||||||
|
content_hash TEXT NOT NULL DEFAULT '', -- sha256 of extracted text — idempotent upload
|
||||||
|
extraction_status TEXT NOT NULL DEFAULT 'pending', -- pending/processing/completed/failed
|
||||||
|
content_tsv tsvector GENERATED ALWAYS AS (
|
||||||
|
to_tsvector('simple',
|
||||||
|
coalesce(concept_tag,'') || ' ' || coalesce(headline_holding,'') || ' ' ||
|
||||||
|
coalesce(summary,'') || ' ' || coalesce(analysis_text,''))
|
||||||
|
) STORED,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Idempotent re-upload (INV-G3): same yomon number = same digest. yomon_number
|
||||||
|
-- can be '' transiently (before extraction), so the unique index is partial.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_digests_yomon_number
|
||||||
|
ON digests(yomon_number) WHERE yomon_number <> '';
|
||||||
|
-- Secondary dedup key when yomon_number couldn't be parsed.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_digests_content_hash
|
||||||
|
ON digests(content_hash) WHERE content_hash <> '';
|
||||||
|
|
||||||
|
-- HNSW (not ivfflat): the digests radar is a small, slowly-growing corpus
|
||||||
|
-- (~1/day). ivfflat trains `lists` centroids and probes a subset at query time,
|
||||||
|
-- so on a small table a single probe can hit an empty list and return 0 rows
|
||||||
|
-- (recall cliff). HNSW has no list-training/probe step — correct recall from
|
||||||
|
-- the first row — so it is the right index for a corpus that starts ~empty.
|
||||||
|
DROP INDEX IF EXISTS idx_digests_embedding; -- drop any pre-existing ivfflat
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_digests_embedding_hnsw
|
||||||
|
ON digests USING hnsw (embedding vector_cosine_ops);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_digests_linked ON digests(linked_case_law_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_digests_practice_area ON digests(practice_area);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_digests_concept_tag ON digests(concept_tag);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_digests_subject_tags ON digests USING gin(subject_tags);
|
||||||
|
-- Lexical half of a future hybrid (Phase-1 search is semantic-only; index is ready).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_digests_content_tsv ON digests USING gin(content_tsv);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── X13 — Court Verdict Fetch queue ──────────────────────────────────────
|
||||||
|
# A lightweight, observable, idempotent job queue for the auto-fetch
|
||||||
|
# subsystem (docs/spec/X13-court-fetch.md). One row per court verdict we try
|
||||||
|
# to pull from a public source. Mirrors the extraction-queue pattern: status
|
||||||
|
# is always explicit (INV-CF2 — no silent drop), the canonical case number is
|
||||||
|
# the idempotency key (INV-CF5), and ``attempts`` drives the human-fallback
|
||||||
|
# gate (INV-CF3 — flip to 'manual' after N autonomous failures).
|
||||||
|
# V31 — digests (X12) took V30 when it merged first.
|
||||||
|
SCHEMA_V31_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS court_fetch_jobs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
case_number_norm TEXT NOT NULL UNIQUE, -- idempotency key (INV-CF5)
|
||||||
|
citation_raw TEXT NOT NULL DEFAULT '',
|
||||||
|
tier TEXT NOT NULL DEFAULT '', -- supreme | admin | skip
|
||||||
|
court TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', -- pending|running|done|failed|manual
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
case_law_id UUID REFERENCES case_law(id) ON DELETE SET NULL,
|
||||||
|
digest_id UUID, -- source digest (X12), nullable for ad-hoc
|
||||||
|
source_url TEXT NOT NULL DEFAULT '', -- provenance (INV-CF7)
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_court_fetch_jobs_status ON court_fetch_jobs(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_court_fetch_jobs_digest ON court_fetch_jobs(digest_id)
|
||||||
|
WHERE digest_id IS NOT NULL;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -1263,7 +1412,11 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
|||||||
await conn.execute(SCHEMA_V25_SQL)
|
await conn.execute(SCHEMA_V25_SQL)
|
||||||
await conn.execute(SCHEMA_V26_SQL)
|
await conn.execute(SCHEMA_V26_SQL)
|
||||||
await conn.execute(SCHEMA_V27_SQL)
|
await conn.execute(SCHEMA_V27_SQL)
|
||||||
logger.info("Database schema initialized (v1-v27)")
|
await conn.execute(SCHEMA_V28_SQL)
|
||||||
|
await conn.execute(SCHEMA_V29_SQL)
|
||||||
|
await conn.execute(SCHEMA_V30_SQL)
|
||||||
|
await conn.execute(SCHEMA_V31_SQL)
|
||||||
|
logger.info("Database schema initialized (v1-v31)")
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -3438,6 +3591,311 @@ async def delete_case_law(case_law_id: UUID) -> bool:
|
|||||||
return result == "DELETE 1"
|
return result == "DELETE 1"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Digests (X12 — radar layer; separate table, INV-DIG1/2/3) ────────
|
||||||
|
|
||||||
|
_DIGEST_COLS = (
|
||||||
|
"id, yomon_number, digest_date, publication, source_firm, concept_tag, "
|
||||||
|
"headline_holding, analysis_text, summary, underlying_citation, "
|
||||||
|
"underlying_court, underlying_date, underlying_judge, practice_area, "
|
||||||
|
"appeal_subtype, subject_tags, linked_case_law_id, source_document_path, "
|
||||||
|
"content_hash, extraction_status, created_at, updated_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
_DIGEST_UPDATE_ALLOWED = {
|
||||||
|
"yomon_number", "digest_date", "publication", "source_firm", "concept_tag",
|
||||||
|
"headline_holding", "analysis_text", "summary", "underlying_citation",
|
||||||
|
"underlying_court", "underlying_date", "underlying_judge", "practice_area",
|
||||||
|
"appeal_subtype", "subject_tags", "source_document_path", "content_hash",
|
||||||
|
"extraction_status",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_digest(row: asyncpg.Record | dict | None) -> dict | None:
|
||||||
|
"""Normalize a digests row: ISO-format dates, ensure subject_tags is a list."""
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
d = dict(row)
|
||||||
|
for k in ("digest_date", "underlying_date", "created_at", "updated_at"):
|
||||||
|
if d.get(k) is not None and hasattr(d[k], "isoformat"):
|
||||||
|
d[k] = d[k].isoformat()
|
||||||
|
if d.get("subject_tags") is None:
|
||||||
|
d["subject_tags"] = []
|
||||||
|
if d.get("id") is not None:
|
||||||
|
d["id"] = str(d["id"])
|
||||||
|
if d.get("linked_case_law_id") is not None:
|
||||||
|
d["linked_case_law_id"] = str(d["linked_case_law_id"])
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
async def create_digest(
|
||||||
|
*,
|
||||||
|
analysis_text: str,
|
||||||
|
yomon_number: str = "",
|
||||||
|
digest_date: date | None = None,
|
||||||
|
publication: str = "כל יום",
|
||||||
|
source_firm: str = "עפר טויסטר, עורכי דין",
|
||||||
|
concept_tag: str = "",
|
||||||
|
headline_holding: str = "",
|
||||||
|
summary: str = "",
|
||||||
|
underlying_citation: str = "",
|
||||||
|
underlying_court: str = "",
|
||||||
|
underlying_date: date | None = None,
|
||||||
|
underlying_judge: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
source_document_path: str = "",
|
||||||
|
extraction_status: str = "processing",
|
||||||
|
) -> dict:
|
||||||
|
"""Upsert a digest (X12). Idempotent on yomon_number (INV-G3): a repeat
|
||||||
|
upload of the same yomon updates in place. content_hash is the secondary
|
||||||
|
dedup key for digests whose number couldn't be parsed."""
|
||||||
|
pool = await get_pool()
|
||||||
|
content_hash = _content_hash(analysis_text)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# Upsert on the partial unique index uq_digests_yomon_number
|
||||||
|
# (yomon_number WHERE yomon_number <> ''). Predicate repeated in
|
||||||
|
# ON CONFLICT as required for partial indexes.
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
INSERT INTO digests (
|
||||||
|
yomon_number, digest_date, publication, source_firm, concept_tag,
|
||||||
|
headline_holding, analysis_text, summary, underlying_citation,
|
||||||
|
underlying_court, underlying_date, underlying_judge, practice_area,
|
||||||
|
appeal_subtype, subject_tags, source_document_path,
|
||||||
|
content_hash, extraction_status
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||||
|
$14, $15, $16, $17, $18
|
||||||
|
)
|
||||||
|
ON CONFLICT (yomon_number) WHERE yomon_number <> ''
|
||||||
|
DO UPDATE SET
|
||||||
|
digest_date = COALESCE(EXCLUDED.digest_date, digests.digest_date),
|
||||||
|
publication = EXCLUDED.publication,
|
||||||
|
source_firm = EXCLUDED.source_firm,
|
||||||
|
concept_tag = EXCLUDED.concept_tag,
|
||||||
|
headline_holding = EXCLUDED.headline_holding,
|
||||||
|
analysis_text = EXCLUDED.analysis_text,
|
||||||
|
summary = EXCLUDED.summary,
|
||||||
|
underlying_citation = EXCLUDED.underlying_citation,
|
||||||
|
underlying_court = EXCLUDED.underlying_court,
|
||||||
|
underlying_date = COALESCE(EXCLUDED.underlying_date, digests.underlying_date),
|
||||||
|
underlying_judge = EXCLUDED.underlying_judge,
|
||||||
|
practice_area = EXCLUDED.practice_area,
|
||||||
|
appeal_subtype = EXCLUDED.appeal_subtype,
|
||||||
|
subject_tags = EXCLUDED.subject_tags,
|
||||||
|
source_document_path = COALESCE(NULLIF(EXCLUDED.source_document_path, ''), digests.source_document_path),
|
||||||
|
content_hash = EXCLUDED.content_hash,
|
||||||
|
extraction_status = EXCLUDED.extraction_status,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING {_DIGEST_COLS}
|
||||||
|
""",
|
||||||
|
yomon_number, digest_date, publication, source_firm, concept_tag,
|
||||||
|
headline_holding, analysis_text, summary, underlying_citation,
|
||||||
|
underlying_court, underlying_date, underlying_judge, practice_area,
|
||||||
|
appeal_subtype, list(subject_tags or []), source_document_path,
|
||||||
|
content_hash, extraction_status,
|
||||||
|
)
|
||||||
|
return _row_to_digest(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_digest(digest_id: UUID | str) -> dict | None:
|
||||||
|
pool = await get_pool()
|
||||||
|
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
f"SELECT {_DIGEST_COLS} FROM digests WHERE id = $1", cid,
|
||||||
|
)
|
||||||
|
return _row_to_digest(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_digest_by_content_hash(content_hash: str) -> dict | None:
|
||||||
|
if not content_hash:
|
||||||
|
return None
|
||||||
|
pool = await get_pool()
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
f"SELECT {_DIGEST_COLS} FROM digests WHERE content_hash = $1", content_hash,
|
||||||
|
)
|
||||||
|
return _row_to_digest(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_digest(digest_id: UUID | str, **fields) -> dict | None:
|
||||||
|
"""Patch metadata fields on a digest row. Whitelist via _DIGEST_UPDATE_ALLOWED."""
|
||||||
|
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
|
||||||
|
updates = {k: v for k, v in fields.items() if k in _DIGEST_UPDATE_ALLOWED}
|
||||||
|
if not updates:
|
||||||
|
return await get_digest(cid)
|
||||||
|
pool = await get_pool()
|
||||||
|
set_parts = []
|
||||||
|
params: list = [cid]
|
||||||
|
for i, (k, v) in enumerate(updates.items(), start=2):
|
||||||
|
if k == "subject_tags":
|
||||||
|
v = list(v or [])
|
||||||
|
set_parts.append(f"{k} = ${i}")
|
||||||
|
params.append(v)
|
||||||
|
set_parts.append("updated_at = now()")
|
||||||
|
sql = f"UPDATE digests SET {', '.join(set_parts)} WHERE id = $1 RETURNING {_DIGEST_COLS}"
|
||||||
|
row = await pool.fetchrow(sql, *params)
|
||||||
|
return _row_to_digest(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def store_digest_embedding(digest_id: UUID | str, vector: list[float]) -> None:
|
||||||
|
pool = await get_pool()
|
||||||
|
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE digests SET embedding = $2, updated_at = now() WHERE id = $1",
|
||||||
|
cid, vector,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def link_digest_to_case_law(
|
||||||
|
digest_id: UUID | str, case_law_id: UUID | str | None,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Set (or clear, with None) the bridge to the underlying ruling (INV-DIG3)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
|
||||||
|
clid = None
|
||||||
|
if case_law_id is not None:
|
||||||
|
clid = case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
f"UPDATE digests SET linked_case_law_id = $2, updated_at = now() "
|
||||||
|
f"WHERE id = $1 RETURNING {_DIGEST_COLS}",
|
||||||
|
cid, clid,
|
||||||
|
)
|
||||||
|
return _row_to_digest(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_digest(digest_id: UUID | str) -> bool:
|
||||||
|
pool = await get_pool()
|
||||||
|
cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
|
||||||
|
result = await pool.execute("DELETE FROM digests WHERE id = $1", cid)
|
||||||
|
return result == "DELETE 1"
|
||||||
|
|
||||||
|
|
||||||
|
async def list_digests(
|
||||||
|
practice_area: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
linked: bool | None = None,
|
||||||
|
search: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List digests with simple filters. linked=True/False filters on whether
|
||||||
|
the underlying ruling is in the library yet (INV-DIG3 gap surfacing)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
conditions: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
if practice_area:
|
||||||
|
conditions.append(f"practice_area = ${idx}")
|
||||||
|
params.append(practice_area)
|
||||||
|
idx += 1
|
||||||
|
if concept_tag:
|
||||||
|
conditions.append(f"concept_tag ILIKE ${idx}")
|
||||||
|
params.append(f"%{concept_tag}%")
|
||||||
|
idx += 1
|
||||||
|
if linked is True:
|
||||||
|
conditions.append("linked_case_law_id IS NOT NULL")
|
||||||
|
elif linked is False:
|
||||||
|
conditions.append("linked_case_law_id IS NULL")
|
||||||
|
if search:
|
||||||
|
conditions.append(
|
||||||
|
f"(yomon_number ILIKE ${idx} OR concept_tag ILIKE ${idx} "
|
||||||
|
f"OR headline_holding ILIKE ${idx} OR underlying_citation ILIKE ${idx} "
|
||||||
|
f"OR summary ILIKE ${idx})"
|
||||||
|
)
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
idx += 1
|
||||||
|
where_sql = (" WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||||
|
params.extend([limit, offset])
|
||||||
|
sql = (
|
||||||
|
f"SELECT {_DIGEST_COLS} FROM digests{where_sql} "
|
||||||
|
f"ORDER BY digest_date DESC NULLS LAST, created_at DESC "
|
||||||
|
f"LIMIT ${idx} OFFSET ${idx + 1}"
|
||||||
|
)
|
||||||
|
rows = await pool.fetch(sql, *params)
|
||||||
|
return [_row_to_digest(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def search_digests_semantic(
|
||||||
|
query_embedding: list[float],
|
||||||
|
practice_area: str = "",
|
||||||
|
subject_tag: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Pure-semantic search over the digests radar (X12). Single vector per row
|
||||||
|
(no chunks/halachot), so no RRF here — see X12 §6. Joins the linked ruling's
|
||||||
|
citation when present so the researcher sees the pointer target directly."""
|
||||||
|
pool = await get_pool()
|
||||||
|
conditions = ["d.embedding IS NOT NULL"]
|
||||||
|
params: list = [query_embedding, limit]
|
||||||
|
idx = 3
|
||||||
|
if practice_area:
|
||||||
|
conditions.append(f"d.practice_area = ${idx}")
|
||||||
|
params.append(practice_area)
|
||||||
|
idx += 1
|
||||||
|
if subject_tag:
|
||||||
|
conditions.append(f"${idx} = ANY(d.subject_tags)")
|
||||||
|
params.append(subject_tag)
|
||||||
|
idx += 1
|
||||||
|
if concept_tag:
|
||||||
|
conditions.append(f"d.concept_tag ILIKE ${idx}")
|
||||||
|
params.append(f"%{concept_tag}%")
|
||||||
|
idx += 1
|
||||||
|
sql = f"""
|
||||||
|
SELECT {', '.join('d.' + c for c in _DIGEST_COLS.split(', '))},
|
||||||
|
cl.case_number AS linked_case_number,
|
||||||
|
cl.case_name AS linked_case_name,
|
||||||
|
cl.searchable AS linked_searchable,
|
||||||
|
1 - (d.embedding <=> $1) AS score
|
||||||
|
FROM digests d
|
||||||
|
LEFT JOIN case_law cl ON cl.id = d.linked_case_law_id
|
||||||
|
WHERE {' AND '.join(conditions)}
|
||||||
|
ORDER BY d.embedding <=> $1
|
||||||
|
LIMIT $2
|
||||||
|
"""
|
||||||
|
rows = await pool.fetch(sql, *params)
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = _row_to_digest(r)
|
||||||
|
d["linked_case_number"] = r["linked_case_number"]
|
||||||
|
d["linked_case_name"] = r["linked_case_name"]
|
||||||
|
d["linked_searchable"] = r["linked_searchable"]
|
||||||
|
d["score"] = float(r["score"])
|
||||||
|
d["type"] = "digest"
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def find_case_law_by_citation_fuzzy(citation: str) -> dict | None:
|
||||||
|
"""Best-effort match of a digest's underlying_citation to a case_law row,
|
||||||
|
for autolink (INV-DIG3). Tries: (1) exact case_number; (2) canonical docket
|
||||||
|
substring (e.g. '46111-12-22') contained in a case_law.case_number. Returns
|
||||||
|
the first match or None — never raises, never mutates."""
|
||||||
|
citation = (citation or "").strip()
|
||||||
|
if not citation:
|
||||||
|
return None
|
||||||
|
pool = await get_pool()
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT * FROM case_law WHERE case_number = $1 LIMIT 1",
|
||||||
|
citation,
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
return _row_to_case_law(row)
|
||||||
|
# Extract a docket-like token: digits with '-' or '/' separators, e.g.
|
||||||
|
# 46111-12-22 or 3975/22. Match it as a substring of case_number.
|
||||||
|
m = re.search(r"\d+[-/]\d+(?:[-/]\d+)?", citation)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
docket = m.group(0)
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT * FROM case_law "
|
||||||
|
"WHERE case_number ILIKE $1 ORDER BY created_at LIMIT 1",
|
||||||
|
f"%{docket}%",
|
||||||
|
)
|
||||||
|
return _row_to_case_law(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def store_precedent_chunks(
|
async def store_precedent_chunks(
|
||||||
case_law_id: UUID, chunks: list[dict],
|
case_law_id: UUID, chunks: list[dict],
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -3794,6 +4252,8 @@ async def list_halachot(
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
exclude_low_quality: bool = False,
|
exclude_low_quality: bool = False,
|
||||||
order_by_priority: bool = False,
|
order_by_priority: bool = False,
|
||||||
|
cluster: bool = False,
|
||||||
|
include_equivalents: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""List halachot with optional triage controls (#84).
|
"""List halachot with optional triage controls (#84).
|
||||||
|
|
||||||
@@ -3804,6 +4264,9 @@ async def list_halachot(
|
|||||||
order_by_priority — replace FIFO with an active-learning order (#84.3):
|
order_by_priority — replace FIFO with an active-learning order (#84.3):
|
||||||
negatively-treated first, then most-uncertain (lowest confidence), then
|
negatively-treated first, then most-uncertain (lowest confidence), then
|
||||||
oldest — so the chair sees the highest-value decisions first.
|
oldest — so the chair sees the highest-value decisions first.
|
||||||
|
cluster — annotate each row with ``cluster_id`` + ``cluster_size`` (#84.2):
|
||||||
|
same-precedent halachot within HALACHA_CLUSTER_COSINE form one group so
|
||||||
|
the UI can collapse near-identical principles into a single review card.
|
||||||
"""
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
conditions = []
|
conditions = []
|
||||||
@@ -3868,9 +4331,49 @@ async def list_halachot(
|
|||||||
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()
|
||||||
out.append(d)
|
out.append(d)
|
||||||
|
if cluster and out:
|
||||||
|
await _annotate_clusters(pool, out)
|
||||||
|
if include_equivalents and out:
|
||||||
|
await _annotate_equivalents(pool, out)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _annotate_clusters(pool, out: list[dict]) -> None:
|
||||||
|
"""Add cluster_id + cluster_size to each row (#84.2), display-only.
|
||||||
|
|
||||||
|
Same-precedent halachot within HALACHA_CLUSTER_COSINE are unioned into one
|
||||||
|
group. Singletons get their own id as cluster_id and size 1. Pairwise is
|
||||||
|
confined to the returned set (cheap; the queue is ~hundreds of rows)."""
|
||||||
|
ids = [d["id"] for d in out]
|
||||||
|
max_dist = 1.0 - config.HALACHA_CLUSTER_COSINE
|
||||||
|
pairs = await pool.fetch(
|
||||||
|
"SELECT a.id AS a, b.id AS b FROM halachot a JOIN halachot b "
|
||||||
|
"ON a.case_law_id = b.case_law_id AND a.id < b.id "
|
||||||
|
"AND a.embedding IS NOT NULL AND b.embedding IS NOT NULL "
|
||||||
|
"AND (a.embedding <=> b.embedding) <= $2 "
|
||||||
|
"WHERE a.id = ANY($1::uuid[]) AND b.id = ANY($1::uuid[])",
|
||||||
|
ids, max_dist,
|
||||||
|
)
|
||||||
|
parent = {str(i): str(i) for i in ids}
|
||||||
|
|
||||||
|
def find(x: str) -> str:
|
||||||
|
while parent[x] != x:
|
||||||
|
parent[x] = parent[parent[x]]
|
||||||
|
x = parent[x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
for p in pairs:
|
||||||
|
ra, rb = find(str(p["a"])), find(str(p["b"]))
|
||||||
|
if ra != rb:
|
||||||
|
parent[ra] = rb
|
||||||
|
from collections import Counter
|
||||||
|
sizes = Counter(find(str(i)) for i in ids)
|
||||||
|
for d in out:
|
||||||
|
root = find(str(d["id"]))
|
||||||
|
d["cluster_id"] = root
|
||||||
|
d["cluster_size"] = sizes[root]
|
||||||
|
|
||||||
|
|
||||||
async def update_halacha(
|
async def update_halacha(
|
||||||
halacha_id: UUID,
|
halacha_id: UUID,
|
||||||
review_status: str | None = None,
|
review_status: str | None = None,
|
||||||
@@ -4093,6 +4596,255 @@ async def store_corroboration(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parallel-authority (equivalent halachot) — #84.2 follow-up ───────────────
|
||||||
|
#
|
||||||
|
# A NON-citation, symmetric link between halachot of different precedents that
|
||||||
|
# state the same principle. Kept entirely separate from the citation corroboration
|
||||||
|
# above so the citator's counts never include non-citation recurrences.
|
||||||
|
|
||||||
|
def _equiv_order(a: UUID, b: UUID) -> tuple[UUID, UUID]:
|
||||||
|
"""Canonical ordering (halacha_a < halacha_b) so the pair is symmetric+unique."""
|
||||||
|
return (a, b) if str(a) < str(b) else (b, a)
|
||||||
|
|
||||||
|
|
||||||
|
async def link_equivalent_halachot(
|
||||||
|
a: UUID, b: UUID, *, cosine: float = 0.0, note: str = "", created_by: str = "",
|
||||||
|
) -> bool:
|
||||||
|
"""Record that two halachot (different precedents) state the same principle.
|
||||||
|
|
||||||
|
Idempotent (symmetric UNIQUE). Returns False and does nothing if a == b or
|
||||||
|
the two belong to the SAME precedent (parallel authority is cross-precedent
|
||||||
|
by definition; within-precedent sameness is the dedup/cluster concern)."""
|
||||||
|
if a == b:
|
||||||
|
return False
|
||||||
|
pool = await get_pool()
|
||||||
|
same = await pool.fetchval(
|
||||||
|
"SELECT (SELECT case_law_id FROM halachot WHERE id=$1) "
|
||||||
|
" = (SELECT case_law_id FROM halachot WHERE id=$2)", a, b,
|
||||||
|
)
|
||||||
|
if same:
|
||||||
|
return False
|
||||||
|
lo, hi = _equiv_order(a, b)
|
||||||
|
await pool.execute(
|
||||||
|
"INSERT INTO equivalent_halachot (halacha_a, halacha_b, cosine, note, created_by) "
|
||||||
|
"VALUES ($1,$2,$3,$4,$5) ON CONFLICT (halacha_a, halacha_b) DO UPDATE SET "
|
||||||
|
"cosine=GREATEST(equivalent_halachot.cosine, EXCLUDED.cosine), "
|
||||||
|
"note=COALESCE(NULLIF(EXCLUDED.note,''), equivalent_halachot.note)",
|
||||||
|
lo, hi, round(float(cosine), 3), note, created_by,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def unlink_equivalent_halachot(a: UUID, b: UUID) -> bool:
|
||||||
|
pool = await get_pool()
|
||||||
|
lo, hi = _equiv_order(a, b)
|
||||||
|
res = await pool.execute(
|
||||||
|
"DELETE FROM equivalent_halachot WHERE halacha_a=$1 AND halacha_b=$2", lo, hi,
|
||||||
|
)
|
||||||
|
return res.endswith(" 1")
|
||||||
|
|
||||||
|
|
||||||
|
async def list_equivalent_for_halacha(halacha_id: UUID) -> list[dict]:
|
||||||
|
"""The other halachot linked as parallel authority to this one (both sides)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT e.cosine, h.id::text AS halacha_id, h.rule_statement, "
|
||||||
|
" cl.case_number, cl.case_name "
|
||||||
|
"FROM equivalent_halachot e "
|
||||||
|
"JOIN halachot h ON h.id = CASE WHEN e.halacha_a=$1 THEN e.halacha_b ELSE e.halacha_a END "
|
||||||
|
"JOIN case_law cl ON cl.id = h.case_law_id "
|
||||||
|
"WHERE e.halacha_a=$1 OR e.halacha_b=$1 "
|
||||||
|
"ORDER BY e.cosine DESC", halacha_id,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"halacha_id": r["halacha_id"],
|
||||||
|
"rule_statement": r["rule_statement"],
|
||||||
|
"case_number": r["case_number"],
|
||||||
|
"case_name": r["case_name"],
|
||||||
|
"cosine": float(r["cosine"]) if r["cosine"] is not None else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _annotate_equivalents(pool, out: list[dict]) -> None:
|
||||||
|
"""Attach an `equivalents` list to each row (#84.2) — parallel-authority links.
|
||||||
|
|
||||||
|
Adds both directions, so when both halachot of a pair are on the same page
|
||||||
|
each one lists the other."""
|
||||||
|
ids = [d["id"] for d in out]
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT e.halacha_a, e.halacha_b, e.cosine, "
|
||||||
|
" ha.rule_statement AS a_rule, cla.case_number AS a_case, "
|
||||||
|
" hb.rule_statement AS b_rule, clb.case_number AS b_case "
|
||||||
|
"FROM equivalent_halachot e "
|
||||||
|
"JOIN halachot ha ON ha.id = e.halacha_a "
|
||||||
|
"JOIN case_law cla ON cla.id = ha.case_law_id "
|
||||||
|
"JOIN halachot hb ON hb.id = e.halacha_b "
|
||||||
|
"JOIN case_law clb ON clb.id = hb.case_law_id "
|
||||||
|
"WHERE e.halacha_a = ANY($1::uuid[]) OR e.halacha_b = ANY($1::uuid[])",
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
idset = {str(i) for i in ids}
|
||||||
|
by_src: dict[str, list[dict]] = {}
|
||||||
|
for r in rows:
|
||||||
|
a, b = str(r["halacha_a"]), str(r["halacha_b"])
|
||||||
|
cos = float(r["cosine"]) if r["cosine"] is not None else None
|
||||||
|
if a in idset:
|
||||||
|
by_src.setdefault(a, []).append({
|
||||||
|
"halacha_id": b, "case_number": r["b_case"],
|
||||||
|
"rule_statement": r["b_rule"], "cosine": cos})
|
||||||
|
if b in idset:
|
||||||
|
by_src.setdefault(b, []).append({
|
||||||
|
"halacha_id": a, "case_number": r["a_case"],
|
||||||
|
"rule_statement": r["a_rule"], "cosine": cos})
|
||||||
|
for d in out:
|
||||||
|
d["equivalents"] = by_src.get(str(d["id"]), [])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Gold-set evaluation (#81.7 / #81.8) ──────────────────────────────────────
|
||||||
|
|
||||||
|
async def goldset_create_sample(
|
||||||
|
n: int = 150, batch: str = "default", reset: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Stratified sample of halachot (round-robin over case×rule_type) into a
|
||||||
|
tagging batch. Idempotent (ON CONFLICT); ``reset`` clears the batch first."""
|
||||||
|
pool = await get_pool()
|
||||||
|
if reset:
|
||||||
|
await pool.execute("DELETE FROM halacha_goldset WHERE batch = $1", batch)
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT id, case_law_id, rule_type FROM halachot WHERE rule_statement <> ''"
|
||||||
|
)
|
||||||
|
from collections import defaultdict
|
||||||
|
buckets: dict = defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
buckets[(r["case_law_id"], r["rule_type"])].append(r["id"])
|
||||||
|
keys = list(buckets.values())
|
||||||
|
sample: list = []
|
||||||
|
i = 0
|
||||||
|
while len(sample) < n and any(keys):
|
||||||
|
b = keys[i % len(keys)]
|
||||||
|
if b:
|
||||||
|
sample.append(b.pop())
|
||||||
|
i += 1
|
||||||
|
if i > n * 50:
|
||||||
|
break
|
||||||
|
inserted = 0
|
||||||
|
for hid in sample:
|
||||||
|
res = await pool.execute(
|
||||||
|
"INSERT INTO halacha_goldset (halacha_id, batch) VALUES ($1, $2) "
|
||||||
|
"ON CONFLICT (halacha_id, batch) DO NOTHING", hid, batch,
|
||||||
|
)
|
||||||
|
if res.endswith(" 1"):
|
||||||
|
inserted += 1
|
||||||
|
total = await pool.fetchval(
|
||||||
|
"SELECT count(*) FROM halacha_goldset WHERE batch = $1", batch)
|
||||||
|
return {"batch": batch, "inserted": inserted, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
async def goldset_list(batch: str = "default") -> list[dict]:
|
||||||
|
"""Gold-set items joined with the halacha content + the machine's labels."""
|
||||||
|
pool = await get_pool()
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT g.id, g.halacha_id::text AS halacha_id, g.is_holding, "
|
||||||
|
" g.correct_type, g.quote_complete, g.tagged_by, g.tagged_at, "
|
||||||
|
" g.ai_is_holding, g.ai_correct_type, g.ai_rationale, g.ai_generated_at, "
|
||||||
|
" h.rule_statement, h.supporting_quote, h.reasoning_summary, "
|
||||||
|
" h.rule_type, h.confidence, h.quality_flags, h.review_status, "
|
||||||
|
" cl.case_number, cl.case_name, cl.source_type "
|
||||||
|
"FROM halacha_goldset g JOIN halachot h ON h.id = g.halacha_id "
|
||||||
|
"LEFT JOIN case_law cl ON cl.id = h.case_law_id "
|
||||||
|
"WHERE g.batch = $1 ORDER BY g.created_at, g.id", batch,
|
||||||
|
)
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
if d.get("tagged_at") is not None:
|
||||||
|
d["tagged_at"] = d["tagged_at"].isoformat()
|
||||||
|
if d.get("ai_generated_at") is not None:
|
||||||
|
d["ai_generated_at"] = d["ai_generated_at"].isoformat()
|
||||||
|
if d.get("confidence") is not None:
|
||||||
|
d["confidence"] = float(d["confidence"])
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def goldset_set_ai_recommendation(
|
||||||
|
goldset_id: UUID, *, ai_is_holding: bool | None,
|
||||||
|
ai_correct_type: str = "", ai_rationale: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Store the independent AI second-opinion for a gold-set item (QA aid)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE halacha_goldset SET ai_is_holding = $2, ai_correct_type = $3, "
|
||||||
|
"ai_rationale = $4, ai_generated_at = now() WHERE id = $1",
|
||||||
|
goldset_id, ai_is_holding, ai_correct_type, ai_rationale,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def goldset_tag(
|
||||||
|
goldset_id: UUID, *, is_holding: bool | None = None,
|
||||||
|
correct_type: str | None = None, quote_complete: bool | None = None,
|
||||||
|
tagged_by: str = "chair",
|
||||||
|
) -> dict | None:
|
||||||
|
"""Save one human tag (partial — only provided fields change)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
sets = ["tagged_by = $2", "tagged_at = now()"]
|
||||||
|
params: list = [goldset_id, tagged_by]
|
||||||
|
i = 3
|
||||||
|
if is_holding is not None:
|
||||||
|
sets.append(f"is_holding = ${i}"); params.append(is_holding); i += 1
|
||||||
|
if correct_type is not None:
|
||||||
|
sets.append(f"correct_type = ${i}"); params.append(correct_type); i += 1
|
||||||
|
if quote_complete is not None:
|
||||||
|
sets.append(f"quote_complete = ${i}"); params.append(quote_complete); i += 1
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
f"UPDATE halacha_goldset SET {', '.join(sets)} WHERE id = $1 RETURNING *", *params,
|
||||||
|
)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def goldset_score(batch: str = "default") -> dict:
|
||||||
|
"""Measure each extraction validator against the human tags (#81.8).
|
||||||
|
|
||||||
|
A validator flag predicts "NOT a clean holding"; ground truth is
|
||||||
|
is_holding == false. truncated_quote is scored against quote_complete."""
|
||||||
|
items = await goldset_list(batch)
|
||||||
|
labeled = [r for r in items if r.get("is_holding") is not None]
|
||||||
|
from collections import defaultdict
|
||||||
|
counters: dict = defaultdict(lambda: {"tp": 0, "fp": 0, "fn": 0, "tn": 0})
|
||||||
|
|
||||||
|
def tally(name: str, predicted_bad: bool, truly_bad: bool) -> None:
|
||||||
|
c = counters[name]
|
||||||
|
key = ("tp" if truly_bad else "fp") if predicted_bad else ("fn" if truly_bad else "tn")
|
||||||
|
c[key] += 1
|
||||||
|
|
||||||
|
for r in labeled:
|
||||||
|
rule = r.get("rule_statement") or ""
|
||||||
|
quote = r.get("supporting_quote") or ""
|
||||||
|
rtype = r.get("rule_type") or "binding"
|
||||||
|
qc = r["quote_complete"] if r["quote_complete"] is not None else True
|
||||||
|
truly_bad = r["is_holding"] is False
|
||||||
|
flags = halacha_quality.compute_quality_flags(rule, quote, "", qc, rtype)
|
||||||
|
tally("any_flag", bool(flags), truly_bad)
|
||||||
|
tally("application", halacha_quality.FLAG_APPLICATION in flags, truly_bad)
|
||||||
|
tally("non_decision", halacha_quality.FLAG_NON_DECISION in flags, truly_bad)
|
||||||
|
tally("thin_restatement", halacha_quality.FLAG_THIN_RESTATEMENT in flags, truly_bad)
|
||||||
|
tally("truncated_quote", halacha_quality.is_quote_truncated(quote), qc is False)
|
||||||
|
|
||||||
|
def prf(c: dict) -> dict:
|
||||||
|
p = c["tp"] / (c["tp"] + c["fp"]) if (c["tp"] + c["fp"]) else 0.0
|
||||||
|
rec = c["tp"] / (c["tp"] + c["fn"]) if (c["tp"] + c["fn"]) else 0.0
|
||||||
|
f1 = 2 * p * rec / (p + rec) if (p + rec) else 0.0
|
||||||
|
return {"precision": round(p, 3), "recall": round(rec, 3), "f1": round(f1, 3), **c}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"batch": batch, "total": len(items), "labeled": len(labeled),
|
||||||
|
"validators": {name: prf(c) for name, c in counters.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]:
|
async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]:
|
||||||
"""Return all corroboration rows for one halacha, ordered by match_score DESC."""
|
"""Return all corroboration rows for one halacha, ordered by match_score DESC."""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
@@ -5209,3 +5961,110 @@ async def find_missing_precedent_by_citation(
|
|||||||
citation.strip(),
|
citation.strip(),
|
||||||
)
|
)
|
||||||
return _row_to_missing_precedent(row) if row else None
|
return _row_to_missing_precedent(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── X13 — Court Verdict Fetch jobs ───────────────────────────────────────
|
||||||
|
# CRUD for the auto-fetch queue (docs/spec/X13-court-fetch.md). Status is
|
||||||
|
# always explicit; failures are recorded, never swallowed (INV-CF2). Upsert
|
||||||
|
# is keyed on the canonical case number (INV-CF5).
|
||||||
|
|
||||||
|
def _row_to_court_fetch_job(row) -> dict:
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def court_fetch_job_upsert(
|
||||||
|
case_number_norm: str,
|
||||||
|
citation_raw: str = "",
|
||||||
|
tier: str = "",
|
||||||
|
court: str = "",
|
||||||
|
digest_id: UUID | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Idempotent create-or-get of a fetch job by canonical case number.
|
||||||
|
|
||||||
|
Re-requesting the same case number returns the existing row (with a
|
||||||
|
``_existing`` flag) rather than creating a duplicate — the canonical
|
||||||
|
number is a UNIQUE key. A job that already reached a terminal state is
|
||||||
|
returned as-is so callers can decide whether to retry.
|
||||||
|
"""
|
||||||
|
if not (case_number_norm or "").strip():
|
||||||
|
raise ValueError("case_number_norm is required")
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
"SELECT * FROM court_fetch_jobs WHERE case_number_norm = $1",
|
||||||
|
case_number_norm,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
out = _row_to_court_fetch_job(existing)
|
||||||
|
out["_existing"] = True
|
||||||
|
return out
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""INSERT INTO court_fetch_jobs
|
||||||
|
(case_number_norm, citation_raw, tier, court, digest_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *""",
|
||||||
|
case_number_norm, citation_raw, tier, court, digest_id,
|
||||||
|
)
|
||||||
|
out = _row_to_court_fetch_job(row)
|
||||||
|
out["_existing"] = False
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def court_fetch_job_update(
|
||||||
|
job_id: UUID,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
case_law_id: UUID | None = None,
|
||||||
|
source_url: str | None = None,
|
||||||
|
bump_attempts: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Patch a job row. Only provided fields change; ``updated_at`` always does."""
|
||||||
|
sets = ["updated_at = now()"]
|
||||||
|
args: list = []
|
||||||
|
if status is not None:
|
||||||
|
args.append(status); sets.append(f"status = ${len(args)}")
|
||||||
|
if error is not None:
|
||||||
|
args.append(error); sets.append(f"error = ${len(args)}")
|
||||||
|
if case_law_id is not None:
|
||||||
|
args.append(case_law_id); sets.append(f"case_law_id = ${len(args)}")
|
||||||
|
if source_url is not None:
|
||||||
|
args.append(source_url); sets.append(f"source_url = ${len(args)}")
|
||||||
|
if bump_attempts:
|
||||||
|
sets.append("attempts = attempts + 1")
|
||||||
|
args.append(job_id)
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"UPDATE court_fetch_jobs SET {', '.join(sets)} "
|
||||||
|
f"WHERE id = ${len(args)} RETURNING *",
|
||||||
|
*args,
|
||||||
|
)
|
||||||
|
return _row_to_court_fetch_job(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def court_fetch_job_get(case_number_norm: str) -> dict | None:
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT * FROM court_fetch_jobs WHERE case_number_norm = $1",
|
||||||
|
case_number_norm,
|
||||||
|
)
|
||||||
|
return _row_to_court_fetch_job(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def court_fetch_job_list(status: str | None = None, limit: int = 100) -> list[dict]:
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if status:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT * FROM court_fetch_jobs WHERE status = $1 "
|
||||||
|
"ORDER BY created_at DESC LIMIT $2",
|
||||||
|
status, limit,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT * FROM court_fetch_jobs ORDER BY created_at DESC LIMIT $1",
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
return [_row_to_court_fetch_job(r) for r in rows]
|
||||||
|
|||||||
268
mcp-server/src/legal_mcp/services/digest_library.py
Normal file
268
mcp-server/src/legal_mcp/services/digest_library.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""Orchestrator for the Digests radar (X12).
|
||||||
|
|
||||||
|
A digest ("כל יום" daily one-pager) is a SECONDARY source that POINTS at a
|
||||||
|
ruling — it is never cited in a decision (INV-DIG1) and never enters the
|
||||||
|
precedent/halacha pipeline (INV-DIG2). Ingest is therefore a short, standalone
|
||||||
|
path that reuses only ATOMIC services (extract_text, embeddings), NOT the
|
||||||
|
canonical ``ingest.ingest_document`` (which is bound to case_law):
|
||||||
|
|
||||||
|
file → extract_text → content_hash (idempotent) → LLM metadata extract
|
||||||
|
→ create_digest → single embedding (concept+headline+summary+analysis)
|
||||||
|
→ try_autolink(underlying_citation → case_law) [INV-DIG3]
|
||||||
|
→ extraction_status='completed'
|
||||||
|
|
||||||
|
claude_session rule: ``digest_metadata_extractor`` (local CLI) is imported
|
||||||
|
LAZILY inside ``ingest_digest`` only, so this module is import-safe from the
|
||||||
|
FastAPI container for the search/list/link/delete paths (DB + voyage only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp import config
|
||||||
|
from legal_mcp.services import db, embeddings, extractor, ingest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ProgressCb = Callable[[str, int, str], Awaitable[None]]
|
||||||
|
|
||||||
|
DIGEST_LIBRARY_DIR = Path(config.DATA_DIR) / "digests"
|
||||||
|
|
||||||
|
_VALID_PRACTICE_AREAS = frozenset(
|
||||||
|
{"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _noop_progress(_status: str, _percent: int, _msg: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _embedding_text(fields: dict) -> str:
|
||||||
|
"""The single vector indexes the digest as an atomic discovery unit."""
|
||||||
|
parts = [
|
||||||
|
fields.get("concept_tag", ""),
|
||||||
|
fields.get("headline_holding", ""),
|
||||||
|
fields.get("summary", ""),
|
||||||
|
fields.get("analysis_text", ""),
|
||||||
|
]
|
||||||
|
return "\n".join(p for p in parts if p).strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def try_autolink(digest_id: UUID | str, underlying_citation: str) -> str | None:
|
||||||
|
"""Best-effort link of a digest to the underlying ruling in case_law
|
||||||
|
(INV-DIG3). Returns the case_law_id (str) if linked, else None. Never raises."""
|
||||||
|
citation = (underlying_citation or "").strip()
|
||||||
|
if not citation:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
match = await db.find_case_law_by_citation_fuzzy(citation)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("digest try_autolink lookup failed for %r: %s", citation, e)
|
||||||
|
return None
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
await db.link_digest_to_case_law(digest_id, match["id"])
|
||||||
|
return str(match["id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_digest(
|
||||||
|
*,
|
||||||
|
file_path: str | Path,
|
||||||
|
yomon_number: str = "",
|
||||||
|
digest_date: date | str | None = None,
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
progress: ProgressCb | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Ingest one digest. **MCP-tool-only** (uses the local LLM extractor).
|
||||||
|
|
||||||
|
User-supplied args win over LLM-extracted values for the same field
|
||||||
|
(the chair typed them deliberately); empty args are filled from the LLM.
|
||||||
|
Idempotent on yomon_number / content_hash (INV-G3).
|
||||||
|
"""
|
||||||
|
progress = progress or _noop_progress
|
||||||
|
if practice_area and practice_area not in _VALID_PRACTICE_AREAS:
|
||||||
|
raise ValueError(f"invalid practice_area: {practice_area!r}")
|
||||||
|
|
||||||
|
src = Path(file_path)
|
||||||
|
if not src.exists():
|
||||||
|
raise ValueError(f"file not found: {file_path}")
|
||||||
|
|
||||||
|
await progress("staging", 5, "מעתיק קובץ")
|
||||||
|
staged = ingest._stage_file(src, DIGEST_LIBRARY_DIR, "incoming")
|
||||||
|
rel_path = str(staged.relative_to(config.DATA_DIR)) \
|
||||||
|
if str(staged).startswith(str(config.DATA_DIR)) else str(staged)
|
||||||
|
|
||||||
|
await progress("extracting_text", 20, "מחלץ טקסט")
|
||||||
|
raw_text, _page_count, _offsets = await extractor.extract_text(str(staged))
|
||||||
|
raw_text = (raw_text or "").strip()
|
||||||
|
if not raw_text:
|
||||||
|
raise ValueError("no text extracted from digest")
|
||||||
|
|
||||||
|
# Idempotency: identical text already ingested → return existing row.
|
||||||
|
content_hash = db._content_hash(raw_text)
|
||||||
|
existing = await db.get_digest_by_content_hash(content_hash)
|
||||||
|
if existing:
|
||||||
|
await progress("completed", 100, "יומון זהה כבר קיים — לא נוצר כפל")
|
||||||
|
return {
|
||||||
|
"status": "exists",
|
||||||
|
"digest_id": existing["id"],
|
||||||
|
"yomon_number": existing.get("yomon_number", ""),
|
||||||
|
"linked_case_law_id": existing.get("linked_case_law_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# LLM metadata extraction (lazy import — keeps this module container-safe).
|
||||||
|
await progress("extracting_metadata", 45, "מחלץ מטא-דאטה (LLM)")
|
||||||
|
from legal_mcp.services import digest_metadata_extractor
|
||||||
|
extracted = await digest_metadata_extractor.extract(raw_text)
|
||||||
|
|
||||||
|
def _coerce_date(v) -> date | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
if isinstance(v, date):
|
||||||
|
return v
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(v[:10])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Merge: explicit user args win; otherwise fall back to LLM extraction.
|
||||||
|
fields = {
|
||||||
|
"analysis_text": raw_text,
|
||||||
|
"yomon_number": yomon_number.strip() or extracted.get("yomon_number", ""),
|
||||||
|
"digest_date": _coerce_date(digest_date) or extracted.get("digest_date"),
|
||||||
|
"concept_tag": extracted.get("concept_tag", ""),
|
||||||
|
"headline_holding": extracted.get("headline_holding", ""),
|
||||||
|
"summary": extracted.get("summary", ""),
|
||||||
|
"underlying_citation": extracted.get("underlying_citation", ""),
|
||||||
|
"underlying_court": extracted.get("underlying_court", ""),
|
||||||
|
"underlying_date": extracted.get("underlying_date"),
|
||||||
|
"underlying_judge": extracted.get("underlying_judge", ""),
|
||||||
|
"practice_area": practice_area or extracted.get("practice_area", ""),
|
||||||
|
"appeal_subtype": appeal_subtype.strip() or extracted.get("appeal_subtype", ""),
|
||||||
|
"subject_tags": list(subject_tags) if subject_tags else extracted.get("subject_tags", []),
|
||||||
|
"source_document_path": rel_path,
|
||||||
|
"extraction_status": "processing",
|
||||||
|
}
|
||||||
|
|
||||||
|
await progress("storing", 70, "שומר רשומה")
|
||||||
|
record = await db.create_digest(**fields)
|
||||||
|
digest_id = record["id"]
|
||||||
|
|
||||||
|
# Single embedding for the whole digest (atomic discovery unit — X12 §6).
|
||||||
|
await progress("embedding", 85, "מחשב embedding")
|
||||||
|
emb_text = _embedding_text(fields)
|
||||||
|
if emb_text:
|
||||||
|
try:
|
||||||
|
vecs = await embeddings.embed_texts([emb_text], input_type="document")
|
||||||
|
if vecs:
|
||||||
|
await db.store_digest_embedding(digest_id, vecs[0])
|
||||||
|
except Exception as e: # surfaced, not swallowed (§6)
|
||||||
|
logger.warning("digest embedding failed for %s: %s", digest_id, e)
|
||||||
|
|
||||||
|
# Bridge to the underlying ruling if it is already in the library (INV-DIG3).
|
||||||
|
await progress("linking", 95, "מנסה לקשר לפסק המקורי")
|
||||||
|
linked_id = await try_autolink(digest_id, fields["underlying_citation"])
|
||||||
|
|
||||||
|
await db.update_digest(digest_id, extraction_status="completed")
|
||||||
|
await progress("completed", 100, "הושלם")
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"digest_id": digest_id,
|
||||||
|
"yomon_number": fields["yomon_number"],
|
||||||
|
"underlying_citation": fields["underlying_citation"],
|
||||||
|
"linked_case_law_id": linked_id,
|
||||||
|
"fields_extracted": sorted(extracted.keys()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def link_digest(digest_id: UUID | str, case_law_id: UUID | str) -> dict:
|
||||||
|
"""Manually link a digest to an underlying ruling (INV-DIG3). Idempotent."""
|
||||||
|
digest = await db.get_digest(digest_id)
|
||||||
|
if not digest:
|
||||||
|
raise ValueError("digest not found")
|
||||||
|
ruling = await db.get_case_law(
|
||||||
|
case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
|
||||||
|
)
|
||||||
|
if not ruling:
|
||||||
|
raise ValueError("case_law not found")
|
||||||
|
updated = await db.link_digest_to_case_law(digest_id, case_law_id)
|
||||||
|
return {
|
||||||
|
"linked": True,
|
||||||
|
"digest_id": str(digest_id),
|
||||||
|
"case_law_id": str(case_law_id),
|
||||||
|
"case_number": ruling.get("case_number"),
|
||||||
|
"digest": updated,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def relink_digest(digest_id: UUID | str) -> dict:
|
||||||
|
"""Re-run autolink for a digest whose underlying ruling may now be in the
|
||||||
|
library. No-op if already linked or no match found."""
|
||||||
|
digest = await db.get_digest(digest_id)
|
||||||
|
if not digest:
|
||||||
|
raise ValueError("digest not found")
|
||||||
|
if digest.get("linked_case_law_id"):
|
||||||
|
return {"linked": True, "digest_id": str(digest_id),
|
||||||
|
"case_law_id": digest["linked_case_law_id"], "changed": False}
|
||||||
|
linked_id = await try_autolink(digest_id, digest.get("underlying_citation", ""))
|
||||||
|
return {
|
||||||
|
"linked": linked_id is not None,
|
||||||
|
"digest_id": str(digest_id),
|
||||||
|
"case_law_id": linked_id,
|
||||||
|
"changed": linked_id is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def search_digests(
|
||||||
|
query: str,
|
||||||
|
practice_area: str = "",
|
||||||
|
subject_tag: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Semantic search over the digests radar. Container-safe (voyage + DB)."""
|
||||||
|
if not query.strip():
|
||||||
|
return []
|
||||||
|
query_vec = await embeddings.embed_query(query)
|
||||||
|
return await db.search_digests_semantic(
|
||||||
|
query_embedding=query_vec,
|
||||||
|
practice_area=practice_area,
|
||||||
|
subject_tag=subject_tag,
|
||||||
|
concept_tag=concept_tag,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_digest(digest_id: UUID | str) -> dict | None:
|
||||||
|
return await db.get_digest(digest_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_digests(
|
||||||
|
practice_area: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
linked: bool | None = None,
|
||||||
|
search: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
return await db.list_digests(
|
||||||
|
practice_area=practice_area,
|
||||||
|
concept_tag=concept_tag,
|
||||||
|
linked=linked,
|
||||||
|
search=search,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_digest(digest_id: UUID | str) -> bool:
|
||||||
|
return await db.delete_digest(digest_id)
|
||||||
137
mcp-server/src/legal_mcp/services/digest_metadata_extractor.py
Normal file
137
mcp-server/src/legal_mcp/services/digest_metadata_extractor.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""Auto-extract catalog metadata from a "כל יום" daily digest (X12).
|
||||||
|
|
||||||
|
A digest is a one-page secondary summary (Ofer Toister) of a single ruling.
|
||||||
|
This module reads its raw text and asks the local Claude CLI to extract the
|
||||||
|
fields the radar needs: yomon number, concept tag, headline holding, a short
|
||||||
|
summary, the UNDERLYING ruling's citation (the critical bridge field — INV-DIG3),
|
||||||
|
its court / date / judge, practice area and subject tags.
|
||||||
|
|
||||||
|
claude_session rule: this module imports ``claude_session`` (the local CLI),
|
||||||
|
so it is **MCP-tool-only** — never import it from the FastAPI container. It is
|
||||||
|
pulled in lazily inside ``digest_library.ingest_digest`` only.
|
||||||
|
|
||||||
|
Unlike ``precedent_metadata_extractor`` (which patches a DB row), this returns
|
||||||
|
a plain dict from raw text; ``digest_library`` decides how to merge/store it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date as date_type
|
||||||
|
|
||||||
|
from legal_mcp.config import parse_llm_json
|
||||||
|
from legal_mcp.services import claude_session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_PRACTICE_AREAS = {"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
|
||||||
|
|
||||||
|
|
||||||
|
# Concatenated with f-strings at call time, NOT .format() — the JSON example
|
||||||
|
# below contains '{' / '}' which str.format would treat as placeholders and
|
||||||
|
# crash (same trap documented in precedent_metadata_extractor).
|
||||||
|
DIGEST_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. לפניך "יומון" — סיכום עמוד-אחד של משרד עפר טויסטר (עלון "כל יום")
|
||||||
|
על פסק דין/החלטה אחת בתחום תכנון ובנייה / היטל השבחה / פיצויים (ס' 197). חלץ ממנו מטא-דאטה לקטלוג.
|
||||||
|
|
||||||
|
**אל תמציא** — שדה שלא מופיע בטקסט → השאר ריק (מחרוזת ריקה / מערך ריק).
|
||||||
|
|
||||||
|
## פלט נדרש
|
||||||
|
החזר JSON אחד (object — לא array), ללא markdown וללא הסברים:
|
||||||
|
|
||||||
|
{
|
||||||
|
"yomon_number": "מספר היומון מהכותרת ('יומון מס' 5163' → '5163'). ספרות בלבד. אם אין — ריק.",
|
||||||
|
"digest_date_iso": "YYYY-MM-DD — תאריך גיליון היומון (בכותרת, למשל '7 ביוני 2026' → '2026-06-07').",
|
||||||
|
"concept_tag": "תג-המושג שבמרכאות בראש העמוד (למשל 'שיקול הדעת המצומצם', 'Cherry-picking'). ביטוי קצר אחד.",
|
||||||
|
"headline_holding": "כותרת-ההלכה המודגשת מתחת לתג — משפט אחד שמסכם מה נקבע (למשל 'ביהמ\\"ש - שיקול דעת הוועדה המחוזית אינו מצומצם לטעות חמורה').",
|
||||||
|
"summary": "תקציר ניטרלי 2-3 משפטים בגוף שלישי: מה הייתה השאלה ומה הוכרע. בלי שיפוט.",
|
||||||
|
"underlying_citation": "מראה-המקום של פסק הדין/ההחלטה המקורי, כפי שמופיע בתחתית היומון, מילה במילה (למשל 'עת\\"מ 46111-12-22 יכין-אפק בע\\"מ נ' הוועדה המחוזית'). זהו השדה הקריטי — חלץ אותו במלואו ובדיוק.",
|
||||||
|
"underlying_court": "הערכאה שנתנה את פסק הדין המקורי (למשל 'בית המשפט לעניינים מנהליים מרכז-לוד', 'ועדת הערר מחוז ירושלים').",
|
||||||
|
"underlying_date_iso": "YYYY-MM-DD — תאריך מתן פסק הדין/ההחלטה המקורי (לרוב 'ניתן ביום DD.M.YY' בתחתית). שים לב: זה שונה מתאריך גיליון היומון!",
|
||||||
|
"underlying_judge": "שם השופט/ת או יו\\"ר ההרכב שנתן את פסק הדין המקורי (למשל 'יעל טויסטר ישראלי'). בלי תארים ('עו\\"ד', 'כב' השופט').",
|
||||||
|
"practice_area": "אחד מ-3: 'rishuy_uvniya' (רישוי ובנייה/הקלות/שימוש חורג) / 'betterment_levy' (היטל השבחה) / 'compensation_197' (פיצויים ס'197). אם לא ברור — ריק.",
|
||||||
|
"appeal_subtype": "תת-סוג קצר אם בולט (למשל 'הקלה', 'שיקול דעת הוועדה', 'מימוש במכר'). אחרת ריק.",
|
||||||
|
"subject_tags": ["3-7 תגיות בעברית snake_case (שיקול_דעת, הקלה, ועדה_מחוזית, היטל_השבחה, ...)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
## כללי איכות
|
||||||
|
1. **underlying_citation** — השדה החשוב ביותר; הוא הגשר לפסק הדין המקורי. חלץ מההערות/התחתית, מילה במילה.
|
||||||
|
2. **הבחן בין שני התאריכים**: digest_date_iso = תאריך גיליון היומון (בכותרת); underlying_date_iso = מועד מתן פסק הדין (בתחתית, 'ניתן ביום...'). אל תבלבל.
|
||||||
|
3. **summary** — ניטרלי, גוף שלישי, בלי מילות שיפוט.
|
||||||
|
4. **subject_tags** — snake_case, תחום ועדת ערר תכנון ובנייה בלבד.
|
||||||
|
5. אם רכיב לא מופיע בבירור — השאר את אותו שדה ריק. אל תנחש.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_str(result: dict, key: str) -> str:
|
||||||
|
v = result.get(key)
|
||||||
|
return v.strip() if isinstance(v, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_date(result: dict, key: str) -> date_type | None:
|
||||||
|
v = result.get(key)
|
||||||
|
if not isinstance(v, str) or not v.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date_type.fromisoformat(v.strip()[:10])
|
||||||
|
except ValueError:
|
||||||
|
logger.debug("digest_metadata_extractor: ignoring invalid %s=%r", key, v)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def extract(raw_text: str) -> dict:
|
||||||
|
"""Extract digest metadata from raw text. Returns a dict (never raises).
|
||||||
|
|
||||||
|
Keys: yomon_number, digest_date (date|None), concept_tag, headline_holding,
|
||||||
|
summary, underlying_citation, underlying_court, underlying_date (date|None),
|
||||||
|
underlying_judge, practice_area, appeal_subtype, subject_tags (list[str]).
|
||||||
|
Missing/invalid fields are omitted so the caller's merge keeps user values.
|
||||||
|
"""
|
||||||
|
text = (raw_text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
user_msg = f"--- תחילת היומון ---\n{text}\n--- סוף היומון ---"
|
||||||
|
try:
|
||||||
|
result = await claude_session.query_json(
|
||||||
|
user_msg, system=DIGEST_EXTRACTION_PROMPT,
|
||||||
|
)
|
||||||
|
except Exception as e: # surfaced as warning, not swallowed silently (§6)
|
||||||
|
logger.warning("digest_metadata_extractor: query failed: %s", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
logger.warning(
|
||||||
|
"digest_metadata_extractor: expected dict, got %s",
|
||||||
|
type(result).__name__,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
out: dict = {}
|
||||||
|
for key in (
|
||||||
|
"yomon_number", "concept_tag", "headline_holding", "summary",
|
||||||
|
"underlying_citation", "underlying_court", "underlying_judge",
|
||||||
|
"appeal_subtype",
|
||||||
|
):
|
||||||
|
s = _norm_str(result, key)
|
||||||
|
if s:
|
||||||
|
out[key] = s
|
||||||
|
|
||||||
|
dd = _norm_date(result, "digest_date_iso")
|
||||||
|
if dd is not None:
|
||||||
|
out["digest_date"] = dd
|
||||||
|
ud = _norm_date(result, "underlying_date_iso")
|
||||||
|
if ud is not None:
|
||||||
|
out["underlying_date"] = ud
|
||||||
|
|
||||||
|
pa = _norm_str(result, "practice_area")
|
||||||
|
if pa in _VALID_PRACTICE_AREAS and pa:
|
||||||
|
out["practice_area"] = pa
|
||||||
|
|
||||||
|
tags = result.get("subject_tags")
|
||||||
|
if isinstance(tags, list):
|
||||||
|
clean = [str(t).strip() for t in tags if str(t).strip()]
|
||||||
|
if clean:
|
||||||
|
out["subject_tags"] = clean
|
||||||
|
|
||||||
|
return out
|
||||||
56
mcp-server/src/legal_mcp/tools/court_fetch.py
Normal file
56
mcp-server/src/legal_mcp/tools/court_fetch.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""MCP tools for the X13 court-verdict auto-fetch subsystem.
|
||||||
|
|
||||||
|
- ``court_verdict_fetch`` — classify a citation, fetch the verdict from the
|
||||||
|
matching public source (Supreme portal / נט המשפט), and ingest it into the
|
||||||
|
precedent library via the canonical pipeline. The standalone entry point
|
||||||
|
(also driven automatically from digest auto-link, see X12/X13).
|
||||||
|
- ``court_fetch_status`` — inspect the fetch-job queue (pending/failed/manual).
|
||||||
|
|
||||||
|
Local-only: ``court_verdict_fetch`` runs the ingest pipeline, which drives
|
||||||
|
halacha extraction via the local ``claude`` CLI — same constraint as
|
||||||
|
``precedent_process_pending``. Invoking it from the container will fail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from legal_mcp.services import court_fetch_orchestrator as orch
|
||||||
|
from legal_mcp.services import db
|
||||||
|
from legal_mcp.tools.envelope import err as _err, ok as _ok
|
||||||
|
|
||||||
|
|
||||||
|
async def court_verdict_fetch(citation: str) -> str:
|
||||||
|
"""אחזור אוטומטי של פסק-דין בית-משפט וקליטה לקורפוס.
|
||||||
|
|
||||||
|
מקבל ציטוט (למשל 'עת"מ 46111-12-22' או 'עע"מ 1234/22'), מסווג את הערכאה,
|
||||||
|
מוריד את הפסק מהמקור הציבורי המתאים, וקולט אותו דרך צינור-הקליטה הקנוני.
|
||||||
|
ערר/בל"מ (ועדת-ערר) אינם ניתנים לאחזור ציבורי ויסומנו כפער.
|
||||||
|
"""
|
||||||
|
if not (citation or "").strip():
|
||||||
|
return _err("citation is required")
|
||||||
|
try:
|
||||||
|
result = await orch.fetch_and_ingest(citation.strip())
|
||||||
|
except Exception as e: # noqa: BLE001 — surfaced, not swallowed (INV-CF2)
|
||||||
|
return _err(f"אחזור נכשל: {e}")
|
||||||
|
|
||||||
|
status = result.get("status")
|
||||||
|
if status in ("done", "already_done"):
|
||||||
|
return _ok(result, message="הפסק נקלט לקורפוס")
|
||||||
|
if status == "skipped":
|
||||||
|
return _ok(result, message="ועדת-ערר — לא ניתן לאחזור ציבורי (סומן כפער)")
|
||||||
|
if status in ("manual", "awaiting_manual"):
|
||||||
|
return _ok(result, message="האחזור האוטונומי נכשל — הוסלם להורדה ידנית")
|
||||||
|
if status == "unrecognized":
|
||||||
|
return _err("הציטוט לא זוהה כמספר-תיק תקין")
|
||||||
|
return _ok(result, message=f"סטטוס: {status}")
|
||||||
|
|
||||||
|
|
||||||
|
async def court_fetch_status(case_number: str = "", status_filter: str = "") -> str:
|
||||||
|
"""סטטוס תור-האחזור. case_number לפריט יחיד, או status_filter לסינון רשימה."""
|
||||||
|
if case_number.strip():
|
||||||
|
from legal_mcp.services.court_citation import normalize_case_number
|
||||||
|
job = await db.court_fetch_job_get(normalize_case_number(case_number))
|
||||||
|
if not job:
|
||||||
|
return _ok({"job": None}, message="אין job עבור תיק זה")
|
||||||
|
return _ok({"job": job})
|
||||||
|
jobs = await db.court_fetch_job_list(status=status_filter.strip() or None)
|
||||||
|
return _ok({"jobs": jobs, "count": len(jobs)})
|
||||||
161
mcp-server/src/legal_mcp/tools/digests.py
Normal file
161
mcp-server/src/legal_mcp/tools/digests.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""MCP tools for the Digests radar (X12).
|
||||||
|
|
||||||
|
A digest ("כל יום" daily one-pager, Ofer Toister) is a SECONDARY, discovery-
|
||||||
|
layer source that POINTS at a ruling. It is distinct from the three citation
|
||||||
|
corpora:
|
||||||
|
|
||||||
|
- ``search_precedent_library`` — authoritative external court rulings.
|
||||||
|
- ``search_internal_decisions`` — appeals-committee decisions.
|
||||||
|
- ``search_decisions`` — Dafna's prior decisions (style corpus).
|
||||||
|
|
||||||
|
A digest is NEVER cited in a decision (INV-DIG1) and NEVER enters the halacha
|
||||||
|
pipeline (INV-DIG2). ``search_digests`` is a research compass: it surfaces the
|
||||||
|
relevant digest + the UNDERLYING ruling's citation, which is then ingested into
|
||||||
|
the precedent library and cited from there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import db, digest_library, telemetry
|
||||||
|
from legal_mcp.tools.envelope import empty, err as _err, ok as _ok
|
||||||
|
|
||||||
|
|
||||||
|
async def digest_upload(
|
||||||
|
file_path: str,
|
||||||
|
yomon_number: str = "",
|
||||||
|
digest_date: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""העלאת יומון ("כל יום") לקורפוס-הגילוי + חילוץ מטא-דאטה אוטומטי.
|
||||||
|
|
||||||
|
היומון הוא מקור-משני המצביע על פסק הדין המקורי — אינו מצוטט בהחלטה.
|
||||||
|
Args:
|
||||||
|
file_path: נתיב מלא לקובץ PDF/DOCX של היומון.
|
||||||
|
yomon_number: מספר היומון (אופציונלי — יחולץ מהטקסט אם ריק).
|
||||||
|
digest_date: ISO date של גיליון היומון (אופציונלי).
|
||||||
|
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
|
||||||
|
subject_tags: תגיות נושא (אופציונלי — יחולצו אם ריק).
|
||||||
|
Returns: JSON עם digest_id, מספר היומון, מראה-המקום, וקישור-אוטומטי אם נמצא.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await digest_library.ingest_digest(
|
||||||
|
file_path=file_path,
|
||||||
|
yomon_number=yomon_number,
|
||||||
|
digest_date=digest_date or None,
|
||||||
|
practice_area=practice_area,
|
||||||
|
appeal_subtype=appeal_subtype,
|
||||||
|
subject_tags=subject_tags or None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def digest_list(
|
||||||
|
practice_area: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
linked: bool | None = None,
|
||||||
|
search: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> str:
|
||||||
|
"""רשימת יומונים בקורפוס-הגילוי, עם פילטרים. linked=false → יומונים שהפסק
|
||||||
|
המקורי שלהם עוד לא נקלט לספריית הפסיקה (פער-ידע גלוי)."""
|
||||||
|
rows = await digest_library.list_digests(
|
||||||
|
practice_area=practice_area,
|
||||||
|
concept_tag=concept_tag,
|
||||||
|
linked=linked,
|
||||||
|
search=search,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return _ok(rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def digest_get(digest_id: str) -> str:
|
||||||
|
"""יומון ספציפי לפי מזהה."""
|
||||||
|
try:
|
||||||
|
cid = UUID(digest_id)
|
||||||
|
except ValueError:
|
||||||
|
return _err("digest_id לא תקין")
|
||||||
|
record = await digest_library.get_digest(cid)
|
||||||
|
if not record:
|
||||||
|
return _err("יומון לא נמצא")
|
||||||
|
return _ok(record)
|
||||||
|
|
||||||
|
|
||||||
|
async def digest_link(digest_id: str, case_law_id: str) -> str:
|
||||||
|
"""קישור ידני של יומון לפסק הדין המקורי בספריית הפסיקה (INV-DIG3)."""
|
||||||
|
try:
|
||||||
|
UUID(digest_id)
|
||||||
|
UUID(case_law_id)
|
||||||
|
except ValueError:
|
||||||
|
return _err("מזהה לא תקין")
|
||||||
|
try:
|
||||||
|
result = await digest_library.link_digest(digest_id, case_law_id)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def digest_relink(digest_id: str) -> str:
|
||||||
|
"""ניסיון-קישור מחדש: בודק אם פסק הדין המקורי של היומון כבר בספרייה ומקשר."""
|
||||||
|
try:
|
||||||
|
UUID(digest_id)
|
||||||
|
except ValueError:
|
||||||
|
return _err("digest_id לא תקין")
|
||||||
|
try:
|
||||||
|
result = await digest_library.relink_digest(digest_id)
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def digest_delete(digest_id: str) -> str:
|
||||||
|
"""מחיקת יומון מקורפוס-הגילוי."""
|
||||||
|
try:
|
||||||
|
cid = UUID(digest_id)
|
||||||
|
except ValueError:
|
||||||
|
return _err("digest_id לא תקין")
|
||||||
|
ok_ = await digest_library.delete_digest(cid)
|
||||||
|
if not ok_:
|
||||||
|
return _err("יומון לא נמצא")
|
||||||
|
return _ok({"deleted": True, "digest_id": digest_id})
|
||||||
|
|
||||||
|
|
||||||
|
async def search_digests(
|
||||||
|
query: str,
|
||||||
|
practice_area: str = "",
|
||||||
|
subject_tag: str = "",
|
||||||
|
concept_tag: str = "",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> str:
|
||||||
|
"""חיפוש סמנטי בקורפוס-הגילוי (יומוני "כל יום"). מצפן-מחקר בלבד — מחזיר את
|
||||||
|
היומון הרלוונטי + מראה-המקום של הפסק המקורי (radar). היומון אינו מצוטט
|
||||||
|
בהחלטה (INV-DIG1); הצטט מהפסק המקורי דרך search_precedent_library."""
|
||||||
|
if not query or len(query.strip()) < 2:
|
||||||
|
return empty("שאילתה קצרה מדי (פחות מ-2 תווים).")
|
||||||
|
q = query.strip()
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
results = await digest_library.search_digests(
|
||||||
|
query=q,
|
||||||
|
practice_area=practice_area,
|
||||||
|
subject_tag=subject_tag,
|
||||||
|
concept_tag=concept_tag,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
|
telemetry.log_search_bg(
|
||||||
|
search_type="digests",
|
||||||
|
query=q,
|
||||||
|
results=results,
|
||||||
|
duration_ms=elapsed_ms,
|
||||||
|
practice_area=practice_area or None,
|
||||||
|
user_agent="unknown",
|
||||||
|
)
|
||||||
|
if not results:
|
||||||
|
return empty("לא נמצאו יומונים תואמים.")
|
||||||
|
return _ok(results)
|
||||||
80
mcp-server/tests/test_court_citation.py
Normal file
80
mcp-server/tests/test_court_citation.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Unit tests for the X13 court-citation classifier."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from legal_mcp.services.court_citation import classify, normalize_case_number
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_filed_format_the_example():
|
||||||
|
"""The plan's example: עת"מ 46111-12-22 → admin, parsed into (46111,12,22)."""
|
||||||
|
c = classify('עת"מ 46111-12-22 יכין-אפק בע"מ נ\' הוועדה המחוזית')
|
||||||
|
assert c.tier == "admin"
|
||||||
|
assert c.court_prefix in ('עת"מ', "עת״מ")
|
||||||
|
assert c.case_number_raw == "46111-12-22"
|
||||||
|
assert c.case_number_norm == "46111-12-22"
|
||||||
|
assert (c.file_number, c.month, c.year) == ("46111", "12", "22")
|
||||||
|
assert c.fetchable is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_filed_number_defaults_admin():
|
||||||
|
c = classify("46111-12-22")
|
||||||
|
assert c.tier == "admin"
|
||||||
|
assert (c.file_number, c.month, c.year) == ("46111", "12", "22")
|
||||||
|
|
||||||
|
|
||||||
|
def test_supreme_prefixes():
|
||||||
|
for cit, pref in [
|
||||||
|
('עע"מ 1234/22', "supreme"),
|
||||||
|
('בג"ץ 5678/21', "supreme"),
|
||||||
|
('ע"א 999/20', "supreme"),
|
||||||
|
('רע"א 4/19', "supreme"),
|
||||||
|
('בר"מ 8126/24', "supreme"),
|
||||||
|
]:
|
||||||
|
c = classify(cit)
|
||||||
|
assert c.tier == pref, f"{cit} -> {c.tier}"
|
||||||
|
assert c.fetchable is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_appeals_committee_is_skip():
|
||||||
|
"""ערר / בל"מ must never be auto-fetched (needs Nevo) — INV-CF6."""
|
||||||
|
for cit in ['ערר 1110/20', 'בל"מ 8048/24', "ערר 1015-01-24 ירושלים שקופה"]:
|
||||||
|
c = classify(cit)
|
||||||
|
assert c.tier == "skip", f"{cit} -> {c.tier}"
|
||||||
|
assert c.fetchable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_wins_over_court_match():
|
||||||
|
"""An 'ערר' citation that also contains court-like digits stays skip."""
|
||||||
|
c = classify("ראה החלטתי בערר 1041/24 ובהמשך")
|
||||||
|
assert c.tier == "skip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_amn_prefix():
|
||||||
|
c = classify('עמ"נ 12345-06-23')
|
||||||
|
assert c.tier == "admin"
|
||||||
|
assert (c.file_number, c.month, c.year) == ("12345", "06", "23")
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_group_serial_has_no_filed_triple():
|
||||||
|
"""Supreme serial 1234/22 normalizes but yields no (file,month,year)."""
|
||||||
|
c = classify('עע"מ 1234/22')
|
||||||
|
assert c.case_number_norm == "1234-22"
|
||||||
|
assert c.file_number is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_implausible_month_not_parsed_as_filed():
|
||||||
|
# 1234-22-05 has month=22 → not a valid filed triple.
|
||||||
|
assert classify("1234-22-05").tier in ("unknown", "admin")
|
||||||
|
c = classify("1234-22-05")
|
||||||
|
if c.tier == "admin":
|
||||||
|
assert c.month is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_and_garbage():
|
||||||
|
assert classify("").tier == "unknown"
|
||||||
|
assert classify("שלום עולם בלי ציטוט").tier == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_case_number():
|
||||||
|
assert normalize_case_number('עת"מ 46111/12/22') == "46111-12-22"
|
||||||
|
assert normalize_case_number("1110/20") == "1110-20"
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
| `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) |
|
| `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) |
|
||||||
| `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth |
|
| `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth |
|
||||||
| `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank |
|
| `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank |
|
||||||
|
| `legal-court-fetch-service.config.cjs` | pm2/js | **שירות-מארח Tier-1 לאחזור פסקי-דין מנט המשפט (X13)** — מריץ `python -m legal_mcp.court_fetch_service.server` ב-pm2, bound ל-`10.0.1.1:8771`, Bearer-auth (`COURT_FETCH_SHARED_SECRET` מ-`~/.legal-court-fetch-service.env`). מריץ דפדפן Camoufox (open-source) כי הקונטיינר לא יכול. תלות לאחזור-בפועל: `camofox-browser` רץ (`CAMOFOX_URL`) + `faster-whisper` ל-reCAPTCHA אודיו; אחרת מחזיר ok:false וה-orchestrator מסלים ל-fallback אנושי. מראָה לדפוס `legal-chat-service.config.cjs`. ספ: `docs/spec/X13-court-fetch.md`. התקנה: `pm2 start scripts/legal-court-fetch-service.config.cjs && pm2 save`. בריאות: `curl http://10.0.1.1:8771/health`. | pm2 (host-side) |
|
||||||
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
||||||
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
||||||
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
||||||
@@ -38,8 +39,9 @@
|
|||||||
| `rechunk_legacy_precedents.py` | python | **#57** — re-chunk + re-embed פסיקה שהוטמעה לפני תיקון ה-chunker (#55). בוחר כל `case_law` עם chunk זעיר (`length(trim(content))<50` — טביעת-האצבע של ה-chunker הישן) ומריץ `ingest.reindex_case_law` (re-chunk+re-embed מ-`full_text` שמור בלבד — ללא re-OCR/LLM, feedback_no_reocr_retrofit; idempotent DELETE-then-INSERT). idempotent ברמת-הבאטץ' (שואב מחדש את הסט המושפע בכל ריצה). דגל `--limit N`. רץ עם venv של mcp-server (`cd mcp-server && .venv/bin/python ../scripts/rechunk_legacy_precedents.py`) | חד-פעמי — מיגרציית-נתונים של פסיקה legacy (תוקן 2026-06-03) |
|
| `rechunk_legacy_precedents.py` | python | **#57** — re-chunk + re-embed פסיקה שהוטמעה לפני תיקון ה-chunker (#55). בוחר כל `case_law` עם chunk זעיר (`length(trim(content))<50` — טביעת-האצבע של ה-chunker הישן) ומריץ `ingest.reindex_case_law` (re-chunk+re-embed מ-`full_text` שמור בלבד — ללא re-OCR/LLM, feedback_no_reocr_retrofit; idempotent DELETE-then-INSERT). idempotent ברמת-הבאטץ' (שואב מחדש את הסט המושפע בכל ריצה). דגל `--limit N`. רץ עם venv של mcp-server (`cd mcp-server && .venv/bin/python ../scripts/rechunk_legacy_precedents.py`) | חד-פעמי — מיגרציית-נתונים של פסיקה legacy (תוקן 2026-06-03) |
|
||||||
| `backfill_nevo_preamble.py` | python | **#86.2** — מיגרציית-נתונים: חיתוך preamble/רציו של נבו שדלף לפסיקה שהוטמעה לפני תיקון #86.1. מאתר כל `case_law` ש-`strip_nevo_preamble(full_text)` עדיין מקצר (דליפה היסטורית), ומבצע: (1) לכידת ה-מיני-רציו ל-`case_law.nevo_ratio` (gold-set ל-#86.3); (2) שכתוב `full_text` החתוך + חישוב-מחדש של `content_hash`; (3) `reindex_case_law` (re-chunk+embed, ללא re-OCR/LLM); (4) **סימון (לא מחיקה)** הלכות ש-`supporting_quote` שלהן בתוך ה-preamble שהוסר → `pending_review` + quality_flag `nevo_preamble_leak`. **שומר-בטיחות:** שורות עם keep%<`--min-keep` (ברירת-מחדל 60) מוחרגות מ-`--apply` כחשד over-strip (אלא אם `--include-suspicious`). **dry-run כברירת-מחדל**; `--apply` כותב backup JSON + manifest CSV ל-`data/audit/` תחילה. idempotent. רץ עם venv של mcp-server. **chair-gated** (לאמת manifest לפני apply) | מיגרציית-נתונים — dry-run בוצע (19 פסקים, 27 הלכות מזוהמות); apply ממתין לאישור |
|
| `backfill_nevo_preamble.py` | python | **#86.2** — מיגרציית-נתונים: חיתוך preamble/רציו של נבו שדלף לפסיקה שהוטמעה לפני תיקון #86.1. מאתר כל `case_law` ש-`strip_nevo_preamble(full_text)` עדיין מקצר (דליפה היסטורית), ומבצע: (1) לכידת ה-מיני-רציו ל-`case_law.nevo_ratio` (gold-set ל-#86.3); (2) שכתוב `full_text` החתוך + חישוב-מחדש של `content_hash`; (3) `reindex_case_law` (re-chunk+embed, ללא re-OCR/LLM); (4) **סימון (לא מחיקה)** הלכות ש-`supporting_quote` שלהן בתוך ה-preamble שהוסר → `pending_review` + quality_flag `nevo_preamble_leak`. **שומר-בטיחות:** שורות עם keep%<`--min-keep` (ברירת-מחדל 60) מוחרגות מ-`--apply` כחשד over-strip (אלא אם `--include-suspicious`). **dry-run כברירת-מחדל**; `--apply` כותב backup JSON + manifest CSV ל-`data/audit/` תחילה. idempotent. רץ עם venv של mcp-server. **chair-gated** (לאמת manifest לפני apply) | מיגרציית-נתונים — dry-run בוצע (19 פסקים, 27 הלכות מזוהמות); apply ממתין לאישור |
|
||||||
| `nevo_ratio_benchmark.py` | python | **#86.3** — מדידת איכות חילוץ-הלכות מול ה-מיני-רציו של נבו (gold-set מקצועי חינמי). לכל פסק עם `nevo_ratio` (או נגזר מ-`full_text` אם טרם בוצע backfill): LLM-judge מקומי (`claude_session`, אפס עלות) ממפה סמנטית את הלכות-המערכת מול הלכות-נבו ומפיק **recall** (כיסוי הלכות-נבו), **precision** (אחוז הלכותינו הממופות), **granularity** (יחס פירוק — איתות over-extraction ל-#81.5). `--case <num>` / `--all [--limit N]` / `--model` / `--out`. כותב CSV ל-`data/audit/`. רץ עם venv של mcp-server (דורש Claude CLI מקומי). אומת על בג"ץ 1764/05: recall 0.875, precision 1.0, granularity 1.75x | ידני — מדידת-איכות (CI/ad-hoc) |
|
| `nevo_ratio_benchmark.py` | python | **#86.3** — מדידת איכות חילוץ-הלכות מול ה-מיני-רציו של נבו (gold-set מקצועי חינמי). לכל פסק עם `nevo_ratio` (או נגזר מ-`full_text` אם טרם בוצע backfill): LLM-judge מקומי (`claude_session`, אפס עלות) ממפה סמנטית את הלכות-המערכת מול הלכות-נבו ומפיק **recall** (כיסוי הלכות-נבו), **precision** (אחוז הלכותינו הממופות), **granularity** (יחס פירוק — איתות over-extraction ל-#81.5). `--case <num>` / `--all [--limit N]` / `--model` / `--out`. כותב CSV ל-`data/audit/`. רץ עם venv של mcp-server (דורש Claude CLI מקומי). אומת על בג"ץ 1764/05: recall 0.875, precision 1.0, granularity 1.75x | ידני — מדידת-איכות (CI/ad-hoc) |
|
||||||
| `halacha_goldset.py` | python | **#81.7** — הארנס gold-set לאיכות חילוץ-הלכות. `export --n N` מייצא מדגם מרובד (לפי precedent×rule_type) ל-CSV עם עמודות-תיוג ריקות (`is_holding`/`correct_type`/`quote_complete`) לתיוג ידני (חיים/דפנה). `score --in <csv>` קורא את ה-CSV המתויג ומודד כל ולידטור (`compute_quality_flags`/`is_fact_dependent`/`is_quote_truncated`/`is_thin_restatement`) מול אמת-המידה האנושית: P/R/F1 + confusion. בסיס ל-#81.8 (כיול סף האישור). מייבא את אותם ולידטורים שה-extractor מריץ. רץ עם venv של mcp-server | ידני — export→תיוג→score |
|
| `halacha_goldset.py` | python | **#81.7** — הארנס gold-set לאיכות חילוץ-הלכות. `export --n N` מייצא מדגם מרובד (לפי precedent×rule_type) ל-CSV עם עמודות-תיוג ריקות (`is_holding`/`correct_type`/`quote_complete`) לתיוג ידני (חיים/דפנה). `score --in <csv>` קורא את ה-CSV המתויג ומודד כל ולידטור (`compute_quality_flags`/`is_fact_dependent`/`is_quote_truncated`/`is_thin_restatement`) מול אמת-המידה האנושית: P/R/F1 + confusion. בסיס ל-#81.8 (כיול סף האישור). מייבא את אותם ולידטורים שה-extractor מריץ. רץ עם venv של mcp-server. **הערה:** קיים גם דף-תיוג אינטראקטיבי DB-backed (`/goldset`) — זה ה-CSV-fallback | ידני — export→תיוג→score |
|
||||||
| `halacha_batch_reconcile.py` | python | **#82.7** — dedup חוצה-פסקים offline (שמרני, **dry-run בלבד**). dedup-on-insert משווה רק תוך-פסק; כאן סף מחמיר (cosine ≥0.95, `--cosine`) ולא-הרסני: מאתר זוגות הלכות near-duplicate בין פסקים שונים (pgvector `<=>` exact) עם איתות לקסיקלי (Jaccard/Levenshtein) ומדווח ל-CSV ב-`data/audit/` לסקירת היו"ר. לא מדלג/ממזג/מוחק. `--include-pending`. רץ עם venv של mcp-server. אומת: 819 הלכות → 5 זוגות מועמדים | ידני — דוח-סקירה |
|
| `goldset_ai_recommend.py` | python | **#81.7 QA** — מייצר **חוות-דעת-AI שנייה** (claude מקומי, אפס עלות) לכל פריט ב-`halacha_goldset`: `is_holding`+`type`+נימוק, נשמר ב-`ai_*` ומוצג בדף לצד התיוג האנושי לזיהוי אי-הסכמות. **עצמאי** מהוולידטורים שנמדדים (אין מעגליות) ו**לא** מוחל אוטומטית. `--force` (חידוש)/`--limit N`. **חובה מקומי** (claude_session). | ידני — לאחר יצירת/הרחבת batch |
|
||||||
|
| `halacha_batch_reconcile.py` | python | **#82.7** — dedup חוצה-פסקים offline (שמרני, **dry-run בלבד**). dedup-on-insert משווה רק תוך-פסק; כאן סף מחמיר (cosine ≥0.95, `--cosine`) ולא-הרסני: מאתר זוגות הלכות near-duplicate בין פסקים שונים (pgvector `<=>` exact) עם איתות לקסיקלי (Jaccard/Levenshtein) ומדווח ל-CSV ב-`data/audit/` לסקירת היו"ר. לא מדלג/ממזג/מוחק. `--include-pending`. **`--link`** רושם את הזוגות שנמצאו כ-`equivalent_halachot` (parallel authority, #84.2 — קישור-מקביל ברמת-הלכה, **לא** ציטוט; idempotent, לא-הרסני). רץ עם venv של mcp-server. אומת: 800 הלכות → 5 זוגות (קושרו). | ידני — דוח-סקירה / `--link` לקישור |
|
||||||
| `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest <path>`. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) |
|
| `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest <path>`. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) |
|
||||||
| `audit_corpus_integrity.py` | python | בדיקה תקופתית של עקביות הקורפוס — 3 בדיקות SQL read-only על `case_law` ו-`cases`: (A) `external_upload` עם prefix פנימי `ערר`/`בל"מ`; (B) `internal_committee` חסר `chair_name`/`district`; (C) `cases.practice_area` מחוץ ל-{`rishuy_uvniya`, `betterment_levy`, `compensation_197`, `''`}. כותב log מצטבר ל-`data/logs/corpus_integrity_audit.log` ובמצב הפרות שולח wakeup ל-CEO ב-Paperclip (best-effort, רק אם `PAPERCLIP_API_URL`+`PAPERCLIP_API_KEY` מוגדרים). דגל: `--no-notify`. Idempotent, יוצא 0. **Cron יומי 07:00**: `0 7 * * * /home/chaim/legal-ai/mcp-server/.venv/bin/python /home/chaim/legal-ai/scripts/audit_corpus_integrity.py` | `0 7 * * *` (cron) |
|
| `audit_corpus_integrity.py` | python | בדיקה תקופתית של עקביות הקורפוס — 3 בדיקות SQL read-only על `case_law` ו-`cases`: (A) `external_upload` עם prefix פנימי `ערר`/`בל"מ`; (B) `internal_committee` חסר `chair_name`/`district`; (C) `cases.practice_area` מחוץ ל-{`rishuy_uvniya`, `betterment_levy`, `compensation_197`, `''`}. כותב log מצטבר ל-`data/logs/corpus_integrity_audit.log` ובמצב הפרות שולח wakeup ל-CEO ב-Paperclip (best-effort, רק אם `PAPERCLIP_API_URL`+`PAPERCLIP_API_KEY` מוגדרים). דגל: `--no-notify`. Idempotent, יוצא 0. **Cron יומי 07:00**: `0 7 * * * /home/chaim/legal-ai/mcp-server/.venv/bin/python /home/chaim/legal-ai/scripts/audit_corpus_integrity.py` | `0 7 * * *` (cron) |
|
||||||
| `backfill_legal_arguments.py` | python | Backfill `legal_arguments` לתיקים עם `claims` קיימים (TaskMaster #36). מקבץ פרופוזיציות גולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד) דרך `argument_aggregator.aggregate_claims_to_arguments` (Claude CLI). תומך `--dry-run`/`--apply`/`--force`/`--case <num>...`. **חייב לרוץ מהמכונה המקומית** (לא קונטיינר) — `claude_session` דורש Claude CLI | ידני per-case (`python scripts/backfill_legal_arguments.py --apply --case 1017-03-26`) |
|
| `backfill_legal_arguments.py` | python | Backfill `legal_arguments` לתיקים עם `claims` קיימים (TaskMaster #36). מקבץ פרופוזיציות גולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד) דרך `argument_aggregator.aggregate_claims_to_arguments` (Claude CLI). תומך `--dry-run`/`--apply`/`--force`/`--case <num>...`. **חייב לרוץ מהמכונה המקומית** (לא קונטיינר) — `claude_session` דורש Claude CLI | ידני per-case (`python scripts/backfill_legal_arguments.py --apply --case 1017-03-26`) |
|
||||||
@@ -82,6 +84,7 @@
|
|||||||
| `run_curator_sonnet_rerun.sh` | A/B test #3 (2026-05-05) — ריצה חוזרת של Sonnet 4.5 על אותו CMP-78. תוצאה: 12:52 דק׳ (לעומת 20:13 בריצה המקורית — כי בלי לולאת interaction.json). זיהה תוצאה שגויה ("דחייה") **בעקביות עם הריצה המקורית** — Sonnet עקבי-בטעות, DeepSeek אקראי. | בדיקה חד-פעמית — לא להריץ שוב |
|
| `run_curator_sonnet_rerun.sh` | A/B test #3 (2026-05-05) — ריצה חוזרת של Sonnet 4.5 על אותו CMP-78. תוצאה: 12:52 דק׳ (לעומת 20:13 בריצה המקורית — כי בלי לולאת interaction.json). זיהה תוצאה שגויה ("דחייה") **בעקביות עם הריצה המקורית** — Sonnet עקבי-בטעות, DeepSeek אקראי. | בדיקה חד-פעמית — לא להריץ שוב |
|
||||||
| `ingest_incoming_batch.py` | python | קליטת batch של החלטות ועדת ערר מ-`data/precedents/incoming/` דרך המסלול הקנוני (`ingest_internal_decision`) + חילוץ מטא-דאטה לכל תיק (המסלול הפנימי לא מתזמן metadata — INV-ING3). רצף (לא מקבילי, להימנע מעומס CLI). רשימת `DECISIONS` נערכת ידנית לכל batch. config מ-`~/.env`. תומך תהליך [[project_precedent_incoming_workflow]]. | ידני, per-batch (חלופה ל-MCP `internal_decision_upload` כש-batch גדול) |
|
| `ingest_incoming_batch.py` | python | קליטת batch של החלטות ועדת ערר מ-`data/precedents/incoming/` דרך המסלול הקנוני (`ingest_internal_decision`) + חילוץ מטא-דאטה לכל תיק (המסלול הפנימי לא מתזמן metadata — INV-ING3). רצף (לא מקבילי, להימנע מעומס CLI). רשימת `DECISIONS` נערכת ידנית לכל batch. config מ-`~/.env`. תומך תהליך [[project_precedent_incoming_workflow]]. | ידני, per-batch (חלופה ל-MCP `internal_decision_upload` כש-batch גדול) |
|
||||||
| `drain_halacha_queue.py` | python | ריקון תור חילוץ ההלכות (`process_pending_extractions kind='halacha'`) ב-batches של 4 עד שהתור ריק (2 סבבים ריקים). משמש אחרי `ingest_incoming_batch.py`. | ידני אחרי batch (חלופה ל-MCP `precedent_process_pending`) |
|
| `drain_halacha_queue.py` | python | ריקון תור חילוץ ההלכות (`process_pending_extractions kind='halacha'`) ב-batches של 4 עד שהתור ריק (2 סבבים ריקים). משמש אחרי `ingest_incoming_batch.py`. | ידני אחרי batch (חלופה ל-MCP `precedent_process_pending`) |
|
||||||
|
| `ingest_digests_batch.py` | python | קליטת batch של יומוני "כל יום" מ-`data/digests/incoming/` דרך המסלול העצמאי של קורפוס-הגילוי (`digest_library.ingest_digest`) — חילוץ-LLM (תג-מושג, כותרת-הלכה, מראה-מקום, שני-תאריכים), embedding יחיד, ו-autolink לפסק המקורי (X12/INV-DIG3). רצף (לא מקבילי). מזהה-יומון+תאריך נגזרים משם-הקובץ; העלון החודשי מדולג. קבצים מועברים ל-`processed/`. config מ-`~/.env`. | ידני, per-batch (חלופה ל-MCP `digest_upload`) |
|
||||||
|
|
||||||
## סקריפטים שנמחקו (git history בלבד)
|
## סקריפטים שנמחקו (git history בלבד)
|
||||||
|
|
||||||
|
|||||||
100
scripts/goldset_ai_recommend.py
Normal file
100
scripts/goldset_ai_recommend.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the AI second-opinion for gold-set items (#81.7 QA aid).
|
||||||
|
|
||||||
|
For each gold-set halacha, an INDEPENDENT local-LLM (claude_session, zero cost)
|
||||||
|
judges: is it a real generalizable holding, what is its correct rule_type, and a
|
||||||
|
one-line rationale. Stored in halacha_goldset.ai_* and shown beside the human
|
||||||
|
tag so the chair can spot disagreements and reconsider.
|
||||||
|
|
||||||
|
This is a QA aid, NOT ground truth and NOT auto-applied. It is also independent
|
||||||
|
of the rule-based validators that #81.8 measures, so it doesn't bias that score.
|
||||||
|
|
||||||
|
Must run locally (claude_session needs the local CLI — not the container):
|
||||||
|
|
||||||
|
cd ~/legal-ai/mcp-server
|
||||||
|
.venv/bin/python ../scripts/goldset_ai_recommend.py # missing only
|
||||||
|
.venv/bin/python ../scripts/goldset_ai_recommend.py --force # regenerate all
|
||||||
|
.venv/bin/python ../scripts/goldset_ai_recommend.py --limit 10 # smoke
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import claude_session, db
|
||||||
|
|
||||||
|
VALID_TYPES = {"binding", "interpretive", "obiter", "application", "procedural", "persuasive"}
|
||||||
|
|
||||||
|
SYSTEM = (
|
||||||
|
"אתה בוחן-איכות משפטי המסווג 'הלכות' שחולצו מהחלטות ועדת-ערר ומפסקי-דין. "
|
||||||
|
"לכל פריט הכרע שתי שאלות, באופן עצמאי ולפי המהות:\n"
|
||||||
|
"1) is_holding — האם זו הלכה אמיתית בת-הכללה ובת-הסתמכות (true), או שזו יישום "
|
||||||
|
"תלוי-עובדות / אמרת-אגב / ציטוט-עובדה ולא כלל בר-הכללה (false).\n"
|
||||||
|
"2) type — הסוג הנכון: 'binding' (עיקרון הכרחי להכרעה), 'interpretive' (פרשנות "
|
||||||
|
"חוק/מונח/תכנית), 'procedural' (סדר-דין: מועדים/סמכות/מיצוי/נטל), 'persuasive' "
|
||||||
|
"(אסמכתה לא-מחייבת), 'application' (החלה על עובדות התיק — לרוב לא-הלכה), "
|
||||||
|
"'obiter' (אמרת-אגב שלא הוכרעה — לא-הלכה).\n"
|
||||||
|
"עקביות: is_holding=true → binding/interpretive/procedural/persuasive; "
|
||||||
|
"is_holding=false → application/obiter.\n"
|
||||||
|
'החזר JSON בלבד: {"is_holding": true/false, "type": "<אחד מהשישה>", '
|
||||||
|
'"rationale": "<משפט אחד קצר בעברית>"}. ללא markdown.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt(item: dict) -> str:
|
||||||
|
src = "פסק-דין" if item.get("source_type") == "court_ruling" else "החלטת ועדת-ערר"
|
||||||
|
return (
|
||||||
|
f"מקור: {src} ({item.get('case_number') or ''}).\n"
|
||||||
|
f"סוג שהמכונה נתנה: {item.get('rule_type')}.\n\n"
|
||||||
|
f"ניסוח הכלל:\n{item.get('rule_statement') or ''}\n\n"
|
||||||
|
f"ציטוט תומך:\n{item.get('supporting_quote') or ''}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main(args: argparse.Namespace) -> int:
|
||||||
|
items = await db.goldset_list(args.batch)
|
||||||
|
todo = [it for it in items if args.force or not it.get("ai_generated_at")]
|
||||||
|
if args.limit:
|
||||||
|
todo = todo[: args.limit]
|
||||||
|
print(f"gold-set {args.batch}: {len(items)} items, {len(todo)} to recommend", flush=True)
|
||||||
|
|
||||||
|
ok, fail, disagree = 0, 0, 0
|
||||||
|
for i, it in enumerate(todo, 1):
|
||||||
|
try:
|
||||||
|
v = await claude_session.query_json(_prompt(it), system=SYSTEM, effort="low")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
fail += 1
|
||||||
|
print(f"[{i}/{len(todo)}] {it['case_number']}: FAIL {e}", flush=True)
|
||||||
|
continue
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
fail += 1
|
||||||
|
continue
|
||||||
|
ai_hold = bool(v.get("is_holding"))
|
||||||
|
ai_type = str(v.get("type") or "").strip()
|
||||||
|
if ai_type not in VALID_TYPES:
|
||||||
|
ai_type = ""
|
||||||
|
await db.goldset_set_ai_recommendation(
|
||||||
|
UUID(str(it["id"])), ai_is_holding=ai_hold, ai_correct_type=ai_type,
|
||||||
|
ai_rationale=str(v.get("rationale") or "")[:300],
|
||||||
|
)
|
||||||
|
ok += 1
|
||||||
|
# note disagreements with the human tag (if tagged)
|
||||||
|
flag = ""
|
||||||
|
if it.get("is_holding") is not None and it["is_holding"] != ai_hold:
|
||||||
|
disagree += 1
|
||||||
|
flag = " ⚠ DISAGREE is_holding"
|
||||||
|
print(f"[{i}/{len(todo)}] {it['case_number']}: ai={ai_hold}/{ai_type}{flag}", flush=True)
|
||||||
|
|
||||||
|
print(f"\nDONE — {ok} stored, {fail} failed, {disagree} disagree with existing human tag",
|
||||||
|
flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--batch", default="default")
|
||||||
|
ap.add_argument("--force", action="store_true", help="regenerate even if present")
|
||||||
|
ap.add_argument("--limit", type=int, default=None)
|
||||||
|
sys.exit(asyncio.run(main(ap.parse_args())))
|
||||||
@@ -91,7 +91,22 @@ async def main(args: argparse.Namespace) -> int:
|
|||||||
w = csv.DictWriter(f, fieldnames=list(pairs[0].keys()))
|
w = csv.DictWriter(f, fieldnames=list(pairs[0].keys()))
|
||||||
w.writeheader()
|
w.writeheader()
|
||||||
w.writerows(pairs)
|
w.writerows(pairs)
|
||||||
print(f"\nreport: {out} (review-only — nothing changed)", flush=True)
|
print(f"\nreport: {out}", flush=True)
|
||||||
|
|
||||||
|
if args.link and pairs:
|
||||||
|
# #84.2 — record each pair as parallel authority (equivalent_halachot).
|
||||||
|
# Non-destructive: links only, never merges/deletes. Idempotent.
|
||||||
|
linked = 0
|
||||||
|
for p in pairs:
|
||||||
|
if await db.link_equivalent_halachot(
|
||||||
|
p["id_a"], p["id_b"], cosine=p["cosine"],
|
||||||
|
note="cross-precedent parallel authority (halacha_batch_reconcile)",
|
||||||
|
created_by="batch_reconcile",
|
||||||
|
):
|
||||||
|
linked += 1
|
||||||
|
print(f"linked {linked}/{len(pairs)} pairs as equivalent_halachot", flush=True)
|
||||||
|
elif pairs:
|
||||||
|
print("(review-only — pass --link to record them as equivalent_halachot)", flush=True)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -102,5 +117,7 @@ if __name__ == "__main__":
|
|||||||
help="min cosine for a cross-precedent candidate (default 0.95)")
|
help="min cosine for a cross-precedent candidate (default 0.95)")
|
||||||
ap.add_argument("--include-pending", action="store_true",
|
ap.add_argument("--include-pending", action="store_true",
|
||||||
help="also scan pending_review halachot (default: approved/published only)")
|
help="also scan pending_review halachot (default: approved/published only)")
|
||||||
|
ap.add_argument("--link", action="store_true",
|
||||||
|
help="record found pairs as equivalent_halachot (parallel authority, #84.2)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
sys.exit(asyncio.run(main(args)))
|
sys.exit(asyncio.run(main(args)))
|
||||||
|
|||||||
137
scripts/ingest_digests_batch.py
Normal file
137
scripts/ingest_digests_batch.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""Batch ingest of "כל יום" daily digests staged in data/digests/incoming/ (X12).
|
||||||
|
|
||||||
|
Sequential (NOT concurrent — same load-spike caution as ingest_incoming_batch.py)
|
||||||
|
ingest of each yomon PDF via the standalone digest pipeline
|
||||||
|
(``digest_library.ingest_digest``), which:
|
||||||
|
- extracts text, dedups on content_hash (idempotent),
|
||||||
|
- runs the local LLM metadata extractor (concept_tag, headline, underlying
|
||||||
|
citation, two dates, practice_area, subject_tags),
|
||||||
|
- stores a single embedding,
|
||||||
|
- auto-links to the underlying ruling if it is already in the precedent
|
||||||
|
library (INV-DIG3).
|
||||||
|
|
||||||
|
The digest is a SECONDARY, radar-only source — it never enters the precedent /
|
||||||
|
halacha pipeline and is never cited in a decision (INV-DIG1/2). After this run,
|
||||||
|
relink unmatched digests once the originals are uploaded, or surface them via
|
||||||
|
missing_precedent_create.
|
||||||
|
|
||||||
|
Yomon number + issue date are parsed from the filename
|
||||||
|
("יומון 5158 - 31.5.26.pdf") as hints; the LLM also extracts them from the
|
||||||
|
body and the explicit hint wins. The monthly bulletin (e.g. "201 יוני.pdf") is
|
||||||
|
multi-topic and skipped (Phase 3).
|
||||||
|
|
||||||
|
Run: mcp-server/.venv/bin/python scripts/ingest_digests_batch.py
|
||||||
|
(optionally pass explicit file paths as args)
|
||||||
|
Config (POSTGRES_URL, VOYAGE_API_KEY, ANTHROPIC_API_KEY) auto-loads from ~/.env.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||||
|
|
||||||
|
from legal_mcp import config # noqa: E402
|
||||||
|
from legal_mcp.services import digest_library as svc # noqa: E402
|
||||||
|
|
||||||
|
INCOMING = Path(config.DATA_DIR) / "digests" / "incoming"
|
||||||
|
PROCESSED = Path(config.DATA_DIR) / "digests" / "processed"
|
||||||
|
|
||||||
|
# Matches "יומון 5158 - 31.5.26" → ("5158", "31.5.26")
|
||||||
|
_NAME_RE = re.compile(r"יומון\s*(\d+)\s*-\s*(\d{1,2})\.(\d{1,2})\.(\d{2,4})")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_name(fname: str) -> tuple[str, str | None]:
|
||||||
|
"""Return (yomon_number, iso_date_or_None) parsed from the filename."""
|
||||||
|
m = _NAME_RE.search(fname)
|
||||||
|
if not m:
|
||||||
|
return "", None
|
||||||
|
num, dd, mm, yy = m.groups()
|
||||||
|
year = int(yy)
|
||||||
|
if year < 100:
|
||||||
|
year += 2000
|
||||||
|
try:
|
||||||
|
iso = f"{year:04d}-{int(mm):02d}-{int(dd):02d}"
|
||||||
|
except ValueError:
|
||||||
|
iso = None
|
||||||
|
return num, iso
|
||||||
|
|
||||||
|
|
||||||
|
def _discover() -> list[Path]:
|
||||||
|
if not INCOMING.exists():
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for p in sorted(INCOMING.glob("*.pdf")):
|
||||||
|
if "יומון" not in p.name:
|
||||||
|
print(f"⊘ skip (not a single yomon): {p.name}", flush=True)
|
||||||
|
continue
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def main(argv: list[str]) -> None:
|
||||||
|
files = [Path(a) for a in argv] if argv else _discover()
|
||||||
|
if not files:
|
||||||
|
print(f"No yomon PDFs found in {INCOMING}", flush=True)
|
||||||
|
return
|
||||||
|
PROCESSED.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for idx, fp in enumerate(files):
|
||||||
|
rec = {"file": fp.name}
|
||||||
|
if not fp.exists():
|
||||||
|
rec["error"] = "file-missing"
|
||||||
|
print(f"✗ {fp.name}: file missing", flush=True)
|
||||||
|
results.append(rec)
|
||||||
|
continue
|
||||||
|
yomon_number, iso_date = _parse_name(fp.name)
|
||||||
|
try:
|
||||||
|
out = await svc.ingest_digest(
|
||||||
|
file_path=fp,
|
||||||
|
yomon_number=yomon_number,
|
||||||
|
digest_date=iso_date,
|
||||||
|
)
|
||||||
|
rec.update({
|
||||||
|
"status": out.get("status"),
|
||||||
|
"digest_id": out.get("digest_id"),
|
||||||
|
"yomon_number": out.get("yomon_number"),
|
||||||
|
"underlying_citation": out.get("underlying_citation"),
|
||||||
|
"linked_case_law_id": out.get("linked_case_law_id"),
|
||||||
|
})
|
||||||
|
link = "🔗 linked" if out.get("linked_case_law_id") else "⚠ unlinked"
|
||||||
|
print(
|
||||||
|
f"✓ {fp.name}: {out.get('status')} | yomon={out.get('yomon_number')} | "
|
||||||
|
f"{link} | {out.get('underlying_citation')}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
# Move to processed/ so re-runs are clean (idempotent anyway).
|
||||||
|
try:
|
||||||
|
shutil.move(str(fp), str(PROCESSED / fp.name))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" (could not move {fp.name}: {e})", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
rec["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
print(f"✗ {fp.name}: {e}", flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
results.append(rec)
|
||||||
|
|
||||||
|
print("\n===SUMMARY===", flush=True)
|
||||||
|
for r in results:
|
||||||
|
print(r, flush=True)
|
||||||
|
linked = sum(1 for r in results if r.get("linked_case_law_id"))
|
||||||
|
unlinked = sum(
|
||||||
|
1 for r in results
|
||||||
|
if r.get("status") in ("completed", "exists") and not r.get("linked_case_law_id")
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"\nTotal: {len(results)} | linked: {linked} | unlinked (need precedent upload): {unlinked}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main(sys.argv[1:]))
|
||||||
65
scripts/legal-court-fetch-service.config.cjs
Normal file
65
scripts/legal-court-fetch-service.config.cjs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* pm2 ecosystem entry for legal-court-fetch-service — the host-side Tier-1
|
||||||
|
* verdict fetcher (X13). It drives a Camoufox stealth browser against
|
||||||
|
* נט המשפט to download administrative/district-court verdicts the Supreme
|
||||||
|
* portal (Tier 0) doesn't carry. Lives on the host because the legal-ai
|
||||||
|
* container can't run a browser. See docs/spec/X13-court-fetch.md.
|
||||||
|
*
|
||||||
|
* Mirrors legal-chat-service.config.cjs (same security model):
|
||||||
|
* 1. Bind to 10.0.1.1 (docker0 bridge gateway) — host + docker-bridge
|
||||||
|
* containers only; nothing from outside the host.
|
||||||
|
* 2. Bearer token auth — COURT_FETCH_SHARED_SECRET loaded from
|
||||||
|
* /home/chaim/.legal-court-fetch-service.env (chmod 600) and mirrored in
|
||||||
|
* Coolify so the FastAPI proxy sends a matching Authorization header.
|
||||||
|
* The service refuses to start without the secret.
|
||||||
|
*
|
||||||
|
* Prereqs for Tier-1 to actually fetch (otherwise it returns ok:false and the
|
||||||
|
* orchestrator escalates to the human fallback — INV-CF3):
|
||||||
|
* - camofox-browser running, CAMOFOX_URL set (e.g. http://127.0.0.1:9377).
|
||||||
|
* git clone https://github.com/jo-inc/camofox-browser && npm i && npm start
|
||||||
|
* - faster-whisper installed in the venv for the reCAPTCHA audio solver.
|
||||||
|
*
|
||||||
|
* Install (once):
|
||||||
|
* pm2 start /home/chaim/legal-ai/scripts/legal-court-fetch-service.config.cjs
|
||||||
|
* pm2 save
|
||||||
|
* Smoke test:
|
||||||
|
* curl http://10.0.1.1:8771/health
|
||||||
|
* Update:
|
||||||
|
* pm2 restart legal-court-fetch-service --update-env
|
||||||
|
*/
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const ENV_FILE = "/home/chaim/.legal-court-fetch-service.env";
|
||||||
|
const env = {
|
||||||
|
HOME: "/home/chaim",
|
||||||
|
PATH: "/home/chaim/.local/bin:/usr/local/bin:/usr/bin:/bin",
|
||||||
|
PYTHONUNBUFFERED: "1",
|
||||||
|
// CAMOFOX_URL: "http://127.0.0.1:9377", // set when camofox-browser is up
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(ENV_FILE, "utf8");
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
if (!line || line.trim().startsWith("#")) continue;
|
||||||
|
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||||
|
if (m) env[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`legal-court-fetch-service: failed to load ${ENV_FILE}: ${e.message}`);
|
||||||
|
console.error("Service will refuse to start without COURT_FETCH_SHARED_SECRET.");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "legal-court-fetch-service",
|
||||||
|
cwd: "/home/chaim/legal-ai/mcp-server",
|
||||||
|
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
|
||||||
|
args: "-m legal_mcp.court_fetch_service.server --port 8771 --host 10.0.1.1",
|
||||||
|
env,
|
||||||
|
restart_delay: 5000,
|
||||||
|
max_restarts: 10,
|
||||||
|
autorestart: true,
|
||||||
|
max_memory_restart: "1G",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
41
web-ui/src/app/goldset/page.tsx
Normal file
41
web-ui/src/app/goldset/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { GoldsetPanel } from "@/components/goldset/goldset-panel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gold-set tagging page (#81.7 / #81.8).
|
||||||
|
*
|
||||||
|
* Interactive review of a stratified halacha sample. The chair/Dafna labels each
|
||||||
|
* item (is_holding / correct_type / quote_complete); those human labels are the
|
||||||
|
* ground truth that measures the extraction validators and recalibrates the
|
||||||
|
* auto-approve threshold. Tags MUST be human — no AI pre-fill (circular bias).
|
||||||
|
*/
|
||||||
|
export default function GoldsetPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||||
|
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||||
|
<span aria-hidden> · </span>
|
||||||
|
<span className="text-navy">מדגם-זהב לתיוג</span>
|
||||||
|
</nav>
|
||||||
|
<h1 className="text-navy mb-0">מדגם-זהב לתיוג איכות</h1>
|
||||||
|
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
|
||||||
|
מדגם מרובד של הלכות שחולצו. לכל הלכה הכריעו שלוש שאלות —
|
||||||
|
<strong> האם זו הלכה אמיתית</strong>, <strong>מה הסוג הנכון</strong>,
|
||||||
|
ו<strong>האם הציטוט שלם</strong>. ההכרעות שלכם הן אמת-המידה שמודדת את
|
||||||
|
דיוק המחלץ ומכיילת את סף-האישור האוטומטי. שיפוט משפטי אנושי בלבד —
|
||||||
|
לא תיוג-AI (כדי למנוע הטיה מעגלית).
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|
||||||
|
<GoldsetPanel />
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ href: "/precedents", label: "ספריית פסיקה" },
|
{ href: "/precedents", label: "ספריית פסיקה" },
|
||||||
{ href: "/missing-precedents", label: "פסיקה חסרה" },
|
{ href: "/missing-precedents", label: "פסיקה חסרה" },
|
||||||
|
{ href: "/goldset", label: "מדגם-זהב" },
|
||||||
{ href: "/training", label: "אימון סגנון" },
|
{ href: "/training", label: "אימון סגנון" },
|
||||||
{ href: "/methodology", label: "מתודולוגיה" },
|
{ href: "/methodology", label: "מתודולוגיה" },
|
||||||
],
|
],
|
||||||
|
|||||||
501
web-ui/src/components/goldset/goldset-panel.tsx
Normal file
501
web-ui/src/components/goldset/goldset-panel.tsx
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Check, X, ChevronDown, ChevronLeft, Info, AlertTriangle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
useGoldset, useGoldsetScore, useTagGoldset, useCreateGoldsetSample,
|
||||||
|
type GoldsetItem,
|
||||||
|
} from "@/lib/api/goldset";
|
||||||
|
|
||||||
|
const TYPES: { value: string; label: string }[] = [
|
||||||
|
{ value: "binding", label: "מחייבת" },
|
||||||
|
{ value: "interpretive", label: "פרשני" },
|
||||||
|
{ value: "application", label: "יישום" },
|
||||||
|
{ value: "obiter", label: "אמרת-אגב" },
|
||||||
|
{ value: "procedural", label: "פרוצדורלי" },
|
||||||
|
{ value: "persuasive", label: "משכנע" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Consistency between is_holding and the type (#81.7): a real holding is
|
||||||
|
// binding/interpretive/procedural/persuasive; a NON-holding is its reason —
|
||||||
|
// application (fact-bound) or obiter (not decided). Other pairings contradict.
|
||||||
|
const HOLDING_TYPES = new Set(["binding", "interpretive", "procedural", "persuasive"]);
|
||||||
|
const NON_HOLDING_TYPES = new Set(["application", "obiter"]);
|
||||||
|
|
||||||
|
function inconsistentTag(it: GoldsetItem): string | null {
|
||||||
|
if (it.is_holding === null || !it.correct_type) return null;
|
||||||
|
if (it.is_holding === true && NON_HOLDING_TYPES.has(it.correct_type)) {
|
||||||
|
return "סימנת \"הלכה\" אך הסוג הוא יישום/אמרת-אגב — אלה דווקא הסיבות שמשהו אינו הלכה.";
|
||||||
|
}
|
||||||
|
if (it.is_holding === false && HOLDING_TYPES.has(it.correct_type)) {
|
||||||
|
return "סימנת \"לא הלכה\" אך הסוג מציין הלכה (מחייבת/פרשני/…); ל\"לא\" מתאים יישום או אמרת-אגב.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLAG_LABELS: Record<string, string> = {
|
||||||
|
non_decision: "אי-הכרעה", truncated_quote: "ציטוט קטוע", thin_restatement: "ניסוח דק",
|
||||||
|
quote_unverified: "ציטוט לא מאומת", nli_unsupported: "כלל לא נגזר", application: "יישום",
|
||||||
|
near_duplicate: "כפילות-קרובה", nevo_preamble_leak: "דליפת רציו",
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanCitation(s: string | null | undefined): string {
|
||||||
|
if (!s) return "—";
|
||||||
|
return s.replace(/[--]/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source separation (פסקי-דין מול החלטות ועדת-ערר) for convenient tagging.
|
||||||
|
function sourceLabel(s: string | null): string {
|
||||||
|
return s === "court_ruling" ? "פסק-דין"
|
||||||
|
: s === "appeals_committee" ? "ועדת ערר" : "אחר";
|
||||||
|
}
|
||||||
|
const SOURCE_FILTERS: { value: "all" | "court_ruling" | "appeals_committee"; label: string }[] = [
|
||||||
|
{ value: "all", label: "הכל" },
|
||||||
|
{ value: "court_ruling", label: "פסקי דין" },
|
||||||
|
{ value: "appeals_committee", label: "ועדת ערר" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isTagged(it: GoldsetItem): boolean {
|
||||||
|
// Fully tagged only when ALL THREE answers are set — otherwise, in
|
||||||
|
// "hide tagged" mode, a card would vanish the moment is_holding is clicked,
|
||||||
|
// before correct_type / quote_complete can be set.
|
||||||
|
return it.is_holding !== null && it.quote_complete !== null && !!it.correct_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The AI second-opinion disagrees with the human tag (on is_holding or type).
|
||||||
|
function aiDisagrees(it: GoldsetItem): boolean {
|
||||||
|
if (!it.ai_generated_at) return false;
|
||||||
|
const holdDiff = it.is_holding !== null && it.ai_is_holding !== null
|
||||||
|
&& it.is_holding !== it.ai_is_holding;
|
||||||
|
const typeDiff = !!it.correct_type && !!it.ai_correct_type
|
||||||
|
&& it.correct_type !== it.ai_correct_type;
|
||||||
|
return holdDiff || typeDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Score panel ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ScorePanel({ batch }: { batch: string }) {
|
||||||
|
const { data } = useGoldsetScore(batch);
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
if (!data || data.labeled === 0) return null;
|
||||||
|
const rows = Object.entries(data.validators);
|
||||||
|
// negatives so far (truly "not a holding") = tp+fn of any validator.
|
||||||
|
const af = data.validators.any_flag;
|
||||||
|
const negatives = af ? af.tp + af.fn : 0;
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-rule bg-surface">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-gold-wash/30"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||||
|
<span className="font-semibold text-navy">ציון הוולידטורים מול התיוג</span>
|
||||||
|
<span className="text-ink-muted">({data.labeled} מתויגות)</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="px-4 pb-3 overflow-x-auto">
|
||||||
|
<p className="text-[0.72rem] text-ink-muted mb-2">
|
||||||
|
המדדים מודדים זיהוי <strong>"לא-הלכה"</strong> (יישום / ציטוט-קטוע / אי-הכרעה...).
|
||||||
|
{negatives < 10
|
||||||
|
? ` עד כה תויגו רק ${negatives} פריטי "לא הלכה" — המספרים יהפכו משמעותיים ככל שיצטברו עוד (במיוחד מבקט המסומנים).`
|
||||||
|
: " precision גבוה = מעט אזעקות-שווא; recall גבוה = תופס את רוב ה'לא-הלכה'."}
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-sm tabular-nums">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-ink-muted text-[0.72rem] border-b border-rule">
|
||||||
|
<th className="text-start py-1 ps-1">ולידטור</th>
|
||||||
|
<th className="text-start">Precision</th>
|
||||||
|
<th className="text-start">Recall</th>
|
||||||
|
<th className="text-start">F1</th>
|
||||||
|
<th className="text-start">tp/fp/fn/tn</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map(([name, v]) => (
|
||||||
|
<tr key={name} className="border-b border-rule-soft last:border-0">
|
||||||
|
<td className="py-1 ps-1 text-navy">{name}</td>
|
||||||
|
<td>{v.precision.toFixed(2)}</td>
|
||||||
|
<td>{v.recall.toFixed(2)}</td>
|
||||||
|
<td className="font-semibold">{v.f1.toFixed(2)}</td>
|
||||||
|
<td className="text-ink-muted text-[0.72rem]">
|
||||||
|
{v.tp}/{v.fp}/{v.fn}/{v.tn}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rule-type help (info popover) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TYPE_HELP: { label: string; def: string; test: string; example: string }[] = [
|
||||||
|
{
|
||||||
|
label: "מחייבת",
|
||||||
|
def: "העיקרון שהיה הכרחי להכרעה — ה-holding האמיתי. בר-הסתמכות מלא.",
|
||||||
|
test: "מבחן וומבו: הפוך את הכלל — אם התוצאה הייתה משתנה → מחייבת.",
|
||||||
|
example: "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "פרשני",
|
||||||
|
def: "קביעה שמפרשת הוראת-חוק / מונח / תכנית (מה המשמעות של סעיף X).",
|
||||||
|
test: "עונה ל'מה פירוש הנורמה?' ולא ל'מה הדין?'.",
|
||||||
|
example: "תכלית הפטור לפי ס' 19(ב)(4) היא לעודד פעילות ציבורית.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "יישום",
|
||||||
|
def: "החלת כלל על עובדות התיק הספציפי — תלוי-עובדות, לא בר-הכללה (לרוב 'לא הלכה').",
|
||||||
|
test: "מכיל 'במקרה דנן', שמות-צדדים, סכומים, המבנה הקונקרטי.",
|
||||||
|
example: "במקרה דנן ההיתר בטל כי השומה שגתה ב-12,000 ₪.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "אמרת-אגב",
|
||||||
|
def: "נאמר אגב אורחא, לא הכרחי להכרעה; הערכאה לא הכריעה בו. לא מחייב.",
|
||||||
|
test: "מבחן וומבו הפוך: היפוך הכלל לא משנה את התוצאה. דגלים: 'למעלה מן הצורך', 'מבלי לקבוע מסמרות'.",
|
||||||
|
example: "אף שאיננו נדרשים להכריע, נעיר כי ייתכן ש...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "פרוצדורלי",
|
||||||
|
def: "כלל סדר-דין: מועדים, סמכות, זכות-עמידה, מיצוי הליכים, נטל.",
|
||||||
|
test: "עוסק ב'איך' מתנהל ההליך, לא במהות התכנונית.",
|
||||||
|
example: "המועד להגשת ערר הוא 30 יום.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "משכנע",
|
||||||
|
def: "אסמכתה לא-מחייבת את הערכאה — שכנוע בלבד.",
|
||||||
|
test: "מקור שאינו כובל: ועדת-ערר אחרת, דעת-מיעוט, ספרות.",
|
||||||
|
example: "ועדת ערר ירושלים מסתמכת על החלטת ועדת ערר ממחוז אחר.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function RuleTypeHelp() {
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center text-ink-muted hover:text-gold-deep"
|
||||||
|
aria-label="הסבר על סוגי ההלכה"
|
||||||
|
title="הסבר על הסוגים"
|
||||||
|
>
|
||||||
|
<Info className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-[min(92vw,560px)] max-h-[70vh] overflow-y-auto p-0">
|
||||||
|
<div className="p-3 border-b border-rule">
|
||||||
|
<p className="font-semibold text-navy text-sm">סוגי ההלכה — במה הם נבדלים</p>
|
||||||
|
<p className="text-[0.72rem] text-ink-muted mt-0.5">
|
||||||
|
כלל-אצבע: סימנת "הלכה" → לרוב מחייבת / פרשני / פרוצדורלי / משכנע. סימנת "לא" → לרוב יישום / אמרת-אגב.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y divide-rule-soft">
|
||||||
|
{TYPE_HELP.map((t) => (
|
||||||
|
<li key={t.label} className="p-3 space-y-1" dir="rtl">
|
||||||
|
<div className="font-semibold text-navy text-sm">{t.label}</div>
|
||||||
|
<div className="text-[0.78rem] text-ink-soft leading-relaxed">{t.def}</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted"><span className="font-medium">מבחן:</span> {t.test}</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted"><span className="font-medium">דוגמה:</span> {t.example}</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tag card ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TagCard({
|
||||||
|
it, focused, onTag,
|
||||||
|
}: {
|
||||||
|
it: GoldsetItem;
|
||||||
|
focused: boolean;
|
||||||
|
onTag: (tag: { is_holding?: boolean; correct_type?: string; quote_complete?: boolean }) => void;
|
||||||
|
}) {
|
||||||
|
const tagged = isTagged(it);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-goldset-id={it.id}
|
||||||
|
className={`rounded-lg border bg-surface p-4 space-y-3 transition-colors
|
||||||
|
${focused ? "border-gold ring-2 ring-gold/40 shadow-md" : tagged ? "border-rule-soft opacity-70" : "border-rule"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-[0.72rem] text-ink-muted flex-wrap">
|
||||||
|
<span className="font-semibold text-navy">{cleanCitation(it.case_number)}</span>
|
||||||
|
<Badge variant="outline"
|
||||||
|
className={`text-[0.65rem] ${it.source_type === "court_ruling"
|
||||||
|
? "bg-navy-soft/30 text-navy border-navy/30"
|
||||||
|
: "bg-gold-wash text-gold-deep border-gold/40"}`}>
|
||||||
|
{sourceLabel(it.source_type)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-[0.65rem]">מכונה: {it.rule_type}</Badge>
|
||||||
|
{it.confidence != null && (
|
||||||
|
<Badge variant="outline" className="text-[0.65rem] tabular-nums">ביטחון {it.confidence.toFixed(2)}</Badge>
|
||||||
|
)}
|
||||||
|
{(it.quality_flags ?? []).map((f) => (
|
||||||
|
<Badge key={f} variant="outline" className="text-[0.65rem] bg-danger-bg text-danger border-danger/40">
|
||||||
|
{FLAG_LABELS[f] ?? f}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{tagged && (
|
||||||
|
<Badge variant="outline" className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40 ms-auto">
|
||||||
|
<Check className="w-3 h-3 me-1" /> תויג
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-navy font-medium leading-relaxed" dir="rtl">{it.rule_statement}</p>
|
||||||
|
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl">
|
||||||
|
“{it.supporting_quote}”
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
{it.ai_generated_at && (() => {
|
||||||
|
const aiType = TYPES.find((t) => t.value === it.ai_correct_type)?.label ?? it.ai_correct_type;
|
||||||
|
const holdDisagree = it.is_holding !== null && it.ai_is_holding !== null
|
||||||
|
&& it.is_holding !== it.ai_is_holding;
|
||||||
|
const typeDisagree = !!it.correct_type && !!it.ai_correct_type
|
||||||
|
&& it.correct_type !== it.ai_correct_type;
|
||||||
|
const anyTag = it.is_holding !== null || !!it.correct_type;
|
||||||
|
return (
|
||||||
|
<div className={`rounded-md border p-2.5 text-[0.78rem] space-y-1
|
||||||
|
${holdDisagree ? "border-amber-400 bg-amber-50" : "border-rule bg-rule-soft/20"}`} dir="rtl">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold text-navy">🤖 המלצת AI:</span>
|
||||||
|
<span>{it.ai_is_holding ? "הלכה" : "לא הלכה"}</span>
|
||||||
|
{aiType && <span className="text-ink-muted">· {aiType}</span>}
|
||||||
|
{anyTag && (
|
||||||
|
<span className={`ms-auto text-[0.7rem] px-1.5 py-0.5 rounded
|
||||||
|
${holdDisagree || typeDisagree
|
||||||
|
? "bg-amber-100 text-amber-800"
|
||||||
|
: "bg-emerald-50 text-emerald-700"}`}>
|
||||||
|
{holdDisagree ? "⚠ חולק על 'הלכה/לא'"
|
||||||
|
: typeDisagree ? "⚠ חולק על הסוג"
|
||||||
|
: "✓ מסכים איתך"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{it.ai_rationale && <div className="text-ink-soft leading-relaxed">{it.ai_rationale}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3 pt-1 border-t border-rule-soft">
|
||||||
|
{/* is_holding */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] text-ink-muted mb-1">האם זו הלכה אמיתית?</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant={it.is_holding === true ? "default" : "ghost"}
|
||||||
|
className={it.is_holding === true ? "bg-gold text-navy hover:bg-gold-deep" : ""}
|
||||||
|
onClick={() => onTag({ is_holding: true })}>הלכה (H)</Button>
|
||||||
|
<Button size="sm" variant={it.is_holding === false ? "default" : "ghost"}
|
||||||
|
className={it.is_holding === false ? "bg-danger text-parchment hover:bg-danger" : "text-danger"}
|
||||||
|
onClick={() => onTag({ is_holding: false })}>לא (N)</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* correct_type */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] text-ink-muted mb-1 flex items-center gap-1">
|
||||||
|
הסוג הנכון
|
||||||
|
<RuleTypeHelp />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{TYPES.map((t) => (
|
||||||
|
<Button key={t.value} size="sm"
|
||||||
|
variant={it.correct_type === t.value ? "default" : "ghost"}
|
||||||
|
className={`text-[0.7rem] px-2 ${it.correct_type === t.value ? "bg-navy text-parchment hover:bg-navy-soft" : ""}`}
|
||||||
|
onClick={() => onTag({ correct_type: t.value })}>{t.label}</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{inconsistentTag(it) && (
|
||||||
|
<p className="mt-1 text-[0.68rem] text-amber-700 flex items-start gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
|
||||||
|
{inconsistentTag(it)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* quote_complete */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] text-ink-muted mb-1">הציטוט שלם?</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant={it.quote_complete === true ? "default" : "ghost"}
|
||||||
|
className={it.quote_complete === true ? "bg-gold text-navy hover:bg-gold-deep" : ""}
|
||||||
|
onClick={() => onTag({ quote_complete: true })}>שלם (C)</Button>
|
||||||
|
<Button size="sm" variant={it.quote_complete === false ? "default" : "ghost"}
|
||||||
|
className={it.quote_complete === false ? "bg-danger text-parchment hover:bg-danger" : "text-danger"}
|
||||||
|
onClick={() => onTag({ quote_complete: false })}>קטוע (X)</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main panel ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function GoldsetPanel() {
|
||||||
|
const batch = "default";
|
||||||
|
const { data, isPending, error } = useGoldset(batch);
|
||||||
|
const tag = useTagGoldset(batch);
|
||||||
|
const createSample = useCreateGoldsetSample(batch);
|
||||||
|
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||||
|
// Single mutually-exclusive view mode — can't get "stuck" like the old
|
||||||
|
// independent toggles (where the disagree filter hid the untagged items).
|
||||||
|
const [viewMode, setViewMode] =
|
||||||
|
useState<"all" | "untagged" | "tagged" | "disagree">("all");
|
||||||
|
const [sourceFilter, setSourceFilter] =
|
||||||
|
useState<"all" | "court_ruling" | "appeals_committee">("all");
|
||||||
|
|
||||||
|
const items = useMemo(() => data?.items ?? [], [data]);
|
||||||
|
const taggedCount = items.filter(isTagged).length;
|
||||||
|
const untaggedCount = items.length - taggedCount;
|
||||||
|
const disagreeCount = items.filter(aiDisagrees).length;
|
||||||
|
const sourceCounts = useMemo(() => ({
|
||||||
|
court_ruling: items.filter((i) => i.source_type === "court_ruling").length,
|
||||||
|
appeals_committee: items.filter((i) => i.source_type === "appeals_committee").length,
|
||||||
|
}), [items]);
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
let v = items;
|
||||||
|
if (sourceFilter !== "all") v = v.filter((i) => i.source_type === sourceFilter);
|
||||||
|
if (viewMode === "untagged") v = v.filter((i) => !isTagged(i));
|
||||||
|
else if (viewMode === "tagged") v = v.filter(isTagged);
|
||||||
|
else if (viewMode === "disagree") v = v.filter(aiDisagrees);
|
||||||
|
// group-sort: כל פסקי-הדין יחד, ואז כל החלטות ועדת-הערר (הפרדה ברורה).
|
||||||
|
const order = (s: string | null) =>
|
||||||
|
s === "court_ruling" ? 0 : s === "appeals_committee" ? 1 : 2;
|
||||||
|
return [...v].sort((a, b) => order(a.source_type) - order(b.source_type));
|
||||||
|
}, [items, viewMode, sourceFilter]);
|
||||||
|
|
||||||
|
const focused = focusedId ? visible.find((i) => i.id === focusedId) ?? null : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusedId && visible.some((i) => i.id === focusedId)) return;
|
||||||
|
setFocusedId(visible[0]?.id ?? null);
|
||||||
|
}, [focusedId, visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusedId) return;
|
||||||
|
document.querySelector(`[data-goldset-id="${focusedId}"]`)
|
||||||
|
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}, [focusedId]);
|
||||||
|
|
||||||
|
const move = (delta: 1 | -1) => {
|
||||||
|
if (!visible.length) return;
|
||||||
|
const idx = focusedId ? visible.findIndex((i) => i.id === focusedId) : -1;
|
||||||
|
const next = idx < 0 ? (delta > 0 ? 0 : visible.length - 1)
|
||||||
|
: Math.max(0, Math.min(visible.length - 1, idx + delta));
|
||||||
|
setFocusedId(visible[next].id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doTag = async (
|
||||||
|
it: GoldsetItem,
|
||||||
|
t: { is_holding?: boolean; correct_type?: string; quote_complete?: boolean },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await tag.mutateAsync({ id: it.id, tag: t });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const tagName = (e.target as HTMLElement)?.tagName?.toLowerCase();
|
||||||
|
if (tagName === "input" || tagName === "textarea" || tagName === "select") return;
|
||||||
|
if (e.key === "j") { e.preventDefault(); move(1); }
|
||||||
|
else if (e.key === "k") { e.preventDefault(); move(-1); }
|
||||||
|
else if (focused && (e.key === "h" || e.key === "H")) { e.preventDefault(); doTag(focused, { is_holding: true }); }
|
||||||
|
else if (focused && (e.key === "n" || e.key === "N")) { e.preventDefault(); doTag(focused, { is_holding: false }); }
|
||||||
|
else if (focused && (e.key === "c" || e.key === "C")) { e.preventDefault(); doTag(focused, { quote_complete: true }); }
|
||||||
|
else if (focused && (e.key === "x" || e.key === "X")) { e.preventDefault(); doTag(focused, { quote_complete: false }); }
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [focused, visible]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">{error.message}</div>;
|
||||||
|
}
|
||||||
|
if (isPending) {
|
||||||
|
return <div className="space-y-3">{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-40 w-full" />)}</div>;
|
||||||
|
}
|
||||||
|
if (!items.length) {
|
||||||
|
return (
|
||||||
|
<div className="text-center text-ink-muted py-16 space-y-4">
|
||||||
|
<p className="text-lg">אין מדגם-זהב עדיין.</p>
|
||||||
|
<Button disabled={createSample.isPending}
|
||||||
|
onClick={() => createSample.mutate(150)}
|
||||||
|
className="bg-gold text-navy hover:bg-gold-deep">
|
||||||
|
צור מדגם של 150 הלכות לתיוג
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = items.length ? Math.round((taggedCount / items.length) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ScorePanel batch={batch} />
|
||||||
|
|
||||||
|
{/* source separation — פסקי-דין מול החלטות ועדת-ערר */}
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30 w-fit">
|
||||||
|
{SOURCE_FILTERS.map((s) => (
|
||||||
|
<Button key={s.value} size="sm"
|
||||||
|
variant={sourceFilter === s.value ? "default" : "ghost"}
|
||||||
|
className={sourceFilter === s.value ? "bg-gold text-navy hover:bg-gold-deep" : ""}
|
||||||
|
onClick={() => setSourceFilter(s.value)}>
|
||||||
|
{s.label}
|
||||||
|
{s.value === "court_ruling" && ` (${sourceCounts.court_ruling})`}
|
||||||
|
{s.value === "appeals_committee" && ` (${sourceCounts.appeals_committee})`}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap text-sm">
|
||||||
|
<span className="text-navy font-semibold tabular-nums">{taggedCount}/{items.length} תויגו</span>
|
||||||
|
<div className="h-2 w-40 rounded-full bg-rule-soft overflow-hidden">
|
||||||
|
<div className="h-full bg-gold" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-ink-muted text-[0.72rem]">
|
||||||
|
מקלדת: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd>
|
||||||
|
{" "}· הלכה <kbd className="bg-rule-soft px-1.5 rounded">H</kbd> / לא <kbd className="bg-rule-soft px-1.5 rounded">N</kbd>
|
||||||
|
{" "}· ציטוט שלם <kbd className="bg-rule-soft px-1.5 rounded">C</kbd> / קטוע <kbd className="bg-rule-soft px-1.5 rounded">X</kbd>
|
||||||
|
</span>
|
||||||
|
<div className="ms-auto flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30">
|
||||||
|
{([
|
||||||
|
{ v: "all", label: `הכל (${items.length})` },
|
||||||
|
{ v: "untagged", label: `לא תויגו (${untaggedCount})` },
|
||||||
|
{ v: "tagged", label: `תויגו (${taggedCount})` },
|
||||||
|
{ v: "disagree", label: `⚠ אי-הסכמות (${disagreeCount})` },
|
||||||
|
] as const).map((m) => (
|
||||||
|
<Button key={m.v} size="sm"
|
||||||
|
variant={viewMode === m.v ? "default" : "ghost"}
|
||||||
|
className={viewMode === m.v
|
||||||
|
? (m.v === "disagree" ? "bg-amber-500 text-white hover:bg-amber-600" : "bg-gold text-navy hover:bg-gold-deep")
|
||||||
|
: (m.v === "disagree" ? "text-amber-700" : "")}
|
||||||
|
onClick={() => setViewMode(m.v)}>
|
||||||
|
{m.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{visible.map((it) => (
|
||||||
|
<TagCard key={it.id} it={it} focused={it.id === focusedId}
|
||||||
|
onTag={(t) => doTag(it, t)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw } from "lucide-react";
|
import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -20,6 +20,9 @@ const QUALITY_FLAG_LABELS: Record<string, string> = {
|
|||||||
thin_restatement: "ניסוח דק",
|
thin_restatement: "ניסוח דק",
|
||||||
quote_unverified: "ציטוט לא מאומת",
|
quote_unverified: "ציטוט לא מאומת",
|
||||||
nli_unsupported: "כלל לא נגזר מהציטוט",
|
nli_unsupported: "כלל לא נגזר מהציטוט",
|
||||||
|
application: "יישום תלוי-עובדות",
|
||||||
|
near_duplicate: "כפילות-קרובה",
|
||||||
|
nevo_preamble_leak: "דליפת רציו נבו",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(iso: string | null | undefined) {
|
function formatDate(iso: string | null | undefined) {
|
||||||
@@ -57,13 +60,17 @@ type EditState = { rule_statement: string; reasoning_summary: string };
|
|||||||
function HalachaCard({
|
function HalachaCard({
|
||||||
h, focused, onApprove, onReject, onDefer, onSave,
|
h, focused, onApprove, onReject, onDefer, onSave,
|
||||||
}: {
|
}: {
|
||||||
h: Halacha;
|
h: Halacha & { variants?: Halacha[] };
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
onDefer: () => void;
|
onDefer: () => void;
|
||||||
onSave: (patch: Partial<EditState>) => Promise<void>;
|
onSave: (patch: Partial<EditState>) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
const variants = h.variants ?? [];
|
||||||
|
const equivalents = h.equivalents ?? [];
|
||||||
|
const [showVariants, setShowVariants] = useState(false);
|
||||||
|
const [showEquiv, setShowEquiv] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState<EditState>({
|
const [draft, setDraft] = useState<EditState>({
|
||||||
rule_statement: h.rule_statement,
|
rule_statement: h.rule_statement,
|
||||||
@@ -111,6 +118,18 @@ function HalachaCard({
|
|||||||
<Badge variant="outline" className="text-[0.65rem]">
|
<Badge variant="outline" className="text-[0.65rem]">
|
||||||
{ruleTypeLabel(h.rule_type)}
|
{ruleTypeLabel(h.rule_type)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{variants.length > 0 && (
|
||||||
|
<Badge variant="outline"
|
||||||
|
className="text-[0.65rem] bg-navy-soft/30 text-navy border-navy/30">
|
||||||
|
+{variants.length} וריאנטים
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{equivalents.length > 0 && (
|
||||||
|
<Badge variant="outline"
|
||||||
|
className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40">
|
||||||
|
עיקרון מקביל ב-{equivalents.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<CorroborationBadge halacha={h} />
|
<CorroborationBadge halacha={h} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,6 +199,67 @@ function HalachaCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{variants.length > 0 && (
|
||||||
|
<div className="rounded-md border border-navy/20 bg-navy-soft/10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowVariants((v) => !v)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-[0.72rem] text-navy hover:bg-navy-soft/20 transition-colors"
|
||||||
|
aria-expanded={showVariants}
|
||||||
|
>
|
||||||
|
{showVariants ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
|
||||||
|
<span className="font-medium">
|
||||||
|
{variants.length} ניסוחים כמעט-זהים של אותו עיקרון
|
||||||
|
</span>
|
||||||
|
<span className="me-auto text-ink-muted">החלטה תחול על כולם</span>
|
||||||
|
</button>
|
||||||
|
{showVariants && (
|
||||||
|
<ul className="px-4 pb-3 pt-1 space-y-2">
|
||||||
|
{variants.map((v) => (
|
||||||
|
<li key={v.id} className="text-[0.78rem] text-ink-soft leading-relaxed border-r-2 border-navy/20 pr-3" dir="rtl">
|
||||||
|
{v.rule_statement}
|
||||||
|
<span className="text-[0.65rem] text-ink-muted tabular-nums ms-2">
|
||||||
|
(ביטחון {v.confidence.toFixed(2)})
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{equivalents.length > 0 && (
|
||||||
|
<div className="rounded-md border border-gold/30 bg-gold-wash/40">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEquiv((v) => !v)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-[0.72rem] text-gold-deep hover:bg-gold-wash/70 transition-colors"
|
||||||
|
aria-expanded={showEquiv}
|
||||||
|
>
|
||||||
|
{showEquiv ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
|
||||||
|
<span className="font-medium">
|
||||||
|
עיקרון מקביל ב-{equivalents.length} החלטות אחרות (אסמכתה מקבילה)
|
||||||
|
</span>
|
||||||
|
<span className="me-auto text-ink-muted">לא ציטוט — הישנות עצמאית</span>
|
||||||
|
</button>
|
||||||
|
{showEquiv && (
|
||||||
|
<ul className="px-4 pb-3 pt-1 space-y-2">
|
||||||
|
{equivalents.map((e) => (
|
||||||
|
<li key={e.halacha_id} className="text-[0.78rem] text-ink-soft leading-relaxed border-r-2 border-gold/30 pr-3" dir="rtl">
|
||||||
|
<span className="font-semibold text-navy">{cleanCitation(e.case_number)}</span>
|
||||||
|
{" — "}{e.rule_statement}
|
||||||
|
{e.cosine != null && (
|
||||||
|
<span className="text-[0.65rem] text-ink-muted tabular-nums ms-2">
|
||||||
|
(דמיון {e.cosine.toFixed(2)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
|
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<>
|
<>
|
||||||
@@ -292,37 +372,64 @@ function HalachaRestoreCard({
|
|||||||
|
|
||||||
// ─── Shared group type ────────────────────────────────────────────────────────
|
// ─── Shared group type ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** #84.2 — a review card: the canonical halacha plus its near-duplicate
|
||||||
|
* variants (empty for singletons). Acting on the card acts on the whole set. */
|
||||||
|
type ReviewItem = Halacha & { variants: Halacha[] };
|
||||||
|
|
||||||
type Group = {
|
type Group = {
|
||||||
caseLawId: string;
|
caseLawId: string;
|
||||||
caseNumber: string;
|
caseNumber: string;
|
||||||
court: string;
|
court: string;
|
||||||
decisionDate: string | null;
|
decisionDate: string | null;
|
||||||
precedentLevel: string;
|
precedentLevel: string;
|
||||||
items: Halacha[];
|
items: ReviewItem[]; // canonicals (clusters collapsed)
|
||||||
|
pendingTotal: number; // total halachot incl. variants (for the count badge)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** All halacha ids represented by a review item (canonical + its variants). */
|
||||||
|
function itemIds(it: ReviewItem): string[] {
|
||||||
|
return [it.id, ...it.variants.map((v) => v.id)];
|
||||||
|
}
|
||||||
|
|
||||||
function buildGroups(items: Halacha[]): Group[] {
|
function buildGroups(items: Halacha[]): Group[] {
|
||||||
const map = new Map<string, Group>();
|
const byCase = new Map<string, Halacha[]>();
|
||||||
for (const h of items) {
|
for (const h of items) {
|
||||||
const k = h.case_law_id;
|
const arr = byCase.get(h.case_law_id) ?? [];
|
||||||
let g = map.get(k);
|
arr.push(h);
|
||||||
if (!g) {
|
byCase.set(h.case_law_id, arr);
|
||||||
g = {
|
}
|
||||||
caseLawId: k,
|
const groups: Group[] = [];
|
||||||
caseNumber: h.case_number ?? "",
|
for (const [caseLawId, members] of byCase) {
|
||||||
court: h.court ?? "",
|
// collapse near-duplicate clusters (#84.2): one card per cluster_id.
|
||||||
decisionDate: h.decision_date ?? null,
|
const clusters = new Map<string, Halacha[]>();
|
||||||
precedentLevel: h.precedent_level ?? "",
|
for (const h of members) {
|
||||||
items: [],
|
const key = h.cluster_id ?? h.id;
|
||||||
};
|
const arr = clusters.get(key) ?? [];
|
||||||
map.set(k, g);
|
arr.push(h);
|
||||||
|
clusters.set(key, arr);
|
||||||
}
|
}
|
||||||
g.items.push(h);
|
const reviewItems: ReviewItem[] = [];
|
||||||
|
for (const mem of clusters.values()) {
|
||||||
|
// canonical = strongest phrasing (highest confidence, verified wins ties)
|
||||||
|
mem.sort((a, b) =>
|
||||||
|
b.confidence - a.confidence
|
||||||
|
|| Number(b.quote_verified) - Number(a.quote_verified));
|
||||||
|
reviewItems.push({ ...mem[0], variants: mem.slice(1) });
|
||||||
|
}
|
||||||
|
// active-learning order within the case: most-uncertain first
|
||||||
|
reviewItems.sort((a, b) => a.confidence - b.confidence);
|
||||||
|
const first = members[0];
|
||||||
|
groups.push({
|
||||||
|
caseLawId,
|
||||||
|
caseNumber: first.case_number ?? "",
|
||||||
|
court: first.court ?? "",
|
||||||
|
decisionDate: first.decision_date ?? null,
|
||||||
|
precedentLevel: first.precedent_level ?? "",
|
||||||
|
items: reviewItems,
|
||||||
|
pendingTotal: members.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (const g of map.values()) {
|
return groups.sort((a, b) => b.pendingTotal - a.pendingTotal);
|
||||||
g.items.sort((a, b) => a.confidence - b.confidence);
|
|
||||||
}
|
|
||||||
return Array.from(map.values()).sort((a, b) => b.items.length - a.items.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Restore panel (used for "rejected" and "approved" tabs) ──────────────────
|
// ─── Restore panel (used for "rejected" and "approved" tabs) ──────────────────
|
||||||
@@ -422,7 +529,7 @@ function RestorePanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="tabular-nums">
|
<Badge variant="outline" className="tabular-nums">
|
||||||
{g.items.length}
|
{g.pendingTotal}
|
||||||
</Badge>
|
</Badge>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -450,7 +557,12 @@ function RestorePanel({
|
|||||||
// ─── Pending queue panel (main review flow) ───────────────────────────────────
|
// ─── Pending queue panel (main review flow) ───────────────────────────────────
|
||||||
|
|
||||||
function PendingPanel() {
|
function PendingPanel() {
|
||||||
const { data, isPending, error } = useHalachotPending(500);
|
// #84.1 — "clean" = quality-gated + prioritized + clustered review queue;
|
||||||
|
// "needsfix" = the flagged 'needs extraction fix' bucket.
|
||||||
|
const [view, setView] = useState<"clean" | "needsfix">("clean");
|
||||||
|
const { data, isPending, error } = useHalachotPending({
|
||||||
|
limit: 500, needsFix: view === "needsfix",
|
||||||
|
});
|
||||||
const update = useUpdateHalacha();
|
const update = useUpdateHalacha();
|
||||||
const batch = useBatchReviewHalachot();
|
const batch = useBatchReviewHalachot();
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
@@ -463,8 +575,8 @@ function PendingPanel() {
|
|||||||
|
|
||||||
const totalCount = data?.items.length ?? 0;
|
const totalCount = data?.items.length ?? 0;
|
||||||
|
|
||||||
const visibleItems = useMemo<Halacha[]>(() => {
|
const visibleItems = useMemo<ReviewItem[]>(() => {
|
||||||
const out: Halacha[] = [];
|
const out: ReviewItem[] = [];
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
if (expandedIds.has(g.caseLawId)) out.push(...g.items);
|
if (expandedIds.has(g.caseLawId)) out.push(...g.items);
|
||||||
}
|
}
|
||||||
@@ -483,7 +595,7 @@ function PendingPanel() {
|
|||||||
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
}, [focusedId]);
|
}, [focusedId]);
|
||||||
|
|
||||||
const focused = focusedId
|
const focused: ReviewItem | null = focusedId
|
||||||
? visibleItems.find((h) => h.id === focusedId) ?? null
|
? visibleItems.find((h) => h.id === focusedId) ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -503,16 +615,26 @@ function PendingPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const review = async (
|
const review = async (
|
||||||
h: Halacha,
|
it: ReviewItem,
|
||||||
status: "approved" | "rejected" | "deferred",
|
status: "approved" | "rejected" | "deferred",
|
||||||
extra?: Partial<EditState>,
|
extra?: Partial<EditState>,
|
||||||
) => {
|
) => {
|
||||||
|
const ids = itemIds(it);
|
||||||
try {
|
try {
|
||||||
await update.mutateAsync({
|
// #84.2 — a cluster card applies the decision to all its variants at once
|
||||||
id: h.id,
|
// (an edit, which is canonical-specific, stays single).
|
||||||
patch: { review_status: status, ...extra },
|
if (ids.length > 1 && !extra) {
|
||||||
});
|
await batch.mutateAsync({ ids, status });
|
||||||
toast.success(REVIEW_TOAST[status] ?? "עודכן");
|
} else {
|
||||||
|
await update.mutateAsync({
|
||||||
|
id: it.id,
|
||||||
|
patch: { review_status: status, ...extra },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
(REVIEW_TOAST[status] ?? "עודכן")
|
||||||
|
+ (ids.length > 1 ? ` (${ids.length} וריאנטים)` : ""),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "שגיאה");
|
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||||||
}
|
}
|
||||||
@@ -521,7 +643,7 @@ function PendingPanel() {
|
|||||||
const reviewGroup = async (g: Group, status: "approved" | "rejected") => {
|
const reviewGroup = async (g: Group, status: "approved" | "rejected") => {
|
||||||
try {
|
try {
|
||||||
const res = await batch.mutateAsync({
|
const res = await batch.mutateAsync({
|
||||||
ids: g.items.map((h) => h.id), status,
|
ids: g.items.flatMap(itemIds), status,
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
`${status === "approved" ? "אושרו" : "נדחו"} ${res.updated} הלכות`,
|
`${status === "approved" ? "אושרו" : "נדחו"} ${res.updated} הלכות`,
|
||||||
@@ -573,37 +695,63 @@ function PendingPanel() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [focused, visibleItems]);
|
}, [focused, visibleItems]);
|
||||||
|
|
||||||
|
const viewToggle = (
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30 w-fit">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={view === "clean" ? "default" : "ghost"}
|
||||||
|
className={view === "clean" ? "bg-gold text-navy hover:bg-gold-deep" : ""}
|
||||||
|
onClick={() => setView("clean")}
|
||||||
|
>
|
||||||
|
תור נקי
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={view === "needsfix" ? "default" : "ghost"}
|
||||||
|
className={view === "needsfix" ? "bg-gold text-navy hover:bg-gold-deep" : ""}
|
||||||
|
onClick={() => setView("needsfix")}
|
||||||
|
>
|
||||||
|
דורש תיקון-חילוץ
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
let body: ReactNode;
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
body = (
|
||||||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
|
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
|
||||||
{error.message}
|
{error.message}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
} else if (isPending) {
|
||||||
|
body = (
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
} else if (!groups.length) {
|
||||||
|
body = (
|
||||||
if (!groups.length) {
|
|
||||||
return (
|
|
||||||
<div className="text-center text-ink-muted py-16">
|
<div className="text-center text-ink-muted py-16">
|
||||||
<p className="text-lg">אין הלכות הממתינות לאישור.</p>
|
<p className="text-lg">
|
||||||
<p className="text-sm mt-2">העלה פסיקה חדשה — ההלכות שיחולצו ממנה יופיעו כאן.</p>
|
{view === "needsfix"
|
||||||
|
? "בקט התיקון ריק — אין הלכות מסומנות-איכות."
|
||||||
|
: "אין הלכות נקיות הממתינות לאישור."}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
{view === "needsfix"
|
||||||
|
? "פריטים שסומנו (ציטוט לא-מאומת, יישום, כפילות-קרובה וכו') יופיעו כאן."
|
||||||
|
: "העלה פסיקה חדשה — ההלכות שיחולצו ממנה יופיעו כאן."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
body = (
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3 text-sm text-ink-muted flex-wrap">
|
<div className="flex items-center gap-3 text-sm text-ink-muted flex-wrap">
|
||||||
<span>
|
<span>
|
||||||
<span className="text-navy font-semibold">{totalCount}</span> ממתינות
|
<span className="text-navy font-semibold">{totalCount}</span>
|
||||||
ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות
|
{view === "needsfix" ? " מסומנות" : " ממתינות"}
|
||||||
|
{" "}ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות
|
||||||
</span>
|
</span>
|
||||||
<span className="me-auto text-[0.72rem]">
|
<span className="me-auto text-[0.72rem]">
|
||||||
ניווט: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd>
|
ניווט: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd>
|
||||||
@@ -650,7 +798,8 @@ function PendingPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40 tabular-nums">
|
<Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40 tabular-nums">
|
||||||
{g.items.length} ממתינות
|
{g.pendingTotal} ממתינות
|
||||||
|
{g.pendingTotal !== g.items.length && ` · ${g.items.length} כרטיסים`}
|
||||||
</Badge>
|
</Badge>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -658,12 +807,12 @@ function PendingPanel() {
|
|||||||
<div className="p-4 space-y-3 bg-rule-soft/20">
|
<div className="p-4 space-y-3 bg-rule-soft/20">
|
||||||
<div className="flex items-center gap-2 justify-end pb-1">
|
<div className="flex items-center gap-2 justify-end pb-1">
|
||||||
<span className="me-auto text-[0.72rem] text-ink-muted">
|
<span className="me-auto text-[0.72rem] text-ink-muted">
|
||||||
פעולה קבוצתית על {g.items.length} ההלכות:
|
פעולה קבוצתית על {g.pendingTotal} ההלכות:
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm" variant="ghost" disabled={batch.isPending}
|
size="sm" variant="ghost" disabled={batch.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(`לדחות את כל ${g.items.length} ההלכות בפסק זה?`))
|
if (window.confirm(`לדחות את כל ${g.pendingTotal} ההלכות בפסק זה?`))
|
||||||
reviewGroup(g, "rejected");
|
reviewGroup(g, "rejected");
|
||||||
}}
|
}}
|
||||||
className="text-danger hover:text-danger hover:bg-danger-bg"
|
className="text-danger hover:text-danger hover:bg-danger-bg"
|
||||||
@@ -673,7 +822,7 @@ function PendingPanel() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm" disabled={batch.isPending}
|
size="sm" disabled={batch.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(`לאשר את כל ${g.items.length} ההלכות בפסק זה?`))
|
if (window.confirm(`לאשר את כל ${g.pendingTotal} ההלכות בפסק זה?`))
|
||||||
reviewGroup(g, "approved");
|
reviewGroup(g, "approved");
|
||||||
}}
|
}}
|
||||||
className="bg-gold text-navy hover:bg-gold-deep"
|
className="bg-gold text-navy hover:bg-gold-deep"
|
||||||
@@ -708,6 +857,14 @@ function PendingPanel() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{viewToggle}
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,7 +887,7 @@ const TAB_LABELS: Record<Tab, string> = {
|
|||||||
|
|
||||||
export function HalachaReviewPanel() {
|
export function HalachaReviewPanel() {
|
||||||
const [tab, setTab] = useState<Tab>("pending");
|
const [tab, setTab] = useState<Tab>("pending");
|
||||||
const { data: pendingData } = useHalachotPending(500);
|
const { data: pendingData } = useHalachotPending({ limit: 500 });
|
||||||
const rejectedCount = useHalachaCount("rejected");
|
const rejectedCount = useHalachaCount("rejected");
|
||||||
const approvedCount = useHalachaCount("approved");
|
const approvedCount = useHalachaCount("approved");
|
||||||
|
|
||||||
|
|||||||
111
web-ui/src/lib/api/goldset.ts
Normal file
111
web-ui/src/lib/api/goldset.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Gold-set tagging API (#81.7 / #81.8).
|
||||||
|
*
|
||||||
|
* The chair/Dafna manually labels a stratified sample of halachot
|
||||||
|
* (is_holding / correct_type / quote_complete). Those human labels are the
|
||||||
|
* ground truth used to measure the extraction validators and recalibrate the
|
||||||
|
* auto-approve threshold. Endpoints under /api/goldset.
|
||||||
|
*/
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
|
||||||
|
export type GoldsetItem = {
|
||||||
|
id: string;
|
||||||
|
halacha_id: string;
|
||||||
|
// human tags (null until tagged)
|
||||||
|
is_holding: boolean | null;
|
||||||
|
correct_type: string;
|
||||||
|
quote_complete: boolean | null;
|
||||||
|
tagged_by: string;
|
||||||
|
tagged_at: string | null;
|
||||||
|
// halacha content + the machine's own labels
|
||||||
|
rule_statement: string;
|
||||||
|
supporting_quote: string;
|
||||||
|
reasoning_summary: string;
|
||||||
|
rule_type: string;
|
||||||
|
confidence: number | null;
|
||||||
|
quality_flags?: string[];
|
||||||
|
review_status: string;
|
||||||
|
case_number: string | null;
|
||||||
|
case_name: string | null;
|
||||||
|
source_type: string | null; // 'court_ruling' | 'appeals_committee' | ''
|
||||||
|
// AI second-opinion (QA aid — independent, not ground truth, not auto-applied)
|
||||||
|
ai_is_holding: boolean | null;
|
||||||
|
ai_correct_type: string;
|
||||||
|
ai_rationale: string;
|
||||||
|
ai_generated_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GoldsetScore = {
|
||||||
|
batch: string;
|
||||||
|
total: number;
|
||||||
|
labeled: number;
|
||||||
|
validators: Record<
|
||||||
|
string,
|
||||||
|
{ precision: number; recall: number; f1: number; tp: number; fp: number; fn: number; tn: number }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GoldsetTag = {
|
||||||
|
is_holding?: boolean | null;
|
||||||
|
correct_type?: string;
|
||||||
|
quote_complete?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const keys = {
|
||||||
|
all: ["goldset"] as const,
|
||||||
|
list: (batch: string) => ["goldset", "list", batch] as const,
|
||||||
|
score: (batch: string) => ["goldset", "score", batch] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useGoldset(batch = "default") {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: keys.list(batch),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<{ items: GoldsetItem[]; batch: string }>(
|
||||||
|
`/api/goldset?batch=${encodeURIComponent(batch)}`,
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
staleTime: 5_000,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGoldsetScore(batch = "default") {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: keys.score(batch),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<GoldsetScore>(
|
||||||
|
`/api/goldset/score?batch=${encodeURIComponent(batch)}`,
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
staleTime: 5_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTagGoldset(batch = "default") {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, tag }: { id: string; tag: GoldsetTag }) =>
|
||||||
|
apiRequest<{ ok: boolean }>(`/api/goldset/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: { ...tag, tagged_by: "chair" },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: keys.list(batch) });
|
||||||
|
qc.invalidateQueries({ queryKey: keys.score(batch) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateGoldsetSample(batch = "default") {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (n: number) =>
|
||||||
|
apiRequest<{ batch: string; inserted: number; total: number }>(
|
||||||
|
"/api/goldset/sample",
|
||||||
|
{ method: "POST", body: { n, batch } },
|
||||||
|
),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: keys.list(batch) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -92,6 +92,20 @@ export type Halacha = {
|
|||||||
* negatively (distinguished/criticized/overruled). */
|
* negatively (distinguished/criticized/overruled). */
|
||||||
corroboration_count?: number;
|
corroboration_count?: number;
|
||||||
corroboration_negative?: boolean;
|
corroboration_negative?: boolean;
|
||||||
|
/* #84.2 near-duplicate clustering (present only when fetched with cluster=true):
|
||||||
|
* same-precedent halachot within the cluster cosine share a cluster_id, so the
|
||||||
|
* UI collapses them into one review card. cluster_size === 1 → singleton. */
|
||||||
|
cluster_id?: string;
|
||||||
|
cluster_size?: number;
|
||||||
|
/* #84.2 parallel authority (present only when fetched with include_equivalents):
|
||||||
|
* the SAME principle stated independently in OTHER precedents — recurrence, not
|
||||||
|
* citation (distinct from corroboration_count). */
|
||||||
|
equivalents?: {
|
||||||
|
halacha_id: string;
|
||||||
|
case_number: string;
|
||||||
|
rule_statement: string;
|
||||||
|
cosine: number | null;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RelatedCase = {
|
export type RelatedCase = {
|
||||||
@@ -566,14 +580,32 @@ export function useRequestHalachotExtraction() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHalachotPending(limit = 200) {
|
/** #84.1/#84.2/#84.3 — the chair review queue.
|
||||||
|
*
|
||||||
|
* Default ("clean") view: quality-gated (flagged items hidden), priority-ordered
|
||||||
|
* (most-uncertain/negatively-treated first), and near-duplicate-clustered into
|
||||||
|
* one card. Pass `needsFix: true` for the 'needs extraction fix' bucket — every
|
||||||
|
* pending item carrying a quality flag (filtered client-side). */
|
||||||
|
export function useHalachotPending(
|
||||||
|
opts: { limit?: number; needsFix?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const { limit = 200, needsFix = false } = opts;
|
||||||
|
const qs = needsFix
|
||||||
|
? `review_status=pending_review&exclude_low_quality=false&limit=${limit}`
|
||||||
|
: `review_status=pending_review&exclude_low_quality=true`
|
||||||
|
+ `&order_by_priority=true&cluster=true&include_equivalents=true&limit=${limit}`;
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: libraryKeys.halachotPending(),
|
queryKey: [...libraryKeys.halachotPending(), needsFix ? "needsfix" : "clean"],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: async ({ signal }) => {
|
||||||
apiRequest<{ items: Halacha[]; count: number }>(
|
const res = await apiRequest<{ items: Halacha[]; count: number }>(
|
||||||
`/api/halachot?review_status=pending_review&limit=${limit}`,
|
`/api/halachot?${qs}`,
|
||||||
{ signal },
|
{ signal },
|
||||||
),
|
);
|
||||||
|
if (!needsFix) return res;
|
||||||
|
// needs-fix bucket = pending items that carry a quality flag
|
||||||
|
const items = res.items.filter((h) => (h.quality_flags?.length ?? 0) > 0);
|
||||||
|
return { items, count: items.length };
|
||||||
|
},
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
|
|||||||
105
web/app.py
105
web/app.py
@@ -6033,11 +6033,14 @@ async def halachot_list(
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
exclude_low_quality: bool = False,
|
exclude_low_quality: bool = False,
|
||||||
order_by_priority: bool = False,
|
order_by_priority: bool = False,
|
||||||
|
cluster: bool = False,
|
||||||
|
include_equivalents: bool = False,
|
||||||
):
|
):
|
||||||
"""List halachot. ``exclude_low_quality`` hides flagged items (#84.1) and
|
"""List halachot. ``exclude_low_quality`` hides flagged items (#84.1),
|
||||||
``order_by_priority`` switches to the active-learning order (#84.3). Both
|
``order_by_priority`` switches to the active-learning order (#84.3),
|
||||||
default off so existing callers are unaffected; the review-queue view opts
|
``cluster`` annotates near-duplicate groups for one-card review (#84.2), and
|
||||||
in."""
|
``include_equivalents`` attaches cross-precedent parallel-authority links. All
|
||||||
|
default off so existing callers are unaffected; the review queue opts in."""
|
||||||
cid: UUID | None = None
|
cid: UUID | None = None
|
||||||
if case_law_id:
|
if case_law_id:
|
||||||
try:
|
try:
|
||||||
@@ -6051,10 +6054,104 @@ async def halachot_list(
|
|||||||
limit=limit, offset=offset,
|
limit=limit, offset=offset,
|
||||||
exclude_low_quality=exclude_low_quality,
|
exclude_low_quality=exclude_low_quality,
|
||||||
order_by_priority=order_by_priority,
|
order_by_priority=order_by_priority,
|
||||||
|
cluster=cluster,
|
||||||
|
include_equivalents=include_equivalents,
|
||||||
)
|
)
|
||||||
return {"items": rows, "count": len(rows)}
|
return {"items": rows, "count": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
|
class EquivalentLinkRequest(BaseModel):
|
||||||
|
other_id: str
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/halachot/{halacha_id}/equivalents")
|
||||||
|
async def halacha_equivalents_list(halacha_id: str):
|
||||||
|
"""Cross-precedent parallel-authority links for a halacha (#84.2)."""
|
||||||
|
try:
|
||||||
|
hid = UUID(halacha_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "halacha_id לא תקין")
|
||||||
|
return {"items": await db.list_equivalent_for_halacha(hid)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/halachot/{halacha_id}/equivalents")
|
||||||
|
async def halacha_equivalents_link(halacha_id: str, req: EquivalentLinkRequest):
|
||||||
|
"""Chair links two halachot as the same principle across precedents (#84.2)."""
|
||||||
|
try:
|
||||||
|
hid = UUID(halacha_id)
|
||||||
|
oid = UUID(req.other_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "מזהה הלכה לא תקין")
|
||||||
|
ok = await db.link_equivalent_halachot(hid, oid, note=req.note, created_by="chair")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
400, "לא ניתן לקשר — אותה הלכה או שתי הלכות מאותו פסק (קישור-מקביל הוא חוצה-פסקים)")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/halachot/{halacha_id}/equivalents/{other_id}")
|
||||||
|
async def halacha_equivalents_unlink(halacha_id: str, other_id: str):
|
||||||
|
try:
|
||||||
|
hid, oid = UUID(halacha_id), UUID(other_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "מזהה הלכה לא תקין")
|
||||||
|
return {"ok": await db.unlink_equivalent_halachot(hid, oid)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Gold-set tagging (#81.7 / #81.8) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class GoldsetSampleRequest(BaseModel):
|
||||||
|
n: int = 150
|
||||||
|
batch: str = "default"
|
||||||
|
reset: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class GoldsetTagRequest(BaseModel):
|
||||||
|
is_holding: bool | None = None
|
||||||
|
correct_type: str | None = None
|
||||||
|
quote_complete: bool | None = None
|
||||||
|
tagged_by: str = "chair"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/goldset")
|
||||||
|
async def goldset_list_ep(batch: str = "default"):
|
||||||
|
"""The gold-set tagging queue (halacha content + machine labels + human tags)."""
|
||||||
|
return {"items": await db.goldset_list(batch), "batch": batch}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/goldset/sample")
|
||||||
|
async def goldset_sample_ep(req: GoldsetSampleRequest):
|
||||||
|
"""Create/extend a stratified gold-set batch for tagging (#81.7)."""
|
||||||
|
return await db.goldset_create_sample(n=req.n, batch=req.batch, reset=req.reset)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/goldset/score")
|
||||||
|
async def goldset_score_ep(batch: str = "default"):
|
||||||
|
"""Measure the extraction validators against the human tags (#81.8)."""
|
||||||
|
return await db.goldset_score(batch)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/goldset/{goldset_id}")
|
||||||
|
async def goldset_tag_ep(goldset_id: str, req: GoldsetTagRequest):
|
||||||
|
"""Save one human tag on a gold-set item."""
|
||||||
|
try:
|
||||||
|
gid = UUID(goldset_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "מזהה לא תקין")
|
||||||
|
if req.correct_type and req.correct_type not in (
|
||||||
|
"binding", "interpretive", "obiter", "application", "procedural", "persuasive",
|
||||||
|
):
|
||||||
|
raise HTTPException(400, "correct_type לא תקין")
|
||||||
|
row = await db.goldset_tag(
|
||||||
|
gid, is_holding=req.is_holding, correct_type=req.correct_type,
|
||||||
|
quote_complete=req.quote_complete, tagged_by=req.tagged_by,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "פריט לא נמצא")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/halachot/{halacha_id}")
|
@app.patch("/api/halachot/{halacha_id}")
|
||||||
async def halacha_update(halacha_id: str, req: HalachaUpdateRequest):
|
async def halacha_update(halacha_id: str, req: HalachaUpdateRequest):
|
||||||
"""Approve / reject / edit a halacha. Used by the chair review queue."""
|
"""Approve / reject / edit a halacha. Used by the chair review queue."""
|
||||||
|
|||||||
Reference in New Issue
Block a user