Files
legal-ai/scripts/backfill_lesson_synthesis.py
Chaim 14ab7b0cae
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(learning): lesson synthesis — merge overlapping lessons into richer super-lessons (#158 / INV-LRN8)
הפתרון-האמיתי לחיתוך-השקט limit=15 (#157): במקום ערימת לקחים גולמיים חופפים, ממזגים
לקחי-סגנון דומים ל"לקח-על" אחד עשיר ומוכלל — הסט שזורם לכותב קטֵן ומשתבח (Authorial
Style Profiling). מחקה את סינתזת-הקנוני (V41/INV-LRN6) על אותה טבלה (G2, אין מאגר-מקביל).

מנגנון (services/lesson_synthesis.py, מסלול-יחיד):
- אשכול greedy לפי cosine (LESSON_SYNTH_CLUSTER_THRESHOLD) בתוך shard practice_area+category.
- מיזוג ע"י claude_session מעוגן-מקור (INV-AH, סגנון-בלבד INV-LRN5, abstain) + שער-drift
  (cosine מול centroid ≥ LESSON_SYNTH_DRIFT_FLOOR).
- לקח-על נכתב source='synthesis' + synthesized_from; המקורות→review_status='superseded'.
- שער מדורג-הפיך (הכרעת-יו"ר): מקורות approved → לקח-על approved (זורם), veto-יו"ר משחזר
  את המקורות (db.revert_lesson_synthesis, מחובר ל-PATCH lessons).
- idempotency: lookup-cosine מול synthesis קיים לפני INSERT.

נגזרות: SCHEMA_V46 (embedding vector(1024) + synthesized_from + ivfflat); כלי-MCP
lesson_synthesize_pending; scripts/backfill_lesson_synthesis.py (--dry-run/--apply, audit CSV);
config LESSON_SYNTH_*; spec INV-LRN8; SCRIPTS.md.

get_recent_decision_lessons ללא שינוי — superseded יוצא (מסנן approved), synthesis נכנס.
UI badges (synthesis/superseded) נדחים לשער-העיצוב (מוגנים ב-fallback, ללא קריסה).

בדיקות: py_compile ✓ · leak-guard G12 ✓ · smoke-test טהור לאשכול/cosine/centroid ✓.
אימות functional מלא (dry-run מול DB+voyage+claude CLI) — בהוסט אחרי-deploy, כמו V41.

Invariants: G2 (מסלול-יחיד, אותה טבלה), INV-AH (עיגון+drift), INV-LRN1/G10 (שער מדורג-הפיך),
INV-LRN5 (סגנון-בלבד), INV-LRN8 (חדש). depends-on #157/#159.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:24:42 +00:00

148 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Backfill — LLM synthesis of decision_lessons (#158 / INV-LRN8).
WHAT THIS DOES
--------------
Walks (practice_area, category) shards that hold ≥2 live APPROVED style lessons,
clusters near-duplicates (cosine), and asks a local ``claude_session`` model (Opus
by default) to merge each cluster into ONE richer, generalised "super-lesson"
grounded in the source lessons (INV-AH) with a drift guard. Accepted merges are
written as ``source='synthesis'`` rows (review_status='approved', graduated gate)
and their sources flip to ``superseded`` (provenance, no longer writer-fed). The
writer-fed set shrinks, so its limit=15 stops truncating (#157).
All logic lives in services/lesson_synthesis.py (G2) — this is the batch driver:
shard ordering, throttling, dry-run reporting and a CSV audit trail.
IDEMPOTENCY / RESUME
--------------------
Re-running is safe: superseded sources are excluded from candidates, and an accepted
merge that matches an existing synthesis (cosine) is skipped (duplicate_skipped).
USAGE
-----
cd ~/legal-ai/mcp-server
.venv/bin/python ../scripts/backfill_lesson_synthesis.py --dry-run # all shards, no writes
.venv/bin/python ../scripts/backfill_lesson_synthesis.py --dry-run --practice-area rishuy_uvniya --category style
.venv/bin/python ../scripts/backfill_lesson_synthesis.py --apply # full throttled run
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import os
import sys
from collections import Counter
from datetime import datetime, timezone
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp.services import db, lesson_synthesis # noqa: E402
try: # stdlib-only module, importable from system python too
from legal_mcp.services import usage_limits
except Exception: # pragma: no cover
usage_limits = None
AUDIT_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "audit")
def _throttled() -> tuple[bool, str]:
if usage_limits is None:
return False, "usage_limits unavailable"
usage = usage_limits.subscription_usage()
if usage is None:
return False, "usage read failed (proceeding)"
over, _reset, detail = usage_limits.ceiling_status(usage)
return over, detail
def _short(s: str, n: int = 100) -> str:
s = (s or "").replace("\n", " ")
return s if len(s) <= n else s[: n - 1] + ""
async def _run(apply: bool, practice_area: str, category: str,
throttle: bool, verbose: bool) -> int:
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]
mode = "APPLY" if apply else "DRY-RUN"
print(f"[{mode}] {len(shards)} shards with ≥2 approved lessons "
f"(throttle={'on' if throttle else 'off'})\n")
if not shards:
print("nothing to do.")
return 0
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
os.makedirs(AUDIT_DIR, exist_ok=True)
audit_path = os.path.join(
AUDIT_DIR, f"lesson-synthesis-{'apply' if apply else 'dryrun'}-{stamp}.csv")
counts: Counter[str] = Counter()
stopped = False
with open(audit_path, "w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
w.writerow(["practice_area", "category", "cluster_size", "status",
"drift_cosine", "applied", "reason", "after", "member_ids"])
for n, s in enumerate(shards, 1):
if throttle:
over, detail = _throttled()
if over:
print(f"\n⏸ usage ceiling reached ({detail}) — stopping at "
f"shard {n - 1}/{len(shards)}. Re-run to resume.")
stopped = True
break
pa, cat = s["practice_area"], s["category"]
res = await lesson_synthesis.run_shard(pa, cat, apply=apply)
print(f"[{n}/{len(shards)}] {pa}/{cat}: {res['candidates']} candidates → "
f"{len(res['clusters'])} clusters")
for c in res["clusters"]:
counts[c["status"]] += 1
w.writerow([pa, cat, len(c["members"]), c["status"],
c.get("drift_cosine"), c.get("applied"),
c.get("reason", ""), c.get("proposed", ""),
"|".join(c["members"])])
mark = {"accepted": "", "duplicate_skipped": "=", "abstained": "·",
"drift_rejected": "", "llm_error": "!", "too_small": "·"}.get(c["status"], "?")
print(f" {mark} {c['status']:<18} size={len(c['members'])} "
f"drift={c.get('drift_cosine')}{' [written]' if c.get('applied') else ''}")
if verbose and c.get("proposed"):
print(f"{_short(c['proposed'])}")
processed = sum(counts.values())
print(f"\n── summary ({mode}) — {processed} clusters"
f"{' (stopped early)' if stopped else ''} ──")
for status, c in counts.most_common():
print(f" {status:<18} {c}")
print(f"\naudit CSV: {audit_path}")
if not apply:
print("dry-run — nothing written. Re-run with --apply to commit.")
return 0
def main() -> int:
p = argparse.ArgumentParser(description="LLM synthesis of decision_lessons (#158 / INV-LRN8)")
p.add_argument("--apply", action="store_true", help="commit to the DB (default: dry-run)")
p.add_argument("--dry-run", action="store_true", help="explicit dry-run (default)")
p.add_argument("--practice-area", default="", help="limit to one practice_area")
p.add_argument("--category", default="", help="limit to one category (style/structure/lexicon/tabular)")
p.add_argument("--no-throttle", action="store_true", help="skip usage-ceiling checks")
p.add_argument("--verbose", action="store_true", help="print merged text per cluster")
args = p.parse_args()
return asyncio.run(_run(
apply=args.apply, practice_area=args.practice_area, category=args.category,
throttle=not args.no_throttle, verbose=args.verbose,
))
if __name__ == "__main__":
raise SystemExit(main())