"""Block-level style-exemplar extraction (channel B of Style Acquisition). Splits one of Dafna's decisions into section→paragraph units, embeds them (Voyage), and stores them in `style_exemplars` so the writer can retrieve real block-level prose by section/outcome/practice_area (07-learning §0.2 channel B). This is the SINGLE source of truth for exemplar extraction (G2): both the one-time backfill (`scripts/backfill_style_exemplars.py`) and the live final-enrollment path (`_enroll_final_in_library`) call `extract_and_store`. Before this, exemplars were frozen at the seed backfill — new finals enrolled into style_corpus but were never broken into exemplars, so the richest style channel never grew. Embedding is Voyage-over-REST → container-safe. """ from __future__ import annotations import logging from legal_mcp.services import db, embeddings from legal_mcp.services.chunker import _split_into_sections logger = logging.getLogger(__name__) # 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] def units_for(full_text: str) -> list[tuple[str, str]]: """(section, paragraph) units for a decision — pure, no I/O.""" units: list[tuple[str, str]] = [] for section_type, section_text in _split_into_sections(full_text or ""): section = _SECTION_MAP.get(section_type, "other") for para in _paragraphs(section_text): units.append((section, para)) return units async def extract_and_store( decision_number: str, source: str, full_text: str, practice_area: str = "", outcome: str = "", ) -> int: """Idempotently (re)build a decision's block-level style exemplars: split → embed → replace. Returns the number of exemplars stored. Raises on failure; callers in best-effort paths (enroll) should wrap in try/except.""" units = units_for(full_text) if not units: return 0 texts = [u[1] for u in units] vecs = await embeddings.embed_texts(texts, input_type="document") await db.delete_style_exemplars(decision_number, source) for (section, para), vec in zip(units, vecs): await db.insert_style_exemplar( decision_number=decision_number, source=source, practice_area=practice_area, outcome=outcome, section=section, paragraph_text=para, word_count=len(para.split()), embedding=vec, ) return len(units)