fix(retrieval): make decisions findable by name + unhide committee uploads
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m57s

Root cause of "agent can't find the Agasi decision in the corpus" (CMPA-55):
the decision was fully ingested, but the retrieval layer failed on the
realistic agent query — searching by case name.

- RC-A (#52): lexical tsvector covered only chunk content + halacha text,
  so a bare-name query ("אגסי") matched decisions that *cite* the case, not
  the case itself. Add meta_tsv on case_law(case_name, case_number) (SCHEMA
  V20) and OR it into the lexical halacha/chunk SQL with a match boost, so a
  name/number hit surfaces the case's own rows. Agasi: rank 4 → rank 1.
- RC-B (#53): precedent_library_list hard-defaulted source_kind=external_upload
  and never exposed the param, hiding uploaded ערר/בל"מ (internal_committee)
  decisions. Thread source_kind through service → tool → MCP tool (supports
  'internal_committee' / 'all_committees').
- #54: agent instructions (researcher/analyst/writer) — search-by-name
  protocol: add content/case-number, search both corpora, use all_committees
  before declaring "not in corpus".
- #55: chunker produced tiny fragment chunks ("דיון", "החלטה") from header
  keywords matched mid-sentence. Anchor SECTION_PATTERNS to line start +
  merge sub-min sections; exclude <50-char fragments at query time (484
  existing fragments hidden; full re-chunk tracked as #57).

Tests: scripts/test_retrieval_by_name.py (name ranks case above citer +
substantive regressions); chunker unit checks (0 tiny chunks). New findings
filed as tasks #56 (halacha source_kind leak) and #57 (re-chunk migration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 11:26:19 +00:00
parent 165efc62b0
commit 58ab003206
11 changed files with 355 additions and 57 deletions

View File

@@ -455,12 +455,12 @@ X שאלות עומדות להכרעה:
### 8א. אימות פסיקה
סרוק את עמדות היו"ר וזהה כל אזכור פסיקה (בג"ץ, עע"מ, עת"מ, ע"א, ערר וכו').
לכל פסק דין שמוזכר:
1. חפש ב**קורפוס הסמכותי** (`search_precedent_library`) — חובה ראשונה. שם נמצאות הלכות מאושרות עם supporting_quote מוכן לציטוט.
1. חפש ב**קורפוס הסמכותי** (`search_precedent_library`) — חובה ראשונה. שם נמצאות הלכות מאושרות עם supporting_quote מוכן לציטוט. הקורפוס כולל גם הלכות מהחלטות ועדות ערר שהועלו (internal_committee).
2. חפש בקאנון דפנה (`search_decisions`, `find_similar_cases`)
3. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות
4. **אם נמצא ב-precedent_library** — צטט citation+supporting_quote מדויקים מהקורפוס.
5. **אם נמצא רק במסמכי התיק** — סמן: "מקור: כתבי טענות, דורש אימות מול הקורפוס".
6. **אם לא נמצא בכלל** — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש.
6. **אם לא נמצא בכלל** — קודם **נסה שוב עם הקשר** (לא שם לבדו): צרף מונחי תוכן או מספר תיק לשאילתה. שם תיק לבדו (`"אגסי"`) אינו מפתח אמין — הוא עלול להחזיר את מי שמצטט את התיק ולא את התיק עצמו. רק אם גם זה ריק — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש.
הוסף לסעיף "7א. שאילתות לקורפוסים" כל query נוסף שהורצה ב-pass 2.

View File

@@ -269,9 +269,18 @@ search_internal_decisions(
**מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו.
#### 2ב.4א — איתור החלטה ספציפית לפי שם — פרוטוקול לפני "לא בקורפוס" ⚠️
שם תיק לבדו (למשל `"אגסי"`) **אינו מפתח חיפוש אמין**. ההטמעה הסמנטית והאינדקס הלקסיקלי בנויים על תוכן ההלכה/הפסקה — כך ששאילתת-שם עלולה להחזיר דווקא החלטות ש**מצטטות** את התיק, ולא את התיק עצמו. לפני שמכריזים שהחלטה אינה בקורפוס:
1. **הוסף הקשר לשאילתה** — לא `"אגסי"` אלא `"אגסי פטור 19(ג)(1) שתי דירות 140 מ"ר"`, או חפש לפי **מספר התיק** (`"ערר 81002-01-21"`).
2. **חפש בשני הקורפוסים**`search_precedent_library` **וגם** `search_internal_decisions`. החלטות ערר/בל"מ שהיו"ר מעלה נשמרות כ-`internal_committee` ומתגלות בחיפוש הפנימי.
3. **לאימות קיום / דפדוף**`precedent_library_list(search="<שם>", source_kind="all_committees")`. ברירת המחדל `external_upload` **מסתירה** החלטות ועדת ערר שהועלו — חובה `all_committees` או `internal_committee`.
4. רק אם **כל** הניסיונות לעיל ריקים — הכרז "לא בקורפוס" ועבור ל-2ב.5.
#### 2ב.5 — תיעוד פסיקה חסרה (`missing_precedent_create`) — חובה
**מתי לקרוא:** לכל ציטוט שהצדדים הביאו (בכתב ערר / תגובה / תגובת ועדה) **שלא נמצא בקורפוס** אחרי חיפוש מובנה (`search_precedent_library` + `search_internal_decisions` + `precedent_search_library`).
**מתי לקרוא:** לכל ציטוט שהצדדים הביאו (בכתב ערר / תגובה / תגובת ועדה) **שלא נמצא בקורפוס** אחרי חיפוש מובנה לפי פרוטוקול 2ב.4א (`search_precedent_library` + `search_internal_decisions` + `precedent_search_library`, כולל שאילתה עם הקשר/מספר תיק).
**למה זה חשוב:**
- ה-writer יודע שלא להסתמך על פסיקה שלא ב-DB ("טוענים שמופיע" ≠ "אומת")

View File

@@ -351,6 +351,8 @@ fi
חפש לפי `practice_area` (rishuy_uvniya / betterment_levy / compensation_197) ולפי `subject_tag` רלוונטי. הלכות שלא אושרו ע"י דפנה לא מוחזרות מהכלי — אם החיפוש ריק, חזור ל-`search_decisions` בלבד.
**איתור החלטה לפי שם:** אם אתה מחפש החלטה ספציפית בשמה (למשל "אגסי"), אל תחפש בשם לבדו — צרף מונחי תוכן או מספר תיק (`"אגסי 19(ג)(1) 140 מ"ר"` / `"ערר 81002-01-21"`). שאילתת-שם בלבד עלולה להחזיר את מי שמצטט את ההחלטה ולא את ההחלטה עצמה.
### ⚠️ ניסוח ציטוטי פסיקה בקול ההחלטה — לפי `source_kind`
כל רשומה בקורפוס נושאת `source_kind` (ראה בפלט של `precedent_library_get` / `search_precedent_library` / `search_internal_decisions`). הניסוח בבלוק י **משתנה לפי הסוג** — לא רק הציטוט, אלא **התפקיד הרטורי** של פסק הדין בהנמקה:

View File

@@ -1388,50 +1388,59 @@
{
"id": 1,
"title": "Audit + migration practice_area (1xxx→rishuy_uvniya, 8xxx→betterment_levy, 9xxx→compensation_197)",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 2,
"title": "Audit + reclassify case_law source_kind external_upload → internal_committee עבור 'ערר' prefix",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 3,
"title": "Delete + re-extract halachot עבור רשומות שעברו reclassification",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 4,
"title": "תיקון נתיב יצירת תיק לתיוג practice_area נכון מההתחלה",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 5,
"title": "תיקון /api/precedent-library/upload לניתוב לפי תחילית הציטוט",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 6,
"title": "מבחני רגרסיה לכל 3 הbaגים",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 7,
"title": "תיקון MCP `case_update` + API `PUT /api/cases/{case_number}` לתמוך בעדכון practice_area + appeal_subtype",
"status": "done",
"details": "התגלה ב-26/05/2026: MCP tool case_update והAPI לא מקבלים את השדה practice_area, ולכן אי-אפשר לתקן תיוג שגוי דרך הממשק. נאלצתי לעדכן ידנית ב-SQL. צריך להוסיף את השדות ל-CaseUpdateRequest ב-web/app.py וב-cases_tools.case_update בmcp-server."
"details": "התגלה ב-26/05/2026: MCP tool case_update והAPI לא מקבלים את השדה practice_area, ולכן אי-אפשר לתקן תיוג שגוי דרך הממשק. נאלצתי לעדכן ידנית ב-SQL. צריך להוסיף את השדות ל-CaseUpdateRequest ב-web/app.py וב-cases_tools.case_update בmcp-server.",
"parentId": "undefined"
},
{
"id": 8,
"title": "[prevention] DB CHECK constraints: source_kind='internal_committee' ⇒ chair_name NOT NULL; cases.practice_area enum",
"status": "done",
"description": "מיגרציה: ALTER TABLE case_law ADD CONSTRAINT chair_required_for_internal CHECK (source_kind <> 'internal_committee' OR (chair_name IS NOT NULL AND chair_name <> '')); וכן CHECK על cases.practice_area לערכים תקינים. חייב לרוץ אחרי subtask #2 (backfill) אחרת constraint creation ייכשל."
"description": "מיגרציה: ALTER TABLE case_law ADD CONSTRAINT chair_required_for_internal CHECK (source_kind <> 'internal_committee' OR (chair_name IS NOT NULL AND chair_name <> '')); וכן CHECK על cases.practice_area לערכים תקינים. חייב לרוץ אחרי subtask #2 (backfill) אחרת constraint creation ייכשל.",
"parentId": "undefined"
},
{
"id": 9,
"title": "[prevention] Unify practice_area taxonomy — מיפוי או מחיקה של appeals_committee מ-practice_area.py",
"status": "done",
"description": "ב-mcp-server/src/legal_mcp/services/practice_area.py:21 יש PRACTICE_AREAS={appeals_committee,national_insurance,labor_law} שסותר את ה-DB constraint של case_law (rishuy_uvniya/betterment_levy/compensation_197). grep מקיף לכל caller של 'appeals_committee'; להחליף במיפוי מפורש או למחוק."
"description": "ב-mcp-server/src/legal_mcp/services/practice_area.py:21 יש PRACTICE_AREAS={appeals_committee,national_insurance,labor_law} שסותר את ה-DB constraint של case_law (rishuy_uvniya/betterment_levy/compensation_197). grep מקיף לכל caller של 'appeals_committee'; להחליף במיפוי מפורש או למחוק.",
"parentId": "undefined"
}
],
"updatedAt": "2026-05-26T08:35:22.762800Z"
@@ -1452,19 +1461,22 @@
"id": 1,
"title": "Backfill chair_name + district לכל 7 הרשומות החסרות (LLM extraction)",
"status": "done",
"description": "psql query: SELECT id, case_number FROM case_law WHERE source_kind='internal_committee' AND (chair_name IS NULL OR chair_name=''); לכל אחת — חילוץ ע\"י precedent_metadata_extractor.extract_and_apply."
"description": "psql query: SELECT id, case_number FROM case_law WHERE source_kind='internal_committee' AND (chair_name IS NULL OR chair_name=''); לכל אחת — חילוץ ע\"י precedent_metadata_extractor.extract_and_apply.",
"parentId": "undefined"
},
{
"id": 2,
"title": "[prevention] Validation: chair_name+district required ב-internal_decisions_upload (API+MCP)",
"status": "done",
"description": "ב-web/app.py:4607-4680 כיום chair_name/district = Form(\"\") (default ריק). שנה ל-required עם validation שדוחה ריק כשsource_kind='internal_committee'. הוסף enum של 6 ערכי district (ירושלים/מרכז/תל אביב/צפון/דרום/ארצי)."
"description": "ב-web/app.py:4607-4680 כיום chair_name/district = Form(\"\") (default ריק). שנה ל-required עם validation שדוחה ריק כשsource_kind='internal_committee'. הוסף enum של 6 ערכי district (ירושלים/מרכז/תל אביב/צפון/דרום/ארצי).",
"parentId": "undefined"
},
{
"id": 3,
"title": "[prevention] UI dropdown ל-district בטופס העלאת החלטות ועדה (web-ui)",
"status": "done",
"description": "במקום free-text — Select של 6 הערכים. גם בטופס חיפוש (search_internal_decisions)."
"description": "במקום free-text — Select של 6 הערכים. גם בטופס חיפוש (search_internal_decisions).",
"parentId": "undefined"
}
],
"updatedAt": "2026-05-26T08:35:22.762800Z"
@@ -1521,32 +1533,38 @@
{
"id": 1,
"title": "Migration + model missing_precedents",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 2,
"title": "API endpoints POST/GET/upload/PATCH /api/missing-precedents",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 3,
"title": "MCP tools missing_precedent_create/list/close",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 4,
"title": "Next.js page /missing-precedents עם list + detail + upload form",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 5,
"title": "Auto-creation hook במחקר (legal-researcher יוצר רשומה כשמזהה ציטוט חסר)",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 6,
"title": "Webhook עדכון לפלאגין Paperclip + Comment לחיים",
"status": "done"
"status": "done",
"parentId": "undefined"
}
],
"updatedAt": "2026-05-26T08:35:22.762800Z"
@@ -1564,27 +1582,32 @@
{
"id": 1,
"title": "Migration + models legal_arguments + legal_argument_propositions",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 2,
"title": "LLM aggregation job (Hermes/DeepSeek profile)",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 3,
"title": "API + MCP tool aggregate_claims_to_arguments",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 4,
"title": "UI display update — case detail page מציג טיעונים אמיתיים",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 5,
"title": "Backfill לכל התיקים הקיימים",
"status": "done"
"status": "done",
"parentId": "undefined"
}
],
"updatedAt": "2026-05-26T08:35:22.762800Z"
@@ -1602,27 +1625,32 @@
{
"id": 1,
"title": "הוספת 3 ערכי enum ל-practice_area.py APPEALS_COMMITTEE_SUBTYPES",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 2,
"title": "כתיבת 3 templates מתודולוגיים ב-docs/methodology/extension-request-{type}.md",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 3,
"title": "אוטו-זיהוי בקוד יצירת תיק (subject='בקשה להארכת מועד' → קביעת subtype לפי practice_area)",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 4,
"title": "UI badge + filter ייעודי לבל\"מ",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 5,
"title": "עדכון web/paperclip_client.py mapping ל-company עבור 3 הערכים החדשים",
"status": "done"
"status": "done",
"parentId": "undefined"
}
],
"updatedAt": "2026-05-26T08:35:22.762800Z"
@@ -1647,55 +1675,65 @@
{
"id": 1,
"title": "עדכון .claude/agents/legal-ceo.md — routing + statuses + wake reasons",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 2,
"title": "עדכון .claude/agents/legal-analyst.md — practice_area, legal_arguments, בל\"מ detection",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 3,
"title": "עדכון .claude/agents/legal-researcher.md — 2 layers, missing_precedents, citations, בל\"מ templates",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 4,
"title": "עדכון .claude/agents/legal-writer.md — legal_arguments view, בל\"מ templates",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 5,
"title": "עדכון .claude/agents/legal-qa.md — בל\"מ-aware validation",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 6,
"title": "עדכון .claude/agents/HEARTBEAT.md — כללי routing משותפים + research_complete status",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 7,
"title": "סנכרון לכל החברות CMPA mirror — sync_agents_across_companies.py",
"status": "done"
"status": "done",
"parentId": "undefined"
},
{
"id": 8,
"title": "[alignment] researcher docs: דרישה מפורשת שכל 'ערר' → internal_decision_upload, לעולם לא precedent_library_upload",
"status": "done",
"description": "בלגל-researcher.md: דוגמת קוד מפורשת + flowchart החלטה: לפי תחילית הציטוט. הסבר על השלילה של precedent_library_upload כשמדובר ב-ערר. תלוי במשימה #39 (MCP tool חדש)."
"description": "בלגל-researcher.md: דוגמת קוד מפורשת + flowchart החלטה: לפי תחילית הציטוט. הסבר על השלילה של precedent_library_upload כשמדובר ב-ערר. תלוי במשימה #39 (MCP tool חדש).",
"parentId": "undefined"
},
{
"id": 9,
"title": "[alignment] analyst docs: הסבר על 2 taxonomies של practice_area + מתי משתמשים בכל אחת",
"status": "done",
"description": "בלגל-analyst.md: טבלה ברורה — practice_area (case_law) vs practice_area (cases). מתי להעביר rishuy_uvniya ומתי appeals_committee. אחרי משימה #30.9 (taxonomy unification) — סביר שזה ייפשט."
"description": "בלגל-analyst.md: טבלה ברורה — practice_area (case_law) vs practice_area (cases). מתי להעביר rishuy_uvniya ומתי appeals_committee. אחרי משימה #30.9 (taxonomy unification) — סביר שזה ייפשט.",
"parentId": "undefined"
},
{
"id": 10,
"title": "[alignment] writer docs: הבחנה בין source_kind בציטוט (binding vs persuasive)",
"status": "done",
"description": "בלגל-writer.md: 'החלטת ועדת ערר אחרת ⇒ עקביות אופקית, לא הלכה מחייבת'. 'פס\"ד עליון/מנהלי ⇒ סמכותי בינדינג'. דוגמאות פרזיולוגיה מ-skills/decision/SKILL.md."
"description": "בלגל-writer.md: 'החלטת ועדת ערר אחרת ⇒ עקביות אופקית, לא הלכה מחייבת'. 'פס\"ד עליון/מנהלי ⇒ סמכותי בינדינג'. דוגמאות פרזיולוגיה מ-skills/decision/SKILL.md.",
"parentId": "undefined"
}
],
"updatedAt": "2026-05-26T07:41:47.880478Z"
@@ -1870,17 +1908,96 @@
"priority": "low",
"subtasks": [],
"updatedAt": "2026-05-26T11:27:09.039154Z"
},
{
"id": "52",
"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) כך שחיפוש לפי שם יפגע ברשומה עצמה.",
"status": "done",
"priority": "high",
"dependencies": [],
"details": "קבצים: mcp-server/src/legal_mcp/services/db.py (SCHEMA_V12_SQL ~שורה 774, search_precedent_library_lexical), hybrid_search.py (_merge_sem_lex). דורש ALTER TABLE + migration על Postgres (localhost:5433) + restart MCP server. בדיקה: search_internal_decisions('אגסי') ו-search_precedent_library('אגסי') חייבים להחזיר את אגסי (1a87efe5) בעמוד הראשון.",
"testStrategy": "reproduction test: query='אגסי' → expect case_law_id 1a87efe5 in top-3. regression: substantive query עדיין מחזיר 0.6+ score.",
"subtasks": [],
"updatedAt": "2026-05-30T11:05:36.307Z"
},
{
"id": "53",
"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.",
"status": "done",
"priority": "high",
"dependencies": [
"52"
],
"details": "קבצים: web/app.py (precedent_library_list ~5194, all_committees expansion ב-db.list_external_case_law ~2708), mcp-server tool def ל-precedent_library_list. בדיקה: precedent_library_list יכול להחזיר את אגסי כשמבקשים committees; חיפוש סמנטי כבר מאוחד (אומת).",
"testStrategy": "precedent_library_list(source_kind='all_committees', practice_area='betterment_levy') כולל את אגסי+וינפלד. regression: ברירת מחדל external_upload עדיין מחזירה 14 ולא שוברת UI.",
"subtasks": [],
"updatedAt": "2026-05-30T11:09:44.511Z"
},
{
"id": "54",
"title": "[Retrieval RC-3] הנחיית סוכנים — איתור לפי שם + שני קורפוסים",
"description": "לעדכן הנחיות legal-analyst/researcher/writer: לאיתור החלטה ספציפית לפי שם להוסיף מונחי תוכן או מספר תיק, ולחפש בשני הקורפוסים (search_internal_decisions + search_precedent_library) לפני שמסיקים 'לא קיים בקורפוס'. כולל יצירת missing_precedent רק אחרי חיפוש כפול.",
"status": "done",
"priority": "medium",
"dependencies": [
"53"
],
"details": "קבצים: .claude/agents/legal-analyst.md, legal-researcher.md, legal-writer.md. אחרי שינוי skills/agent config — להריץ sync_agents_across_companies.py.",
"testStrategy": "קריאת ההנחיות מאשרת fallback ברור; (אם אפשר) הרצת סוכן על שאילתת-שם מחזירה את ההחלטה.",
"subtasks": [],
"updatedAt": "2026-05-30T11:12:44.727Z"
},
{
"id": "55",
"title": "[Retrieval RC-4] תיקון chunking — פרגמנטים זעירים",
"description": "בתוצאות החיפוש מופיעים chunks של מילה-שתיים ('דיון','דיון וב','סיכום ו') כתוצאות מובילות. מציפים תוצאות ומורידים דירוג תוכן אמיתי. לחקור את chunker.py (פיצול לפי כותרת-סעיף שיוצר chunks ריקים) ולתקן: מינימום אורך chunk / מיזוג כותרת לגוף.",
"status": "done",
"priority": "medium",
"dependencies": [
"54"
],
"details": "קבצים: mcp-server/src/legal_mcp/services/chunker.py (SECTION_PATTERNS). דורש שיקול re-chunk לרשומות קיימות — לבדוק עלות מול feedback_no_reocr_retrofit (להשתמש בטקסט שמור, לא re-OCR).",
"testStrategy": "אין chunks < N תווים בקורפוס אחרי תיקון; search_internal_decisions('אגסי') לא מציג פרגמנטי 'דיון'.",
"subtasks": [],
"updatedAt": "2026-05-30T11:19:23.923Z"
},
{
"id": "56",
"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 נפרד'.",
"status": "pending",
"priority": "low",
"dependencies": [],
"details": "db.py: search_precedent_library_semantic (~שורה הקודמת ל-3311), search_precedent_library_lexical (3346). שתי הפונקציות: halacha_filters=['h.review_status IN ...'] — חסר cl.source_kind. נמצא בעת בדיקת רגרסיה למשימה 53.",
"testStrategy": "לאחר החלטה: אם מסננים — search_precedent_library('...substantive...', external) לא מחזיר case_law_id internal; אם משאירים — docstring מעודכן + טסט מאשר התנהגות מכוונת.",
"subtasks": [],
"updatedAt": "2026-05-30T11:09:30.257989+00:00"
},
{
"id": "57",
"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) ולהריץ בקבוצות.",
"status": "pending",
"priority": "low",
"dependencies": [
"55"
],
"details": "מקור: case_law.full_text קיים. נתיב: chunker.chunk_document(_hierarchical) → embeddings → החלפת precedent_chunks לתיק. למחוק chunks ישנים של התיק לפני הוספה. אחרי הרצה — ניתן להסיר את פילטר ה->=50 query (אופציונלי). תיקים מושפעים: SELECT DISTINCT case_law_id WHERE length(trim(content))<50.",
"testStrategy": "אחרי re-chunk לתיק לדוגמה: 0 chunks<50 לאותו case_law_id; search_internal_decisions עדיין מחזיר את התיק; ספירת chunks סבירה.",
"subtasks": [],
"updatedAt": "2026-05-30T11:19:06.142606+00:00"
}
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-05-04T17:29:25.687Z",
"taskCount": 29,
"completedCount": 24,
"lastModified": "2026-05-30T11:19:23.923Z",
"taskCount": 57,
"completedCount": 52,
"tags": [
"legal-ai"
],
"updated": "2026-05-26T06:39:31.733370"
]
}
}
}

