feat(principles): decision-level panel extraction regime — cap-5 + dedup-frees-slot (Phase B, #152)

extract() routes to _extract_via_panel when HALACHA_PANEL_REGIME_ENABLED: the
3-model panel proposes → votes/score → approval rule → dedup vs corpus (known
links as citation, frees a cap slot) → cap HALACHA_PANEL_MAX_NEW genuinely-new
principles/decision (by score), rest dropped. Replaces single-model auto-approve;
legacy path kept as <2-judge fallback. db.store_panel_principles persists the
pre-decided verdict + source-aware canonical create/link (G9 reviewer=panel:...).
Dry-run validated on 29468-08-23: ~18 → 4 principles. 6 new tests; full suite 422 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 11:05:44 +00:00
parent a4114cce5e
commit 6b2fd562ae
4 changed files with 301 additions and 1 deletions

View File

@@ -31,7 +31,7 @@ import asyncpg
from legal_mcp import config
from legal_mcp.config import parse_llm_json
from legal_mcp.services import (
claude_session, db, embeddings, halacha_quality, proofreader,
claude_session, db, embeddings, halacha_quality, panel_extraction, proofreader,
)
logger = logging.getLogger(__name__)
@@ -603,6 +603,15 @@ async def extract(case_law_id: UUID | str, force: bool = False,
stop_keepalive = asyncio.Event()
keepalive_task = asyncio.create_task(_lock_keepalive(lock_conn, stop_keepalive))
try:
if config.HALACHA_PANEL_REGIME_ENABLED:
# #152 Phase B — decision-level 3-model panel (votes+cap+source label).
res = await _extract_via_panel(case_law_id, force=force)
if res is not None:
return res
# panel unavailable (all judges down) → degrade to legacy path so
# extraction still makes progress instead of stalling the queue.
logger.warning("panel regime returned no result for %s"
"falling back to legacy per-chunk extraction", case_law_id)
return await _extract_impl(case_law_id, force=force, effort=effort)
finally:
# Stop the keepalive and await it BEFORE reusing lock_conn for unlock —
@@ -894,3 +903,122 @@ async def _extract_impl(case_law_id: UUID, force: bool = False,
"preserved_approved": preserved_approved,
"total_chunks": len(chunks),
}
# Cap the text sent to each judge — extractable chunks already exclude
# facts/intro/arguments, but a very long decision could still blow a context.
_PANEL_MAX_CHARS = 80_000
# Canonical states a new extraction may dedup-link against (frees a cap slot).
_PANEL_DEDUP_STATES = ("pending_synthesis", "pending_review", "approved", "published")
async def _extract_via_panel(
case_law_id: UUID, force: bool = False, dry_run: bool = False,
) -> dict | None:
"""Decision-level tri-model panel extraction (#152, Phase B).
Replaces the per-chunk single-model auto-approve with: 3 models propose →
cross-model votes + mean-score → chair's approval rule → dedup vs corpus
(link known → frees a slot) → cap of HALACHA_PANEL_MAX_NEW genuinely-new
principles per decision (by score). A principle from a binding higher court
is a הלכה; from the appeals committee a כלל פרשני (labeling via source).
Returns the result dict, or **None** when fewer than 2 judges are reachable
(caller falls back to the legacy path so the queue still drains). ``dry_run``
computes the full plan WITHOUT writing — used for validation/chair preview.
"""
record = await db.get_case_law(case_law_id)
if not record:
return {"status": "not_found", "extracted": 0, "stored": 0}
# Idempotency: panel extraction is decision-level (no per-chunk checkpoints).
# Without force, skip if this decision already has halachot (avoid dup re-run).
if not force and not dry_run:
existing = await db.list_halachot(case_law_id=case_law_id, limit=1)
if existing:
total = len(await db.list_halachot(case_law_id=case_law_id, limit=10_000))
await db.set_case_law_halacha_status(case_law_id, "completed")
return {"status": "completed", "extracted": total, "stored": total,
"resumed": True, "panel": True}
source_kind = record.get("source_kind") or "external_upload"
is_binding = bool(record.get("is_binding"))
full_text = record.get("full_text") or ""
chunks, used_fallback = await _select_extractable_chunks(case_law_id)
if not chunks:
if not dry_run:
await db.set_case_law_halacha_status(case_law_id, "completed")
return {"status": "no_chunks", "extracted": 0, "stored": 0, "panel": True}
preserved = 0
if force and not dry_run:
reset = await db.reset_halacha_extraction(case_law_id)
preserved = reset.get("preserved", 0)
if not dry_run:
await db.set_case_law_halacha_status(case_law_id, "processing")
text = "\n\n".join(c["content"] for c in chunks)[:_PANEL_MAX_CHARS]
clusters = await panel_extraction.panel_extract(
text, source_kind=source_kind, is_binding=is_binding,
)
if not clusters:
# distinguish "judges down" (→ fallback) from "genuinely nothing found".
if sum(panel_extraction.panel_judges.available().values()) < 2:
return None
if not dry_run:
await db.mark_all_chunks_extracted(case_law_id)
await db.set_case_law_halacha_status(case_law_id, "completed")
return {"status": "completed", "extracted": 0, "stored": 0,
"new": 0, "linked": 0, "panel": True, "preserved_approved": preserved}
kept = [c for c in clusters if c["verdict"] in ("approved", "pending_review")]
max_new = config.HALACHA_PANEL_MAX_NEW
new_count = linked = dropped_cap = 0
to_store: list[dict] = []
for c in kept: # strongest first (panel_extract sorts by votes,score)
emb = c.get("embedding")
canonical_id, instance_type = None, "original"
if emb is not None and config.HALACHA_CANONICAL_LOOKUP_ENABLED:
match = await db.nearest_canonical_halacha(
emb, threshold=config.HALACHA_CANONICAL_THRESHOLD,
status_filter=_PANEL_DEDUP_STATES,
)
if match:
canonical_id, instance_type = match[0], "citation"
if instance_type == "original":
if new_count >= max_new: # cap: linked don't count, only new
dropped_cap += 1
continue
new_count += 1
else:
linked += 1
to_store.append({
"rule_statement": c["rule_statement"], "supporting_quote": c["supporting_quote"],
"reasoning_summary": c["reasoning_summary"], "rule_type": c["rule_type"],
"confidence": c["score"], "score": c["score"], "votes": c["votes"],
"voters": c["voters"], "review_status": c["verdict"], "embedding": emb,
"instance_type": instance_type, "canonical_id": canonical_id,
"quote_verified": _verify_quote(c["supporting_quote"], full_text),
})
if dry_run:
return {"status": "dry_run", "panel": True, "source_kind": source_kind,
"candidates": clusters, "to_store": to_store,
"new": new_count, "linked": linked, "dropped_over_cap": dropped_cap}
res = await db.store_panel_principles(case_law_id, to_store)
await db.mark_all_chunks_extracted(case_law_id)
await db.set_case_law_halacha_status(case_law_id, "completed")
total = len(await db.list_halachot(case_law_id=case_law_id, limit=10_000))
logger.info(
"halacha panel: case_law=%s (%s) — %d new + %d linked stored, "
"%d dropped over cap-%d", case_law_id, source_kind,
res["created_new"], res["linked"], dropped_cap, max_new,
)
return {"status": "completed", "extracted": total,
"stored": res["created_new"] + res["linked"], "new": res["created_new"],
"linked": res["linked"], "dropped_over_cap": dropped_cap,
"panel": True, "preserved_approved": preserved}