lean-memory 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,483 @@
1
+ """Phase 1, Pass 2: GLiNER2 candidate generation (the deterministic high-recall arm).
2
+
3
+ The hybrid pipeline (BET 2) is *not* "rules emit, LLM fills gaps" — it is "deterministic
4
+ over-generates candidates at high recall, LLM does mandatory constrained typing/validation."
5
+ This module is the over-generation step: turn an episode into a *broad* set of
6
+ `(subject, relation, object)` candidates with char spans + model confidence, then hand them
7
+ to the router (Pass 3) which decides which to escalate to the LLM (Pass 4).
8
+
9
+ Two backends, mirroring Phase 0's FakeEmbedder / IdentityReranker split:
10
+
11
+ * ``Gliner2Generator`` — the real model (gliner2 1.3.1, ~205M, CPU-friendly). Lazy-loads
12
+ so importing this module never pulls torch/transformers. Raises
13
+ a clean ImportError pointing at the ``[extract]`` extra if the
14
+ package is missing.
15
+ * ``StubCandidateGenerator`` — deterministic, zero-dependency heuristics (capitalized tokens
16
+ as entities + a tiny verb→relation lexicon). This is the OFFLINE
17
+ DEFAULT so the whole test suite runs with no downloads, no GPU,
18
+ no servers — exactly like FakeEmbedder.
19
+
20
+ Both implement the same ``CandidateGenerator.generate(episode) -> list[Candidate]`` contract,
21
+ so ``Memory`` can swap the real model in behind the interface without touching the pipeline.
22
+
23
+ Why over-generate (low threshold, broad schema): GLiNER2-class relation extraction is weak
24
+ unaided (~17.8% zero-shot Micro-F1), and surface extractors mis-type edges and cannot emit
25
+ inferential ones. We deliberately accept false positives here because the recall-biased router
26
+ + constrained LLM typing downstream are what make the edges trustworthy. The cost guard is the
27
+ router's escalation rate (<20% target), not the candidate count.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from abc import ABC, abstractmethod
34
+ from typing import Any, Optional
35
+
36
+ from ..types import Episode
37
+
38
+ # Candidate is the cross-pass currency (Pass 2 emits → Pass 3 routes → Pass 4 types).
39
+ # It lives in taxonomy.py alongside the relation taxonomy so the router/typer share one
40
+ # definition. See integrationNotes for the exact taxonomy.py contents this import expects.
41
+ from .taxonomy import Candidate
42
+
43
+ # ── Default extraction schema (broad on purpose — over-generation, per spec Pass 2) ──
44
+ # These are intentionally generic, high-frequency types/relations. The LLM-typing pass maps
45
+ # the *relation* onto our structural taxonomy (asserts|supersedes|extends|derives); these
46
+ # labels are only the surface-form candidate handles, never the final stored predicate.
47
+ DEFAULT_ENTITY_TYPES: tuple[str, ...] = (
48
+ "person",
49
+ "organization",
50
+ "location",
51
+ "product",
52
+ "date",
53
+ "preference",
54
+ "role",
55
+ "skill",
56
+ )
57
+ DEFAULT_RELATION_TYPES: tuple[str, ...] = (
58
+ "works_at",
59
+ "lives_in",
60
+ "located_in",
61
+ "likes",
62
+ "dislikes",
63
+ "is_a",
64
+ "has",
65
+ "uses",
66
+ "knows",
67
+ "owns",
68
+ "member_of",
69
+ )
70
+
71
+ # LOW threshold => high recall (over-generate). The real gliner2 default is 0.5; the spec
72
+ # wants us below that so candidates the LLM can later validate are not pre-filtered — but 0.1
73
+ # over-generated (~8.4 facts/turn on real conversational data). Calibrated to 0.4 via the
74
+ # 2026-07 granularity sweep (bench/results/calibration/2026-07-granularity-sweep.json):
75
+ # smallest swept threshold with facts/turn <= 4 (3.67 facts/turn, median fact_text 173 chars).
76
+ # Median length is threshold-insensitive (171-187 across the whole sweep) so it does not gate.
77
+ DEFAULT_THRESHOLD = 0.4
78
+ # A candidate at or below this model confidence is *pre-flagged* for LLM typing (needs_typing).
79
+ # The router (Pass 3) ORs this with its other triggers (coref/ellipsis/derives); we only set
80
+ # the cheap, locally-knowable signal here.
81
+ # Frozen 2026-07 to 0.4 (was implicit 0.5) — the chosen escalation operating point from the
82
+ # real-turn calibration after the coref (Task 4) + prior_entity-drop (Task 6) fixes. At
83
+ # (typing=0.4, conf=0.4) the post-drop probe escalates 14.6% (103/704), a derives-dominated
84
+ # residual (by_reason derives=102, coreference=1), clearing the <20% gate with margin — the
85
+ # highest (typing, conf) pair with probe rate < 0.15. See
86
+ # bench/results/calibration/2026-07-escalation-postdrop-p1.json and the calibration README.
87
+ DEFAULT_TYPING_THRESHOLD = 0.4
88
+
89
+
90
+ class CandidateGenerator(ABC):
91
+ """Abstract Pass-2 candidate generator: episode → over-generated relation candidates.
92
+
93
+ Implementations MUST be side-effect free (no writes, no entity resolution) — they only
94
+ propose. Resolution, routing, typing, and versioning happen in later passes. Returning an
95
+ empty list is valid (an episode may contain no extractable edges).
96
+ """
97
+
98
+ #: candidate-gating recall knob; lower => more candidates. Surfaced so the harness/router
99
+ #: can log it and ablate it.
100
+ threshold: float = DEFAULT_THRESHOLD
101
+ #: confidence at/below which a candidate is pre-marked needs_typing (router input).
102
+ typing_threshold: float = DEFAULT_TYPING_THRESHOLD
103
+
104
+ @abstractmethod
105
+ def generate(self, episode: Episode) -> list[Candidate]:
106
+ """Return Pass-2 ``Candidate``s for this episode (source-tagged, with char spans)."""
107
+
108
+
109
+ # ──────────────────────────────────────────────────────────────────────────────
110
+ # Real backend: gliner2 1.3.1
111
+ # ──────────────────────────────────────────────────────────────────────────────
112
+ class Gliner2Generator(CandidateGenerator):
113
+ """GLiNER2-backed candidate generator (the real Pass-2 model).
114
+
115
+ Lazy-loads ``gliner2`` on first ``generate`` (NOT at import / construction) so that merely
116
+ importing this module — or running the offline test suite — never triggers a torch import
117
+ or a HuggingFace download. The weights download on the first real call unless a local dir
118
+ path or HF cache is provided.
119
+
120
+ Configured to OVER-GENERATE: a single forward pass for entities and one for relations, both
121
+ at a low ``threshold`` with a broad schema, ``include_confidence=True`` and
122
+ ``include_spans=True`` so each candidate carries the model's confidence and real character
123
+ offsets. We reconstruct ``(subject, relation, object)`` triples ourselves because
124
+ ``extract_relations`` returns 2-tuples keyed by relation name under a ``relation_extraction``
125
+ wrapper — the relation is the dict KEY, never a third tuple element.
126
+ """
127
+
128
+ def __init__(
129
+ self,
130
+ model_name: str = "fastino/gliner2-base-v1",
131
+ *,
132
+ entity_types: tuple[str, ...] = DEFAULT_ENTITY_TYPES,
133
+ relation_types: tuple[str, ...] = DEFAULT_RELATION_TYPES,
134
+ threshold: float = DEFAULT_THRESHOLD,
135
+ typing_threshold: float = DEFAULT_TYPING_THRESHOLD,
136
+ default_subject: str = "user",
137
+ model: Optional[Any] = None,
138
+ ) -> None:
139
+ self.model_name = model_name
140
+ self.entity_types = list(entity_types)
141
+ self.relation_types = list(relation_types)
142
+ self.threshold = threshold
143
+ self.typing_threshold = typing_threshold
144
+ self.default_subject = default_subject
145
+ # `model` lets tests/quality-tier callers inject a preloaded GLiNER2 (or a duck-typed
146
+ # fake exposing extract_entities/extract_relations) without a download. None => lazy load.
147
+ self._model = model
148
+
149
+ # ── lazy model load (the only place that imports gliner2) ──
150
+ def _ensure_model(self) -> Any:
151
+ if self._model is not None:
152
+ return self._model
153
+ try:
154
+ from gliner2 import GLiNER2 # noqa: PLC0415 — intentional lazy import
155
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
156
+ raise ImportError(
157
+ "Gliner2Generator requires the 'gliner2' package (with torch). "
158
+ "Install the extraction extra: pip install 'lean-memory[extract]' "
159
+ "(plain `pip install gliner2` omits torch; the [extract]/[local] extra adds it). "
160
+ "For offline tests use StubCandidateGenerator instead — it needs no model."
161
+ ) from exc
162
+ # map_location="cpu" keeps us on CPU even if a checkpoint defaults elsewhere; the base
163
+ # model is built for efficient CPU inference (no GPU required).
164
+ self._model = GLiNER2.from_pretrained(self.model_name, map_location="cpu")
165
+ return self._model
166
+
167
+ def generate(self, episode: Episode) -> list[Candidate]:
168
+ text = episode.raw
169
+ if not text or not text.strip():
170
+ return []
171
+ model = self._ensure_model()
172
+
173
+ # Two low-threshold forward passes with confidences + char spans.
174
+ ent_result = model.extract_entities(
175
+ text,
176
+ self.entity_types,
177
+ threshold=self.threshold,
178
+ include_confidence=True,
179
+ include_spans=True,
180
+ )
181
+ rel_result = model.extract_relations(
182
+ text,
183
+ self.relation_types,
184
+ threshold=self.threshold,
185
+ include_confidence=True,
186
+ include_spans=True,
187
+ )
188
+
189
+ # Map each entity surface span → its model confidence, so a relation's head/tail
190
+ # inherits the better-known entity confidence when the relation element omits it.
191
+ entity_conf = self._index_entity_confidence(ent_result)
192
+ return self._relations_to_candidates(rel_result, episode, entity_conf)
193
+
194
+ # ── shape parsing (all per the verified gliner2 1.3.1 return shapes) ──
195
+ @staticmethod
196
+ def _index_entity_confidence(ent_result: dict[str, Any]) -> dict[str, float]:
197
+ """{entity surface text → confidence}. ent_result is keyed by entity-type name; each
198
+ value is a list of {"text","confidence","start","end"} dicts (both flags on)."""
199
+ index: dict[str, float] = {}
200
+ for items in (ent_result or {}).values():
201
+ for it in items or []:
202
+ if isinstance(it, dict) and "text" in it:
203
+ txt = it["text"]
204
+ conf = float(it.get("confidence", 1.0))
205
+ # keep the max confidence if a surface form appears under several types
206
+ if txt not in index or conf > index[txt]:
207
+ index[txt] = conf
208
+ return index
209
+
210
+ def _relations_to_candidates(
211
+ self,
212
+ rel_result: dict[str, Any],
213
+ episode: Episode,
214
+ entity_conf: dict[str, float],
215
+ ) -> list[Candidate]:
216
+ out: list[Candidate] = []
217
+ # extract_relations always wraps under "relation_extraction"; the relation NAME is the
218
+ # inner dict key, each element is a (head, tail) 2-tuple (default) or a {"head","tail"}
219
+ # dict (when include_confidence/include_spans are set, which we always do).
220
+ relmap = (rel_result or {}).get("relation_extraction", {}) or {}
221
+ for relation_name, pairs in relmap.items():
222
+ for p in pairs or []:
223
+ head_txt, head_span, head_conf = _read_endpoint(p, "head")
224
+ tail_txt, tail_span, tail_conf = _read_endpoint(p, "tail")
225
+ if not head_txt or not tail_txt:
226
+ continue
227
+ # Confidence: prefer the relation endpoints, fall back to entity-pass confidence.
228
+ conf = _combine_confidence(
229
+ head_conf,
230
+ tail_conf,
231
+ entity_conf.get(head_txt),
232
+ entity_conf.get(tail_txt),
233
+ )
234
+ fact_text = _candidate_sentence(episode.raw, head_span, tail_span)
235
+ out.append(
236
+ Candidate(
237
+ subject_name=head_txt,
238
+ predicate=relation_name,
239
+ object_literal=tail_txt,
240
+ fact_text=fact_text,
241
+ valid_at=episode.t_ref,
242
+ confidence=conf,
243
+ source="gliner2",
244
+ subject_span=head_span,
245
+ object_span=tail_span,
246
+ # Pre-flag low-confidence candidates for the LLM-typing batch. The router
247
+ # ORs this with coref/ellipsis/cross-turn/derives triggers (Pass 3).
248
+ needs_typing=conf < self.typing_threshold,
249
+ )
250
+ )
251
+ return out
252
+
253
+
254
+ # ──────────────────────────────────────────────────────────────────────────────
255
+ # Offline default backend: deterministic heuristics (no model)
256
+ # ──────────────────────────────────────────────────────────────────────────────
257
+ # Tiny verb→relation lexicon. This is deliberately *not* good — it is the reproducible,
258
+ # zero-download stand-in for GLiNER2 so Pass 2 is exercisable in tests. Patterns are ordered;
259
+ # first match wins per sentence. (RulesExtractor uses a similar lexicon for its predicate slot.)
260
+ _VERB_RELATIONS: list[tuple[re.Pattern[str], str]] = [
261
+ (re.compile(r"\b(?:works?|working)\s+(?:at|for)\b", re.I), "works_at"),
262
+ (re.compile(r"\b(?:lives?|living|based)\s+in\b", re.I), "lives_in"),
263
+ (re.compile(r"\b(?:located|situated)\s+in\b", re.I), "located_in"),
264
+ (re.compile(r"\b(?:dislikes?|hates?)\b", re.I), "dislikes"),
265
+ (re.compile(r"\b(?:likes?|loves?|enjoys?|prefers?)\b", re.I), "likes"),
266
+ (re.compile(r"\b(?:is|am|are)\s+(?:an?\s+)?", re.I), "is_a"),
267
+ (re.compile(r"\b(?:uses?|using)\b", re.I), "uses"),
268
+ (re.compile(r"\b(?:owns?|has|have)\b", re.I), "has"),
269
+ (re.compile(r"\b(?:knows?)\b", re.I), "knows"),
270
+ ]
271
+
272
+ #: Relations clear-cut enough that a first-person, single-object utterance about them
273
+ #: is "trivially explicit" → high confidence → the router routes it `direct` (no LLM).
274
+ #: The looser/structural ones (is_a, has, knows, located_in) stay heuristic and escalate.
275
+ _EXPLICIT_RELATIONS: frozenset[str] = frozenset(
276
+ {"works_at", "lives_in", "likes", "dislikes", "uses"}
277
+ )
278
+
279
+ # First-person markers → the candidate subject is the configured default subject (the user),
280
+ # matching RulesExtractor's behaviour so stub and rules agree on "I/my/me" → "user".
281
+ _FIRST_PERSON = re.compile(r"\b(?:I|I'm|my|me|mine)\b")
282
+ _SENT_SPLIT = re.compile(r"(?<=[.!?])\s+")
283
+ # A "named entity" heuristic: a (possibly multi-word) Capitalized run, e.g. "Acme", "San Francisco".
284
+ _CAP_RUN = re.compile(r"\b[A-Z][\w.&-]*(?:\s+[A-Z][\w.&-]*)*")
285
+
286
+
287
+ class StubCandidateGenerator(CandidateGenerator):
288
+ """Deterministic, offline Pass-2 generator — the test/CI default (no model, no download).
289
+
290
+ Heuristics only: split into sentences; for each sentence with a recognized relation verb,
291
+ pick a subject (the user for first-person sentences, else the first capitalized run) and an
292
+ object (a capitalized run after the verb, else the trailing noun phrase). Emits one
293
+ ``Candidate`` per matched sentence with ``source="stub"`` and real ``str.find``-based char
294
+ spans, so downstream span/confidence handling is exercised identically to the real backend.
295
+
296
+ Determinism contract (mirrors FakeEmbedder): identical episode text → byte-identical
297
+ candidate list across processes and machines. Confidence is a fixed, modest constant so the
298
+ router's needs_typing path is reachable in tests without a model.
299
+ """
300
+
301
+ #: A trivially-explicit fact (known relation verb + first-person subject + clean
302
+ #: object) is HIGH confidence → routes `direct`, skipping the LLM, per the spec's
303
+ #: "trivially-explicit high-confidence intra-utterance facts skip the LLM." A
304
+ #: heuristic guess (capitalized-run subject, unknown-ish structure) stays LOW so
305
+ #: the router escalates it. This split is what makes the BET-2 <20%-escalation
306
+ #: target reachable on the OFFLINE default backend (not just with real GLiNER2).
307
+ STUB_CONFIDENCE_EXPLICIT = 0.9
308
+ STUB_CONFIDENCE_HEURISTIC = 0.4
309
+
310
+ def __init__(
311
+ self,
312
+ *,
313
+ default_subject: str = "user",
314
+ threshold: float = DEFAULT_THRESHOLD,
315
+ typing_threshold: float = DEFAULT_TYPING_THRESHOLD,
316
+ ) -> None:
317
+ self.default_subject = default_subject
318
+ self.threshold = threshold
319
+ self.typing_threshold = typing_threshold
320
+
321
+ def generate(self, episode: Episode) -> list[Candidate]:
322
+ text = episode.raw or ""
323
+ out: list[Candidate] = []
324
+ cursor = 0 # track offset of each sentence within the episode for valid char spans
325
+ for sentence in _split_sentences(text):
326
+ # locate this sentence in the raw text to keep spans absolute (not sentence-relative)
327
+ base = text.find(sentence, cursor)
328
+ if base < 0:
329
+ base = cursor
330
+ cursor = base + len(sentence)
331
+
332
+ matched = self._match_relation(sentence)
333
+ if matched is None:
334
+ continue
335
+ relation, verb_end = matched
336
+
337
+ first_person = bool(_FIRST_PERSON.search(sentence))
338
+ subj_text, subj_span = self._subject(sentence, base, first_person)
339
+ # Object scan starts after BOTH the subject span and the relation verb (S-V-O order),
340
+ # so first-person sentences ("I work at Acme") pick "Acme", not the leading "I".
341
+ subj_end_rel = (subj_span[1] - base) if subj_span else 0
342
+ obj_text, obj_span = self._object(sentence, base, max(subj_end_rel, verb_end))
343
+ if obj_text is None:
344
+ continue
345
+
346
+ # Explicit = first-person subject + a known/explicit relation verb + a clean
347
+ # object span. Those are HIGH confidence and route `direct`; everything else
348
+ # (capitalized-run subject guess, no inference cue) is a heuristic edge the
349
+ # router should escalate. Gives a meaningful (sub-100%) escalation rate offline.
350
+ explicit = first_person and relation in _EXPLICIT_RELATIONS
351
+ conf = self.STUB_CONFIDENCE_EXPLICIT if explicit else self.STUB_CONFIDENCE_HEURISTIC
352
+
353
+ out.append(
354
+ Candidate(
355
+ subject_name=subj_text,
356
+ predicate=relation,
357
+ object_literal=obj_text,
358
+ fact_text=sentence.strip(),
359
+ valid_at=episode.t_ref,
360
+ confidence=conf,
361
+ source="stub",
362
+ subject_span=subj_span,
363
+ object_span=obj_span,
364
+ needs_typing=conf < self.typing_threshold,
365
+ )
366
+ )
367
+ return out
368
+
369
+ def _match_relation(self, sentence: str) -> Optional[tuple[str, int]]:
370
+ """First matching relation verb → (relation, end-offset of the verb in the sentence).
371
+ The end-offset anchors object selection to the post-verb span (S-V-O)."""
372
+ for pat, rel in _VERB_RELATIONS:
373
+ m = pat.search(sentence)
374
+ if m:
375
+ return rel, m.end()
376
+ return None
377
+
378
+ def _subject(
379
+ self, sentence: str, base: int, first_person: bool
380
+ ) -> tuple[str, Optional[tuple[int, int]]]:
381
+ """First-person → default subject (no span; it is implicit). Else first capitalized run."""
382
+ if first_person:
383
+ return self.default_subject, None
384
+ m = _CAP_RUN.search(sentence)
385
+ if m:
386
+ return m.group(0), (base + m.start(), base + m.end())
387
+ # fall back to the lead token so we always have a subject to type against
388
+ first = sentence.split()
389
+ lead = first[0].strip(".,!?;:'\"") if first else self.default_subject
390
+ return (lead or self.default_subject), None
391
+
392
+ def _object(
393
+ self, sentence: str, base: int, after: int
394
+ ) -> tuple[Optional[str], Optional[tuple[int, int]]]:
395
+ """Pick a capitalized run that starts at/after ``after`` (post subject+verb) as the
396
+ object; else the trailing word. ``after`` is a sentence-relative offset. Returns
397
+ (object_text, char_span) or (None, None) if nothing usable. Trailing punctuation
398
+ (e.g. the sentence-final '.') is trimmed so spans stay clean."""
399
+ for m in _CAP_RUN.finditer(sentence):
400
+ if m.start() >= after: # object must come after the subject + relation verb
401
+ return _trim_span(sentence, base, m.start(), m.end())
402
+ # no capitalized run after the verb → use the last meaningful token as a literal object
403
+ tokens = [t.strip(".,!?;:'\"") for t in sentence.split() if t.strip(".,!?;:'\"")]
404
+ if tokens:
405
+ last = tokens[-1]
406
+ idx = sentence.rfind(last)
407
+ if idx >= 0:
408
+ return _trim_span(sentence, base, idx, idx + len(last))
409
+ return last, None
410
+ return None, None
411
+
412
+
413
+ # ──────────────────────────────────────────────────────────────────────────────
414
+ # shared helpers
415
+ # ──────────────────────────────────────────────────────────────────────────────
416
+ _TRAILING_PUNCT = ".,!?;:'\""
417
+
418
+
419
+ def _split_sentences(text: str) -> list[str]:
420
+ return [s for s in _SENT_SPLIT.split(text.strip()) if s.strip()]
421
+
422
+
423
+ def _trim_span(
424
+ sentence: str, base: int, start: int, end: int
425
+ ) -> tuple[str, tuple[int, int]]:
426
+ """Strip trailing punctuation from a sentence-relative [start,end) run and return
427
+ (text, absolute_char_span). Keeps span and text in lock-step so callers can slice raw."""
428
+ while end > start and sentence[end - 1] in _TRAILING_PUNCT:
429
+ end -= 1
430
+ return sentence[start:end], (base + start, base + end)
431
+
432
+
433
+ def _read_endpoint(
434
+ pair: Any, key: str
435
+ ) -> tuple[Optional[str], Optional[tuple[int, int]], Optional[float]]:
436
+ """Read (text, span, confidence) for the 'head'/'tail' of a gliner2 relation element.
437
+
438
+ Handles both shapes: a plain 2-tuple ``(head_text, tail_text)`` (no flags) and the
439
+ ``{"head": {"text","confidence","start","end"}, "tail": {...}}`` dict (flags on). Returns
440
+ (None, None, None) when the endpoint is missing/malformed."""
441
+ if isinstance(pair, dict):
442
+ ep = pair.get(key)
443
+ if isinstance(ep, dict):
444
+ txt = ep.get("text")
445
+ start, end = ep.get("start"), ep.get("end")
446
+ span = (int(start), int(end)) if start is not None and end is not None else None
447
+ conf = ep.get("confidence")
448
+ return txt, span, (float(conf) if conf is not None else None)
449
+ return None, None, None
450
+ if isinstance(pair, (tuple, list)) and len(pair) >= 2:
451
+ # default shape: index 0 = head text, index 1 = tail text; no spans/confidence available
452
+ return (str(pair[0] if key == "head" else pair[1]) or None), None, None
453
+ return None, None, None
454
+
455
+
456
+ def _combine_confidence(*vals: Optional[float]) -> float:
457
+ """Conservative confidence for a candidate = min of the available endpoint/entity confidences.
458
+
459
+ Using the min (not the mean) keeps over-generation honest: a relation is only as trustworthy
460
+ as its weakest grounded span, which biases borderline candidates toward needs_typing."""
461
+ present = [v for v in vals if v is not None]
462
+ if not present:
463
+ return 1.0 # no signal from the model element → defer the gating to the router
464
+ return float(min(present))
465
+
466
+
467
+ def _candidate_sentence(
468
+ text: str, span_a: Optional[tuple[int, int]], span_b: Optional[tuple[int, int]]
469
+ ) -> str:
470
+ """Best-effort standalone sentence for a candidate: the slice of `text` spanning both
471
+ endpoints (so the LLM-typing pass and the embedder see a contiguous, readable string).
472
+ Falls back to the whole text when spans are unavailable."""
473
+ spans = [s for s in (span_a, span_b) if s is not None]
474
+ if not spans:
475
+ return text.strip()
476
+ lo = min(s[0] for s in spans)
477
+ hi = max(s[1] for s in spans)
478
+ # widen to sentence-ish boundaries so the snippet reads as a clause, not a fragment
479
+ left = text.rfind(".", 0, lo)
480
+ left = 0 if left < 0 else left + 1
481
+ right = text.find(".", hi)
482
+ right = len(text) if right < 0 else right + 1
483
+ return text[left:right].strip() or text[lo:hi].strip()