View File

@@ -201,11 +201,20 @@ async def precedent_library_list(
precedent_level: str = "",
source_type: str = "",
search: str = "",
source_kind: str = "external_upload",
limit: int = 100,
) -> str:
"""רשימת הפסיקה בקורפוס הסמכותי, עם פילטרים."""
"""רשימת הפסיקה בקורפוס, עם פילטרים.
source_kind: 'external_upload' (ברירת מחדל — פס"ד בתי משפט) /
'internal_committee' (החלטות ועדות ערר ערר/בל"מ שהועלו) /
'all_committees' (שתיהן — internal + appeals_committee).
החלטות ערר/בל"מ שמעלים נשמרות כ-internal_committee — כדי לראותן
ברשימה השתמש ב-source_kind='internal_committee' או 'all_committees'.
"""
return await plib.precedent_library_list(
practice_area, court, precedent_level, source_type, search, limit,
practice_area, court, precedent_level, source_type, search,
source_kind, limit,
)

View File

@@ -97,13 +97,32 @@ def _assign_pages(chunks: list[Chunk], text: str, page_offsets: list[int]) -> No
pos = idx + max(1, len(c.content) // 2)
# A section shorter than this (stripped chars) is not a real section — it's
# an artifact of a header keyword matched mid-text. Such a fragment is merged
# into the preceding section rather than emitted as its own chunk. See #55:
# unanchored keywords like "דיון"/"החלטה"/"מסקנה" appearing inside a sentence
# used to carve tiny boundary chunks ("דיון). במסגרת ה") that polluted search.
MIN_SECTION_CHARS = 60
def _split_into_sections(text: str) -> list[tuple[str, str]]:
"""Split text into (section_type, text) pairs based on Hebrew headers."""
"""Split text into (section_type, text) pairs based on Hebrew headers.
Header keywords are matched only at the **start of a line** (after
optional whitespace / list numbering like ``5.`` or ``ג.``). A real
section header in these decisions sits on its own line; anchoring to
the line start prevents common words ("דיון", "החלטה", "מסקנה") that
appear mid-sentence from being treated as section boundaries — which
previously produced tiny fragment chunks (#55).
"""
# Find all section headers and their positions
markers: list[tuple[int, str]] = []
for pattern, section_type in SECTION_PATTERNS:
for match in re.finditer(pattern, text):
# ^ + MULTILINE: line start only. Optional leading spaces/tabs and an
# optional ordinal prefix ("5.", "5)", "ג.") before the keyword.
anchored = rf"^[ \t]*(?:\d+[.)]\s*|[א-ת][.)]\s*)?(?:{pattern})"
for match in re.finditer(anchored, text, re.MULTILINE):
markers.append((match.start(), section_type))
if not markers:
@@ -120,11 +139,18 @@ def _split_into_sections(text: str) -> list[tuple[str, str]]:
if intro_text:
sections.append(("intro", intro_text))
# Each section
# Each section. A section whose text is too short to stand alone is
# merged into the previous section (keeping the previous type) so a
# near-adjacent pair of headers can't produce a fragment chunk.
for i, (pos, section_type) in enumerate(markers):
end = markers[i + 1][0] if i + 1 < len(markers) else len(text)
section_text = text[pos:end].strip()
if section_text:
if not section_text:
continue
if len(section_text) < MIN_SECTION_CHARS and sections:
prev_type, prev_text = sections[-1]
sections[-1] = (prev_type, f"{prev_text}\n{section_text}")
else:
sections.append((section_type, section_text))
return sections

View File

@@ -1070,6 +1070,29 @@ ALTER TABLE case_law ADD COLUMN IF NOT EXISTS citation_formatted TEXT DEFAULT ''
"""
# ── V20: case-name / case-number lexical match ────────────────────
# RC-A fix: the V12 tsvectors cover only chunk *content* + halacha
# text, so a bare case-name query ("אגסי") matched decisions that
# *cite* the case rather than the case itself. case_name and
# case_number live on the parent case_law row, so we add a dedicated
# meta tsvector there and OR it into the lexical search — a name/number
# hit then surfaces all of that case's chunks + halachot. 'simple'
# config (no stemmer) preserves Hebrew names + alphanumeric case
# numbers like "81002-01-21" exactly as V12 does for content.
SCHEMA_V20_SQL = """
ALTER TABLE case_law
ADD COLUMN IF NOT EXISTS meta_tsv tsvector
GENERATED ALWAYS AS (
to_tsvector('simple',
coalesce(case_name,'') || ' ' || coalesce(case_number,'')
)
) STORED;
CREATE INDEX IF NOT EXISTS idx_case_law_meta_tsv
ON case_law USING GIN(meta_tsv);
"""
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
async with pool.acquire() as conn:
await conn.execute(SCHEMA_SQL)
@@ -1092,7 +1115,8 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
await conn.execute(SCHEMA_V17_SQL)
await conn.execute(SCHEMA_V18_SQL)
await conn.execute(SCHEMA_V19_SQL)
logger.info("Database schema initialized (v1-v19)")
await conn.execute(SCHEMA_V20_SQL)
logger.info("Database schema initialized (v1-v20)")
async def init_schema() -> None:
@@ -3217,6 +3241,9 @@ async def search_precedent_library_semantic(
ON parent.id = pc.parent_chunk_id
WHERE {' AND '.join(chunk_filters)}
AND pc.embedding IS NOT NULL
-- #55: exclude tiny fragment chunks (artifacts of pre-fix
-- mid-sentence header splits) that carry no retrievable signal.
AND length(trim(pc.content)) >= 50
ORDER BY pc.embedding <=> $1
LIMIT $2
"""
@@ -3411,11 +3438,17 @@ async def search_precedent_library_lexical(
h.practice_areas, h.subject_tags, h.confidence, h.rule_type,
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
cl.precedent_level, cl.chair_name, cl.district,
ts_rank_cd(h.rule_tsv, plainto_tsquery('simple', $1)) AS score
GREATEST(
ts_rank_cd(h.rule_tsv, plainto_tsquery('simple', $1)),
ts_rank_cd(cl.meta_tsv, plainto_tsquery('simple', $1))
)
+ CASE WHEN cl.meta_tsv @@ plainto_tsquery('simple', $1)
THEN 1.0 ELSE 0.0 END AS score
FROM halachot h
JOIN case_law cl ON cl.id = h.case_law_id
WHERE {' AND '.join(halacha_filters)}
AND h.rule_tsv @@ plainto_tsquery('simple', $1)
AND (h.rule_tsv @@ plainto_tsquery('simple', $1)
OR cl.meta_tsv @@ plainto_tsquery('simple', $1))
ORDER BY score DESC
LIMIT $2
"""
@@ -3439,14 +3472,22 @@ async def search_precedent_library_lexical(
parent.page_number AS parent_page_number,
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
cl.precedent_level, cl.practice_area, cl.chair_name, cl.district,
ts_rank_cd(pc.content_tsv, plainto_tsquery('simple', $1)) AS score
GREATEST(
ts_rank_cd(pc.content_tsv, plainto_tsquery('simple', $1)),
ts_rank_cd(cl.meta_tsv, plainto_tsquery('simple', $1))
)
+ CASE WHEN cl.meta_tsv @@ plainto_tsquery('simple', $1)
THEN 1.0 ELSE 0.0 END AS score
FROM precedent_chunks pc
JOIN case_law cl ON cl.id = pc.case_law_id
LEFT JOIN precedent_chunks parent
ON parent.id = pc.parent_chunk_id
WHERE {' AND '.join(chunk_filters)}
AND pc.embedding IS NOT NULL
AND pc.content_tsv @@ plainto_tsquery('simple', $1)
-- #55: exclude tiny fragment chunks (see semantic query above).
AND length(trim(pc.content)) >= 50
AND (pc.content_tsv @@ plainto_tsquery('simple', $1)
OR cl.meta_tsv @@ plainto_tsquery('simple', $1))
ORDER BY score DESC
LIMIT $2
"""

View File

@@ -533,6 +533,7 @@ async def list_precedents(
precedent_level: str = "",
source_type: str = "",
search: str = "",
source_kind: str = "external_upload",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
@@ -542,6 +543,7 @@ async def list_precedents(
precedent_level=precedent_level,
source_type=source_type,
search=search,
source_kind=source_kind,
limit=limit,
offset=offset,
)

View File

@@ -103,6 +103,7 @@ async def precedent_library_list(
precedent_level: str = "",
source_type: str = "",
search: str = "",
source_kind: str = "external_upload",
limit: int = 100,
) -> str:
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים."""
@@ -112,6 +113,7 @@ async def precedent_library_list(
precedent_level=precedent_level,
source_type=source_type,
search=search,
source_kind=source_kind,
limit=limit,
)
return _ok(rows)

View File

@@ -12,6 +12,7 @@
| `sync_missing_agent_skills.py` | python | סקריפט "אל-כשל" להוספת `paperclipSkillSync` ל-`הגהת מסמכים` ו-`מנתח משפטי` שפיספסו את ה-sync ההיסטורי (Gap #28). תומך `--verify`/`--dry-run`/`--apply`. גיבוי אוטומטי ל-`agents-pre-skill-sync-*.sql`. דורש `PAPERCLIP_BOARD_API_KEY` (Infisical /paperclip ב-nautilus env). idempotent. | חד-פעמי (בוצע 2026-05-04). שמור לרפרנס |
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — הסקריפט מדלג על הסוכן עם warning. בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות לפני sync.** | ידני אחרי כל שינוי |
| `fix_paperclipai_skills_drift.py` | python | סקריפט חד-פעמי (בוצע 2026-05-04) שניקה drift על `paperclipai/*` skills בין CMP ל-CMPA. הסיר `paperclip-dev` מכל 14 הסוכנים, ודאג ש-`paperclip-converting-plans-to-tasks` קיים רק על CEO ו-analyst. תומך `--apply` (ברירת מחדל: dry-run). דורש `PAPERCLIP_BOARD_API_KEY`. נשמר לרפרנס למקרה שhdrift חוזר. | חד-פעמי (בוצע) |
| `test_retrieval_by_name.py` | python | בדיקת אחזור-לפי-שם (#52/RC-A) — מאמת ש`search_precedent_library`/`search_internal_decisions` מדרגים את ההחלטה עצמה (אגסי) מעל מי שמצטט אותה, + רגרסיות לשאילתות מהותיות. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/test_retrieval_by_name.py` (exit 0 = עבר). | ידני אחרי שינוי שכבת חיפוש |
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python
"""Repro + regression test for retrieval-by-name (RC-A, tasks #52).
Bug: searching the precedent corpus by a bare case NAME ("אגסי") fails to
surface the decision itself, because the lexical tsvector covers only chunk
content + halacha text — not case_name / case_number. A name query therefore
matches decisions that *cite* the case, not the case.
Run with the MCP venv:
DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data \
mcp-server/.venv/bin/python scripts/test_retrieval_by_name.py
Exit 0 = all assertions pass. Non-zero = failure (prints what was found).
"""
import asyncio
import sys
sys.path.insert(0, "/home/chaim/legal-ai/mcp-server/src")
from legal_mcp.services import embeddings, hybrid_search # noqa: E402
AGASI_ID = "1a87efe5-6e13-4ed4-a9ec-3f2f7d61e4ec"
# Vinfeld CITES Agasi (its halacha quote names אגסי) but is NOT Agasi.
# An exact name match must rank the case itself above any case citing it.
VINFELD_ID = "bd5d849c-c15f-43c3-96ab-d44337af9cb5"
NAME_QUERY = "אגסי"
SUBSTANTIVE_QUERY = 'פטור היטל השבחה לפי סעיף 19(ג)(1) שתי דירות 140 מ"ר אחת מושכרת'
def _ids(rows):
return [str(r.get("case_law_id")) for r in rows]
def _rank_of(rows, cid):
for i, r in enumerate(rows, 1):
if str(r.get("case_law_id")) == cid:
return i
return None
async def _search(query, source_kind, limit=10):
query_emb = await embeddings.embed_query(query)
return await hybrid_search.search_precedent_library_hybrid(
query,
query_emb,
source_kind=source_kind,
limit=limit,
include_halachot=True,
)
async def main():
results = {"pass": [], "fail": []}
# 1) THE BUG: bare-name query must rank the case ITSELF (Agasi) above any
# case that merely CITES it (Vinfeld), and within the top 3.
rows = await _search(NAME_QUERY, "internal_committee", limit=10)
a_rank = _rank_of(rows, AGASI_ID)
v_rank = _rank_of(rows, VINFELD_ID)
ok = bool(a_rank) and a_rank <= 3 and (v_rank is None or a_rank < v_rank)
msg = (f"[name/internal] query='{NAME_QUERY}' -> Agasi rank={a_rank}, "
f"Vinfeld(citer) rank={v_rank} (top ids: {_ids(rows)[:5]})")
(results["pass"] if ok else results["fail"]).append(msg)
# 2) REGRESSION: substantive query must still find Agasi with a real score.
rows = await _search(SUBSTANTIVE_QUERY, "internal_committee", limit=10)
rank = _rank_of(rows, AGASI_ID)
top_score = float(rows[0]["score"]) if rows else 0.0
msg = f"[substantive/internal] Agasi rank={rank}, top_score={top_score:.3f}"
(results["pass"] if rank and rank <= 8 else results["fail"]).append(msg)
# 3) REGRESSION: substantive query in the full precedent library still works
# (Vinfeld/נווה שלום etc. should surface; just assert non-empty + has betterment content).
rows = await _search(SUBSTANTIVE_QUERY, "external_upload", limit=10)
msg = f"[substantive/external] returned {len(rows)} rows (top ids: {_ids(rows)[:3]})"
(results["pass"] if len(rows) >= 3 else results["fail"]).append(msg)
print("\n=== PASS ===")
for m in results["pass"]:
print("", m)
print("=== FAIL ===")
for m in results["fail"]:
print("", m)
return 1 if results["fail"] else 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))