From 4cad17df3afa3a0eec7b854839e5a095c7c8f5b4 Mon Sep 17 00:00:00 2001 From: Chaim Date: Fri, 12 Jun 2026 06:59:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(learning):=20FU-4=20=E2=80=94=20=D7=96?= =?UTF-8?q?=D7=99=D7=A7=D7=95=D7=A7-=D7=A8=D7=95=D7=91=D7=A8=D7=99=D7=A7?= =?UTF-8?q?=D7=94=20propose-only=20=D7=9E=D7=94=D7=9B=D7=A8=D7=A2=D7=95?= =?UTF-8?q?=D7=AA-=D7=94=D7=99=D7=95"=D7=A8=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit job תקופתי שסוגר את לולאת-הלמידה: מצליב את סבבי-הפאנל (FU-1, הצבעות+ נימוקים) מול הכרעות-היו"ר (FU-2 seeds), מזהה כשלים שיטתיים, ומציע KEEP_SYSTEM v2 + exemplars מופשטים — כדוח-diff לעיון-היו"ר. לעולם לא auto-applied. - db.panel_rounds_vs_chair() — read-only LATERAL join: לכל הלכה עם seed chair-live (FU-2, אמת אנושית) + סבב-פאנל אחרון (FU-1) → הצבעות+נימוקי- 3-השופטים מול keep/drop של היו"ר. הסיגנל היחיד = הכרעת-יו"ר, לא הצבעות-הפאנל (anti-echo-chamber, INV-LRN1). - scripts/halacha_rubric_distill.py: • analyze_pairs() — ליבה דטרמיניסטית טהורה (offline-testable): false-keep (פאנל שמר, יו"ר דחה), false-drop, פיצולים-שהוכרעו, שיעור-מחלוקת-עם- היו"ר לכל שופט; בוחר ראיות-מחלוקת מכוסות. • הצעת-LLM מקומית (claude_session, tools="", אפס עלות): מזהה דפוסי-כשל ומציע נוסח-רובריקה v2 + exemplars מופשטים (INV-LRN5 — בלי מהות-תיק). • כותב data/learning/rubric-proposal-.md עם diff(KEEP_SYSTEM→v2); אף שורת-קוד לא משתנה. אימוץ = עריכה ידנית דרך PR (INV-LRN1). • <12 זוגות → "אין מספיק נתונים" (מצב נוכחי: seeds עדיין מצטברים). • --no-llm (סטטיסטיקה בלבד) / --limit N. - tests/test_rubric_distill.py — 8 בדיקות offline על analyze_pairs. - SCRIPTS.md עודכן. smoke read-only עבר (0 זוגות → insufficient-data). תואם הדפוס הקיים (style_lesson_panel/halacha_panel_audit): פאנל מציע, הטמעה נשארת שער-יו"ר ידני. Invariants: INV-LRN1 (propose-only) · INV-LRN5 (טוהר-רובריקה) · INV-G10 · anti-echo-chamber. בלי שער/UI חדש. Co-Authored-By: Claude Opus 4.8 (1M context) --- mcp-server/src/legal_mcp/services/db.py | 44 +++++ mcp-server/tests/test_rubric_distill.py | 87 +++++++++ scripts/SCRIPTS.md | 1 + scripts/halacha_rubric_distill.py | 227 ++++++++++++++++++++++++ 4 files changed, 359 insertions(+) create mode 100644 mcp-server/tests/test_rubric_distill.py create mode 100644 scripts/halacha_rubric_distill.py diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 5755df4..26a2aba 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -5237,6 +5237,50 @@ async def seed_goldset_from_chair( return False +async def panel_rounds_vs_chair(limit: int = 2000) -> list[dict]: + """Read-only join for rubric distillation (#133 / FU-4). + + For every halacha that has BOTH a chair-live gold-set seed (FU-2 — the + human keep/drop ground-truth) AND at least one captured panel round (FU-1), + return the LATEST round's per-judge votes+reasons+verdict next to the + chair's label and the halacha text. These (panel ⋈ chair) pairs are the + ONLY signal the distillation may learn from — human ground-truth, never the + panel's own votes (echo-chamber guard, INV-LRN1). Purely analytical: reads + capture tables, writes nothing.""" + pool = await get_pool() + rows = await pool.fetch( + """ + SELECT g.halacha_id::text AS halacha_id, + g.is_holding AS chair_keep, g.tagged_at AS chair_at, + h.rule_statement, h.reasoning_summary, h.supporting_quote, + h.rule_type, h.quality_flags, + pr.question, pr.verdict, pr.applied_action, pr.round_ts, + pr.claude_vote, pr.claude_reason, + pr.deepseek_vote, pr.deepseek_reason, + pr.gemini_vote, pr.gemini_reason + FROM halacha_goldset g + JOIN halachot h ON h.id = g.halacha_id + JOIN LATERAL ( + SELECT * FROM halacha_panel_rounds r + WHERE r.halacha_id = g.halacha_id + ORDER BY r.round_ts DESC LIMIT 1 + ) pr ON true + WHERE g.batch = 'chair-live' AND g.is_holding IS NOT NULL + ORDER BY g.tagged_at DESC NULLS LAST + LIMIT $1 + """, + limit, + ) + out = [] + for r in rows: + d = dict(r) + for k in ("chair_at", "round_ts"): + if d.get(k) is not None: + d[k] = d[k].isoformat() + out.append(d) + return out + + async def goldset_tag( goldset_id: UUID, *, is_holding: bool | None = None, correct_type: str | None = None, quote_complete: bool | None = None, diff --git a/mcp-server/tests/test_rubric_distill.py b/mcp-server/tests/test_rubric_distill.py new file mode 100644 index 0000000..700190e --- /dev/null +++ b/mcp-server/tests/test_rubric_distill.py @@ -0,0 +1,87 @@ +"""Tests for #133 / FU-4 — rubric distillation from chair decisions. + +Covers the PURE deterministic core (analyze_pairs): given (panel ⋈ chair) pairs +it must correctly classify the systematic-failure buckets — false-keep (panel +auto-kept, chair dropped), false-drop, chair-resolved splits — and the per-judge +disagreement-with-chair rate. Fully OFFLINE (no DB, no LLM). The LLM proposal +and the report rendering are exercised by the integration smoke run. + +The invariant this locks down: the only label compared against is the chair's +human ruling — never the panel's own votes (echo-chamber guard, INV-LRN1). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# the script lives in ../scripts relative to mcp-server/ +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts")) +import halacha_rubric_distill as rd # noqa: E402 + + +def _pair(chair, verdict, action, c, ds, gm, rule="כלל כלשהו"): + return { + "chair_keep": chair, "verdict": verdict, "applied_action": action, + "rule_statement": rule, + "claude_vote": c, "claude_reason": "rc", + "deepseek_vote": ds, "deepseek_reason": "rd", + "gemini_vote": gm, "gemini_reason": "rg", + } + + +def test_false_keep_detected(): + """Panel auto-approved but the chair dropped → a false-keep (the costly error).""" + a = rd.analyze_pairs([_pair(False, "unanimous_yes", "approved", True, True, True)]) + assert a["n_false_keep"] == 1 + assert a["n_false_drop"] == 0 + + +def test_nli_cleared_counts_as_keep(): + a = rd.analyze_pairs([_pair(False, "unanimous_yes", "nli_cleared", True, True, True)]) + assert a["n_false_keep"] == 1 + + +def test_false_drop_detected(): + a = rd.analyze_pairs([_pair(True, "unanimous_no", "rejected", False, False, False)]) + assert a["n_false_drop"] == 1 + assert a["n_false_keep"] == 0 + + +def test_split_resolved_counted_not_a_false_decision(): + """A split escalates to the chair — no auto-decision, so it is neither a + false-keep nor a false-drop, but it IS a resolved split (learning signal).""" + a = rd.analyze_pairs([_pair(True, "split", "chair", True, False, None)]) + assert a["n_splits_resolved"] == 1 + assert a["n_false_keep"] == 0 and a["n_false_drop"] == 0 + + +def test_judge_disagreement_rate_vs_chair(): + """Disagreement is measured against the chair, never against the other judges.""" + pairs = [ + _pair(True, "split", "chair", True, False, None), # claude agree, deepseek disagree + _pair(True, "split", "chair", True, False, True), # claude agree, deepseek disagree, gemini agree + ] + a = rd.analyze_pairs(pairs) + assert a["judge_stats"]["claude"]["disagree_rate"] == 0.0 + assert a["judge_stats"]["deepseek"]["disagree_rate"] == 1.0 + # gemini voted once (agree) and abstained once → rate 0.0 over 1 vote + assert a["judge_stats"]["gemini"]["voted"] == 1 + assert a["judge_stats"]["gemini"]["disagree_rate"] == 0.0 + + +def test_none_chair_label_ignored(): + a = rd.analyze_pairs([_pair(None, "split", "chair", True, False, None)]) + assert a["n_pairs"] == 1 # counted in total … + assert a["judge_stats"]["claude"]["voted"] == 0 # … but contributes no signal + + +def test_evidence_capped(): + pairs = [_pair(False, "unanimous_yes", "approved", True, True, True) for _ in range(40)] + a = rd.analyze_pairs(pairs) + assert a["n_false_keep"] == 40 + assert len(a["evidence"]) == rd.MAX_EVIDENCE + + +def test_min_pairs_threshold_is_sane(): + assert rd.MIN_PAIRS >= 1 diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index a6879a6..19c9e0b 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -63,6 +63,7 @@ | `curator_apply_pipeline_branch.py` | python | **מקור-אמת לחיווט-הכפתורים של הרמס.** prompt-ה-curator חי רק ב-Paperclip DB (`agents.adapter_config.promptTemplate`). הסקריפט מקדים branch כך שיקיצה עם reason `final_learning_*`/`final_halacha_*` מריצה את ה-pipeline המתאים (HOME/DOTENV/DATA_DIR מוחלטים → DeepSeek+Gemini keys + DATA_DIR נפתרים נכון) ועוצרת, אחרת §A/§B כרגיל. idempotent (מסיר branch קודם). מחיל על שני הסוכנים (CMP+CMPA). `--verify`. **להריץ אחרי reset/יצירה-מחדש של סוכן-curator.** | אחרי reset prompt של curator | | `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_rubric_distill.py` | python | **#133/FU-4 — זיקוק-רובריקה PROPOSE-ONLY.** מצליב `halacha_panel_rounds` (FU-1, הצבעות+נימוקים) מול הכרעות-היו"ר (FU-2, seeds ב-`halacha_goldset` batch `chair-live`) דרך `db.panel_rounds_vs_chair` (read-only), מנתח דטרמיניסטית **כשלים שיטתיים** (false-keep/false-drop, פיצולים-שהוכרעו, שיעור-מחלוקת-עם-היו"ר לכל שופט), ומציע `KEEP_SYSTEM` v2 + exemplars מופשטים (claude_session מקומי, אפס עלות) כ**דוח-diff** ל-`data/learning/rubric-proposal-.md`. **לעולם לא auto-apply** — אימוץ v2 = עריכה אנושית של הקבוע דרך PR (INV-LRN1); exemplars מופשטים בלבד (INV-LRN5); הסיגנל היחיד = הכרעת-יו"ר, לא הצבעות-פאנל (anti-echo). מתחת ל-12 זוגות → "אין מספיק נתונים". `--no-llm` (סטטיסטיקה בלבד) / `--limit N`. **חובה מקומי**. | תקופתי — אחרי שהצטברו הכרעות-יו"ר על מחלוקות-פאנל | | `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 של פיצול-הסמכות | | `halacha_batch_reconcile.py` | python | **#82.7** — dedup חוצה-פסקים offline (שמרני, **dry-run בלבד**). dedup-on-insert משווה רק תוך-פסק; כאן סף מחמיר (cosine ≥0.95, `--cosine`) ולא-הרסני: מאתר זוגות הלכות near-duplicate בין פסקים שונים (pgvector `<=>` exact) עם איתות לקסיקלי (Jaccard/Levenshtein) ומדווח ל-CSV ב-`data/audit/` לסקירת היו"ר. לא מדלג/ממזג/מוחק. `--include-pending`. **`--link`** רושם את הזוגות שנמצאו כ-`equivalent_halachot` (parallel authority, #84.2 — קישור-מקביל ברמת-הלכה, **לא** ציטוט; idempotent, לא-הרסני). רץ עם venv של mcp-server. אומת: 800 הלכות → 5 זוגות (קושרו). | ידני — דוח-סקירה / `--link` לקישור | | `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest `. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) | diff --git a/scripts/halacha_rubric_distill.py b/scripts/halacha_rubric_distill.py new file mode 100644 index 0000000..89b8cf9 --- /dev/null +++ b/scripts/halacha_rubric_distill.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Distil a better panel rubric from the chair's decisions — PROPOSE-ONLY (#133/FU-4). + +The 3-judge KEEP panel (halacha_panel_approve.py) escalates every split to the +chair. FU-1 captured each round's votes+reasons; FU-2 captured the chair's +keep/drop ruling as a gold seed. This job joins the two — (panel ⋈ chair) — and +mines SYSTEMATIC failures: a judge that disagrees with the chair on an axis, a +recurring split the chair resolves the same way (e.g. obiter↔interpretive). It +then proposes a refined ``KEEP_SYSTEM`` v2 + abstract few-shot exemplars, written +as a DIFF report for the chair to review. + +CRITICAL — this is the ACTIVE-LEARNING signal, not an echo chamber: + - The only ground-truth is the chair's human ruling (db.panel_rounds_vs_chair + reads the chair-live gold seeds, never the panel's own votes). + - The proposal is NEVER auto-applied (INV-LRN1). KEEP_SYSTEM lives in code; + adopting v2 is a human edit through a normal PR. This script writes a report + to data/learning/ and touches nothing else. + - Exemplars stay ABSTRACT patterns, never copied case holdings (INV-LRN5). + + cd ~/legal-ai/mcp-server + .venv/bin/python ../scripts/halacha_rubric_distill.py # propose + .venv/bin/python ../scripts/halacha_rubric_distill.py --no-llm # stats only +""" +from __future__ import annotations + +import argparse +import asyncio +import difflib +import json +from datetime import datetime, timezone +from pathlib import Path + +from legal_mcp.services import claude_session, db +# single source of truth for the rubric under refinement +from halacha_panel_approve import KEEP_SYSTEM # noqa: E402 + +# Below this many chair-resolved pairs the patterns are noise — report and stop. +MIN_PAIRS = 12 +# Cap evidence shipped to the model / report (keep the prompt + report tight). +MAX_EVIDENCE = 24 + +_JUDGES = ("claude", "deepseek", "gemini") + + +def analyze_pairs(pairs: list[dict]) -> dict: + """Pure, deterministic mining of the (panel ⋈ chair) pairs — no DB, no LLM. + + Each pair carries the chair's keep/drop (``chair_keep``), the panel verdict + + applied action, and each judge's vote+reason. Returns the systematic- + failure metrics and a capped bundle of disagreement evidence for the model. + """ + n = len(pairs) + judge_stats = {j: {"voted": 0, "agree": 0, "disagree": 0} for j in _JUDGES} + false_keep: list[dict] = [] # panel auto-KEPT, chair DROPPED + false_drop: list[dict] = [] # panel auto-DROPPED, chair KEPT + splits_resolved: list[dict] = [] + for p in pairs: + chair = p.get("chair_keep") + if chair is None: + continue + for j in _JUDGES: + v = p.get(f"{j}_vote") + if v is None: + continue + judge_stats[j]["voted"] += 1 + judge_stats[j]["agree" if bool(v) == bool(chair) else "disagree"] += 1 + action = (p.get("applied_action") or "").strip() + verdict = (p.get("verdict") or "").strip() + ev = { + "rule_statement": p.get("rule_statement") or "", + "verdict": verdict, + "applied_action": action, + "chair_keep": bool(chair), + "reasons": {j: p.get(f"{j}_reason") or "" for j in _JUDGES}, + "votes": {j: p.get(f"{j}_vote") for j in _JUDGES}, + } + # Panel acted automatically (kept) but the chair disagreed → dangerous. + if action in ("approved", "nli_cleared") and chair is False: + false_keep.append(ev) + elif action == "rejected" and chair is True: + false_drop.append(ev) + if verdict in ("split", "incomplete"): + splits_resolved.append(ev) + + for j in _JUDGES: + s = judge_stats[j] + s["disagree_rate"] = round(s["disagree"] / s["voted"], 3) if s["voted"] else None + + # Evidence the model needs to see: every false auto-decision (highest value) + # then chair-resolved splits, capped. + evidence = (false_keep + false_drop + splits_resolved)[:MAX_EVIDENCE] + return { + "n_pairs": n, + "judge_stats": judge_stats, + "n_false_keep": len(false_keep), + "n_false_drop": len(false_drop), + "n_splits_resolved": len(splits_resolved), + "evidence": evidence, + } + + +def _proposal_prompt(analysis: dict) -> str: + """Build the model prompt: current rubric + failure evidence → v2 proposal.""" + ev_lines = [] + for i, e in enumerate(analysis["evidence"], 1): + votes = ", ".join(f"{j}={e['votes'][j]}" for j in _JUDGES) + reasons = " | ".join(f"{j}: {e['reasons'][j]}" for j in _JUDGES if e["reasons"][j]) + ev_lines.append( + f"{i}. הכרעת-יו\"ר: {'שמירה' if e['chair_keep'] else 'דחייה'} | " + f"ורדיקט-פאנל: {e['verdict']} ({e['applied_action'] or 'הוסלם'}) | " + f"הצבעות: {votes}\n כלל: {e['rule_statement'][:200]}\n נימוקי-שופטים: {reasons}" + ) + evidence_block = "\n".join(ev_lines) or "(אין מספיק ראיות-מחלוקת)" + return ( + "להלן רובריקת-ההכרעה הנוכחית של פאנל-שופטים שמסווג 'הלכות' שחולצו מפסיקה " + "כראויות-לשמירה (keep) או לא. מצורפים מקרים שבהם השופטים נחלקו או טעו ביחס " + "להכרעת-היו\"ר (האמת היחידה). זהה את **דפוסי-הכשל השיטתיים** והצע שיפור מינימלי " + "לרובריקה.\n\n" + f"## הרובריקה הנוכחית (KEEP_SYSTEM)\n{KEEP_SYSTEM}\n\n" + f"## סטטיסטיקת-כשל\n" + f"זוגות: {analysis['n_pairs']} | false-keep: {analysis['n_false_keep']} | " + f"false-drop: {analysis['n_false_drop']} | פיצולים-שהוכרעו: {analysis['n_splits_resolved']}\n" + f"שיעור-מחלוקת-עם-היו\"ר לכל שופט: " + + ", ".join(f"{j}={analysis['judge_stats'][j]['disagree_rate']}" for j in _JUDGES) + + f"\n\n## ראיות-מחלוקת\n{evidence_block}\n\n" + "החזר JSON בלבד (ללא markdown) בסכמה:\n" + '{"patterns": ["<דפוס-כשל שיטתי 1>", ...], ' + '"keep_system_v2": "<נוסח מלא מוצע לרובריקה — מופשט, בר-הכללה, בלי מהות-תיק>", ' + '"exemplars": [{"pattern":"<תבנית מופשטת>","label":"keep|drop","why":"<קצר>"}]}\n' + "אזהרה: ה-exemplars והנוסח חייבים להיות **מופשטים** — אסור להעתיק ניסוח-כלל " + "ספציפי או מהות-תיק (INV-LRN5). אם הראיות לא מספיקות לדפוס ברור — החזר " + '{"patterns": [], "keep_system_v2": "", "exemplars": []}.' + ) + + +def _render_report(analysis: dict, proposal: dict | None, ts: str) -> str: + js = analysis["judge_stats"] + lines = [ + f"# הצעת-זיקוק לרובריקת-הפאנל (FU-4) — {ts}", + "", + "> **PROPOSE-ONLY (INV-LRN1).** המסמך הזה הוא הצעה לעיון-היו\"ר בלבד. " + "`KEEP_SYSTEM` חי בקוד (`scripts/halacha_panel_approve.py`); אימוץ v2 = " + "עריכה אנושית דרך PR רגיל. אף שורת-קוד לא שונתה אוטומטית.", + "> הסיגנל היחיד = הכרעת-היו\"ר על מחלוקות-הפאנל (לא הצבעות-הפאנל — echo-chamber).", + "", + "## סטטיסטיקת-כשל", + "", + "| מדד | ערך |", + "|---|---|", + f"| זוגות (panel ⋈ chair) | {analysis['n_pairs']} |", + f"| false-keep (פאנל שמר, יו\"ר דחה) | {analysis['n_false_keep']} |", + f"| false-drop (פאנל דחה, יו\"ר שמר) | {analysis['n_false_drop']} |", + f"| פיצולים שהוכרעו ע\"י היו\"ר | {analysis['n_splits_resolved']} |", + "", + "### שיעור-מחלוקת-עם-היו\"ר לכל שופט", + "", + "| judge | voted | disagree | rate |", + "|---|---|---|---|", + ] + for j in _JUDGES: + lines.append(f"| {j} | {js[j]['voted']} | {js[j]['disagree']} | {js[j]['disagree_rate']} |") + lines.append("") + + if not proposal or not proposal.get("keep_system_v2"): + lines += ["## הצעה", "", "_אין דפוס-כשל מובהק / אין מספיק ראיות — לא הוצעה רובריקה חדשה._", ""] + return "\n".join(lines) + + patterns = proposal.get("patterns") or [] + lines += ["## דפוסי-כשל שזוהו", ""] + lines += [f"- {p}" for p in patterns] or ["- (—)"] + lines += ["", "## diff מוצע ל-KEEP_SYSTEM", "", "```diff"] + diff = difflib.unified_diff( + KEEP_SYSTEM.replace(". ", ".\n").splitlines(), + proposal["keep_system_v2"].replace(". ", ".\n").splitlines(), + fromfile="KEEP_SYSTEM (current)", tofile="KEEP_SYSTEM (proposed v2)", lineterm="", + ) + lines += list(diff) + lines += ["```", "", "## few-shot exemplars מוצעים (מופשטים — INV-LRN5)", ""] + for ex in proposal.get("exemplars") or []: + lines.append(f"- **{ex.get('label','')}** — {ex.get('pattern','')} _( {ex.get('why','')} )_") + lines += ["", "---", "_להחלת ההצעה: ערוך ידנית את `KEEP_SYSTEM` ופתח PR. אין auto-apply (INV-LRN1)._"] + return "\n".join(lines) + + +async def main(args: argparse.Namespace) -> int: + pairs = await db.panel_rounds_vs_chair(limit=args.limit or 2000) + analysis = analyze_pairs(pairs) + print(f"pairs={analysis['n_pairs']} false_keep={analysis['n_false_keep']} " + f"false_drop={analysis['n_false_drop']} splits={analysis['n_splits_resolved']}", + flush=True) + + if analysis["n_pairs"] < MIN_PAIRS: + print(f"insufficient data (<{MIN_PAIRS} chair-resolved pairs) — no proposal. " + "Seeds accrue as the chair reviews panel-judged halachot (FU-2).", flush=True) + proposal = None + elif args.no_llm: + proposal = None + print("--no-llm: stats only, no rubric proposal.", flush=True) + else: + try: + proposal = await claude_session.query_json( + _proposal_prompt(analysis), system=None, tools="", + ) + except Exception as e: + print(f"LLM proposal failed ({e}); writing stats-only report.", flush=True) + proposal = None + if proposal and not isinstance(proposal, dict): + proposal = None + + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + out_dir = Path(__file__).resolve().parents[1] / "data" / "learning" + out_dir.mkdir(parents=True, exist_ok=True) + report = _render_report(analysis, proposal, ts) + out_path = out_dir / f"rubric-proposal-{ts}.md" + out_path.write_text(report, encoding="utf-8") + print(f"wrote {out_path}", flush=True) + if proposal and proposal.get("keep_system_v2"): + print("→ rubric v2 PROPOSED — review the diff and apply via PR if sound (INV-LRN1).", + flush=True) + return 0 + + +if __name__ == "__main__": + ap = argparse.ArgumentParser(description="Propose a panel-rubric refinement from chair decisions (FU-4).") + ap.add_argument("--limit", type=int, default=0, help="max (panel ⋈ chair) pairs to mine") + ap.add_argument("--no-llm", action="store_true", help="deterministic stats only, skip the rubric proposal") + raise SystemExit(asyncio.run(main(ap.parse_args())))