"""Decision-lesson synthesis (#158 / INV-LRN8). The learning channels (panel, curator, chair) accumulate overlapping ``decision_lessons`` on the same style dimension. The writer only consumes the 15 most-recent APPROVED ones per practice_area (a silent cap, #157), so beyond that lessons pile up unused. This pass **clusters** near-duplicate lessons within a (practice_area, category) shard and **merges** each cluster into ONE richer, generalised "super-lesson" — so the writer-fed set shrinks to a small, high-quality profile (Authorial Style Profiling, ספ §0.1) and the cap stops biting. Mirrors the canonical-halacha synthesis (V41 / INV-LRN6) on the SAME table (no parallel store, G2). Invariants: • INV-AH — the super-lesson is GROUNDED in the source lessons only; the model abstains rather than invent, and a re-embedding DRIFT guard rejects a rewrite that drifts from the cluster centroid. • INV-LRN1/G10 — graduated gate (chair decision 2026-06-28): since every source is already ``approved``, the super-lesson flows as ``approved`` (reversible — chair veto in /training restores the sources). A non-approved source ⇒ proposed. • G2 — single synthesis path; the MCP tool and the backfill script both call :func:`run_shard` / :func:`synthesize_cluster` here. • G9 — every outcome (accepted / abstained / drift_rejected / merged-duplicate) returned. LLM calls go through ``claude_session`` (local ``claude -p`` CLI) only — never from the FastAPI container (see claude_session docstring). """ from __future__ import annotations import logging import math from uuid import UUID from legal_mcp import config from legal_mcp.services import claude_session, db, embeddings logger = logging.getLogger(__name__) _SYSTEM = ( "אתה עורך-דין בכיר המזקק כללי-סגנון-וכתיבה לבסיס-ידע של ועדת ערר לתכנון ובנייה. " "תפקידך למזג כמה לקחי-סגנון חופפים לכלל אחד, כללי ומדויק, על *איך* כותבים — לא להמציא " "כלל חדש ולא להוסיף מהות משפטית." ) def _cosine(a, b) -> float: dot = sum(x * y for x, y in zip(a, b)) na = math.sqrt(sum(x * x for x in a)) nb = math.sqrt(sum(y * y for y in b)) if na == 0 or nb == 0: return 0.0 return dot / (na * nb) def _centroid(vecs: list[list[float]]) -> list[float]: n = len(vecs) dim = len(vecs[0]) return [sum(v[i] for v in vecs) / n for i in range(dim)] def _build_prompt(members: list[dict]) -> str: blocks = [] for i, m in enumerate(members, 1): blocks.append(f"### לקח {i}\n{m['lesson_text']}") evidence = "\n\n".join(blocks) return f"""{_SYSTEM} לקחי-המקור (כולם מאותו תחום וקטגוריה, חופפים בנושא): {evidence} ## המשימה מזג את לקחי-המקור לכלל-סגנון **אחד** עשיר ומוכלל המשותף לכולם. שמר כל ניואנס מובחן שמופיע באחד הלקחים, אך נסח אותו פעם אחת, נקי וכללי. ## כללים מחייבים (INV-AH — עיגון, ללא הזיה) 1. **עיגון-מקור בלבד.** הכלל חייב לנבוע מלקחי-המקור שלמעלה. אסור להוסיף כלל, חריג או דוגמה שאינם עולים מהם. 2. **סגנון/שיטה בלבד, לא מהות.** אל תכניס הלכה, עובדה, מספר-תיק או תקדים ספציפי — רק *איך* דפנה כותבת. 3. **כללי ובלתי-תלוי-תיק.** הסר פרטים קונקרטיים; נסח כלל רב-תחולה. 4. **רגיסטר נקי** בעברית, משפט אחד עד שלושה, בלי מילות-מסגרת ("יש לזכור ש...") — רק הכלל עצמו. 5. **הימנעות עדיפה על המצאה.** אם הלקחים אינם באמת מתמזגים לכלל אחד מעוגן — החזר grounded=false. ## פלט — JSON בלבד, ללא markdown וללא הסבר: {{ "lesson_text": "<כלל-הסגנון הממוזג>", "grounded": true, "reason": "<משפט קצר: מה אוחד>" }}""" def _greedy_clusters(candidates: list[dict], threshold: float) -> list[list[dict]]: """Greedy single-link clustering by cosine over candidate embeddings. Each candidate has an 'embedding' (python list). Returns clusters of size ≥2 only (singletons are nothing to merge).""" remaining = [c for c in candidates if c.get("embedding") is not None] clusters: list[list[dict]] = [] used: set = set() for i, seed in enumerate(remaining): if seed["id"] in used: continue cluster = [seed] used.add(seed["id"]) for other in remaining[i + 1:]: if other["id"] in used: continue if _cosine(seed["embedding"], other["embedding"]) >= threshold: cluster.append(other) used.add(other["id"]) if len(cluster) >= 2: clusters.append(cluster) return clusters async def _ensure_embeddings(candidates: list[dict]) -> list[dict]: """Lazy-backfill: embed any candidate whose stored embedding is NULL, persist it, and return the candidates with embeddings populated (skips ones that still fail).""" missing = [c for c in candidates if c.get("embedding") is None] if missing: vecs = await embeddings.embed_texts([c["lesson_text"] for c in missing]) for c, v in zip(missing, vecs): c["embedding"] = list(v) await db.set_lesson_embedding(c["id"], c["embedding"]) return [c for c in candidates if c.get("embedding") is not None] async def synthesize_cluster( members: list[dict], *, model: str | None = None, effort: str | None = None, drift_floor: float | None = None, ) -> dict: """Merge one cluster of lessons. PURE — no DB writes. Returns: {status, proposed, embedding, members:[ids], drift_cosine, reason} status ∈ {accepted, abstained, drift_rejected, llm_error, too_small}. """ model = model or config.LESSON_SYNTH_MODEL effort = effort or config.LESSON_SYNTH_EFFORT drift_floor = config.LESSON_SYNTH_DRIFT_FLOOR if drift_floor is None else drift_floor ids = [str(m["id"]) for m in members] base = {"members": ids, "proposed": "", "embedding": None, "drift_cosine": None, "reason": ""} if len(members) < 2: return {**base, "status": "too_small", "reason": "cluster < 2"} try: result = await claude_session.query_json( _build_prompt(members), model=model, effort=effort, tools="", ) except Exception as e: logger.warning("synthesize_cluster %s: LLM error: %s", ids, e) return {**base, "status": "llm_error", "reason": str(e)} if not isinstance(result, dict) or not result.get("lesson_text"): return {**base, "status": "llm_error", "reason": "malformed LLM output"} if not result.get("grounded", True): return {**base, "status": "abstained", "reason": result.get("reason") or "model abstained (not grounded)"} proposed = str(result["lesson_text"]).strip() if not proposed: return {**base, "status": "abstained", "reason": "empty proposal"} # Drift guard: the merged lesson must stay near the cluster centroid. new_emb = list((await embeddings.embed_texts([proposed]))[0]) centroid = _centroid([m["embedding"] for m in members]) drift = _cosine(new_emb, centroid) if drift < drift_floor: return {**base, "status": "drift_rejected", "proposed": proposed, "drift_cosine": round(drift, 4), "reason": f"drift {drift:.3f} < floor {drift_floor}"} return {**base, "status": "accepted", "proposed": proposed, "embedding": new_emb, "drift_cosine": round(drift, 4), "reason": result.get("reason") or "merged"} async def run_shard( practice_area: str, category: str, *, apply: bool, model: str | None = None, effort: str | None = None, drift_floor: float | None = None, cluster_threshold: float | None = None, ) -> dict: """Synthesize all clusters in one (practice_area, category) shard. Returns {practice_area, category, candidates, clusters:[result...]}. Each result is a synthesize_cluster outcome augmented with ``applied`` and (when applied) ``new_id``. With apply=False this is a pure dry-run (no writes beyond lazy embedding backfill). """ cluster_threshold = (config.LESSON_SYNTH_CLUSTER_THRESHOLD if cluster_threshold is None else cluster_threshold) candidates = await db.fetch_synthesis_candidates(practice_area, category) candidates = await _ensure_embeddings(candidates) clusters = _greedy_clusters(candidates, cluster_threshold) results = [] for members in clusters: res = await synthesize_cluster( members, model=model, effort=effort, drift_floor=drift_floor, ) res["applied"] = False if apply and res["status"] == "accepted": # Idempotency: skip if a near-identical synthesis already exists (re-run safe). dup = await db.nearest_synthesis_lesson( res["embedding"], category, config.HALACHA_CANONICAL_THRESHOLD, ) if dup: res["status"] = "duplicate_skipped" res["reason"] = f"near existing synthesis {dup[0]} (sim {dup[1]:.3f})" else: # graduated gate: all sources are approved (fetch filter) → approved. row = await db.apply_lesson_synthesis( corpus_id=members[0]["style_corpus_id"], lesson_text=res["proposed"], category=category, embedding=res["embedding"], source_ids=[m["id"] for m in members], review_status="approved", ) res["applied"] = True res["new_id"] = str(row.get("id", "")) results.append(res) return {"practice_area": practice_area or "*", "category": category, "candidates": len(candidates), "clusters": results} async def run_pending( practice_area: str = "", category: str = "", *, apply: bool, model: str | None = None, effort: str | None = None, drift_floor: float | None = None, cluster_threshold: float | None = None, ) -> list[dict]: """Run synthesis across shards. If practice_area+category are given, one shard; otherwise iterate every shard with ≥2 live approved lessons. Single entry point (G2).""" if practice_area and category: shards = [{"practice_area": practice_area, "category": category}] else: shards = await db.synthesis_shards(min_size=2) if practice_area: shards = [s for s in shards if s["practice_area"] == practice_area] if category: shards = [s for s in shards if s["category"] == category] out = [] for s in shards: out.append(await run_shard( s["practice_area"], s["category"], apply=apply, model=model, effort=effort, drift_floor=drift_floor, cluster_threshold=cluster_threshold, )) return out