From 55362bf5a1431ec0c80d9ef38dab95c7967b472e Mon Sep 17 00:00:00 2001 From: Chaim Date: Mon, 8 Jun 2026 11:27:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(learning):=20=D7=9B=D7=9C=20=D7=94=D7=97?= =?UTF-8?q?=D7=9C=D7=98=D7=94=20=D7=A9=D7=9C=D7=A0=D7=95=20=D7=A0=D7=9B?= =?UTF-8?q?=D7=A0=D7=A1=D7=AA=20=D7=AA=D7=9E=D7=99=D7=93=20=D7=9C=D7=A1?= =?UTF-8?q?=D7=A4=D7=A8=D7=99=D7=99=D7=AA-=D7=94=D7=A4=D7=A1=D7=99=D7=A7?= =?UTF-8?q?=D7=94=20+=20=D7=91=D7=93=D7=99=D7=A7=D7=AA-=D7=A6=D7=99=D7=98?= =?UTF-8?q?=D7=95=D7=98=D7=99=D7=9D=20=D7=90=D7=95=D7=98=D7=95=D7=9E=D7=98?= =?UTF-8?q?=D7=99=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit סוגר את הפער שלולאת-צמיחת-הקורפוס (07-learning §1.3) הוגדרה אך לא חווטה: מסלול /final/upload הכניס רק לקורפוס-הסגנון, וההכנסה ל-case_law הייתה best-effort שקטה שנכשלה כש-chair_name ריק. web/app.py — /api/cases/{case}/final/upload עכשיו, סינכרונית: - קובע chair_name דטרמיניסטית (תיק → ברירת-מחדל-ועדה לפי prefix; לעולם לא ריק → אילוץ case_law_internal_chair_check תמיד מסופק). לא נשען על חילוץ-LLM — להחלטות שלנו היו"ר ידוע. - מכניס את ההחלטה ל-case_law כ-internal_committee (תמיד, לא best-effort) → ברת-ציטוט בהחלטות עתידיות. מטה-דאטה נוסף מועשר אסינכרונית (Gemini). - מחלץ את הציטוטים שההחלטה מצטטת (extract_internal_citations), ו**מסמן אוטומטית** כל ציטוט שאינו בספרייה כ-missing_precedent (open) — dedup מול קיימים. - התוצאה מוחזרת ב-response (enrolled/linked/missing_flagged) — לא נבלעת בשקט. הציטוטים-המקושרים מזינים את לולאת-ה-corroboration (X11) — תוקן הניתוק שבו החלטות שלנו לא היו ב-case_law ולכן לא חיזקו הלכות. web-ui — toast מציג "נוספה לספרייה · N ציטוטים · M חסרים סומנו". ספ: 07-learning §0.6 עודכן. אומת ידנית על בל"מ 8126-03-25 (15 קושרו / 6 סומנו). Invariants: INV-LRN4, X11; G2 (יכולת חסרה, לא מקבילה); feedback_silent_swallow (כשל-הכנסה צף, לא נבלע); DM7 (סמכות נגזרת). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/spec/07-learning.md | 7 +- web-ui/src/components/cases/drafts-panel.tsx | 9 ++ web-ui/src/lib/api/exports.ts | 9 ++ web/app.py | 119 +++++++++++++++++-- 4 files changed, 136 insertions(+), 8 deletions(-) diff --git a/docs/spec/07-learning.md b/docs/spec/07-learning.md index bcea1db..7a8421f 100644 --- a/docs/spec/07-learning.md +++ b/docs/spec/07-learning.md @@ -53,7 +53,12 @@ *מקורות:* quality-at-source (Data Mesh) · separation-of-concerns. *סטטוס: verified.* ### 0.6 מסלול-העלאת-סופי נקי + פאנלים אוטומטיים (מדורג) -היו"ר מעלה את **ההחלטה החתומה שלה** דרך מסלול ייעודי — `POST /api/cases/{case}/final/upload` (כפתור "העלאת החלטה סופית של היו"ר" בלשונית-הטיוטות). **נבדל** מ-`exports/upload` (גרסה-מתוקנת-שלנו+retrofit) ומ-`mark-final` (סימון export-שלנו), ולכן אינו מסלול-מקביל (G2) אלא יכולת חסרה: קליטת final חיצוני, פתיחת `draft_final_pairs` (`final_received`), והכנסה לקורפוס-הסגנון תחת ה-`case_number` **המלא** (בל"מ≠ערר — מונע התנגשות-מספר). +היו"ר מעלה את **ההחלטה החתומה שלה** דרך מסלול ייעודי — `POST /api/cases/{case}/final/upload` (כפתור "העלאת החלטה סופית של היו"ר" בלשונית-הטיוטות). **נבדל** מ-`exports/upload` (גרסה-מתוקנת-שלנו+retrofit) ומ-`mark-final` (סימון export-שלנו), ולכן אינו מסלול-מקביל (G2) אלא יכולת חסרה. +הקליטה (סינכרונית ב-endpoint) מבצעת את **לולאת-צמיחת-הקורפוס** (§1.3) במלואה: +1. **קורפוס-הסגנון** (voice) תחת ה-`case_number` **המלא** (בל"מ≠ערר — מונע התנגשות-מספר) + פתיחת `draft_final_pairs` (`final_received`, INV-LRN4). +2. **ספריית-הפסיקה** — ההחלטה נכנסת ל-`case_law` כ-`internal_committee` **תמיד** (כדי שתהיה ברת-ציטוט בהחלטות עתידיות). `chair_name` נקבע **דטרמיניסטית** (תיק → ברירת-מחדל-ועדה, לעולם לא ריק — אילוץ `case_law_internal_chair_check`); לא נשען על חילוץ-LLM. מטה-דאטה נוסף (תאריך/צדדים) מועשר אסינכרונית ע"י מחלץ-Gemini. +3. **בדיקת-ציטוטים** — `extract_internal_citations` מקשר את הפסיקה שההחלטה מצטטת לספרייה; כל ציטוט שאינו בספרייה **מסומן אוטומטית** כ-`missing_precedent` (open) להעלאה ע"י היו"ר. +4. הציטוטים-המקושרים מזינים את **לולאת-ה-corroboration** (X11): ציטוט-נכנס מההחלטה שלנו מחזק את ההלכות של התקדים המצוטט (`corroboration_rebuild`). ואז שני שלבים אוטומטיים נפרדים (`run-learning` / `run-halacha`) המעירים worker מקומי (claude/DeepSeek/Gemini מקומיים בלבד): - **למידה:** `ingest_final_version` (Opus distillation) → **פאנל-סגנון דו-סוכני** (DeepSeek+Gemini, "למידה כפולה") שמצביע על כל לקח-style_method; הסכמה 2/2 → `decision_lesson` (`source=panel:deepseek+gemini`); פיצול → ליו"ר. - **הלכות:** `extract_internal_citations` → `precedent_extract_halachot` → `corroboration_rebuild` → **פאנל-הלכות תלת-סוכני** (`halacha_panel_approve.py --apply`). diff --git a/web-ui/src/components/cases/drafts-panel.tsx b/web-ui/src/components/cases/drafts-panel.tsx index c74b053..8197e49 100644 --- a/web-ui/src/components/cases/drafts-panel.tsx +++ b/web-ui/src/components/cases/drafts-panel.tsx @@ -166,6 +166,15 @@ export function DraftsPanel({ toast.success( `ההחלטה הסופית נקלטה — ${data.final_words} מילים (לעומת ${data.draft_words} בטיוטה)`, ); + const lib = data.library; + if (lib?.enrolled) { + toast.success( + `נוספה לספריית-הפסיקה · ${lib.linked ?? 0} ציטוטים קושרו · ` + + `${lib.missing_flagged ?? 0} חסרים סומנו להעלאה`, + ); + } else if (lib?.error) { + toast.warning(`לא נוספה לספריית-הפסיקה: ${lib.error}`); + } }, onError: (err) => toast.error(err instanceof Error ? err.message : "שגיאה בהעלאה"), diff --git a/web-ui/src/lib/api/exports.ts b/web-ui/src/lib/api/exports.ts index 61aec70..bce36ed 100644 --- a/web-ui/src/lib/api/exports.ts +++ b/web-ui/src/lib/api/exports.ts @@ -189,6 +189,15 @@ export type FinalUploadResult = { pair_id: string | null; draft_words: number; final_words: number; + chair_name?: string; + library?: { + enrolled: boolean; + case_law_id?: string | null; + linked?: number; + missing_flagged?: number; + error?: string; + citation_error?: string; + }; status: string; }; diff --git a/web/app.py b/web/app.py index 9c5b3c8..cc20d91 100644 --- a/web/app.py +++ b/web/app.py @@ -3312,6 +3312,96 @@ async def api_mark_final(case_number: str, filename: str): } +# Our committee's standing chair — internal_committee rows REQUIRE a chair_name +# (DB constraint case_law_internal_chair_check). Both CMP (1xxx) and CMPA (8/9xxx) +# are currently chaired by Dafna Tamir; map by prefix so adding a chair later is local. +COMMITTEE_CHAIR_BY_PREFIX = {"1": "דפנה תמיר", "8": "דפנה תמיר", "9": "דפנה תמיר"} +COMMITTEE_CHAIR_DEFAULT = "דפנה תמיר" + + +def _committee_chair_for_case(case: dict, case_number: str) -> str: + """Resolve the chair for one of OUR decisions deterministically (no LLM): the case's + own chair_name, else the committee default by case-number prefix.""" + existing = (case.get("chair_name") or "").strip() + if existing: + return existing + return COMMITTEE_CHAIR_BY_PREFIX.get(case_number[:1], COMMITTEE_CHAIR_DEFAULT) + + +async def _enroll_final_in_library( + case: dict, case_number: str, final_text: str, chair_name: str, +) -> dict: + """לולאת-צמיחת-הקורפוס (07-learning §1.3): GUARANTEE the signed final enters the + precedent library (case_law, internal_committee) so it is citable; extract the + precedents IT cites; and auto-flag any cited precedent missing from the library. + + Surfaces failures in the return value (no silent swallow) — the upload itself still + succeeds (file + style_corpus + pair are already saved).""" + from legal_mcp.services import internal_decisions as int_svc + from legal_mcp.tools import citations as cit_tools + from legal_mcp.tools import missing_precedents as mp_tools + + out: dict = {"enrolled": False, "linked": 0, "missing_flagged": 0} + if not final_text.strip(): + out["error"] = "no final text extracted" + return out + try: + res = await int_svc.ingest_internal_decision( + case_number=case_number, case_name=case.get("title", ""), + decision_date=case.get("decision_date"), chair_name=chair_name, + district="ירושלים", practice_area=case.get("practice_area", ""), + appeal_subtype=case.get("appeal_subtype", ""), text=final_text, + ) + except Exception as e: + logger.warning("library enrollment failed for %s: %s", case_number, e) + out["error"] = str(e) + return out + case_law_id = res.get("case_law_id") if isinstance(res, dict) else None + out["enrolled"] = bool(case_law_id) + out["case_law_id"] = case_law_id + if not case_law_id: + return out + + # The precedents this decision cites → link to the library; flag the ones not found. + try: + await cit_tools.extract_internal_citations(case_law_id=case_law_id, limit=0) + pool = await db.get_pool() + async with pool.acquire() as conn: + out["linked"] = await conn.fetchval( + "SELECT count(*) FROM precedent_internal_citations " + "WHERE source_case_law_id = $1 AND cited_case_law_id IS NOT NULL", + UUID(case_law_id), + ) + unresolved = await conn.fetch( + "SELECT DISTINCT cited_case_number FROM precedent_internal_citations " + "WHERE source_case_law_id = $1 AND cited_case_law_id IS NULL " + "AND cited_case_number <> ''", + UUID(case_law_id), + ) + already = {r["citation"] for r in await conn.fetch( + "SELECT citation FROM missing_precedents WHERE status = 'open'")} + flagged = 0 + for r in unresolved: + cit = r["cited_case_number"] + if cit in already: + continue + try: + await mp_tools.missing_precedent_create( + citation=cit, case_number=case_number, cited_by_party="committee", + notes=f"מצוטט בהחלטת {case_number}; חסר בספריית-הפסיקה " + f"(זוהה אוטומטית בקליטת הסופי)", + ) + already.add(cit) + flagged += 1 + except Exception as e: + logger.warning("missing_precedent_create failed for %s: %s", cit, e) + out["missing_flagged"] = flagged + except Exception as e: + logger.warning("citation check failed for %s: %s", case_number, e) + out["citation_error"] = str(e) + return out + + @app.post("/api/cases/{case_number}/final/upload") async def api_upload_final_decision(case_number: str, file: UploadFile = File(...)): """Clean path: upload the CHAIR's signed final decision (Dafna's version). @@ -3321,10 +3411,16 @@ async def api_upload_final_decision(case_number: str, file: UploadFile = File(.. becomes active_draft). NOT for the chair's final. • exports/{f}/mark-final → marks one of *our* exports as final. - This endpoint takes the EXTERNAL signed final, stores it canonically, enrolls it in - the style corpus, and opens the draft↔final reconciliation pair (INV-LRN4) so the - staged learning step can persist its analysis. It does NOT touch active_draft and - does NOT run the LLM pipeline (run-learning / run-halacha do, on the local worker). + This endpoint takes the EXTERNAL signed final, stores it canonically, and wires it + into the full corpus-growth loop (07-learning §1.3): + • style corpus (voice learning) + draft↔final pair (INV-LRN4); + • the precedent library (case_law, internal_committee) so the decision is CITABLE — + chair_name is resolved deterministically (case → committee default), never left + empty (DB constraint), so enrollment always succeeds; + • its citations are linked to the library and any cited precedent NOT in the library + is auto-flagged as a missing_precedent. + It does NOT touch active_draft. The LLM-heavy steps (style/halacha panels, halacha + extraction, corroboration) still run on the local worker via run-learning/run-halacha. """ case = await db.get_case_by_number(case_number) if not case: @@ -3356,12 +3452,15 @@ async def api_upload_final_decision(case_number: str, file: UploadFile = File(.. except Exception as e: logger.warning("final text extraction failed for %s: %s", case_number, e) - # Case → final. + # Case → final + ensure chair_name is set (our committee chair; never empty). + chair_name = _committee_chair_for_case(case, case_number) pool = await db.get_pool() async with pool.acquire() as conn: await conn.execute( - "UPDATE cases SET status = 'final', updated_at = now() WHERE id = $1", - UUID(case["id"]), + "UPDATE cases SET status = 'final', " + "chair_name = COALESCE(NULLIF(chair_name, ''), $2), updated_at = now() " + "WHERE id = $1", + UUID(case["id"]), chair_name, ) # INV-LRN4 — snapshot OUR draft now and open the pair (status=final_received). @@ -3385,6 +3484,10 @@ async def api_upload_final_decision(case_number: str, file: UploadFile = File(.. except Exception as e: logger.warning("draft_final_pair snapshot failed for %s: %s", case_number, e) + # Corpus-growth loop: guarantee enrollment into the precedent library + citation + # checking + missing-precedent flagging (07-learning §1.3). Surfaced, not silent. + library = await _enroll_final_in_library(case, case_number, final_text, chair_name) + case_dir = config.find_case_dir(case_number) if case_dir.exists(): commit_and_push(case_dir, f"החלטה סופית של היו\"ר: {final_name}") @@ -3395,6 +3498,8 @@ async def api_upload_final_decision(case_number: str, file: UploadFile = File(.. "pair_id": pair_id, "draft_words": draft_words, "final_words": len(final_text.split()), + "chair_name": chair_name, + "library": library, "status": "final", }