Files
legal-ai/scripts/backfill_style_exemplars.py
Chaim 93a9404663
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(learning): grow style-exemplars from every final (P1 #5)
The style-exemplar corpus (channel B — the writer's block-level retrieval of
Dafna's real prose) was FROZEN at the one-time seed backfill: new finals were
enrolled into style_corpus but never broken into exemplars, so the richest style
channel never grew (8137/8126/8174 had 0 exemplars). The writer kept retrieving
only March–April seed paragraphs no matter how many finals were signed.

Extract the per-decision exemplar logic (section→paragraph→Voyage-embed→replace)
into a shared service `legal_mcp.services.style_exemplars.extract_and_store` —
the SINGLE implementation now used by BOTH the one-time backfill and the live
enrollment path (G2; no parallel extractor). `_enroll_final_in_library` calls it
on every final upload (source='internal_committee', the same source the writer's
search_style_exemplars reads). Voyage embeds over REST → container-safe;
best-effort, surfaced in the upload response, never fails the upload.

Effect: every signed final now grows the exemplar corpus, so the writer's
block-level style retrieval improves with each decision — the core "learn from
every decision" fix for channel B. Path A (style_distance_history) will track
whether the larger exemplar pool reduces style-distance over time.

Invariants: G2 (one extraction path shared by backfill + enroll), INV-LRN5
(style/structure prose only — substance routes elsewhere), INV-LRN4 (the
draft↔final loop now feeds the exemplar channel, not just the lesson channel).

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

97 lines
3.7 KiB
Python

#!/usr/bin/env python3
"""T1 — אכלוס style_exemplars מקורפוס דפנה (style_corpus + internal_committee).
מפצל כל החלטה של דפנה לסעיפים (chunker._split_into_sections), ומכל סעיף לפסקאות,
מטמיע (Voyage) ושומר ב-style_exemplars עם section/outcome/practice_area — כדי
שהכותב יוכל לאחזר פסקאות-בלוק אמיתיות של דפנה בזמן כתיבה (T2/T3).
מקור-סגנון בלבד (INV-LRN5) — לא מהות. אידמפוטנטי: מנקה לכל decision לפני הכנסה.
שימוש:
python3 scripts/backfill_style_exemplars.py # dry-run (סופר בלבד)
python3 scripts/backfill_style_exemplars.py --apply # מטמיע ושומר
דורש POSTGRES_URL + מפתח Voyage בסביבה (כמו שאר ה-MCP).
"""
from __future__ import annotations
import argparse
import asyncio
import logging
from legal_mcp.services import db
from legal_mcp.services import style_exemplars as sx
logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger("backfill_exemplars")
# Section mapping + paragraph splitting now live in the shared service
# (legal_mcp.services.style_exemplars) so the backfill and the live
# final-enrollment path use ONE extraction implementation (G2).
async def _gather_sources() -> list[dict]:
"""All of Dafna's decisions: style_corpus + internal_committee (chair דפנה)."""
pool = await db.get_pool()
rows: list[dict] = []
async with pool.acquire() as conn:
sc = await conn.fetch(
"SELECT decision_number, full_text, outcome, practice_area "
"FROM style_corpus WHERE full_text <> ''"
)
for r in sc:
rows.append({
"decision_number": r["decision_number"] or "",
"source": "style_corpus",
"full_text": r["full_text"],
"outcome": r["outcome"] or "",
"practice_area": r["practice_area"] or "",
})
ic = await conn.fetch(
"SELECT case_number, full_text, practice_area FROM case_law "
"WHERE source_kind = 'internal_committee' AND coalesce(chair_name,'') LIKE '%דפנה%' "
"AND coalesce(full_text,'') <> ''"
)
for r in ic:
rows.append({
"decision_number": r["case_number"] or "",
"source": "internal_committee",
"full_text": r["full_text"],
"outcome": "",
"practice_area": r["practice_area"] or "",
})
return rows
async def main(apply: bool) -> None:
sources = await _gather_sources()
log.info("מקורות: %d החלטות של דפנה (style_corpus + internal_committee)", len(sources))
total_paras = 0
for src in sources:
n = len(sx.units_for(src["full_text"]))
if not n:
continue
total_paras += n
log.info(" %-14s %-16s%d פסקאות", src["source"], src["decision_number"], n)
if not apply:
continue
await sx.extract_and_store(
decision_number=src["decision_number"], source=src["source"],
full_text=src["full_text"], practice_area=src["practice_area"],
outcome=src["outcome"],
)
if apply:
cov = await db.count_style_exemplars()
log.info("הושלם. style_exemplars: %s", cov)
else:
log.info("dry-run: %d פסקאות יוטמעו. הרץ --apply לביצוע.", total_paras)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="embed + insert (default: dry-run)")
args = ap.parse_args()
asyncio.run(main(args.apply))