diff --git a/docs/spec/07-learning.md b/docs/spec/07-learning.md index abb8260..bcea1db 100644 --- a/docs/spec/07-learning.md +++ b/docs/spec/07-learning.md @@ -52,6 +52,13 @@ **INV-LRN5 (טוהר-הקול → G4/G11):** שכבת-ידע-הקול (voice-fingerprint, style_patterns, exemplars) **לא תכיל הלכות/עובדות ספציפיות** — רק סגנון ושיטה. מהות מנותבת ל-precedent_library/halacha. ה-distillation מפריד במקור. *מקורות:* 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` **המלא** (בל"מ≠ערר — מונע התנגשות-מספר). +ואז שני שלבים אוטומטיים נפרדים (`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`). +שני הפאנלים **הפיכים** (גיבוי-CSV ל-`data/audit/`) ומסלימים מחלוקות. ההטמעה הסופית ל-`SKILL.md`/`legal-decision-lessons.md` נשארת **אישור-יו"ר ידני** (INV-LRN1/G10) — הפאנל יוצר *הצעות* בלבד. + --- ## 1. שלוש לולאות-המשנה diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index 9b1fb48..9754998 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -49,7 +49,8 @@ | `halacha_goldset.py` | python | **#81.7** — הארנס gold-set לאיכות חילוץ-הלכות. `export --n N` מייצא מדגם מרובד (לפי precedent×rule_type) ל-CSV עם עמודות-תיוג ריקות (`is_holding`/`correct_type`/`quote_complete`) לתיוג ידני (חיים/דפנה). `score --in ` קורא את ה-CSV המתויג ומודד כל ולידטור (`compute_quality_flags`/`is_fact_dependent`/`is_quote_truncated`/`is_thin_restatement`) מול אמת-המידה האנושית: P/R/F1 + confusion. בסיס ל-#81.8 (כיול סף האישור). מייבא את אותם ולידטורים שה-extractor מריץ. רץ עם venv של mcp-server. **הערה:** קיים גם דף-תיוג אינטראקטיבי DB-backed (`/goldset`) — זה ה-CSV-fallback | ידני — export→תיוג→score | | `goldset_ai_recommend.py` | python | **#81.7 QA** — מייצר **חוות-דעת-AI שנייה** (claude מקומי, אפס עלות) לכל פריט ב-`halacha_goldset`: `is_holding`+`type`+נימוק, נשמר ב-`ai_*` ומוצג בדף לצד התיוג האנושי לזיהוי אי-הסכמות. **עצמאי** מהוולידטורים שנמדדים (אין מעגליות) ו**לא** מוחל אוטומטית. `--force` (חידוש)/`--limit N`. **חובה מקומי** (claude_session). | ידני — לאחר יצירת/הרחבת batch | | `goldset_independent_judge.py` | python | **INV-DM7 ולידציה** — שופט-תפקיד **עצמאי שני** ממודל אחר (DeepSeek API ישיר, OpenAI-compatible) ששובר את עיגון-ה-AI: מסווג rule_role **בעיוור** (בלי לראות תיוג-אדם או המלצת-claude) ומחשב מטריצת-הסכמה (deepseek↔אדם מול ai↔אדם) + ציר-גס (כלל-בר-הכללה מול application/obiter). **ממצא (2026-06-07):** ai↔אדם=100% (מעוגן), deepseek↔אדם=50% מדויק אך **92% גס** → תת-הסוג holding/interpretive/procedural עמום-מטבעו (לא לשער עליו); הציר-הגס אמין חוצה-מודלים. read-only על הזהב. `--model`/`--limit`/`--concurrency`. מפתח מ-`~/.hermes/profiles/deepseek/.env`. raw→`/tmp/goldset_judge_raw.json`. | ידני — ולידציית אמינות-תוויות | -| `halacha_panel_approve.py` | python | **פאנל-אישור הלכות (Trust-or-Escalate, dry-run).** 3 שופטים בלתי-תלויי-לינאז' (Opus/claude_session · DeepSeek · Gemini-2.5-flash) מצביעים על ה**ציר-הגס האמין** (92% חוצה-מודלים): נקיות→"הלכה לשמירה?"; nli_unsupported→"הציטוט תומך בכלל?" (שיפוט-מחדש); פגומות→re-extraction. רק ורדיקט מוסכם פועל אוטומטית, **פיצול מסלים ליו"ר** (INV-G10). dry-run בלבד (אין `--apply` עדיין). מפתחות: DeepSeek מ-`~/.hermes/...`, Gemini מ-`~/.env`. **חובה מקומי**. dry-run 2026-06-07: 197→103 אוטו (פה-אחד) / ~15 (רוב). | ידני — טריאז' תור-אישור | +| `halacha_panel_approve.py` | python | **פאנל-אישור הלכות (Trust-or-Escalate, dry-run).** 3 שופטים בלתי-תלויי-לינאז' (Opus/claude_session · DeepSeek · Gemini-2.5-flash) מצביעים על ה**ציר-הגס האמין** (92% חוצה-מודלים): נקיות→"הלכה לשמירה?"; nli_unsupported→"הציטוט תומך בכלל?" (שיפוט-מחדש); פגומות→re-extraction. רק ורדיקט מוסכם פועל אוטומטית, **פיצול מסלים ליו"ר** (INV-G10). `--apply` **מחווט** (clean: רוב 2/3; nli: פה-אחד-entailed מנקה flag) — הפיך, מגבה ל-`data/audit/` קודם. מפתחות: DeepSeek מ-`~/.hermes/...`, Gemini מ-`~/.env`. **חובה מקומי**. dry-run 2026-06-07: 197→103 אוטו (פה-אחד) / ~15 (רוב). | ידני / שלב-אימות-הלכות במסלול-הסופי | +| `style_lesson_panel.py` | python | **פאנל-סגנון דו-סוכני (למידה כפולה).** על-גבי דיסטילציית-ה-Opus (draft↔final ב-`draft_final_pairs.analysis`), שני שופטים בלתי-תלויים — DeepSeek + Gemini-2.5-flash — מצביעים לכל לקח על השאלה הגסה "האם זו הנחיית-סגנון מופשטת ובת-הכללה (INV-LRN5 — קול ולא מהות)?". הסכמה 2/2-keep → נכתב כ-`decision_lesson` (`source=panel:deepseek+gemini`); 2/2-drop → לא נכתב; פיצול/substance → מוסלם ליו"ר. `--apply` הפיך, מגבה ל-`data/audit/`. הטמעה ל-SKILL.md/lessons.md נשארת שער-יו"ר ידני (INV-G10). מפתחות כמו פאנל-ההלכות. **חובה מקומי**. `--case ` / `--pair-id `. | שלב-למידה במסלול-הסופי | | `halacha_panel_audit.py` | python | **רשת-ביטחון לפאנל** (selective-prediction monitoring) — דוגם הלכות שאושרו ע"י הפאנל (`reviewer LIKE 'panel:%'`), מריץ עליהן **שוב** את הצבעת-ה-KEEP של 3 השופטים, ומציף כל מקרה שכעת נוטה DROP (false-keep פוטנציאלי). report-only כברירת-מחדל; `--flag` מחזיר את ה-flips ל-`pending_review` לסקירת-יו"ר. `--sample N`/`--seed`. בסיס 2026-06-07: 0/15. מיועד להרצה תקופתית (שבועי). מייבא שופטים מ-`halacha_panel_approve`. **חובה מקומי**. | תקופתי (שבועי) — ניטור | | `halacha_panel_calibrate.py` | python | **כיול מדיניות-ההצבעה של הפאנל** (Trust-or-Escalate, ICLR 2025). מריץ את שאלת-ה-KEEP של `halacha_panel_approve` על מדגם-הזהב ומודד מול `is_holding` (הציר-הגס) precision+coverage לכל מדיניות (unanimous/majority) + ספירת false-keep/false-drop. נותן את **אחוז-הטעות בפועל** לבחירת סף-סיכון α. מייבא שופטים מ-`halacha_panel_approve` (מקור-אמת יחיד). read-only, **חובה מקומי**. | ידני — לפני חיווט `--apply` | | `halacha_rule_role_backfill.py` | python | **INV-DM7** — backfill חד-פעמי: מסווג-מחדש את ההלכות הישנות (`rule_type IN ('binding','persuasive')` — ערכי-סמכות שנשמרו במסווה תפקיד לפני פיצול הצירים) לאחד מחמשת **תפקידי-הכלל** (holding/interpretive/procedural/application/obiter) דרך claude_session המקומי (אפס עלות). **לא נוגע בסמכות** (נגזרת מ-`precedent_level`). `--apply` (ברירת-מחדל dry-run) / `--limit N` / `--concurrency`. כותב backup CSV ל-`data/audit/` תחילה. fail-safe (פריט שנכשל → נשמר ערך ישן). **חובה מקומי** (claude_session). | ידני חד-פעמי אחרי deploy של פיצול-הסמכות | diff --git a/scripts/halacha_panel_approve.py b/scripts/halacha_panel_approve.py index 1e13bee..895e966 100644 --- a/scripts/halacha_panel_approve.py +++ b/scripts/halacha_panel_approve.py @@ -22,8 +22,9 @@ Three buckets of pending_review: 3. other quality flags (quote_unverified/truncated/thin) → genuine extraction defects → flagged for re-extraction, never auto-approved. -DRY-RUN writes NOTHING. --apply would act on the unanimous verdicts (not yet -wired — review the dry-run first). Local-only (claude_session needs the CLI). +DRY-RUN writes NOTHING. --apply acts on the agreed verdicts (clean: 2/3 majority; +nli: unanimous-entailed clears the flag) — reversible, backed up to data/audit/ first. +Splits/defects stay pending_review for the chair. Local-only (claude_session needs CLI). cd ~/legal-ai/mcp-server .venv/bin/python ../scripts/halacha_panel_approve.py --limit 12 # smoke diff --git a/scripts/style_lesson_panel.py b/scripts/style_lesson_panel.py new file mode 100644 index 0000000..eaa1da5 --- /dev/null +++ b/scripts/style_lesson_panel.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""Two-judge panel to vet distilled STYLE lessons — "double learning" (DeepSeek + Gemini). + +The voice-learning loop (ingest_final_version → learning_loop.analyze_changes) uses +Opus to distill, from a draft→final diff, lessons on HOW דפנה writes — stored as a +PROPOSAL in draft_final_pairs.analysis.changes[]. Per INV-LRN1/G10 those are NOT +auto-committed to the writer-consumed knowledge (SKILL.md / legal-decision-lessons.md). + +This panel adds a SECOND independent layer on top of Opus's distillation — two judges +of different lineage vote per lesson on the coarse, reliable question: "is this an +ABSTRACT, generalizable STYLE/METHOD lesson (INV-LRN5 — voice, not legal substance)?" + + - deepseek (api.deepseek.com) [DeepSeek — same family as the Hermes curator] + - gemini (gemini-2.5-flash) [Google — #1 on LegalBench] + +(No Claude judge here: Opus already produced the proposal; the panel's job is an +independent cross-check, so we use the two NON-Opus lineages = "double learning".) + +Agreement policy (mirrors halacha_panel_approve.py, reversible + CSV-backed): + - 2/2 keep → create a decision_lesson row (source='panel:deepseek+gemini') + - 2/2 drop → recorded as panel-rejected, NOT written + - split / incomplete / substance → ESCALATE to chair (printed + in the JSON report) + +Distilled lessons tagged domain='substance' are skipped outright (INV-LRN5 — legal +substance must never enter the voice knowledge layer). + +Final fold into SKILL.md / legal-decision-lessons.md stays a MANUAL chair gate (G10); +this panel only creates decision_lesson *proposals* attached to the case's style_corpus row. + +Local-only (DeepSeek/Gemini keys live on the host, like the halacha panel). + + cd ~/legal-ai/mcp-server + .venv/bin/python ../scripts/style_lesson_panel.py --case 8126-03-25 # dry-run + .venv/bin/python ../scripts/style_lesson_panel.py --case 8126-03-25 --apply # write proposals + .venv/bin/python ../scripts/style_lesson_panel.py --pair-id --apply +""" +from __future__ import annotations + +import argparse +import asyncio +import csv +import json +import os +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from uuid import UUID + +import httpx + +from legal_mcp.services import db + + +# ── keys (local files, same pattern as halacha_panel_approve.py) ── + +def _env_key(name: str, *files: str) -> str: + for f in files: + p = Path(f).expanduser() + if p.exists(): + for line in p.read_text().splitlines(): + if line.startswith(name + "="): + return line.split("=", 1)[1].strip() + return os.environ.get(name, "") + + +DEEPSEEK_KEY = _env_key("DEEPSEEK_API_KEY", "~/.hermes/profiles/deepseek/.env", "~/.env") +GEMINI_KEY = _env_key("GOOGLE_GEMINI_API_KEY", "~/.env") or _env_key("GEMINI_API_KEY", "~/.env") + + +# ── the coarse question (the reliable axis — generalizable style, not substance) ── + +KEEP_SYSTEM = ( + "אתה עורך-לשון משפטי בכיר המנתח את סגנון-הכתיבה של יו\"ר ועדת ערר. בהינתן לקח-סגנון " + "שחולץ מהשוואת טיוטה לגרסה הסופית, הכרע אם הוא ראוי להישמר כהנחיית-סגנון בת-הכללה " + "לתיקים עתידיים. ראוי (keep=true) = תובנה מופשטת על קול/טון/מבנה/מקצב/ביטויי-מעבר/ניסוח " + "שניתן להחילה על תיקים אחרים. לא-ראוי (keep=false) = מהות משפטית ספציפית (הלכה, עובדה, " + "הכרעה תלוית-תיק), פרט חד-פעמי, או חזרה על תוכן ההחלטה ללא הפשטה סגנונית. " + 'החזר JSON בלבד: {"keep": true/false, "reason": "<משפט קצר>"}. ללא markdown.' +) + + +def _keep_user(change: dict) -> str: + desc = change.get("description") or "" + lesson = change.get("lesson") or "" + block = change.get("block_id") or "" + ctype = change.get("type") or "" + return ( + f"סוג השינוי: {ctype}\n" + f"בלוק: {block}\n\n" + f"תיאור השינוי:\n{desc}\n\n" + f"הלקח שהוצע:\n{lesson}" + ) + + +def _lesson_text(change: dict) -> str: + """The text we would persist as a decision_lesson — prefer the distilled lesson.""" + return (change.get("lesson") or change.get("description") or "").strip() + + +def _category(change: dict) -> str: + """Map a distilled change to a decision_lessons.category (style/structure/lexicon/...).""" + t = (change.get("type") or "").lower() + block = (change.get("block_id") or "").lower() + if "transition" in t or "ביטוי" in t or "מעבר" in t: + return "lexicon" + if "structure" in t or "מבנה" in t or "order" in t: + return "structure" + if "table" in t or "טבל" in t: + return "tabular" + return "style" + + +# ── two judges, one signature: (system, user) -> dict|None ── + +async def judge_deepseek(client: httpx.AsyncClient, system: str, user: str) -> dict | None: + if not DEEPSEEK_KEY: + return None + try: + r = await client.post( + "https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {DEEPSEEK_KEY}", "Content-Type": "application/json"}, + json={"model": "deepseek-chat", "temperature": 0, "max_tokens": 160, + "response_format": {"type": "json_object"}, + "messages": [{"role": "system", "content": system}, + {"role": "user", "content": user}]}, + timeout=90, + ) + r.raise_for_status() + return json.loads(r.json()["choices"][0]["message"]["content"]) + except Exception: + return None + + +async def judge_gemini(client: httpx.AsyncClient, system: str, user: str) -> dict | None: + if not GEMINI_KEY: + return None + try: + r = await client.post( + f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={GEMINI_KEY}", + headers={"Content-Type": "application/json"}, + json={"system_instruction": {"parts": [{"text": system}]}, + "contents": [{"parts": [{"text": user}]}], + "generationConfig": {"temperature": 0, "maxOutputTokens": 4000, + "responseMimeType": "application/json"}}, + timeout=90, + ) + r.raise_for_status() + return json.loads(r.json()["candidates"][0]["content"]["parts"][0]["text"]) + except Exception: + return None + + +def _bool(d: dict | None, key: str) -> bool | None: + if not isinstance(d, dict) or key not in d: + return None + v = d[key] + if isinstance(v, bool): + return v + return str(v).strip().lower() in ("true", "1", "yes", "כן") + + +async def panel_vote(client, system, user, key) -> dict: + """Run the two judges; return per-judge bools + the verdict.""" + ds, gm = await asyncio.gather( + judge_deepseek(client, system, user), + judge_gemini(client, system, user), + ) + votes = {"deepseek": _bool(ds, key), "gemini": _bool(gm, key)} + valid = [v for v in votes.values() if v is not None] + agree_yes = len(valid) == 2 and all(valid) + agree_no = len(valid) == 2 and not any(valid) + votes["_verdict"] = ("agree_yes" if agree_yes else + "agree_no" if agree_no else + "split" if len(valid) == 2 else "incomplete") + return votes + + +# ── inputs: pair (analysis.changes) + the case's style_corpus row ── + +def _as_dict(v): + """analysis/diff_stats come back from asyncpg jsonb as str — normalize to dict.""" + if isinstance(v, str): + try: + return json.loads(v) + except Exception: + return {} + return v or {} + + +async def _resolve_corpus_id(decision_number: str) -> str | None: + """The case's final must be in style_corpus for lessons to attach (FK).""" + pool = await db.get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT id FROM style_corpus WHERE decision_number = $1 " + "ORDER BY created_at DESC LIMIT 1", + decision_number, + ) + return str(row["id"]) if row else None + + +async def _load_pair(args) -> dict | None: + if args.pair_id: + return await db.get_draft_final_pair(UUID(args.pair_id)) + # by case → latest analyzed pair + pairs = await db.list_draft_final_pairs(limit=500) + matches = [p for p in pairs if p.get("case_number") == args.case] + if not matches: + return None + # list_draft_final_pairs has no analysis; re-fetch the full row + return await db.get_draft_final_pair(matches[0]["id"]) + + +async def main(args: argparse.Namespace) -> int: + print(f"judges available — deepseek:{bool(DEEPSEEK_KEY)} gemini:{bool(GEMINI_KEY)}\n", + flush=True) + if not (DEEPSEEK_KEY and GEMINI_KEY): + print("⚠ both DeepSeek and Gemini keys are required for the 2/2 panel.", flush=True) + + pair = await _load_pair(args) + if not pair: + print(f"no draft_final_pair found for {args.pair_id or args.case}", flush=True) + return 1 + if pair.get("status") != "analyzed" or not pair.get("analysis"): + print(f"pair {pair['id']} not analyzed yet (status={pair.get('status')}). " + f"Run ingest_final_version first.", flush=True) + return 1 + + analysis = _as_dict(pair["analysis"]) + changes = analysis.get("changes") or [] + case_number = pair.get("case_number") or args.case or "" + if args.limit: + changes = changes[: args.limit] + + # INV-LRN5: substance never enters the voice layer. + substance = [c for c in changes if (c.get("domain") or "").lower() == "substance"] + style_changes = [c for c in changes if (c.get("domain") or "").lower() != "substance"] + print(f"pair {pair['id']} ({case_number}): {len(changes)} changes " + f"→ {len(style_changes)} style / {len(substance)} substance(skipped)\n", flush=True) + + sem = asyncio.Semaphore(args.concurrency) + results: list[dict] = [] + async with httpx.AsyncClient() as client: + async def run(ch): + async with sem: + v = await panel_vote(client, KEEP_SYSTEM, _keep_user(ch), "keep") + v["_change"] = ch + results.append(v) + + tasks = [run(c) for c in style_changes] + for i in range(0, len(tasks), args.concurrency): + await asyncio.gather(*tasks[i : i + args.concurrency]) + print(f" …{len(results)}/{len(tasks)} judged", flush=True) + + cc = Counter(r["_verdict"] for r in results) + print("\n" + "=" * 60) + print(f"STYLE-LESSON PANEL {'(APPLY)' if args.apply else '(DRY-RUN — no DB writes)'}") + print("=" * 60) + print(f" ✓ keep (2/2): {cc['agree_yes']}") + print(f" ✗ drop (2/2): {cc['agree_no']}") + print(f" → CHAIR (split): {cc['split']}") + print(f" ? incomplete: {cc['incomplete']}") + print(f" ⊘ substance skipped: {len(substance)}") + + report = [{"verdict": r["_verdict"], "deepseek": r["deepseek"], "gemini": r["gemini"], + "category": _category(r["_change"]), "lesson": _lesson_text(r["_change"])[:200]} + for r in results] + Path("/tmp/style_lesson_panel.json").write_text( + json.dumps(report, ensure_ascii=False, indent=1)) + print("\nper-lesson verdicts → /tmp/style_lesson_panel.json") + + if not args.apply: + print("\n(dry-run — pass --apply to write keep-lessons as decision_lesson proposals)") + return 0 + + # ── apply: write 2/2-keep as decision_lesson proposals (reversible) ── + corpus_id = await _resolve_corpus_id(case_number) + if not corpus_id: + print(f"\n✗ no style_corpus row for decision_number={case_number!r}; cannot attach " + f"lessons. Add the final to the style corpus first (document_upload_training).") + return 1 + + keeps = [r for r in results if r["_verdict"] == "agree_yes" and _lesson_text(r["_change"])] + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + audit = Path(__file__).resolve().parent.parent / "data" / "audit" + audit.mkdir(parents=True, exist_ok=True) + backup = audit / f"style-panel-apply-{case_number}-{ts}.csv" + with backup.open("w", encoding="utf-8", newline="") as f: + w = csv.writer(f) + w.writerow(["corpus_id", "category", "source", "lesson_text"]) + for r in keeps: + w.writerow([corpus_id, _category(r["_change"]), "panel:deepseek+gemini", + _lesson_text(r["_change"])]) + + written = 0 + for r in keeps: + await db.add_decision_lesson( + UUID(corpus_id), + lesson_text=_lesson_text(r["_change"]), + category=_category(r["_change"]), + source="panel:deepseek+gemini", + created_by="panel", + ) + written += 1 + + chair = cc["split"] + cc["incomplete"] + print(f"\nAPPLIED (reversible): wrote {written} decision_lesson proposals " + f"(source=panel:deepseek+gemini) · {chair} escalated to chair · " + f"{len(substance)} substance skipped") + print(f"backup → {backup}") + print("NB: fold into SKILL.md / legal-decision-lessons.md stays a manual chair gate (INV-G10).") + return 0 + + +if __name__ == "__main__": + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--case", help="case_number — uses its latest analyzed draft_final_pair") + g.add_argument("--pair-id", dest="pair_id", help="explicit draft_final_pairs.id") + ap.add_argument("--apply", action="store_true", help="write keep-lessons (default: dry-run)") + ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--concurrency", type=int, default=4) + raise SystemExit(asyncio.run(main(ap.parse_args()))) diff --git a/web-ui/src/components/cases/drafts-panel.tsx b/web-ui/src/components/cases/drafts-panel.tsx index 33d143d..c74b053 100644 --- a/web-ui/src/components/cases/drafts-panel.tsx +++ b/web-ui/src/components/cases/drafts-panel.tsx @@ -20,6 +20,9 @@ import { useMarkFinal, useDeleteDraft, useActiveDraft, + useUploadFinalDecision, + useRunFinalLearning, + useRunFinalHalacha, } from "@/lib/api/exports"; import { useCaseFeedback, @@ -41,6 +44,9 @@ import { FileOutput, Plus, Trash2, + Brain, + Scale, + Stamp, } from "lucide-react"; /* Statuses at which a draft is considered ready */ @@ -86,13 +92,22 @@ export function DraftsPanel({ const markFinal = useMarkFinal(caseNumber); const deleteDraft = useDeleteDraft(caseNumber); const resolveMutation = useResolveFeedback(); + const uploadFinal = useUploadFinalDecision(caseNumber); + const runLearning = useRunFinalLearning(caseNumber); + const runHalacha = useRunFinalHalacha(caseNumber); const fileRef = useRef(null); + const finalFileRef = useRef(null); const [deleteTarget, setDeleteTarget] = useState(null); const isDraftReady = status && DRAFT_READY.includes(status); const openFeedbacks = feedbacks?.filter((f) => !f.resolved) ?? []; + // The chair's signed final exists once a "סופי-" file is present (or any is_final). + const hasFinal = Boolean( + exports?.some((f) => f.is_final || f.filename.startsWith("סופי-")), + ); + // Determine draft label based on *actual* v-numbers in filenames (not counts). // "(מתוקנת)" suffix appears when there's at least one עריכה-* file. const draftLabel = (() => { @@ -145,6 +160,38 @@ export function DraftsPanel({ }); } + function handleUploadFinal(file: File) { + uploadFinal.mutate(file, { + onSuccess: (data) => { + toast.success( + `ההחלטה הסופית נקלטה — ${data.final_words} מילים (לעומת ${data.draft_words} בטיוטה)`, + ); + }, + onError: (err) => + toast.error(err instanceof Error ? err.message : "שגיאה בהעלאה"), + }); + } + + function handleRunLearning() { + runLearning.mutate(undefined, { + onSuccess: (d) => + d.status === "ok" + ? toast.success("למידת-הקול הופעלה — רצה ברקע (אופוס + פאנל דיפסיק/גמיני)") + : toast.warning(`לא הופעלה למידה: ${d.reason ?? d.error ?? d.status}`), + onError: () => toast.error("שגיאה בהפעלת למידת-הקול"), + }); + } + + function handleRunHalacha() { + runHalacha.mutate(undefined, { + onSuccess: (d) => + d.status === "ok" + ? toast.success("אימות-ההלכות הופעל — רץ ברקע (פאנל אופוס/דיפסיק/גמיני)") + : toast.warning(`לא הופעל אימות: ${d.reason ?? d.error ?? d.status}`), + onError: () => toast.error("שגיאה בהפעלת אימות-ההלכות"), + }); + } + function handleMarkFinal(filename: string) { markFinal.mutate(filename, { onSuccess: () => toast.success("סומן כסופי"), @@ -200,6 +247,86 @@ export function DraftsPanel({ )} + {/* ── Chair's signed final decision — clean upload + staged learning pipeline ── */} +
+ { + const f = e.target.files?.[0]; + if (f) handleUploadFinal(f); + if (finalFileRef.current) finalFileRef.current.value = ""; + }} + /> +
+
+ +

החלטה סופית של היו״ר

+ {hasFinal && ( + + נקלטה + + )} +
+ +
+

+ העלאת ההחלטה החתומה של דפנה (נבדל מ״העלה גרסה מתוקנת״). הקליטה פותחת השוואת + טיוטה↔סופי; לאחר מכן הפעל את שני השלבים האוטומטיים — הכל רץ ברקע, ורק מחלוקות + בין הסוכנים מוסלמות אליך. +

+ {hasFinal && ( +
+ + + + סטטוס הריצה — בדף{" "} + + התפעול + + +
+ )} +
+ {/* ── Exports list ── */}
diff --git a/web-ui/src/components/training/lessons-tab.tsx b/web-ui/src/components/training/lessons-tab.tsx index ba469f6..cc2ac88 100644 --- a/web-ui/src/components/training/lessons-tab.tsx +++ b/web-ui/src/components/training/lessons-tab.tsx @@ -44,13 +44,18 @@ const CATEGORIES = [ { value: "tabular", label: "טבלאי" }, ] as const; -const SOURCE_BADGE: Record = { +// All labels in Hebrew. Keyed loosely (string) so new sources — e.g. the +// two-judge style panel — render correctly with a graceful fallback. +const SOURCE_BADGE: Record = { manual: { label: "ידני", cls: "bg-rule-soft text-ink-soft" }, chair: { label: "יו״ר", cls: "bg-gold-wash text-gold-deep" }, - curator: { label: "Curator", cls: "bg-info-bg text-info" }, - style_analyzer: { label: "Analyzer", cls: "bg-success-bg text-success" }, + curator: { label: "הרמס (סקירה)", cls: "bg-info-bg text-info" }, + style_analyzer: { label: "מנתח-סגנון", cls: "bg-success-bg text-success" }, + "panel:deepseek+gemini": { label: "פאנל: דיפסיק+גמיני", cls: "bg-gold-wash text-gold-deep" }, }; +const SOURCE_BADGE_FALLBACK = { label: "פאנל", cls: "bg-rule-soft text-ink-soft" }; + export function LessonsTab({ corpusId }: { corpusId: string }) { const { data, isPending } = useCorpusLessons(corpusId); const add = useAddLesson(corpusId); @@ -147,7 +152,7 @@ function LessonItem({ const patch = usePatchLesson(corpusId); const del = useDeleteLesson(corpusId); - const sourceBadge = SOURCE_BADGE[lesson.source]; + const sourceBadge = SOURCE_BADGE[lesson.source] ?? SOURCE_BADGE_FALLBACK; const dirty = text !== lesson.lesson_text || category !== lesson.category; const onSave = async () => { diff --git a/web-ui/src/lib/api/exports.ts b/web-ui/src/lib/api/exports.ts index d6b3cf3..61aec70 100644 --- a/web-ui/src/lib/api/exports.ts +++ b/web-ui/src/lib/api/exports.ts @@ -181,6 +181,73 @@ export function useDeleteDraft(caseNumber: string) { }); } +/* ── Chair's signed final decision — clean upload path + staged pipeline ── */ + +export type FinalUploadResult = { + final_filename: string; + training_copy: string; + pair_id: string | null; + draft_words: number; + final_words: number; + status: string; +}; + +export type FinalTaskResult = { + status: string; + sub_issue_id?: string; + curator_id?: string; + reason?: string; + error?: string; +}; + +/** Upload Dafna's signed final decision (distinct from "upload a revised draft"). */ +export function useUploadFinalDecision(caseNumber: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (file: File): Promise => { + const form = new FormData(); + form.append("file", file); + const res = await fetch(`/api/cases/${caseNumber}/final/upload`, { + method: "POST", + body: form, + }); + if (!res.ok) { + const err = await res + .json() + .catch(() => ({ detail: "שגיאה בהעלאת ההחלטה הסופית" })); + throw new Error(err.detail ?? "שגיאה בהעלאת ההחלטה הסופית"); + } + return res.json() as Promise; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) }); + qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) }); + }, + }); +} + +/** Staged step 1 — voice learning (Opus distillation + DeepSeek+Gemini style panel). */ +export function useRunFinalLearning(caseNumber: string) { + return useMutation({ + mutationFn: () => + apiRequest( + `/api/cases/${caseNumber}/final/run-learning`, + { method: "POST" }, + ), + }); +} + +/** Staged step 2 — halacha validation (cited-halacha extraction + 3-judge panel). */ +export function useRunFinalHalacha(caseNumber: string) { + return useMutation({ + mutationFn: () => + apiRequest( + `/api/cases/${caseNumber}/final/run-halacha`, + { method: "POST" }, + ), + }); +} + export function useMarkFinal(caseNumber: string) { const qc = useQueryClient(); return useMutation({ diff --git a/web-ui/src/lib/api/training.ts b/web-ui/src/lib/api/training.ts index b0cc7f2..af94cc2 100644 --- a/web-ui/src/lib/api/training.ts +++ b/web-ui/src/lib/api/training.ts @@ -469,7 +469,9 @@ export type DecisionLesson = { style_corpus_id: string; lesson_text: string; category: "style" | "structure" | "lexicon" | "tabular" | "general"; - source: "manual" | "curator" | "chair" | "style_analyzer"; + // "panel:deepseek+gemini" — the two-judge style panel; (string) keeps it open + // to future panel sources without a type break. + source: "manual" | "curator" | "chair" | "style_analyzer" | (string & {}); applied_to_skill: boolean; created_by: string; created_at: string; diff --git a/web/app.py b/web/app.py index 2c63921..9c5b3c8 100644 --- a/web/app.py +++ b/web/app.py @@ -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. diff --git a/web/paperclip_client.py b/web/paperclip_client.py index eaf19b5..9a1de21 100644 --- a/web/paperclip_client.py +++ b/web/paperclip_client.py @@ -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",