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,444 @@
|
|
|
1
|
+
"""Pass 4 — LLM constrained typing / validation (the *residual*).
|
|
2
|
+
|
|
3
|
+
This is the last stage of the hybrid extraction pipeline (spec §5). Passes 1–3
|
|
4
|
+
already ran: rules + GLiNER2 over-generate high-recall ``(subject, relation,
|
|
5
|
+
object)`` candidates, and the recall-biased router (Pass 3) flagged the hard ones
|
|
6
|
+
(low GLiNER2 confidence, coreference/ellipsis, cross-turn references, possible
|
|
7
|
+
``derives``). Everything the router did NOT escalate is trivially explicit and is
|
|
8
|
+
typed cheaply/deterministically. This module types the escalated residual.
|
|
9
|
+
|
|
10
|
+
The LLM here does **constrained classification, not open generation** (BET 2): for
|
|
11
|
+
each candidate it (a) assigns exactly one relation from the closed taxonomy
|
|
12
|
+
[asserts | supersedes | extends | derives], (b) sets ``is_inference`` (only
|
|
13
|
+
``derives`` is inferential), (c) resolves coreference to a known entity, (d) may
|
|
14
|
+
surface an implicit cross-utterance edge. Open-ended generation is supermemory's
|
|
15
|
+
expensive, nondeterministic path — we deliberately avoid it.
|
|
16
|
+
|
|
17
|
+
WHY two backends (mirrors FakeEmbedder/IdentityReranker in Phase 0): the whole
|
|
18
|
+
spec is gated on "everything testable offline, zero downloads, zero servers."
|
|
19
|
+
``OllamaTyper`` is the real local backend (a small Phi/Qwen-class model behind the
|
|
20
|
+
``ollama`` HTTP client); ``StubTyper`` is a deterministic, dependency-free default
|
|
21
|
+
so the test suite — and the BET 2 ablation harness — runs with no Ollama server
|
|
22
|
+
and no model pull. ``Memory`` defaults to ``StubTyper`` exactly like it defaults to
|
|
23
|
+
``FakeEmbedder``.
|
|
24
|
+
|
|
25
|
+
Note on the input contract: a ``Candidate`` here is the router's output (Pass 3),
|
|
26
|
+
not a persisted ``Fact``. We keep it defined locally (rather than importing from a
|
|
27
|
+
sibling Pass-2/3 module that may still be in flux) so this file is import-clean on
|
|
28
|
+
its own; the only cross-module contract is the relation taxonomy, imported from
|
|
29
|
+
``taxonomy.py`` — the single source of truth for relation names and which relation
|
|
30
|
+
is inferential.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import re
|
|
37
|
+
from abc import ABC, abstractmethod
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from typing import Optional
|
|
40
|
+
|
|
41
|
+
# ── Relation taxonomy + Candidate: the ONE cross-module contract. ──
|
|
42
|
+
# taxonomy.py is the single source of truth for both the relation set and the
|
|
43
|
+
# pre-typing `Candidate` shape. Pass 2 (gliner_extractor) emits canonical Candidates
|
|
44
|
+
# and Pass 3 (router) routes them, so Pass 4 MUST consume the same type — importing
|
|
45
|
+
# it here (rather than forking a local copy) is what keeps the Pass-3→Pass-4 handoff
|
|
46
|
+
# from crashing on field-name drift.
|
|
47
|
+
from .taxonomy import ( # noqa: E402
|
|
48
|
+
INFERENCE_CUES,
|
|
49
|
+
RELATIONS,
|
|
50
|
+
Candidate,
|
|
51
|
+
is_inference_relation,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Relations the TYPER may emit. The typer decides ONLY the asserts-vs-derives split:
|
|
55
|
+
# it sees a single candidate utterance + episode context, NOT the prior-slot state, so
|
|
56
|
+
# it CANNOT correctly judge supersedes/extends (those need the existing slot fact and
|
|
57
|
+
# are the ContradictionResolver's job). Offering all four to a small model made it
|
|
58
|
+
# anchor on 'extends' for everything (0/10 on the BET-2 set). Constrain to {asserts,
|
|
59
|
+
# derives} to match StubTyper's decision surface exactly.
|
|
60
|
+
_TYPER_RELATIONS = ["asserts", "derives"]
|
|
61
|
+
# Full taxonomy kept for validation/reference.
|
|
62
|
+
_ALLOWED_RELATIONS = list(RELATIONS)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class TypedFact:
|
|
67
|
+
"""The typer's output: a candidate bound to a final taxonomy relation + flags.
|
|
68
|
+
|
|
69
|
+
This is the hand-off to ``Memory.add`` / supersession (Pass 5). It carries
|
|
70
|
+
everything needed to build a ``Fact`` plus the relation decision that drives
|
|
71
|
+
versioning (``supersedes`` vs ``extends``) and the ``is_inference`` flag.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
subject_name: str
|
|
75
|
+
predicate: str # the (possibly re-typed) slot predicate
|
|
76
|
+
relation: str # taxonomy: asserts | supersedes | extends | derives
|
|
77
|
+
fact_text: str
|
|
78
|
+
valid_at: int
|
|
79
|
+
is_inference: int # 1 iff relation == "derives"
|
|
80
|
+
confidence: float
|
|
81
|
+
|
|
82
|
+
object_literal: Optional[str] = None
|
|
83
|
+
subject_id: Optional[str] = None
|
|
84
|
+
object_id: Optional[str] = None
|
|
85
|
+
# Provenance of the decision: "stub" | "ollama" | "fallback" — lets the ablation
|
|
86
|
+
# harness attribute Relation-F1 to the backend that produced each label.
|
|
87
|
+
typed_by: str = "stub"
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
# Invariant the whole downstream pipeline relies on: relation is in-taxonomy
|
|
91
|
+
# and is_inference is consistent with it. Fail fast — a bad relation here
|
|
92
|
+
# corrupts supersession logic silently.
|
|
93
|
+
if self.relation not in RELATIONS:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"relation {self.relation!r} not in taxonomy {RELATIONS!r}"
|
|
96
|
+
)
|
|
97
|
+
expected = 1 if is_inference_relation(self.relation) else 0
|
|
98
|
+
# We don't overwrite silently; mismatches are programmer errors.
|
|
99
|
+
if self.is_inference not in (0, 1):
|
|
100
|
+
raise ValueError(f"is_inference must be 0/1, got {self.is_inference!r}")
|
|
101
|
+
if self.is_inference != expected:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"is_inference={self.is_inference} contradicts relation "
|
|
104
|
+
f"{self.relation!r} (expected {expected})"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TyperError(RuntimeError):
|
|
109
|
+
"""Raised when a real typing backend is unreachable (e.g. no Ollama server).
|
|
110
|
+
|
|
111
|
+
Callers catch this to fall back to ``StubTyper`` and keep the offline promise.
|
|
112
|
+
Kept distinct from ``ValueError`` so a *config* error (bad relation) is never
|
|
113
|
+
confused with an *availability* error (server down).
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Typer(ABC):
|
|
118
|
+
"""Maps router-escalated candidates → typed facts. Pluggable, like Embedder.
|
|
119
|
+
|
|
120
|
+
Implementations MUST be pure functions of their inputs given a fixed backend
|
|
121
|
+
(deterministic decode / temperature 0) so the common path stays reproducible.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
def type_candidates(
|
|
126
|
+
self,
|
|
127
|
+
episode_text: str,
|
|
128
|
+
candidates: list[Candidate],
|
|
129
|
+
known_entities: Optional[list[str]] = None,
|
|
130
|
+
) -> list[TypedFact]:
|
|
131
|
+
"""Type each candidate against the closed taxonomy.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
episode_text: the full episode/turn text — context for coreference and
|
|
135
|
+
cross-utterance edges (the candidate's own ``fact_text`` may be a
|
|
136
|
+
fragment).
|
|
137
|
+
candidates: the residual to type (already filtered by Pass 3).
|
|
138
|
+
known_entities: canonical entity names visible to this namespace/session
|
|
139
|
+
— the resolution target for coreference. May be ``None``.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
One ``TypedFact`` per input candidate, in the same order.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── helpers shared by both backends ──
|
|
147
|
+
def _norm_relation(value: object) -> str:
|
|
148
|
+
"""Coerce any backend's label to a valid taxonomy relation; default ``asserts``.
|
|
149
|
+
|
|
150
|
+
Defensive: an LLM (or a future stub) might emit casing/whitespace noise or a
|
|
151
|
+
synonym. Anything not exactly in the taxonomy collapses to ``asserts`` — the
|
|
152
|
+
safe, non-destructive default (it never triggers supersession).
|
|
153
|
+
"""
|
|
154
|
+
if isinstance(value, str):
|
|
155
|
+
v = value.strip().lower()
|
|
156
|
+
if v in RELATIONS:
|
|
157
|
+
return v
|
|
158
|
+
return "asserts"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_CUE_RE = re.compile(r"\b(?:" + "|".join(re.escape(c) for c in INFERENCE_CUES) + r")\b", re.I)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _looks_inferential(text: str) -> bool:
|
|
165
|
+
"""Cheap, deterministic inference-cue check used by the stub backend.
|
|
166
|
+
|
|
167
|
+
Word-boundary matched (NOT bare substring) so 'so' inside 'also', 'since' inside
|
|
168
|
+
'business', etc. do not false-fire and mistype an ordinary fact as `derives`.
|
|
169
|
+
"""
|
|
170
|
+
return bool(_CUE_RE.search(text))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class StubTyper(Typer):
|
|
174
|
+
"""Deterministic, dependency-free offline default — the ``FakeEmbedder`` analog.
|
|
175
|
+
|
|
176
|
+
No model, no server, no network. Rules (mirroring the spec's taxonomy semantics
|
|
177
|
+
but with zero learning):
|
|
178
|
+
|
|
179
|
+
* an explicit inference cue in the surface text → ``derives`` (is_inference=1)
|
|
180
|
+
* everything else → ``asserts`` (is_inference=0)
|
|
181
|
+
|
|
182
|
+
It performs **no coreference resolution beyond identity**: a candidate's
|
|
183
|
+
``subject_name`` is matched case-insensitively against ``known_entities`` and,
|
|
184
|
+
on an exact hit, ``subject_id`` is left as-is (already resolved) — it never
|
|
185
|
+
invents a link. ``supersedes``/``extends`` are intentionally NOT decided here:
|
|
186
|
+
those are a *slot-level* judgment (does the new object contradict the latest
|
|
187
|
+
object in the same subject+predicate slot?) made by the cheap-then-escalate
|
|
188
|
+
contradiction check in ``Memory.add`` (Pass 5), not by per-candidate typing.
|
|
189
|
+
Emitting them blindly here would corrupt versioning, so the stub stays at the
|
|
190
|
+
honest ``asserts``/``derives`` split.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def type_candidates(
|
|
194
|
+
self,
|
|
195
|
+
episode_text: str,
|
|
196
|
+
candidates: list[Candidate],
|
|
197
|
+
known_entities: Optional[list[str]] = None,
|
|
198
|
+
) -> list[TypedFact]:
|
|
199
|
+
out: list[TypedFact] = []
|
|
200
|
+
for c in candidates:
|
|
201
|
+
# Inference cue is checked on the candidate sentence (word-boundary, via
|
|
202
|
+
# _looks_inferential) — not bare substring, which false-fired on 'also'.
|
|
203
|
+
inferential = _looks_inferential(c.fact_text)
|
|
204
|
+
relation = "derives" if inferential else "asserts"
|
|
205
|
+
out.append(
|
|
206
|
+
TypedFact(
|
|
207
|
+
subject_name=c.subject_name,
|
|
208
|
+
predicate=c.predicate or "",
|
|
209
|
+
relation=relation,
|
|
210
|
+
fact_text=c.fact_text,
|
|
211
|
+
valid_at=c.valid_at,
|
|
212
|
+
is_inference=1 if relation == "derives" else 0,
|
|
213
|
+
# Typing adds no information beyond rules → keep the candidate's
|
|
214
|
+
# confidence (don't inflate it the way a real LLM pass would).
|
|
215
|
+
confidence=c.confidence,
|
|
216
|
+
object_literal=c.object_literal,
|
|
217
|
+
# Entity ids are resolved by Memory (upsert_entity), not the typer.
|
|
218
|
+
subject_id=None,
|
|
219
|
+
object_id=c.object_id,
|
|
220
|
+
typed_by="stub",
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# JSON-Schema (hand-built, no pydantic at import time) constraining the LLM to a
|
|
227
|
+
# typed array, one record per candidate, ``relation`` an enum over the taxonomy.
|
|
228
|
+
# This is what makes the call "constrained classification, not generation": Ollama's
|
|
229
|
+
# structured-output grammar forces the model to pick a relation, not write prose.
|
|
230
|
+
def _response_schema(n: int) -> dict:
|
|
231
|
+
item = {
|
|
232
|
+
"type": "object",
|
|
233
|
+
"properties": {
|
|
234
|
+
"index": {"type": "integer"}, # ties the decision back to candidates[i]
|
|
235
|
+
"relation": {"type": "string", "enum": _TYPER_RELATIONS},
|
|
236
|
+
"is_inference": {"type": "integer", "enum": [0, 1]},
|
|
237
|
+
"subject": {"type": "string"}, # resolved/canonical subject name
|
|
238
|
+
},
|
|
239
|
+
"required": ["index", "relation", "is_inference", "subject"],
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
"type": "object",
|
|
243
|
+
"properties": {
|
|
244
|
+
"results": {
|
|
245
|
+
"type": "array",
|
|
246
|
+
"items": item,
|
|
247
|
+
"minItems": n,
|
|
248
|
+
"maxItems": n,
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
"required": ["results"],
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
_SYSTEM_PROMPT = (
|
|
256
|
+
"You are a relation TYPING module inside a memory engine. You do NOT generate "
|
|
257
|
+
"free text and you do NOT invent facts. For each numbered candidate, decide "
|
|
258
|
+
"whether the fact is directly STATED or is an INFERENCE drawn from the episode "
|
|
259
|
+
"context, and output exactly one relation:\n"
|
|
260
|
+
" - asserts: the fact is stated directly/explicitly in the candidate text.\n"
|
|
261
|
+
" - derives: the fact is NOT stated verbatim — it is inferred as a likely "
|
|
262
|
+
"consequence of other information in the episode (set is_inference=1).\n"
|
|
263
|
+
"Examples: episode 'I live 40km away and own no car. I take the train.' → the "
|
|
264
|
+
"train fact is stated, BUT if the candidate were 'I commute somehow', deriving "
|
|
265
|
+
"the train is inference. A fact that simply restates what the episode says is "
|
|
266
|
+
"'asserts'; a fact you can only conclude by reasoning over the episode is "
|
|
267
|
+
"'derives'.\n"
|
|
268
|
+
"Set is_inference=1 only for 'derives', otherwise 0. Resolve the subject to one "
|
|
269
|
+
"of the known entities when the candidate uses a pronoun or ellipsis; otherwise "
|
|
270
|
+
"keep the given subject. Return one result object per candidate, in order, with "
|
|
271
|
+
"the candidate's index. Output ONLY the JSON object required by the schema."
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class OllamaTyper(Typer):
|
|
276
|
+
"""Real backend: constrained typing via a small local model behind Ollama.
|
|
277
|
+
|
|
278
|
+
``ollama`` is imported lazily (it is an optional dep, like sentence-transformers)
|
|
279
|
+
so this module stays import-clean without it. The model is constrained with
|
|
280
|
+
Ollama's ``format=<json schema>`` structured-output mode and ``temperature=0``
|
|
281
|
+
for a reproducible decode. On an unreachable server (no Ollama running — the
|
|
282
|
+
common CI/offline case) we raise :class:`TyperError`, which the facade catches to
|
|
283
|
+
fall back to :class:`StubTyper`.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
#: A small, locally-pullable default — Phi/Qwen-class per spec ("local Phi/Qwen").
|
|
287
|
+
DEFAULT_MODEL = "qwen2.5:3b"
|
|
288
|
+
|
|
289
|
+
def __init__(
|
|
290
|
+
self,
|
|
291
|
+
model: str = DEFAULT_MODEL,
|
|
292
|
+
*,
|
|
293
|
+
host: Optional[str] = None,
|
|
294
|
+
temperature: float = 0.0,
|
|
295
|
+
) -> None:
|
|
296
|
+
self.model = model
|
|
297
|
+
self.host = host
|
|
298
|
+
self.temperature = temperature
|
|
299
|
+
self._client = None # lazily constructed on first call
|
|
300
|
+
|
|
301
|
+
def _get_client(self):
|
|
302
|
+
"""Lazily import ``ollama`` and build a client. Raises TyperError if the
|
|
303
|
+
package is absent (treated as 'backend unavailable', not a crash)."""
|
|
304
|
+
if self._client is not None:
|
|
305
|
+
return self._client
|
|
306
|
+
try:
|
|
307
|
+
import ollama # type: ignore
|
|
308
|
+
except ImportError as e: # optional dep not installed → unavailable backend
|
|
309
|
+
raise TyperError(
|
|
310
|
+
"ollama package not installed; install with the 'llm' extra "
|
|
311
|
+
"(pip install lean-memory[llm]) or use StubTyper for offline."
|
|
312
|
+
) from e
|
|
313
|
+
# A Client lets us honor a custom host; module-level fns hit localhost.
|
|
314
|
+
self._client = ollama.Client(host=self.host) if self.host else ollama
|
|
315
|
+
return self._client
|
|
316
|
+
|
|
317
|
+
def type_candidates(
|
|
318
|
+
self,
|
|
319
|
+
episode_text: str,
|
|
320
|
+
candidates: list[Candidate],
|
|
321
|
+
known_entities: Optional[list[str]] = None,
|
|
322
|
+
) -> list[TypedFact]:
|
|
323
|
+
if not candidates:
|
|
324
|
+
return []
|
|
325
|
+
client = self._get_client()
|
|
326
|
+
user_prompt = self._build_user_prompt(episode_text, candidates, known_entities)
|
|
327
|
+
schema = _response_schema(len(candidates))
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
resp = client.chat(
|
|
331
|
+
model=self.model,
|
|
332
|
+
messages=[
|
|
333
|
+
{"role": "system", "content": _SYSTEM_PROMPT},
|
|
334
|
+
{"role": "user", "content": user_prompt},
|
|
335
|
+
],
|
|
336
|
+
format=schema, # JSON-Schema → constrained, classification-only output
|
|
337
|
+
options={"temperature": self.temperature},
|
|
338
|
+
)
|
|
339
|
+
except ConnectionError as e:
|
|
340
|
+
# Builtin ConnectionError == "no Ollama server" (ollama 0.6.x re-raises
|
|
341
|
+
# httpx ConnectError as this). The exact catchable signal for fallback.
|
|
342
|
+
raise TyperError(
|
|
343
|
+
f"cannot reach Ollama (is the server running at "
|
|
344
|
+
f"{self.host or 'localhost:11434'}?): {e}"
|
|
345
|
+
) from e
|
|
346
|
+
except Exception as e: # ResponseError (e.g. model not pulled, 404) etc.
|
|
347
|
+
raise TyperError(f"Ollama typing call failed: {e}") from e
|
|
348
|
+
|
|
349
|
+
content = self._extract_content(resp)
|
|
350
|
+
decisions = self._parse_decisions(content, len(candidates))
|
|
351
|
+
return self._apply_decisions(candidates, decisions, known_entities)
|
|
352
|
+
|
|
353
|
+
# ── prompt / response plumbing (kept small + deterministic) ──
|
|
354
|
+
@staticmethod
|
|
355
|
+
def _build_user_prompt(
|
|
356
|
+
episode_text: str,
|
|
357
|
+
candidates: list[Candidate],
|
|
358
|
+
known_entities: Optional[list[str]],
|
|
359
|
+
) -> str:
|
|
360
|
+
lines = [f"EPISODE:\n{episode_text.strip()}", ""]
|
|
361
|
+
if known_entities:
|
|
362
|
+
lines.append("KNOWN ENTITIES: " + ", ".join(known_entities))
|
|
363
|
+
lines.append("")
|
|
364
|
+
lines.append("CANDIDATES:")
|
|
365
|
+
for i, c in enumerate(candidates):
|
|
366
|
+
obj = f" -> {c.object_literal}" if c.object_literal else ""
|
|
367
|
+
lines.append(
|
|
368
|
+
f"[{i}] subject={c.subject_name!r} predicate={c.predicate!r}{obj} "
|
|
369
|
+
f"| text: {c.fact_text.strip()}"
|
|
370
|
+
)
|
|
371
|
+
return "\n".join(lines)
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def _extract_content(resp: object) -> str:
|
|
375
|
+
"""Pull the JSON string from a ChatResponse, tolerating attr/dict access.
|
|
376
|
+
|
|
377
|
+
Real ollama ``ChatResponse`` exposes ``.message.content``; we also accept the
|
|
378
|
+
dict form so a hand-rolled stub response (tests) works unchanged.
|
|
379
|
+
"""
|
|
380
|
+
msg = getattr(resp, "message", None)
|
|
381
|
+
if msg is not None:
|
|
382
|
+
content = getattr(msg, "content", None)
|
|
383
|
+
if content is not None:
|
|
384
|
+
return content
|
|
385
|
+
try:
|
|
386
|
+
return resp["message"]["content"] # type: ignore[index]
|
|
387
|
+
except (TypeError, KeyError):
|
|
388
|
+
raise TyperError(f"unexpected Ollama response shape: {resp!r}")
|
|
389
|
+
|
|
390
|
+
@staticmethod
|
|
391
|
+
def _parse_decisions(content: str, n: int) -> list[dict]:
|
|
392
|
+
"""Parse the constrained JSON. Schema guarantees shape, but we stay defensive
|
|
393
|
+
so a single malformed decode degrades to defaults rather than crashing."""
|
|
394
|
+
try:
|
|
395
|
+
data = json.loads(content)
|
|
396
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
397
|
+
raise TyperError(f"Ollama returned non-JSON content: {e}") from e
|
|
398
|
+
results = data.get("results") if isinstance(data, dict) else None
|
|
399
|
+
if not isinstance(results, list):
|
|
400
|
+
raise TyperError(f"Ollama JSON missing 'results' array: {content!r}")
|
|
401
|
+
# Index decisions for order-independent application.
|
|
402
|
+
by_index: dict[int, dict] = {}
|
|
403
|
+
for r in results:
|
|
404
|
+
if isinstance(r, dict) and isinstance(r.get("index"), int):
|
|
405
|
+
by_index[r["index"]] = r
|
|
406
|
+
return [by_index.get(i, {}) for i in range(n)]
|
|
407
|
+
|
|
408
|
+
@staticmethod
|
|
409
|
+
def _apply_decisions(
|
|
410
|
+
candidates: list[Candidate],
|
|
411
|
+
decisions: list[dict],
|
|
412
|
+
known_entities: Optional[list[str]],
|
|
413
|
+
) -> list[TypedFact]:
|
|
414
|
+
known_lookup = {e.lower(): e for e in (known_entities or [])}
|
|
415
|
+
out: list[TypedFact] = []
|
|
416
|
+
for c, d in zip(candidates, decisions):
|
|
417
|
+
relation = _norm_relation(d.get("relation"))
|
|
418
|
+
inferred = 1 if is_inference_relation(relation) else 0
|
|
419
|
+
# Coreference: prefer the model's resolved subject if it names a known
|
|
420
|
+
# entity; otherwise keep the candidate's own subject (no fabrication).
|
|
421
|
+
resolved = d.get("subject")
|
|
422
|
+
subject_name = c.subject_name
|
|
423
|
+
if isinstance(resolved, str) and resolved.strip():
|
|
424
|
+
key = resolved.strip().lower()
|
|
425
|
+
subject_name = known_lookup.get(key, resolved.strip())
|
|
426
|
+
out.append(
|
|
427
|
+
TypedFact(
|
|
428
|
+
subject_name=subject_name,
|
|
429
|
+
predicate=c.predicate or "",
|
|
430
|
+
relation=relation,
|
|
431
|
+
fact_text=c.fact_text,
|
|
432
|
+
valid_at=c.valid_at,
|
|
433
|
+
is_inference=inferred,
|
|
434
|
+
# A successful LLM typing pass is the spec's quality lever → lift
|
|
435
|
+
# confidence modestly above the raw GLiNER2/rules guess.
|
|
436
|
+
confidence=max(c.confidence, 0.75),
|
|
437
|
+
object_literal=c.object_literal,
|
|
438
|
+
# Entity ids are resolved by Memory (upsert_entity), not the typer.
|
|
439
|
+
subject_id=None,
|
|
440
|
+
object_id=c.object_id,
|
|
441
|
+
typed_by="ollama",
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
return out
|