Merge pull request 'feat(learning): כל החלטה שלנו תמיד בספריית-הפסיקה + בדיקת-ציטוטים וסימון-חסרים אוטומטי' (#163) from worktree-final-into-library into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 40s

This commit was merged in pull request #163.
This commit is contained in:
2026-06-08 11:28:16 +00:00
4 changed files with 136 additions and 8 deletions

View File

@@ -53,7 +53,12 @@
*מקורות:* quality-at-source (Data Mesh) · separation-of-concerns. *סטטוס: verified.* *מקורות:* quality-at-source (Data Mesh) · separation-of-concerns. *סטטוס: verified.*
### 0.6 מסלול-העלאת-סופי נקי + פאנלים אוטומטיים (מדורג) ### 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 מקומיים בלבד): ואז שני שלבים אוטומטיים נפרדים (`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`); פיצול → ליו"ר. - **למידה:** `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`). - **הלכות:** `extract_internal_citations``precedent_extract_halachot``corroboration_rebuild`**פאנל-הלכות תלת-סוכני** (`halacha_panel_approve.py --apply`).

View File

@@ -166,6 +166,15 @@ export function DraftsPanel({
toast.success( toast.success(
`ההחלטה הסופית נקלטה — ${data.final_words} מילים (לעומת ${data.draft_words} בטיוטה)`, `ההחלטה הסופית נקלטה — ${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) => onError: (err) =>
toast.error(err instanceof Error ? err.message : "שגיאה בהעלאה"), toast.error(err instanceof Error ? err.message : "שגיאה בהעלאה"),

View File

@@ -189,6 +189,15 @@ export type FinalUploadResult = {
pair_id: string | null; pair_id: string | null;
draft_words: number; draft_words: number;
final_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; status: string;
}; };

View File

@@ -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") @app.post("/api/cases/{case_number}/final/upload")
async def api_upload_final_decision(case_number: str, file: UploadFile = File(...)): async def api_upload_final_decision(case_number: str, file: UploadFile = File(...)):
"""Clean path: upload the CHAIR's signed final decision (Dafna's version). """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. becomes active_draft). NOT for the chair's final.
• exports/{f}/mark-final → marks one of *our* exports as 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 This endpoint takes the EXTERNAL signed final, stores it canonically, and wires it
the style corpus, and opens the draft↔final reconciliation pair (INV-LRN4) so the into the full corpus-growth loop (07-learning §1.3):
staged learning step can persist its analysis. It does NOT touch active_draft and • style corpus (voice learning) + draft↔final pair (INV-LRN4);
does NOT run the LLM pipeline (run-learning / run-halacha do, on the local worker). • 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) case = await db.get_case_by_number(case_number)
if not case: if not case:
@@ -3356,12 +3452,15 @@ async def api_upload_final_decision(case_number: str, file: UploadFile = File(..
except Exception as e: except Exception as e:
logger.warning("final text extraction failed for %s: %s", case_number, 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() pool = await db.get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
await conn.execute( await conn.execute(
"UPDATE cases SET status = 'final', updated_at = now() WHERE id = $1", "UPDATE cases SET status = 'final', "
UUID(case["id"]), "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). # 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: except Exception as e:
logger.warning("draft_final_pair snapshot failed for %s: %s", case_number, 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) case_dir = config.find_case_dir(case_number)
if case_dir.exists(): if case_dir.exists():
commit_and_push(case_dir, f"החלטה סופית של היו\"ר: {final_name}") 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, "pair_id": pair_id,
"draft_words": draft_words, "draft_words": draft_words,
"final_words": len(final_text.split()), "final_words": len(final_text.split()),
"chair_name": chair_name,
"library": library,
"status": "final", "status": "final",
} }