feat(learning): מסלול נקי להעלאת החלטה סופית + פאנל-סגנון דו-סוכני (DeepSeek+Gemini) #158
@@ -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. שלוש לולאות-המשנה
|
||||
|
||||
@@ -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 של פיצול-הסמכות |
|
||||
|
||||
@@ -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
|
||||
|
||||
324
scripts/style_lesson_panel.py
Normal file
324
scripts/style_lesson_panel.py
Normal 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())))
|
||||
@@ -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">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
122
web/app.py
122
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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user