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.
- lean_memory/__init__.py +14 -0
- lean_memory/embed/__init__.py +0 -0
- lean_memory/embed/base.py +51 -0
- lean_memory/embed/fake.py +41 -0
- lean_memory/embed/sentence_transformer.py +65 -0
- lean_memory/extract/__init__.py +0 -0
- lean_memory/extract/contradiction.py +329 -0
- lean_memory/extract/gliner_extractor.py +483 -0
- lean_memory/extract/llm_typer.py +444 -0
- lean_memory/extract/router.py +418 -0
- lean_memory/extract/rules.py +130 -0
- lean_memory/extract/salience.py +123 -0
- lean_memory/extract/taxonomy.py +244 -0
- lean_memory/mcp_server.py +150 -0
- lean_memory/memory.py +203 -0
- lean_memory/retrieve/__init__.py +0 -0
- lean_memory/retrieve/rerank.py +59 -0
- lean_memory/retrieve/retriever.py +125 -0
- lean_memory/store/__init__.py +0 -0
- lean_memory/store/base.py +91 -0
- lean_memory/store/schema.py +83 -0
- lean_memory/store/sqlite_store.py +301 -0
- lean_memory/types.py +107 -0
- lean_memory-0.1.0.dist-info/METADATA +228 -0
- lean_memory-0.1.0.dist-info/RECORD +27 -0
- lean_memory-0.1.0.dist-info/WHEEL +4 -0
- lean_memory-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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()
|