feat(learning): כל החלטה שלנו תמיד בספריית-הפסיקה + בדיקת-ציטוטים וסימון-חסרים אוטומטי #163
@@ -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`).
|
||||
|
||||
@@ -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 : "שגיאה בהעלאה"),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
119
web/app.py
119
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",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user