feat(principles): canonical_statement synthesis service + throttled backfill (Phase E groundwork, #152)

Grounded (INV-AH) multi-instance synthesis with drift guard + chair gate
(pending_review, G10). Single path used by backfill, MCP tool, nightly drain.
HELD from production run pending the principles-redesign (rename+cull, #152).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 10:57:48 +00:00
parent db93735ed6
commit 338a8a947f
14 changed files with 1250 additions and 74 deletions

View File

@@ -21,7 +21,7 @@ from __future__ import annotations
import time
from uuid import UUID
from legal_mcp.services import db, precedent_library, telemetry
from legal_mcp.services import canonical_synthesis, db, precedent_library, telemetry
from legal_mcp.tools.envelope import empty, err as _err, ok as _ok # GAP-48: SSoT envelope
@@ -439,3 +439,34 @@ async def canonical_halacha_get(canonical_id: str) -> str:
if row is None:
return _err("עיקרון קנוני לא נמצא")
return _ok(row)
async def canonical_synthesize_pending(limit: int = 20) -> str:
"""סנתז ניסוח-קנוני לעקרונות הממתינים (review_status='pending_synthesis'). V41 Phase 4.
לכל עיקרון: מודל מקומי (claude_session) מזקק ניסוח אחד, כללי ומעוגן בציטוטי-המופעים
(INV-AH), שער-drift דוחה סטייה גדולה מדי, והסטטוס מתקדם ל-pending_review לשער-היו"ר
(G10). on-demand / burst ידני; המסה הראשונית מטופלת ב-backfill_canonical_synthesis.py.
Args:
limit: מספר מקסימלי לסבב (עד 100). רב-instance מטופלים ראשונים.
"""
pool = await db.get_pool()
rows = await pool.fetch(
"SELECT id::text AS id FROM canonical_halachot "
"WHERE review_status='pending_synthesis' "
"ORDER BY instance_count DESC, created_at LIMIT $1",
min(max(limit, 1), 100),
)
if not rows:
return _ok({"processed": 0, "results": [], "message": "אין עקרונות ממתינים לסינתזה"})
results = []
counts: dict[str, int] = {}
for r in rows:
res = await canonical_synthesis.synthesize_and_apply(UUID(r["id"]))
counts[res["status"]] = counts.get(res["status"], 0) + 1
results.append({
"canonical_id": res["canonical_id"], "status": res["status"],
"drift_cosine": res.get("drift_cosine"), "reason": res.get("reason", ""),
})
return _ok({"processed": len(results), "by_status": counts, "results": results})