feat(learning): כל החלטה שלנו נכנסת תמיד לספריית-הפסיקה + בדיקת-ציטוטים אוטומטית

סוגר את הפער שלולאת-צמיחת-הקורפוס (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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:27:55 +00:00
parent 7ebd4187a9
commit 55362bf5a1
4 changed files with 136 additions and 8 deletions

View File

@@ -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`).

View File

@@ -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 : "שגיאה בהעלאה"),

View File

@@ -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;
};

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")
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",
}