feat(learning): מסלול נקי להעלאת החלטה סופית + פאנל-סגנון דו-סוכני (DeepSeek+Gemini) #158

Merged
chaim merged 1 commits from worktree-final-upload-pipeline into main 2026-06-08 09:04:16 +00:00
10 changed files with 726 additions and 25 deletions

View File

@@ -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. שלוש לולאות-המשנה

View File

@@ -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>` קורא את ה-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 <num>` / `--pair-id <uuid>`. | שלב-למידה במסלול-הסופי |
| `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 של פיצול-הסמכות |

View File

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

View File

@@ -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 <uuid> --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())))

View File

@@ -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<HTMLInputElement>(null);
const finalFileRef = useRef<HTMLInputElement>(null);
const [deleteTarget, setDeleteTarget] = useState<string | null>(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({
</div>
)}
{/* ── Chair's signed final decision — clean upload + staged learning pipeline ── */}
<section className="rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
<input
ref={finalFileRef}
type="file"
accept=".docx"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleUploadFinal(f);
if (finalFileRef.current) finalFileRef.current.value = "";
}}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Stamp className="w-5 h-5 text-gold-deep shrink-0" />
<h3 className="text-navy text-base">החלטה סופית של היו״ר</h3>
{hasFinal && (
<Badge className="bg-success-bg text-success border-success/40 text-[0.65rem]">
נקלטה
</Badge>
)}
</div>
<Button
size="sm"
variant="outline"
onClick={() => finalFileRef.current?.click()}
disabled={uploadFinal.isPending}
>
{uploadFinal.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<Upload className="w-4 h-4 me-1.5" />
)}
{hasFinal ? "החלף החלטה סופית" : "העלאת החלטה סופית של היו״ר"}
</Button>
</div>
<p className="text-xs text-ink-muted leading-relaxed">
העלאת ההחלטה החתומה של דפנה (נבדל מ״העלה גרסה מתוקנת״). הקליטה פותחת השוואת
טיוטהסופי; לאחר מכן הפעל את שני השלבים האוטומטיים הכל רץ ברקע, ורק מחלוקות
בין הסוכנים מוסלמות אליך.
</p>
{hasFinal && (
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button
size="sm"
onClick={handleRunLearning}
disabled={runLearning.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{runLearning.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<Brain className="w-4 h-4 me-1.5" />
)}
הרץ למידת-קול
</Button>
<Button
size="sm"
onClick={handleRunHalacha}
disabled={runHalacha.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{runHalacha.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<Scale className="w-4 h-4 me-1.5" />
)}
הרץ אימות-הלכות
</Button>
<span className="text-[0.7rem] text-ink-muted">
סטטוס הריצה בדף{" "}
<a href="/operations" className="underline">
התפעול
</a>
</span>
</div>
)}
</section>
{/* ── Exports list ── */}
<section>
<div className="flex items-center justify-between mb-3">

View File

@@ -44,13 +44,18 @@ const CATEGORIES = [
{ value: "tabular", label: "טבלאי" },
] as const;
const SOURCE_BADGE: Record<DecisionLesson["source"], { label: string; cls: string }> = {
// 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<string, { label: string; cls: string }> = {
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 () => {

View File

@@ -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<FinalUploadResult> => {
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<FinalUploadResult>;
},
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<FinalTaskResult>(
`/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<FinalTaskResult>(
`/api/cases/${caseNumber}/final/run-halacha`,
{ method: "POST" },
),
});
}
export function useMarkFinal(caseNumber: string) {
const qc = useQueryClient();
return useMutation({

View File

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

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