flurryx-code-memory 0.4.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.
- code_memory/__init__.py +1 -0
- code_memory/claims/__init__.py +32 -0
- code_memory/claims/extractor.py +325 -0
- code_memory/claims/indexer.py +258 -0
- code_memory/claims/resolver.py +186 -0
- code_memory/claims/store.py +424 -0
- code_memory/cli.py +1192 -0
- code_memory/config.py +268 -0
- code_memory/embed/__init__.py +224 -0
- code_memory/embed/cache.py +204 -0
- code_memory/embed/m3.py +174 -0
- code_memory/embed/ollama.py +92 -0
- code_memory/embed/tei.py +106 -0
- code_memory/episodic/__init__.py +3 -0
- code_memory/episodic/sqlite_store.py +278 -0
- code_memory/extractor/__init__.py +3 -0
- code_memory/extractor/csproj.py +166 -0
- code_memory/extractor/dll.py +385 -0
- code_memory/extractor/gitignore.py +162 -0
- code_memory/extractor/nuget.py +275 -0
- code_memory/extractor/sanity.py +124 -0
- code_memory/extractor/sln.py +108 -0
- code_memory/extractor/treesitter.py +1172 -0
- code_memory/graph/__init__.py +3 -0
- code_memory/graph/falkor_store.py +740 -0
- code_memory/mcp_server.py +1816 -0
- code_memory/metrics.py +260 -0
- code_memory/orchestrator/__init__.py +13 -0
- code_memory/orchestrator/git_delta.py +211 -0
- code_memory/orchestrator/ingest_state.py +71 -0
- code_memory/orchestrator/pipeline.py +1478 -0
- code_memory/orchestrator/reset.py +130 -0
- code_memory/orchestrator/resolver.py +825 -0
- code_memory/orchestrator/retrieve.py +505 -0
- code_memory/resilience.py +73 -0
- code_memory/sync/__init__.py +20 -0
- code_memory/sync/autostart/__init__.py +42 -0
- code_memory/sync/autostart/base.py +106 -0
- code_memory/sync/autostart/launchd.py +115 -0
- code_memory/sync/autostart/schtasks.py +155 -0
- code_memory/sync/autostart/systemd.py +113 -0
- code_memory/sync/hooks.py +164 -0
- code_memory/sync/safety.py +65 -0
- code_memory/sync/snapshot.py +461 -0
- code_memory/sync/store.py +399 -0
- code_memory/sync/sync.py +405 -0
- code_memory/sync/watcher.py +320 -0
- code_memory/vector/__init__.py +3 -0
- code_memory/vector/qdrant_store.py +302 -0
- flurryx_code_memory-0.4.0.dist-info/METADATA +26 -0
- flurryx_code_memory-0.4.0.dist-info/RECORD +53 -0
- flurryx_code_memory-0.4.0.dist-info/WHEEL +4 -0
- flurryx_code_memory-0.4.0.dist-info/entry_points.txt +3 -0
code_memory/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""User-prompt claim extraction (Graphiti-style).
|
|
2
|
+
|
|
3
|
+
The pipeline turns substantive user prompts into structured
|
|
4
|
+
``(subject, predicate, object)`` claims with bi-temporal validity so a
|
|
5
|
+
later session can answer "what did the user say about X last Tuesday?"
|
|
6
|
+
without re-reading every prompt.
|
|
7
|
+
|
|
8
|
+
Layout:
|
|
9
|
+
* :mod:`.extractor` — local-LLM extraction (Ollama, gemma2:9b default).
|
|
10
|
+
* :mod:`.store` — SQLite store with bi-temporal columns and a
|
|
11
|
+
single-valued predicate registry for contradiction
|
|
12
|
+
handling.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .extractor import Claim, ClaimExtractor, ExtractionError
|
|
16
|
+
from .indexer import ClaimsIndexer, make_claims_indexer
|
|
17
|
+
from .resolver import EntityRef, EntityResolver
|
|
18
|
+
from .store import ClaimRecord, ClaimsStore, SINGLE_VALUED_PREDICATES, UpsertResult
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Claim",
|
|
22
|
+
"ClaimExtractor",
|
|
23
|
+
"ClaimRecord",
|
|
24
|
+
"ClaimsIndexer",
|
|
25
|
+
"ClaimsStore",
|
|
26
|
+
"EntityRef",
|
|
27
|
+
"EntityResolver",
|
|
28
|
+
"ExtractionError",
|
|
29
|
+
"SINGLE_VALUED_PREDICATES",
|
|
30
|
+
"UpsertResult",
|
|
31
|
+
"make_claims_indexer",
|
|
32
|
+
]
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Local-LLM claim extractor.
|
|
2
|
+
|
|
3
|
+
Calls an Ollama-served instruct model (gemma2:9b by default) in JSON
|
|
4
|
+
mode and returns a list of :class:`Claim` records. Output is validated
|
|
5
|
+
defensively because LLMs lie:
|
|
6
|
+
|
|
7
|
+
* ``evidence_span`` must be a literal substring of the source prompt.
|
|
8
|
+
Hallucinated triples that paraphrase the input are dropped.
|
|
9
|
+
* ``confidence`` below ``CLAIMS_MIN_CONFIDENCE`` is dropped.
|
|
10
|
+
* Empty / non-string subject or object is dropped.
|
|
11
|
+
|
|
12
|
+
The extractor never raises on a malformed model response — it returns
|
|
13
|
+
an empty list so the caller (an async hook) never blocks the session.
|
|
14
|
+
The only raised exception is :class:`ExtractionError` for hard
|
|
15
|
+
infrastructure failures (Ollama unreachable, model not pulled).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from ..config import CONFIG
|
|
28
|
+
|
|
29
|
+
_LOG = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ExtractionError(RuntimeError):
|
|
33
|
+
"""Raised when the LLM backend itself is unreachable or misconfigured."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Closed predicate vocabulary. The system prompt instructs the model to
|
|
37
|
+
# stay inside this set; _coerce enforces it so a noisy generation can't
|
|
38
|
+
# smuggle in free-form predicates that would defeat single-valued
|
|
39
|
+
# contradiction handling downstream.
|
|
40
|
+
_ALLOWED_PREDICATES: frozenset[str] = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"uses",
|
|
43
|
+
"prefers",
|
|
44
|
+
"rejected",
|
|
45
|
+
"wants-to",
|
|
46
|
+
"is-located-at",
|
|
47
|
+
"depends-on",
|
|
48
|
+
"deployed-to",
|
|
49
|
+
"owns",
|
|
50
|
+
"is-a",
|
|
51
|
+
"mentioned",
|
|
52
|
+
"worked-on",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Claim:
|
|
59
|
+
subject: str
|
|
60
|
+
predicate: str
|
|
61
|
+
object: str
|
|
62
|
+
polarity: bool # True = asserts, False = negates ("does not use X")
|
|
63
|
+
confidence: float
|
|
64
|
+
evidence_span: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# JSON schema embedded in the prompt. Ollama's structured-output mode
|
|
68
|
+
# uses this verbatim to constrain the decoder. The schema is intentionally
|
|
69
|
+
# narrow — predicates are normalized to kebab-case verbs so downstream
|
|
70
|
+
# resolution doesn't have to disambiguate "uses" / "USES" / "Uses".
|
|
71
|
+
_OUTPUT_SCHEMA = {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"properties": {
|
|
74
|
+
"claims": {
|
|
75
|
+
"type": "array",
|
|
76
|
+
"items": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"properties": {
|
|
79
|
+
"subject": {"type": "string", "minLength": 1},
|
|
80
|
+
"predicate": {"type": "string", "minLength": 1},
|
|
81
|
+
"object": {"type": "string", "minLength": 1},
|
|
82
|
+
"polarity": {"type": "boolean"},
|
|
83
|
+
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
|
84
|
+
"evidence_span": {"type": "string", "minLength": 1},
|
|
85
|
+
},
|
|
86
|
+
"required": [
|
|
87
|
+
"subject",
|
|
88
|
+
"predicate",
|
|
89
|
+
"object",
|
|
90
|
+
"polarity",
|
|
91
|
+
"confidence",
|
|
92
|
+
"evidence_span",
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"required": ["claims"],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_SYSTEM_PROMPT = """\
|
|
102
|
+
You extract DURABLE factual claims from a software engineer's chat
|
|
103
|
+
message. Durable = the assertion is likely still true in a future
|
|
104
|
+
session, not transient task state.
|
|
105
|
+
|
|
106
|
+
Output JSON only, matching the provided schema. Each claim is a
|
|
107
|
+
(subject, predicate, object) triple plus polarity, confidence, and an
|
|
108
|
+
``evidence_span`` that is a verbatim substring of the input.
|
|
109
|
+
|
|
110
|
+
Rules:
|
|
111
|
+
- Predicate is kebab-case verb phrase from this closed vocabulary:
|
|
112
|
+
"uses", "prefers", "rejected", "wants-to", "is-located-at",
|
|
113
|
+
"depends-on", "deployed-to", "owns", "is-a", "mentioned",
|
|
114
|
+
"worked-on". Reject any predicate outside this list.
|
|
115
|
+
- Subject and object are short noun phrases lifted from the message;
|
|
116
|
+
normalize case but keep technical identifiers as written.
|
|
117
|
+
- HARD FILTER — skip and emit no claim for:
|
|
118
|
+
* Questions of any kind ("should I…", "why does…", "is X…").
|
|
119
|
+
* Hypotheticals / counterfactuals ("if we used…", "suppose X…").
|
|
120
|
+
* Imperatives directed at YOU the assistant ("fix this", "run X",
|
|
121
|
+
"look at Y") — those are task state, not durable facts.
|
|
122
|
+
* Opinions about third parties or general industry statements.
|
|
123
|
+
* Small talk, acknowledgments, meta-comments about the conversation.
|
|
124
|
+
* Anything that would be obvious from the codebase itself (e.g.
|
|
125
|
+
"this file imports React" — already in the source).
|
|
126
|
+
- Only extract assertions the user is making about their PROJECT, their
|
|
127
|
+
TOOLING choices, their PREFERENCES, OWNERSHIP, or LOCATIONS — facts
|
|
128
|
+
worth recalling next week.
|
|
129
|
+
- ``confidence`` ∈ [0,1] reflects how certain you are this is a
|
|
130
|
+
durable assertion (not a question, speculation, or task state).
|
|
131
|
+
Below 0.7 → don't emit at all.
|
|
132
|
+
- Be CONSERVATIVE. If in doubt, emit nothing. Empty output is the
|
|
133
|
+
correct answer for most messages.
|
|
134
|
+
- If nothing qualifies, return {"claims": []}.
|
|
135
|
+
|
|
136
|
+
Examples:
|
|
137
|
+
|
|
138
|
+
INPUT: "we use Qdrant for vectors and FalkorDB for the graph"
|
|
139
|
+
OUTPUT: {"claims": [
|
|
140
|
+
{"subject":"project","predicate":"uses","object":"Qdrant",
|
|
141
|
+
"polarity":true,"confidence":0.95,"evidence_span":"use Qdrant for vectors"},
|
|
142
|
+
{"subject":"project","predicate":"uses","object":"FalkorDB",
|
|
143
|
+
"polarity":true,"confidence":0.95,"evidence_span":"FalkorDB for the graph"}
|
|
144
|
+
]}
|
|
145
|
+
|
|
146
|
+
INPUT: "should I use Redis here?"
|
|
147
|
+
OUTPUT: {"claims": []}
|
|
148
|
+
|
|
149
|
+
INPUT: "fix the bug in auth.py"
|
|
150
|
+
OUTPUT: {"claims": []}
|
|
151
|
+
|
|
152
|
+
INPUT: "look at this file and tell me what it does"
|
|
153
|
+
OUTPUT: {"claims": []}
|
|
154
|
+
|
|
155
|
+
INPUT: "I don't want to ship dark mode"
|
|
156
|
+
OUTPUT: {"claims": [
|
|
157
|
+
{"subject":"user","predicate":"rejected","object":"dark mode",
|
|
158
|
+
"polarity":true,"confidence":0.9,"evidence_span":"don't want to ship dark mode"}
|
|
159
|
+
]}
|
|
160
|
+
|
|
161
|
+
INPUT: "stop summarizing at the end of every response"
|
|
162
|
+
OUTPUT: {"claims": [
|
|
163
|
+
{"subject":"user","predicate":"prefers","object":"no end-of-turn summaries",
|
|
164
|
+
"polarity":true,"confidence":0.9,
|
|
165
|
+
"evidence_span":"stop summarizing at the end of every response"}
|
|
166
|
+
]}
|
|
167
|
+
|
|
168
|
+
INPUT: "the billing service lives in apps/api/billing"
|
|
169
|
+
OUTPUT: {"claims": [
|
|
170
|
+
{"subject":"billing service","predicate":"is-located-at",
|
|
171
|
+
"object":"apps/api/billing","polarity":true,"confidence":0.95,
|
|
172
|
+
"evidence_span":"billing service lives in apps/api/billing"}
|
|
173
|
+
]}
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class ClaimExtractor:
|
|
178
|
+
"""Thin sync wrapper over Ollama's /api/chat with JSON-mode output.
|
|
179
|
+
|
|
180
|
+
Construction is cheap; the HTTP client is created lazily so import
|
|
181
|
+
of this module never touches the network.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def __init__(
|
|
185
|
+
self,
|
|
186
|
+
url: str | None = None,
|
|
187
|
+
model: str | None = None,
|
|
188
|
+
timeout: float | None = None,
|
|
189
|
+
min_confidence: float | None = None,
|
|
190
|
+
) -> None:
|
|
191
|
+
self.url = (url or CONFIG.ollama_url).rstrip("/")
|
|
192
|
+
self.model = model or CONFIG.claims_llm_model
|
|
193
|
+
self.timeout = timeout if timeout is not None else CONFIG.claims_llm_timeout
|
|
194
|
+
self.min_confidence = (
|
|
195
|
+
min_confidence
|
|
196
|
+
if min_confidence is not None
|
|
197
|
+
else CONFIG.claims_min_confidence
|
|
198
|
+
)
|
|
199
|
+
self._client: httpx.Client | None = None
|
|
200
|
+
|
|
201
|
+
# ------------------------------------------------------------------ http
|
|
202
|
+
|
|
203
|
+
def _http(self) -> httpx.Client:
|
|
204
|
+
if self._client is None:
|
|
205
|
+
self._client = httpx.Client(timeout=self.timeout)
|
|
206
|
+
return self._client
|
|
207
|
+
|
|
208
|
+
def close(self) -> None:
|
|
209
|
+
if self._client is not None:
|
|
210
|
+
self._client.close()
|
|
211
|
+
self._client = None
|
|
212
|
+
|
|
213
|
+
def __enter__(self) -> ClaimExtractor:
|
|
214
|
+
return self
|
|
215
|
+
|
|
216
|
+
def __exit__(self, *exc: object) -> None:
|
|
217
|
+
self.close()
|
|
218
|
+
|
|
219
|
+
# ----------------------------------------------------------------- extract
|
|
220
|
+
|
|
221
|
+
def extract(self, prompt: str) -> list[Claim]:
|
|
222
|
+
"""Run extraction over a single user prompt.
|
|
223
|
+
|
|
224
|
+
Returns the validated, deduplicated, confidence-filtered list.
|
|
225
|
+
Never raises on a malformed model response — returns ``[]``.
|
|
226
|
+
Raises :class:`ExtractionError` only on transport-level failures.
|
|
227
|
+
"""
|
|
228
|
+
prompt = prompt.strip()
|
|
229
|
+
if not prompt:
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
raw = self._call_ollama(prompt)
|
|
234
|
+
except httpx.HTTPError as exc:
|
|
235
|
+
raise ExtractionError(f"Ollama call failed: {exc}") from exc
|
|
236
|
+
|
|
237
|
+
return self._parse_and_validate(raw, prompt)
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------ internals
|
|
240
|
+
|
|
241
|
+
def _call_ollama(self, prompt: str) -> str:
|
|
242
|
+
payload: dict[str, Any] = {
|
|
243
|
+
"model": self.model,
|
|
244
|
+
"format": _OUTPUT_SCHEMA,
|
|
245
|
+
"stream": False,
|
|
246
|
+
"options": {"temperature": 0.0},
|
|
247
|
+
"messages": [
|
|
248
|
+
{"role": "system", "content": _SYSTEM_PROMPT},
|
|
249
|
+
{"role": "user", "content": prompt},
|
|
250
|
+
],
|
|
251
|
+
}
|
|
252
|
+
res = self._http().post(f"{self.url}/api/chat", json=payload)
|
|
253
|
+
res.raise_for_status()
|
|
254
|
+
data = res.json()
|
|
255
|
+
msg = data.get("message") or {}
|
|
256
|
+
return str(msg.get("content") or "")
|
|
257
|
+
|
|
258
|
+
def _parse_and_validate(self, raw: str, source_prompt: str) -> list[Claim]:
|
|
259
|
+
if not raw.strip():
|
|
260
|
+
return []
|
|
261
|
+
try:
|
|
262
|
+
parsed = json.loads(raw)
|
|
263
|
+
except json.JSONDecodeError:
|
|
264
|
+
_LOG.warning("claim extractor: non-JSON response, dropping")
|
|
265
|
+
return []
|
|
266
|
+
|
|
267
|
+
items = parsed.get("claims")
|
|
268
|
+
if not isinstance(items, list):
|
|
269
|
+
return []
|
|
270
|
+
|
|
271
|
+
out: list[Claim] = []
|
|
272
|
+
seen: set[tuple[str, str, str, bool]] = set()
|
|
273
|
+
for item in items:
|
|
274
|
+
claim = self._coerce(item, source_prompt)
|
|
275
|
+
if claim is None:
|
|
276
|
+
continue
|
|
277
|
+
key = (
|
|
278
|
+
claim.subject.lower(),
|
|
279
|
+
claim.predicate.lower(),
|
|
280
|
+
claim.object.lower(),
|
|
281
|
+
claim.polarity,
|
|
282
|
+
)
|
|
283
|
+
if key in seen:
|
|
284
|
+
continue
|
|
285
|
+
seen.add(key)
|
|
286
|
+
out.append(claim)
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
def _coerce(self, item: Any, source_prompt: str) -> Claim | None:
|
|
290
|
+
if not isinstance(item, dict):
|
|
291
|
+
return None
|
|
292
|
+
try:
|
|
293
|
+
subject = str(item["subject"]).strip()
|
|
294
|
+
predicate = str(item["predicate"]).strip().lower().replace(" ", "-")
|
|
295
|
+
obj = str(item["object"]).strip()
|
|
296
|
+
polarity = bool(item["polarity"])
|
|
297
|
+
confidence = float(item["confidence"])
|
|
298
|
+
evidence = str(item["evidence_span"]).strip()
|
|
299
|
+
except (KeyError, TypeError, ValueError):
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
if not subject or not predicate or not obj or not evidence:
|
|
303
|
+
return None
|
|
304
|
+
if predicate not in _ALLOWED_PREDICATES:
|
|
305
|
+
_LOG.debug(
|
|
306
|
+
"claim extractor: dropping out-of-vocab predicate %r", predicate
|
|
307
|
+
)
|
|
308
|
+
return None
|
|
309
|
+
if confidence < self.min_confidence:
|
|
310
|
+
return None
|
|
311
|
+
# Anti-hallucination: evidence must be present in the source.
|
|
312
|
+
if evidence.lower() not in source_prompt.lower():
|
|
313
|
+
_LOG.debug(
|
|
314
|
+
"claim extractor: dropping hallucinated span %r", evidence
|
|
315
|
+
)
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
return Claim(
|
|
319
|
+
subject=subject,
|
|
320
|
+
predicate=predicate,
|
|
321
|
+
object=obj,
|
|
322
|
+
polarity=polarity,
|
|
323
|
+
confidence=confidence,
|
|
324
|
+
evidence_span=evidence,
|
|
325
|
+
)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Qdrant-backed semantic index over user claims.
|
|
2
|
+
|
|
3
|
+
``ClaimsStore`` (SQLite) is the source of truth for the bi-temporal
|
|
4
|
+
claim history. This module layers a vector index on top so retrieval
|
|
5
|
+
can match claims semantically — "we use Postgres" surfaces for a query
|
|
6
|
+
about "DB choice" — instead of relying on the token-overlap heuristic
|
|
7
|
+
in :func:`code_memory.orchestrator.retrieve._rank_claims`.
|
|
8
|
+
|
|
9
|
+
Design choices:
|
|
10
|
+
|
|
11
|
+
* **Keep + flag, not delete.** When a claim is superseded
|
|
12
|
+
(``valid_to`` set), the Qdrant point stays and gets
|
|
13
|
+
``payload.open = False``. Default retrieval filters ``open=true``;
|
|
14
|
+
this keeps the door open for bi-temporal ``as_of`` semantic queries
|
|
15
|
+
later without re-embedding the corpus.
|
|
16
|
+
* **Embed triple + evidence.** The evidence span carries the user's
|
|
17
|
+
raw phrasing, which is where synonym recall lives ("DB" vs
|
|
18
|
+
"Postgres"). The triple alone is too terse to embed well.
|
|
19
|
+
* **Lazy backfill.** First access detects ``len(qdrant_claims) == 0``
|
|
20
|
+
while ``claims.db`` is non-empty and re-embeds every row. Idempotent:
|
|
21
|
+
re-runs are cheap because the embedder caches per-text.
|
|
22
|
+
* **Token-overlap fallback.** If the embedder or Qdrant is unavailable
|
|
23
|
+
the caller falls back to ``_rank_claims`` (see ``retrieve.py``). The
|
|
24
|
+
indexer raises only when the operation is fundamentally
|
|
25
|
+
side-effecting (upsert), not when reads fail.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any, Sequence
|
|
32
|
+
|
|
33
|
+
from ..config import CONFIG, Config, detect_project_slug
|
|
34
|
+
from ..embed import Embedder, HybridVec, get_embedder
|
|
35
|
+
from ..vector import QdrantStore, VectorHit, VectorRecord
|
|
36
|
+
from .store import ClaimRecord, ClaimsStore, UpsertResult
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ClaimsIndexer:
|
|
41
|
+
"""SQLite + Qdrant facade for claim writes and semantic reads.
|
|
42
|
+
|
|
43
|
+
Holds references to the persistent components; callers re-use one
|
|
44
|
+
indexer per project across many upserts. Not thread-safe — the
|
|
45
|
+
underlying SQLite connection isn't either.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
store: ClaimsStore
|
|
49
|
+
vector: QdrantStore
|
|
50
|
+
embedder: Embedder
|
|
51
|
+
collection: str
|
|
52
|
+
_backfilled: bool = False
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------- write
|
|
55
|
+
|
|
56
|
+
def upsert(self, claim: ClaimRecord) -> UpsertResult:
|
|
57
|
+
"""Persist ``claim`` to SQLite + Qdrant atomically (SQLite first).
|
|
58
|
+
|
|
59
|
+
SQLite write is authoritative — if Qdrant raises after the
|
|
60
|
+
SQLite commit, the row still lands and a later
|
|
61
|
+
:meth:`ensure_backfilled` call (or the next ``retrieve``) will
|
|
62
|
+
re-embed it. We prefer "Qdrant temporarily behind" over "lose
|
|
63
|
+
the claim entirely."
|
|
64
|
+
"""
|
|
65
|
+
self.ensure_backfilled()
|
|
66
|
+
result = self.store.upsert(claim)
|
|
67
|
+
|
|
68
|
+
# Close path: any predecessor rows closed by this insert get
|
|
69
|
+
# their ``open`` payload flipped. Cheap: no re-embed.
|
|
70
|
+
if result.closed_ids:
|
|
71
|
+
self.vector.set_payload(
|
|
72
|
+
self.collection,
|
|
73
|
+
result.closed_ids,
|
|
74
|
+
{"open": False},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if result.was_new:
|
|
78
|
+
self._embed_and_upsert(result.claim_id, claim)
|
|
79
|
+
else:
|
|
80
|
+
# Existing-row refresh: triple unchanged, but confidence and
|
|
81
|
+
# ``recorded_at`` may have moved. Update payload so rerank
|
|
82
|
+
# sees the new score without paying for an embed.
|
|
83
|
+
self.vector.set_payload(
|
|
84
|
+
self.collection,
|
|
85
|
+
[result.claim_id],
|
|
86
|
+
_payload_for(claim, open_=True),
|
|
87
|
+
)
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
def upsert_many(self, claims: Sequence[ClaimRecord]) -> list[UpsertResult]:
|
|
91
|
+
return [self.upsert(c) for c in claims]
|
|
92
|
+
|
|
93
|
+
# -------------------------------------------------------------- read
|
|
94
|
+
|
|
95
|
+
def search(
|
|
96
|
+
self,
|
|
97
|
+
query_vec: HybridVec,
|
|
98
|
+
top_k: int = 5,
|
|
99
|
+
*,
|
|
100
|
+
include_closed: bool = False,
|
|
101
|
+
) -> list[VectorHit]:
|
|
102
|
+
"""Semantic top-k over claim points.
|
|
103
|
+
|
|
104
|
+
Default filter is ``open=true`` so superseded claims don't leak
|
|
105
|
+
into the orientation context. ``include_closed`` opens the door
|
|
106
|
+
for future bi-temporal point-in-time queries (see the design
|
|
107
|
+
doc note in :mod:`code_memory.orchestrator.retrieve`).
|
|
108
|
+
|
|
109
|
+
Returns an empty list (not an error) when the collection is
|
|
110
|
+
missing — that's the "claims-disabled project" path and the
|
|
111
|
+
caller should fall back to token-overlap silently.
|
|
112
|
+
"""
|
|
113
|
+
if self.vector._inspect_collection(self.collection) == "missing":
|
|
114
|
+
return []
|
|
115
|
+
filt: dict[str, Any] | None = None if include_closed else {"open": True}
|
|
116
|
+
try:
|
|
117
|
+
return self.vector.search(
|
|
118
|
+
self.collection,
|
|
119
|
+
query_vec,
|
|
120
|
+
top_k=top_k,
|
|
121
|
+
filt=filt,
|
|
122
|
+
mode="dense",
|
|
123
|
+
)
|
|
124
|
+
except Exception: # noqa: BLE001
|
|
125
|
+
# Vector backend hiccup — return empty so the orchestrator
|
|
126
|
+
# falls through to the SQLite token-overlap fallback rather
|
|
127
|
+
# than dropping claims from the context pack entirely.
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------- backfill
|
|
131
|
+
|
|
132
|
+
def ensure_backfilled(self) -> int:
|
|
133
|
+
"""Embed every claim row missing from Qdrant. Idempotent.
|
|
134
|
+
|
|
135
|
+
Runs once per indexer instance. Re-creates the collection if it
|
|
136
|
+
was missing. Returns the count of rows embedded (``0`` when
|
|
137
|
+
already in sync). Cheap on warm runs — the embedder cache hits
|
|
138
|
+
for previously-seen triples.
|
|
139
|
+
|
|
140
|
+
We compare row counts as a soft sync check, not point IDs. If
|
|
141
|
+
SQLite has 42 rows and Qdrant has 42 points we trust they're
|
|
142
|
+
the same set; drift detection would need a per-id scan and we
|
|
143
|
+
don't currently need it.
|
|
144
|
+
"""
|
|
145
|
+
if self._backfilled:
|
|
146
|
+
return 0
|
|
147
|
+
self.vector.ensure_collection(self.collection)
|
|
148
|
+
sqlite_count = self.store.count()
|
|
149
|
+
if sqlite_count == 0:
|
|
150
|
+
self._backfilled = True
|
|
151
|
+
return 0
|
|
152
|
+
qdrant_count = self.vector.count(self.collection)
|
|
153
|
+
if qdrant_count >= sqlite_count:
|
|
154
|
+
self._backfilled = True
|
|
155
|
+
return 0
|
|
156
|
+
# Backfill all rows (open + closed) so bi-temporal queries work
|
|
157
|
+
# later. ``current()`` returns only open rows, so use a wider
|
|
158
|
+
# accessor.
|
|
159
|
+
rows = self._all_rows()
|
|
160
|
+
records: list[VectorRecord] = []
|
|
161
|
+
for claim in rows:
|
|
162
|
+
hv = self.embedder.embed_one(_text_for(claim))
|
|
163
|
+
records.append(
|
|
164
|
+
VectorRecord(
|
|
165
|
+
id=claim.id,
|
|
166
|
+
vector=hv,
|
|
167
|
+
payload=_payload_for(claim, open_=claim.valid_to is None),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
if records:
|
|
171
|
+
self.vector.upsert(self.collection, records)
|
|
172
|
+
self._backfilled = True
|
|
173
|
+
return len(records)
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------ helpers
|
|
176
|
+
|
|
177
|
+
def _embed_and_upsert(self, claim_id: str, claim: ClaimRecord) -> None:
|
|
178
|
+
hv = self.embedder.embed_one(_text_for(claim))
|
|
179
|
+
self.vector.upsert(
|
|
180
|
+
self.collection,
|
|
181
|
+
[
|
|
182
|
+
VectorRecord(
|
|
183
|
+
id=claim_id,
|
|
184
|
+
vector=hv,
|
|
185
|
+
payload=_payload_for(claim, open_=True),
|
|
186
|
+
)
|
|
187
|
+
],
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def _all_rows(self) -> list[ClaimRecord]:
|
|
191
|
+
"""Every row, open or closed. Used for backfill only."""
|
|
192
|
+
rows = self.store.conn.execute(
|
|
193
|
+
"SELECT id, subject, predicate, object, polarity, confidence, "
|
|
194
|
+
"evidence_span, valid_at, valid_to, recorded_at, "
|
|
195
|
+
"head_sha, session_id, source_prompt_id, "
|
|
196
|
+
"entity_subject_id, entity_object_id FROM claims"
|
|
197
|
+
).fetchall()
|
|
198
|
+
# Reuse the row->record decoder.
|
|
199
|
+
from .store import _row_to_claim
|
|
200
|
+
return [_row_to_claim(r) for r in rows]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _text_for(claim: ClaimRecord) -> str:
|
|
204
|
+
"""Composite text used as the embed input for a claim.
|
|
205
|
+
|
|
206
|
+
``subject {predicate} object`` is the canonical triple. The
|
|
207
|
+
evidence span — the verbatim user phrasing — gets appended so the
|
|
208
|
+
embedder also sees the natural-language vocabulary the user used
|
|
209
|
+
when asserting the claim. That's where synonym recall comes from
|
|
210
|
+
(e.g. "DB" in evidence aligns with "Postgres" in object).
|
|
211
|
+
"""
|
|
212
|
+
polarity = "" if claim.polarity else "not "
|
|
213
|
+
head = f"{claim.subject} {polarity}{claim.predicate} {claim.object}".strip()
|
|
214
|
+
if claim.evidence_span:
|
|
215
|
+
return f"{head}\n\n{claim.evidence_span}"
|
|
216
|
+
return head
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _payload_for(claim: ClaimRecord, *, open_: bool) -> dict[str, Any]:
|
|
220
|
+
"""Payload stored alongside each Qdrant point.
|
|
221
|
+
|
|
222
|
+
Carries just enough metadata for reranking (confidence, recency
|
|
223
|
+
via valid_at) and filtering (open). Anything else stays in SQLite.
|
|
224
|
+
"""
|
|
225
|
+
return {
|
|
226
|
+
"open": open_,
|
|
227
|
+
"subject": claim.subject,
|
|
228
|
+
"predicate": claim.predicate,
|
|
229
|
+
"object": claim.object,
|
|
230
|
+
"polarity": claim.polarity,
|
|
231
|
+
"confidence": claim.confidence,
|
|
232
|
+
"valid_at": claim.valid_at,
|
|
233
|
+
"head_sha": claim.head_sha,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def make_claims_indexer(
|
|
238
|
+
project: str | None = None,
|
|
239
|
+
*,
|
|
240
|
+
cfg: Config | None = None,
|
|
241
|
+
embedder: Embedder | None = None,
|
|
242
|
+
vector: QdrantStore | None = None,
|
|
243
|
+
store: ClaimsStore | None = None,
|
|
244
|
+
) -> ClaimsIndexer:
|
|
245
|
+
"""Construct a fully wired :class:`ClaimsIndexer` for ``project``.
|
|
246
|
+
|
|
247
|
+
All deps are optional so tests can inject fakes. Production callers
|
|
248
|
+
typically pass nothing and get the configured embedder + Qdrant
|
|
249
|
+
client + per-project SQLite path.
|
|
250
|
+
"""
|
|
251
|
+
slug = project or detect_project_slug()
|
|
252
|
+
config = cfg or CONFIG.for_project(slug)
|
|
253
|
+
return ClaimsIndexer(
|
|
254
|
+
store=store or ClaimsStore(path=config.claims_db),
|
|
255
|
+
vector=vector or QdrantStore(),
|
|
256
|
+
embedder=embedder or get_embedder(),
|
|
257
|
+
collection=config.qdrant_claims,
|
|
258
|
+
)
|