feat(principles): retroactive cull (Phase C) + source-derived terminology (Phase D, #152)
Phase C — scripts/cull_principles.py: re-adjudicates every existing 'original' principle with the SAME panel regime (panel_keep_score → classify → apply_cap), reversible (CSV backup + rejected canonical recoverable), usage-throttled. panel_extraction.panel_keep_score + apply_cap (shared, G2). Dry-run on 3 decisions: 37→15 survive. Phase D — services/principles.py: source-derived label הלכה (binding court) / כלל פרשני (committee) / עיקרון (persuasive); umbrella עקרונות משפטיים. Wired into canonical_halacha_get/list (principle_class+principle_label). UI string changes deferred to the Claude Design gate. spec INV-LRN7; SCRIPTS.md; 7 new tests; 428 green. Phase E needs no new code — synthesis already targets pending_synthesis, which the cull leaves only on survivors (rejected canonicals → 'rejected'). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ import asyncpg
|
||||
from pgvector.asyncpg import register_vector
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import court_citation, halacha_quality
|
||||
from legal_mcp.services import court_citation, halacha_quality, principles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -6156,7 +6156,8 @@ async def get_canonical_halacha(canonical_id: "UUID") -> "dict | None":
|
||||
"SELECT ch.id::text, ch.canonical_statement, ch.rule_type, "
|
||||
" ch.practice_areas, ch.subject_tags, ch.review_status, "
|
||||
" ch.instance_count, ch.created_at, ch.updated_at, "
|
||||
" cl.case_number AS first_established_case "
|
||||
" cl.case_number AS first_established_case, "
|
||||
" cl.source_kind, cl.is_binding "
|
||||
"FROM canonical_halachot ch "
|
||||
"LEFT JOIN case_law cl ON cl.id = ch.first_established_in "
|
||||
"WHERE ch.id = $1",
|
||||
@@ -6172,10 +6173,12 @@ async def get_canonical_halacha(canonical_id: "UUID") -> "dict | None":
|
||||
"WHERE h.canonical_id = $1 ORDER BY h.instance_type, cl.case_number",
|
||||
canonical_id,
|
||||
)
|
||||
return {
|
||||
**dict(row),
|
||||
"instances": [dict(i) for i in instances],
|
||||
}
|
||||
out = dict(row)
|
||||
# #152: source-derived class + Hebrew label (הלכה / כלל פרשני / עיקרון).
|
||||
out["principle_class"] = principles.principle_class(out.get("source_kind"), out.get("is_binding"))
|
||||
out["principle_label"] = principles.label_for_class(out["principle_class"])
|
||||
out["instances"] = [dict(i) for i in instances]
|
||||
return out
|
||||
|
||||
|
||||
async def list_canonical_halachot(
|
||||
@@ -6190,24 +6193,33 @@ async def list_canonical_halachot(
|
||||
params: list = []
|
||||
idx = 1
|
||||
if practice_area:
|
||||
conditions.append(f"${ idx} = ANY(practice_areas)")
|
||||
conditions.append(f"${ idx} = ANY(ch.practice_areas)")
|
||||
params.append(practice_area)
|
||||
idx += 1
|
||||
if review_status:
|
||||
conditions.append(f"review_status = ${idx}")
|
||||
conditions.append(f"ch.review_status = ${idx}")
|
||||
params.append(review_status)
|
||||
idx += 1
|
||||
params += [limit, offset]
|
||||
rows = await pool.fetch(
|
||||
f"SELECT id::text, canonical_statement, rule_type, practice_areas, "
|
||||
f" subject_tags, review_status, instance_count, created_at, updated_at "
|
||||
f"FROM canonical_halachot "
|
||||
f"SELECT ch.id::text, ch.canonical_statement, ch.rule_type, ch.practice_areas, "
|
||||
f" ch.subject_tags, ch.review_status, ch.instance_count, "
|
||||
f" ch.created_at, ch.updated_at, cl.source_kind, cl.is_binding "
|
||||
f"FROM canonical_halachot ch "
|
||||
f"LEFT JOIN case_law cl ON cl.id = ch.first_established_in "
|
||||
f"WHERE {' AND '.join(conditions)} "
|
||||
f"ORDER BY instance_count DESC, created_at DESC "
|
||||
f"ORDER BY ch.instance_count DESC, ch.created_at DESC "
|
||||
f"LIMIT ${idx} OFFSET ${idx + 1}",
|
||||
*params,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
cls = principles.principle_class(d.pop("source_kind", None), d.pop("is_binding", None))
|
||||
d["principle_class"] = cls
|
||||
d["principle_label"] = principles.label_for_class(cls)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
async def update_canonical_statement(
|
||||
|
||||
@@ -126,6 +126,29 @@ def classify(votes: int, score: float) -> str:
|
||||
return "rejected"
|
||||
|
||||
|
||||
def apply_cap(judged: list[dict], max_new: int | None = None) -> list[dict]:
|
||||
"""Per-decision cap for the retroactive cull (#152, Phase C).
|
||||
|
||||
``judged`` = a decision's principles, each with a panel ``verdict`` + ``score``.
|
||||
Survivors (approved/pending_review) are ranked by score; those beyond ``max_new``
|
||||
are downgraded to 'rejected' (over-cap). Already-rejected stay rejected. Returns
|
||||
a new list with ``final_verdict`` set on each (order preserved). Pure.
|
||||
"""
|
||||
max_new = config.HALACHA_PANEL_MAX_NEW if max_new is None else max_new
|
||||
survivors = [j for j in judged if j.get("verdict") in ("approved", "pending_review")]
|
||||
survivors.sort(key=lambda j: j.get("score", 0.0), reverse=True)
|
||||
keep_ids = {id(j) for j in survivors[:max_new]}
|
||||
out = []
|
||||
for j in judged:
|
||||
v = j.get("verdict")
|
||||
if v in ("approved", "pending_review") and id(j) not in keep_ids:
|
||||
final = "rejected" # over the cap
|
||||
else:
|
||||
final = v
|
||||
out.append({**j, "final_verdict": final})
|
||||
return out
|
||||
|
||||
|
||||
def cluster_candidates(
|
||||
per_model: dict[str, list[dict]], embs: dict[int, list[float]],
|
||||
) -> list[dict]:
|
||||
@@ -195,6 +218,63 @@ def cluster_candidates(
|
||||
return out
|
||||
|
||||
|
||||
def _keep_score_system(source_kind: str, is_binding: bool) -> str:
|
||||
if source_kind == "internal_committee":
|
||||
nature = ("המקור הוא החלטת ועדת-ערר (מיישמת דין, אינה יוצרת הלכה). ראוי-לשמירה = "
|
||||
"כלל פרשני חדש ובר-הכללה שהוועדה גיבשה; לא-ראוי = יישום תלוי-עובדות, "
|
||||
"חזרה על דין מוכר, אמרת-אגב, או חזרה מילולית על הציטוט.")
|
||||
else:
|
||||
nature = ("ראוי-לשמירה = עיקרון משפטי בר-הכללה והסתמכות (הלכה/פרשנות/כלל-פרוצדורלי); "
|
||||
"לא-ראוי = החלה תלוית-עובדות, אמרת-אגב, או חזרה מילולית על הציטוט.")
|
||||
return (
|
||||
"אתה משפטן בכיר בוועדת ערר לתכנון ובנייה. הוכרע אם עיקרון שחולץ מפסיקה ראוי "
|
||||
f"להישמר כתקדים בר-ציטוט. {nature}\n"
|
||||
"תן גם ציון-ביטחון 0-1 לכך שזהו עיקרון בר-הסתמכות אמיתי.\n"
|
||||
'החזר JSON בלבד: {"keep": true/false, "score": 0.0-1.0, "reason": "<משפט קצר>"}. ללא markdown.'
|
||||
)
|
||||
|
||||
|
||||
async def panel_keep_score(
|
||||
rule_statement: str,
|
||||
supporting_quote: str,
|
||||
reasoning_summary: str = "",
|
||||
*,
|
||||
source_kind: str = "external_upload",
|
||||
is_binding: bool = True,
|
||||
) -> dict:
|
||||
"""Run the 3-judge panel on ONE existing principle (Phase C cull, #152).
|
||||
|
||||
Each judge votes keep + score; votes = # keepers, score = mean of the keepers'
|
||||
scores (chaim: "ממוצע המצביעים"), verdict via the shared :func:`classify`.
|
||||
Returns {votes, score, verdict, voters, per_judge} — per_judge keeps raw
|
||||
replies for the active-learning round (FU-1). Used by the retroactive cull;
|
||||
the extractor uses :func:`panel_extract` instead.
|
||||
"""
|
||||
import asyncio
|
||||
system = _keep_score_system(source_kind, is_binding)
|
||||
user = (f"ניסוח העיקרון:\n{rule_statement}\n\n"
|
||||
f"היגיון:\n{reasoning_summary}\n\nציטוט תומך:\n{supporting_quote}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
c, ds, gm = await asyncio.gather(
|
||||
panel_judges.judge_claude(system, user, max_tokens=300),
|
||||
panel_judges.judge_deepseek(client, system, user, max_tokens=300),
|
||||
panel_judges.judge_gemini(client, system, user, max_tokens=2000),
|
||||
)
|
||||
raw = {"claude": c, "deepseek": ds, "gemini": gm}
|
||||
keepers, scores = [], []
|
||||
for name, reply in raw.items():
|
||||
if panel_judges.to_bool(reply, "keep"):
|
||||
keepers.append(name)
|
||||
try:
|
||||
scores.append(max(0.0, min(1.0, float(reply.get("score", 0.0)))))
|
||||
except (TypeError, ValueError):
|
||||
scores.append(0.0)
|
||||
votes = len(keepers)
|
||||
score = round(sum(scores) / votes, 4) if votes else 0.0
|
||||
return {"votes": votes, "score": score, "verdict": classify(votes, score),
|
||||
"voters": sorted(keepers), "per_judge": raw}
|
||||
|
||||
|
||||
async def _run_three(system: str, user: str, max_tokens: int) -> dict[str, object]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
import asyncio
|
||||
|
||||
45
mcp-server/src/legal_mcp/services/principles.py
Normal file
45
mcp-server/src/legal_mcp/services/principles.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Legal-principles terminology — the single source for what a principle is CALLED (#152).
|
||||
|
||||
chaim 2026-06-19: "הלכה" was the wrong umbrella. The corpus holds **עקרונות
|
||||
משפטיים** (legal principles); the term for one depends on its SOURCE:
|
||||
|
||||
• binding higher court (מחוזי/עליון) → "הלכה" (binding precedent)
|
||||
• appeals committee (internal_committee) → "כלל פרשני" (interpretive rule —
|
||||
the committee applies law, never makes it)
|
||||
• non-binding external (persuasive) → "עיקרון" (persuasive principle)
|
||||
|
||||
The class is derived from where a principle was FIRST established
|
||||
(canonical_halachot.first_established_in → case_law.source_kind/is_binding), so no
|
||||
new column is needed. UI/tools call :func:`label` instead of hardcoding "הלכה".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
UMBRELLA = "עקרונות משפטיים"
|
||||
|
||||
CLASS_HALACHA = "halacha"
|
||||
CLASS_INTERPRETIVE_RULE = "interpretive_rule"
|
||||
CLASS_PRINCIPLE = "principle"
|
||||
|
||||
_LABEL = {
|
||||
CLASS_HALACHA: "הלכה",
|
||||
CLASS_INTERPRETIVE_RULE: "כלל פרשני",
|
||||
CLASS_PRINCIPLE: "עיקרון",
|
||||
}
|
||||
|
||||
|
||||
def principle_class(source_kind: str | None, is_binding: bool | None) -> str:
|
||||
"""Map a source to its principle class (stable key, not display text)."""
|
||||
if source_kind == "internal_committee":
|
||||
return CLASS_INTERPRETIVE_RULE
|
||||
if is_binding:
|
||||
return CLASS_HALACHA
|
||||
return CLASS_PRINCIPLE
|
||||
|
||||
|
||||
def label(source_kind: str | None, is_binding: bool | None) -> str:
|
||||
"""Hebrew display term for a principle from this source (#152)."""
|
||||
return _LABEL[principle_class(source_kind, is_binding)]
|
||||
|
||||
|
||||
def label_for_class(cls: str) -> str:
|
||||
return _LABEL.get(cls, _LABEL[CLASS_PRINCIPLE])
|
||||
@@ -407,7 +407,10 @@ async def canonical_halacha_list(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> str:
|
||||
"""רשימת עקרונות קנוניים (canonical_halachot) — שאילתת נוחות לסוכני-הכתיבה.
|
||||
"""רשימת עקרונות משפטיים קנוניים — שאילתת נוחות לסוכני-הכתיבה.
|
||||
|
||||
כל פריט כולל principle_label לפי מקורו (#152): 'הלכה' (פס"ד מחוזי/עליון מחייב),
|
||||
'כלל פרשני' (החלטת ועדת-ערר), או 'עיקרון' (פסיקה משכנעת).
|
||||
|
||||
Args:
|
||||
practice_area: סינון לפי תחום עיסוק (ריק = הכל).
|
||||
|
||||
@@ -106,6 +106,25 @@ def test_cluster_same_model_twice_counts_one_vote_keeps_best_score():
|
||||
assert cl["rule_statement"] == "X"
|
||||
|
||||
|
||||
def test_apply_cap_downgrades_over_cap_survivors_by_score():
|
||||
judged = [
|
||||
{"verdict": "approved", "score": 0.9},
|
||||
{"verdict": "approved", "score": 0.7},
|
||||
{"verdict": "pending_review", "score": 0.8},
|
||||
{"verdict": "rejected", "score": 0.95}, # already rejected stays
|
||||
]
|
||||
out = pe.apply_cap(judged, max_new=2)
|
||||
fv = [j["final_verdict"] for j in out]
|
||||
# top-2 survivors by score = 0.9(approved) + 0.8(pending); 0.7 → over cap → rejected
|
||||
assert fv == ["approved", "rejected", "pending_review", "rejected"]
|
||||
|
||||
|
||||
def test_apply_cap_keeps_all_when_under_cap():
|
||||
judged = [{"verdict": "approved", "score": 0.9}, {"verdict": "pending_review", "score": 0.5}]
|
||||
out = pe.apply_cap(judged, max_new=5)
|
||||
assert [j["final_verdict"] for j in out] == ["approved", "pending_review"]
|
||||
|
||||
|
||||
def test_cluster_sorted_strongest_first():
|
||||
a = _c("X", 0.9) # 1 vote
|
||||
b, c = _c("Y", 0.9), _c("Y", 0.9) # 2 votes
|
||||
|
||||
27
mcp-server/tests/test_principles_terminology.py
Normal file
27
mcp-server/tests/test_principles_terminology.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Terminology mapping — הלכה / כלל פרשני / עיקרון by source (#152, Phase D)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from legal_mcp.services import principles as pr
|
||||
|
||||
|
||||
def test_binding_higher_court_is_halacha():
|
||||
assert pr.label("external_upload", True) == "הלכה"
|
||||
assert pr.principle_class("external_upload", True) == pr.CLASS_HALACHA
|
||||
|
||||
|
||||
def test_committee_is_interpretive_rule():
|
||||
# the appeals committee applies law — never makes a הלכה
|
||||
assert pr.label("internal_committee", True) == "כלל פרשני"
|
||||
assert pr.label("internal_committee", False) == "כלל פרשני"
|
||||
assert pr.principle_class("internal_committee", False) == pr.CLASS_INTERPRETIVE_RULE
|
||||
|
||||
|
||||
def test_non_binding_external_is_principle():
|
||||
assert pr.label("external_upload", False) == "עיקרון"
|
||||
assert pr.label(None, None) == "עיקרון"
|
||||
|
||||
|
||||
def test_label_for_class_roundtrip():
|
||||
for sk, binding in [("external_upload", True), ("internal_committee", False), (None, False)]:
|
||||
cls = pr.principle_class(sk, binding)
|
||||
assert pr.label_for_class(cls) == pr.label(sk, binding)
|
||||
Reference in New Issue
Block a user