Merge pull request 'feat(learning): מסלול נקי להעלאת החלטה סופית + פאנל-סגנון דו-סוכני (DeepSeek+Gemini)' (#158) from worktree-final-upload-pipeline into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s

This commit was merged in pull request #158.
This commit is contained in:
2026-06-08 09:04:16 +00:00
10 changed files with 726 additions and 25 deletions

View File

@@ -3312,6 +3312,128 @@ async def api_mark_final(case_number: str, filename: str):
}
@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).
Distinct from the two pre-existing flows:
• exports/upload → uploads a *revised version of OUR draft* (retrofits bookmarks,
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).
"""
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
if not file.filename:
raise HTTPException(400, "לא צוין שם קובץ")
if Path(file.filename).suffix.lower() != ".docx":
raise HTTPException(400, "רק קבצי DOCX נתמכים")
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(400, f"קובץ גדול מדי. מקסימום: {MAX_FILE_SIZE // (1024*1024)}MB")
export_dir = config.find_case_dir(case_number) / "exports"
export_dir.mkdir(parents=True, exist_ok=True)
final_name = f"סופי-{case_number}.docx"
final_path = export_dir / final_name
final_path.write_bytes(content)
# Enroll in the style corpus. Use the FULL case_number as decision_number so a
# בל"מ never collides with a same-numbered ערר already in the corpus (e.g. ARAR-25-8126).
config.TRAINING_DIR.mkdir(parents=True, exist_ok=True)
training_dest = config.TRAINING_DIR / f"החלטה-{case_number}.docx"
shutil.copy2(str(final_path), str(training_dest))
# Extract the final text (word count for the UI; full text snapshotted into the pair).
final_text = ""
try:
final_text, _pages, _ = await extractor.extract_text(str(final_path))
except Exception as e:
logger.warning("final text extraction failed for %s: %s", case_number, e)
# Case → final.
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"]),
)
# INV-LRN4 — snapshot OUR draft now and open the pair (status=final_received).
pair_id: str | None = None
draft_words = 0
try:
decision = await db.get_decision_by_case(UUID(case["id"]))
draft_text = ""
if decision:
async with pool.acquire() as conn:
brows = await conn.fetch(
"SELECT content FROM decision_blocks "
"WHERE decision_id = $1 AND word_count > 0 ORDER BY block_index",
UUID(decision["id"]),
)
draft_text = "\n\n".join(b["content"] for b in brows if b["content"])
draft_words = len(draft_text.split())
pair_id = await db.create_draft_final_pair(
UUID(case["id"]), draft_text, str(final_path),
)
except Exception as e:
logger.warning("draft_final_pair snapshot failed for %s: %s", case_number, e)
case_dir = config.find_case_dir(case_number)
if case_dir.exists():
commit_and_push(case_dir, f"החלטה סופית של היו\"ר: {final_name}")
return {
"final_filename": final_name,
"training_copy": str(training_dest),
"pair_id": pair_id,
"draft_words": draft_words,
"final_words": len(final_text.split()),
"status": "final",
}
async def _wake_final_task(case_number: str, task: str) -> dict:
"""Shared trigger for the staged learning / halacha steps — wakes the local curator."""
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
final_name = f"סופי-{case_number}.docx"
prefix = case_number[:1]
company_id = (
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
else ""
)
try:
return await pc_wake_curator_for_final(
case_number, final_name, company_id=company_id, task=task,
)
except Exception as e:
logger.warning("final %s wakeup failed for %s: %s", task, case_number, e)
return {"status": "error", "error": str(e)}
@app.post("/api/cases/{case_number}/final/run-learning")
async def api_final_run_learning(case_number: str):
"""Staged step 1 — voice learning: wake the local worker to run ingest_final_version
(Opus distillation) + the 2-judge style panel (DeepSeek+Gemini)."""
return await _wake_final_task(case_number, "learning")
@app.post("/api/cases/{case_number}/final/run-halacha")
async def api_final_run_halacha(case_number: str):
"""Staged step 2 — halacha validation: wake the local worker to extract the cited
halachot, build corroboration, and run the 3-judge halacha panel (--apply)."""
return await _wake_final_task(case_number, "halacha")
@app.post("/api/cases/{case_number}/export-docx")
async def api_export_docx(case_number: str, background_tasks: BackgroundTasks):
"""Trigger DOCX export for a case.

View File

