feat(learning): grow style-exemplars from every final (P1 #5)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

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>
This commit is contained in:
2026-06-28 21:57:38 +00:00
parent 07bd8bee48
commit 93a9404663
3 changed files with 113 additions and 49 deletions

View File

@@ -19,40 +19,15 @@ import argparse
import asyncio
import logging
from legal_mcp.services import db, embeddings
from legal_mcp.services.chunker import _split_into_sections
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")
# chunker section_type → style_exemplars.section
_SECTION_MAP = {
"facts": "background",
"appellant_claims": "claims",
"respondent_claims": "claims",
"legal_analysis": "discussion",
"conclusion": "summary",
"ruling": "summary",
"intro": "other",
"other": "other",
}
MIN_WORDS = 25 # skip tiny fragments
MAX_WORDS = 450 # skip over-long blobs (likely un-split)
MAX_PER_SECTION = 15
def _paragraphs(section_text: str) -> list[str]:
"""Split a section into paragraph units (blank-line separated; fall back to lines)."""
raw = [p.strip() for p in section_text.split("\n\n")]
if len(raw) <= 1:
raw = [p.strip() for p in section_text.split("\n")]
out = []
for p in raw:
wc = len(p.split())
if MIN_WORDS <= wc <= MAX_WORDS:
out.append(p)
return out[:MAX_PER_SECTION]
# 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]:
@@ -94,28 +69,18 @@ async def main(apply: bool) -> None:
total_paras = 0
for src in sources:
units: list[tuple[str, str]] = [] # (section, paragraph)
for section_type, section_text in _split_into_sections(src["full_text"]):
section = _SECTION_MAP.get(section_type, "other")
for para in _paragraphs(section_text):
units.append((section, para))
if not units:
n = len(sx.units_for(src["full_text"]))
if not n:
continue
total_paras += len(units)
log.info(" %-14s %-16s%d פסקאות", src["source"], src["decision_number"], len(units))
total_paras += n
log.info(" %-14s %-16s%d פסקאות", src["source"], src["decision_number"], n)
if not apply:
continue
await db.delete_style_exemplars(src["decision_number"], src["source"])
texts = [u[1] for u in units]
vecs = await embeddings.embed_texts(texts, input_type="document")
for (section, para), vec in zip(units, vecs):
await db.insert_style_exemplar(
decision_number=src["decision_number"], source=src["source"],
practice_area=src["practice_area"], outcome=src["outcome"],
section=section, paragraph_text=para, word_count=len(para.split()),
embedding=vec,
)
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()