@@ -1050,17 +1050,75 @@ async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "")
return result
def _curator_task_brief(task: str, case_number: str, final_filename: str) -> tuple[str, str]:
"""Build the (sub-issue title, description) for a staged final-decision task.
task='learning' — draft↔final voice distillation + the 2-judge style panel.
task='halacha' — extract the halachot CITED in the final + corroboration + the
3-judge halacha panel.
The curator (Hermes) has Bash + MCP tools, so it both calls MCP tools and runs the
local panel scripts. Panels write only reversible, CSV-backed proposals (INV-G10).
"""
if task == "halacha":
title = f"[ערר {case_number}] אימות-הלכות — פאנל 3-סוכנים"
description = (
f"אימות ההלכות שצוטטו בהחלטה הסופית של תיק {case_number} "
f"(`{final_filename}`).\n\n"
f"**שלב 1 — ציטוטים:** הרץ "
f"`mcp__legal-ai__extract_internal_citations(chair_name=\"דפנה תמיר\")` "
f"כדי למפות לאילו תקדימים ההחלטה מפנה.\n"
f"**שלב 2 — חילוץ הלכות:** לכל תקדים מצוטט שקיים בספרייה הרץ "
f"`mcp__legal-ai__precedent_extract_halachot(case_law_id=...)` (idempotent). "
f"תקדים מצוטט שחסר — `mcp__legal-ai__missing_precedent_create`.\n"
f"**שלב 3 — corroboration:** `mcp__legal-ai__corroboration_rebuild` לבניית "
f"אות-התיקוף (treatment) ומדיניות.\n"
f"**שלב 4 — פאנל-הלכות (אוטו-אישור + אסקלציה):** הרץ "
f"`cd ~/legal-ai/mcp-server && .venv/bin/python ../scripts/halacha_panel_approve.py --apply`. "
f"הסכמה 2/3+ → approved/rejected (הפיך, מגובה ל-CSV); פיצול → נשאר pending_review "
f"ליו\"ר. **אל תקבע סמכות binding/persuasive — היא נגזרת מ-precedent_level (INV-DM7).**\n"
f"**שלב 5:** כתוב comment בעברית עם סיכום (כמה אושרו/נדחו/הוסלמו), סגור issue (done)."
)
return title, description
# default: learning (voice distillation + style panel)
title = f"[ערר {case_number}] סקירת ידע — Knowledge Curator"
description = (
f"דפנה סימנה את ההחלטה הסופית של תיק {case_number} כסופית.\n"
f"קובץ סופי: `{final_filename}`\n\n"
f"**שלב 1 — דיסטילציה (חובה, draft↔final):** הרץ "
f"`mcp__legal-ai__ingest_final_version(case_number=\"{case_number}\")`. "
f"הוא משווה את הטיוטה (snapshot מפנקס-ההתאמה) לסופי, מסווג כל שינוי "
f"style_method מול substance (INV-LRN5), ושומר את ההצעה ב-draft_final_pairs "
f"(status→analyzed). **אל תקבע לקח לבד — זו הצעה לאישור.**\n"
f"**שלב 2 — פאנל-סגנון דו-סוכני (DeepSeek+Gemini, אוטו-אישור + אסקלציה):** הרץ "
f"`cd ~/legal-ai/mcp-server && .venv/bin/python ../scripts/style_lesson_panel.py "
f"--case {case_number} --apply`. הסכמה 2/2 → נכתב כ-decision_lesson "
f"(source=panel:deepseek+gemini); פיצול → מוסלם ליו\"ר. רק לקחי style_method "
f"נשקלים (substance מדולג, INV-LRN5).\n"
f"**שלב 3 — הצעה:** מתוך לקחי-הסגנון שאושרו בפאנל, בחר 3-5 דפוסים שלא תועדו "
f"ב-skills/decision/SKILL.md / docs/legal-decision-lessons.md / "
f"daphna-voice-fingerprint.md (אל תציע מה שכבר שם). כתוב comment בעברית, ניטרלי, ממוספר.\n"
f"**שלב 4:** עדכן MEMORY.md, סגור issue (status=done). הטמעה ל-SKILL.md/lessons.md "
f"נשארת אישור-יו\"ר ידני (INV-G10)."
)
return title, description
async def wake_curator_for_final(
case_number: str,
final_filename: str,
company_id: str = "",
task: str = "learning",
) -> dict:
"""Wake the Knowledge Curator (Hermes) when a case is marked final.
"""Wake the Knowledge Curator (Hermes) for a staged final-decision task.
Creates a child issue under the main case issue, assigns it to the
curator, and triggers wakeup. Best-effort — silently skips if no
curator is configured for the company or no main issue is found.
``task`` selects the brief: 'learning' (voice distillation + style panel) or
'halacha' (cited-halacha extraction + corroboration + halacha panel).
Returns ``{"status": "ok"|"skipped", ...}``.
"""
if not PAPERCLIP_BOARD_API_KEY:
@@ -1080,25 +1138,12 @@ async def wake_curator_for_final(
main_issue = next((i for i in issues if i.get("status") == "in_progress"), None) or issues[0]
main_issue_id = main_issue["id"]
description = (
f"דפנה סימנה את ההחלטה הסופית של תיק {case_number} כסופית.\n"
f"קובץ סופי: `{final_filename}`\n\n"
f"**שלב 1 — דיסטילציה (חובה, draft↔final):** הרץ "
f"`mcp__legal-ai__ingest_final_version(case_number=\"{case_number}\")`. "
f"הוא משווה את הטיוטה (snapshot מפנקס-ההתאמה) לסופי, מסווג כל שינוי "
f"style_method מול substance (INV-LRN5), ושומר את ההצעה ב-draft_final_pairs "
f"(status→analyzed). **אל תקבע לקח לבד — זו הצעה לאישור.**\n"
f"**שלב 2 — הצעה:** מתוך השינויים מסוג style_method בלבד, בחר 3-5 דפוסי "
f"סגנון/שיטה שלא תועדו ב-skills/decision/SKILL.md / docs/legal-decision-lessons.md / "
f"daphna-voice-fingerprint.md (אל תציע מה שכבר שם). כתוב comment בעברית, ניטרלי, ממוספר.\n"
f"**שלב 3:** עדכן MEMORY.md, סגור issue (status=done). substance (הלכות/עובדות) — "
f"לא נכנס לקול; אם זוהתה הלכה חדשה הפנה למסלול precedent."
)
title, description = _curator_task_brief(task, case_number, final_filename)
child_resp = await pc_request(
"POST",
f"/api/issues/{main_issue_id}/children",
json={
"title": f"[ערר {case_number}] סקירת ידע — Knowledge Curator",
"title": title,
"description": description,
"status": "in_progress",
"priority": "low",
@@ -1126,7 +1171,7 @@ async def wake_curator_for_final(
json={
"source": "on_demand",
"triggerDetail": "manual",
"reason": f"final_marked_{case_number}",
"reason": f"final_{task}_{case_number}",
"payload": {
"issueId": sub_issue_id,
"mutation": "assignment",