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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Qdrant-backed entity resolution for claim subjects and objects.
|
|
2
|
+
|
|
3
|
+
Each unique entity referenced by a claim becomes a point in a per-project
|
|
4
|
+
``claim_entities__<slug>`` collection. The point payload stores:
|
|
5
|
+
|
|
6
|
+
* ``canonical`` — the first form we saw for this entity (preserved
|
|
7
|
+
casing).
|
|
8
|
+
* ``aliases`` — every distinct form we've seen since.
|
|
9
|
+
|
|
10
|
+
On :meth:`EntityResolver.resolve`:
|
|
11
|
+
|
|
12
|
+
1. Embed the input text via the project's :mod:`Ollama` embedder.
|
|
13
|
+
2. Search the collection. If the top hit's cosine score is
|
|
14
|
+
``>= threshold`` (default ``0.85``), reuse it: append the new
|
|
15
|
+
surface form to ``aliases`` and return the existing ID.
|
|
16
|
+
3. Otherwise create a fresh point with a new UUID.
|
|
17
|
+
|
|
18
|
+
Concurrency caveat: extraction may run in detached background processes,
|
|
19
|
+
so two near-simultaneous extractions of the same entity could each take
|
|
20
|
+
the "create new" branch. That's acceptable — the next extraction sees
|
|
21
|
+
both and merges around whichever wins the search. False merges (two
|
|
22
|
+
distinct entities collapsed into one) are the failure mode worth fearing,
|
|
23
|
+
hence the conservative threshold.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import uuid
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from ..config import CONFIG, Config
|
|
34
|
+
from ..embed import Embedder, get_embedder
|
|
35
|
+
from ..vector import QdrantStore, VectorRecord
|
|
36
|
+
|
|
37
|
+
_LOG = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class EntityRef:
|
|
42
|
+
"""Return type for a single resolve() call."""
|
|
43
|
+
|
|
44
|
+
id: str
|
|
45
|
+
canonical: str
|
|
46
|
+
was_new: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class EntityResolver:
|
|
50
|
+
"""Embed → search → reuse-or-create entity points in Qdrant."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
project: str,
|
|
55
|
+
vector: QdrantStore | None = None,
|
|
56
|
+
embedder: Embedder | None = None,
|
|
57
|
+
threshold: float | None = None,
|
|
58
|
+
cfg: Config | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.cfg = cfg or CONFIG.for_project(project)
|
|
61
|
+
self.threshold = (
|
|
62
|
+
threshold if threshold is not None else CONFIG.claims_entity_threshold
|
|
63
|
+
)
|
|
64
|
+
self.vector = vector or QdrantStore()
|
|
65
|
+
self.embedder = embedder or get_embedder()
|
|
66
|
+
self._collection = self.cfg.qdrant_claim_entities
|
|
67
|
+
self._ensured = False
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------ public
|
|
70
|
+
|
|
71
|
+
def resolve(self, text: str) -> EntityRef | None:
|
|
72
|
+
"""Resolve a surface form to a canonical entity.
|
|
73
|
+
|
|
74
|
+
Returns ``None`` when the input is empty after stripping, or when
|
|
75
|
+
the embedder / Qdrant client fails — the caller should treat
|
|
76
|
+
``None`` as "skip entity resolution for this row" and persist the
|
|
77
|
+
claim without an entity ID.
|
|
78
|
+
"""
|
|
79
|
+
surface = (text or "").strip()
|
|
80
|
+
if not surface:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
self._ensure_collection()
|
|
85
|
+
except Exception: # noqa: BLE001
|
|
86
|
+
_LOG.exception("entity resolver: ensure_collection failed")
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
vec = self.embedder.embed_one(surface)
|
|
91
|
+
except Exception: # noqa: BLE001
|
|
92
|
+
_LOG.exception("entity resolver: embed failed for %r", surface)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
hits = self.vector.search(self._collection, vec, top_k=1)
|
|
97
|
+
except LookupError:
|
|
98
|
+
# Collection vanished between ensure_collection and search.
|
|
99
|
+
# Treat as miss and continue with a new entity.
|
|
100
|
+
hits = []
|
|
101
|
+
except Exception: # noqa: BLE001
|
|
102
|
+
_LOG.exception("entity resolver: search failed for %r", surface)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
if hits and hits[0].score >= self.threshold:
|
|
106
|
+
top = hits[0]
|
|
107
|
+
canonical = str(top.payload.get("canonical") or surface)
|
|
108
|
+
self._record_alias(top.id, surface, top.payload)
|
|
109
|
+
return EntityRef(id=top.id, canonical=canonical, was_new=False)
|
|
110
|
+
|
|
111
|
+
# No close match — mint a new entity.
|
|
112
|
+
new_id = str(uuid.uuid4())
|
|
113
|
+
try:
|
|
114
|
+
self.vector.upsert(
|
|
115
|
+
self._collection,
|
|
116
|
+
[
|
|
117
|
+
VectorRecord(
|
|
118
|
+
id=new_id,
|
|
119
|
+
vector=vec,
|
|
120
|
+
payload={"canonical": surface, "aliases": [surface]},
|
|
121
|
+
)
|
|
122
|
+
],
|
|
123
|
+
)
|
|
124
|
+
except Exception: # noqa: BLE001
|
|
125
|
+
_LOG.exception(
|
|
126
|
+
"entity resolver: upsert failed for new entity %r", surface
|
|
127
|
+
)
|
|
128
|
+
return None
|
|
129
|
+
return EntityRef(id=new_id, canonical=surface, was_new=True)
|
|
130
|
+
|
|
131
|
+
# ----------------------------------------------------------- helpers
|
|
132
|
+
|
|
133
|
+
def _ensure_collection(self) -> None:
|
|
134
|
+
if self._ensured:
|
|
135
|
+
return
|
|
136
|
+
self.vector.ensure_collection(self._collection)
|
|
137
|
+
self._ensured = True
|
|
138
|
+
|
|
139
|
+
def _record_alias(
|
|
140
|
+
self,
|
|
141
|
+
entity_id: str,
|
|
142
|
+
surface: str,
|
|
143
|
+
payload: dict[str, Any],
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Append ``surface`` to the entity's alias list if it's new.
|
|
146
|
+
|
|
147
|
+
Best-effort: failures here only mean the alias list lags slightly
|
|
148
|
+
behind reality. The canonical ID still points at the right
|
|
149
|
+
entity.
|
|
150
|
+
"""
|
|
151
|
+
existing = payload.get("aliases")
|
|
152
|
+
aliases: list[str] = (
|
|
153
|
+
list(existing) if isinstance(existing, list) else []
|
|
154
|
+
)
|
|
155
|
+
if surface in aliases:
|
|
156
|
+
return
|
|
157
|
+
aliases.append(surface)
|
|
158
|
+
# We don't have the original embedding here, so re-embed the
|
|
159
|
+
# canonical form to keep the point's vector stable.
|
|
160
|
+
canonical = str(payload.get("canonical") or surface)
|
|
161
|
+
try:
|
|
162
|
+
vec = self.embedder.embed_one(canonical)
|
|
163
|
+
except Exception: # noqa: BLE001
|
|
164
|
+
_LOG.exception(
|
|
165
|
+
"entity resolver: re-embed for alias update failed (%r)",
|
|
166
|
+
canonical,
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
try:
|
|
170
|
+
self.vector.upsert(
|
|
171
|
+
self._collection,
|
|
172
|
+
[
|
|
173
|
+
VectorRecord(
|
|
174
|
+
id=entity_id,
|
|
175
|
+
vector=vec,
|
|
176
|
+
payload={
|
|
177
|
+
"canonical": canonical,
|
|
178
|
+
"aliases": aliases,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
],
|
|
182
|
+
)
|
|
183
|
+
except Exception: # noqa: BLE001
|
|
184
|
+
_LOG.exception(
|
|
185
|
+
"entity resolver: alias upsert failed for %s", entity_id
|
|
186
|
+
)
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""SQLite store for extracted user claims with bi-temporal validity.
|
|
2
|
+
|
|
3
|
+
Schema mirrors the ``episodes`` table's idempotent-migration pattern.
|
|
4
|
+
Each claim row carries:
|
|
5
|
+
|
|
6
|
+
* ``valid_at`` — when the user asserted it (prompt timestamp)
|
|
7
|
+
* ``valid_to`` — when the assertion was superseded (NULL = current)
|
|
8
|
+
* ``recorded_at`` — when we ingested it (system clock)
|
|
9
|
+
* ``head_sha`` — git HEAD at extraction time
|
|
10
|
+
|
|
11
|
+
The combination gives bi-temporal queries: "as of commit X, what did the
|
|
12
|
+
user say about Y?" and "what was the user's stated preference for Y at
|
|
13
|
+
time T according to what we knew at time T'?".
|
|
14
|
+
|
|
15
|
+
Contradiction handling: predicates listed in :data:`SINGLE_VALUED_PREDICATES`
|
|
16
|
+
are treated as functional — at most one open ``(subject, predicate)`` per
|
|
17
|
+
session. Upserting a new ``(s, p, o2)`` closes the prior ``(s, p, o1)`` by
|
|
18
|
+
setting its ``valid_to = new.valid_at``.
|
|
19
|
+
|
|
20
|
+
Multi-valued predicates (e.g. ``mentioned``, ``worked-on``) coexist
|
|
21
|
+
without conflict.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import sqlite3
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Iterable
|
|
32
|
+
|
|
33
|
+
from ..config import CONFIG
|
|
34
|
+
|
|
35
|
+
# Predicates whose object is functional: at most one currently-valid
|
|
36
|
+
# assertion per (subject, predicate). A second extraction with a
|
|
37
|
+
# different object closes the previous one.
|
|
38
|
+
#
|
|
39
|
+
# Keep this list small and hand-curated — automatic detection would
|
|
40
|
+
# require a richer schema than we want to ask the LLM to produce.
|
|
41
|
+
SINGLE_VALUED_PREDICATES: frozenset[str] = frozenset(
|
|
42
|
+
{
|
|
43
|
+
"prefers",
|
|
44
|
+
"uses", # primary-tool sense: "we use Postgres" → switching closes prior
|
|
45
|
+
"deployed-to",
|
|
46
|
+
"is-located-at",
|
|
47
|
+
"is-a",
|
|
48
|
+
"owns",
|
|
49
|
+
"assigned-to",
|
|
50
|
+
"depends-on",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_BASE_SCHEMA = """
|
|
56
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
subject TEXT NOT NULL,
|
|
59
|
+
predicate TEXT NOT NULL,
|
|
60
|
+
object TEXT NOT NULL,
|
|
61
|
+
polarity INTEGER NOT NULL,
|
|
62
|
+
confidence REAL NOT NULL,
|
|
63
|
+
evidence_span TEXT NOT NULL,
|
|
64
|
+
valid_at REAL NOT NULL,
|
|
65
|
+
valid_to REAL,
|
|
66
|
+
recorded_at REAL NOT NULL,
|
|
67
|
+
head_sha TEXT,
|
|
68
|
+
session_id TEXT,
|
|
69
|
+
source_prompt_id TEXT
|
|
70
|
+
);
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
# Migrations are idempotent. Each statement runs independently; failures
|
|
74
|
+
# from a re-run (duplicate column, already-existing index) are swallowed
|
|
75
|
+
# so an existing DB catches up to the latest schema on open. Columns
|
|
76
|
+
# referenced by an index MUST appear in the migration list before the
|
|
77
|
+
# index that uses them.
|
|
78
|
+
_MIGRATIONS: tuple[str, ...] = (
|
|
79
|
+
"CREATE INDEX IF NOT EXISTS idx_claims_subject ON claims(subject)",
|
|
80
|
+
"CREATE INDEX IF NOT EXISTS idx_claims_predicate ON claims(predicate)",
|
|
81
|
+
"CREATE INDEX IF NOT EXISTS idx_claims_valid_at ON claims(valid_at)",
|
|
82
|
+
"CREATE INDEX IF NOT EXISTS idx_claims_valid_to ON claims(valid_to)",
|
|
83
|
+
"CREATE INDEX IF NOT EXISTS idx_claims_head_sha ON claims(head_sha)",
|
|
84
|
+
"CREATE INDEX IF NOT EXISTS idx_claims_session ON claims(session_id)",
|
|
85
|
+
# Entity-resolution back-references: nullable so legacy rows that
|
|
86
|
+
# were extracted before the resolver shipped keep working.
|
|
87
|
+
"ALTER TABLE claims ADD COLUMN entity_subject_id TEXT",
|
|
88
|
+
"ALTER TABLE claims ADD COLUMN entity_object_id TEXT",
|
|
89
|
+
"CREATE INDEX IF NOT EXISTS idx_claims_entity_subject ON claims(entity_subject_id)",
|
|
90
|
+
"CREATE INDEX IF NOT EXISTS idx_claims_entity_object ON claims(entity_object_id)",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class UpsertResult(str):
|
|
95
|
+
"""Result of :meth:`ClaimsStore.upsert`.
|
|
96
|
+
|
|
97
|
+
Subclasses ``str`` so the old contract — ``upsert`` returning a
|
|
98
|
+
claim id — is preserved for callers that just want the id:
|
|
99
|
+
|
|
100
|
+
cid = store.upsert(record) # works as before
|
|
101
|
+
assert isinstance(cid, str) # still true
|
|
102
|
+
|
|
103
|
+
Newer callers (the Qdrant claim indexer) read the extra fields to
|
|
104
|
+
know which prior rows were closed in the same transaction so they
|
|
105
|
+
can flip ``open=false`` on the matching Qdrant points.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
closed_ids: list[str]
|
|
109
|
+
was_new: bool
|
|
110
|
+
|
|
111
|
+
def __new__(
|
|
112
|
+
cls,
|
|
113
|
+
claim_id: str,
|
|
114
|
+
*,
|
|
115
|
+
closed_ids: list[str] | None = None,
|
|
116
|
+
was_new: bool = True,
|
|
117
|
+
) -> "UpsertResult":
|
|
118
|
+
inst = super().__new__(cls, claim_id)
|
|
119
|
+
inst.closed_ids = list(closed_ids or [])
|
|
120
|
+
inst.was_new = was_new
|
|
121
|
+
return inst
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def claim_id(self) -> str:
|
|
125
|
+
return str(self)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class ClaimRecord:
|
|
130
|
+
subject: str
|
|
131
|
+
predicate: str
|
|
132
|
+
object: str
|
|
133
|
+
polarity: bool = True
|
|
134
|
+
confidence: float = 1.0
|
|
135
|
+
evidence_span: str = ""
|
|
136
|
+
valid_at: float = field(default_factory=time.time)
|
|
137
|
+
valid_to: float | None = None
|
|
138
|
+
recorded_at: float = field(default_factory=time.time)
|
|
139
|
+
head_sha: str | None = None
|
|
140
|
+
session_id: str | None = None
|
|
141
|
+
source_prompt_id: str | None = None
|
|
142
|
+
# Canonical entity IDs from the Qdrant entity resolver. NULL when
|
|
143
|
+
# resolution was skipped (claims_enabled but resolver disabled, or
|
|
144
|
+
# legacy rows from before the resolver shipped).
|
|
145
|
+
entity_subject_id: str | None = None
|
|
146
|
+
entity_object_id: str | None = None
|
|
147
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ClaimsStore:
|
|
151
|
+
"""SQLite-backed claim store with single-valued predicate contradiction."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
154
|
+
self.path = path or CONFIG.claims_db
|
|
155
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
self.conn = sqlite3.connect(self.path)
|
|
157
|
+
self.conn.executescript(_BASE_SCHEMA)
|
|
158
|
+
for stmt in _MIGRATIONS:
|
|
159
|
+
try:
|
|
160
|
+
self.conn.execute(stmt)
|
|
161
|
+
except sqlite3.OperationalError:
|
|
162
|
+
# idempotent migration — already applied
|
|
163
|
+
pass
|
|
164
|
+
self.conn.commit()
|
|
165
|
+
|
|
166
|
+
# -------------------------------------------------------------- write
|
|
167
|
+
|
|
168
|
+
def upsert(self, claim: ClaimRecord) -> UpsertResult:
|
|
169
|
+
"""Insert a claim, closing any conflicting prior assertion.
|
|
170
|
+
|
|
171
|
+
Dedupe: an open row with the same (subject, predicate, object,
|
|
172
|
+
polarity) is refreshed in place — its ``recorded_at`` becomes
|
|
173
|
+
``now``, its ``confidence`` becomes ``max(prev, new)``, and a
|
|
174
|
+
non-empty new ``evidence_span`` overwrites a missing/equal one.
|
|
175
|
+
This prevents bloat when the agent re-asserts the same claim
|
|
176
|
+
across turns or sessions (which happens often with the
|
|
177
|
+
"ACT BEFORE ANSWERING" nudge in the plugins).
|
|
178
|
+
|
|
179
|
+
For single-valued predicates: any open ``(subject, predicate)``
|
|
180
|
+
row with a different ``object`` gets ``valid_to`` set to the
|
|
181
|
+
new claim's ``valid_at``. Polarity flips also close.
|
|
182
|
+
|
|
183
|
+
Returns an :class:`UpsertResult` carrying both the canonical id
|
|
184
|
+
of the (refreshed-or-new) claim AND the ids of any rows that got
|
|
185
|
+
closed by this insertion. Callers maintaining a secondary index
|
|
186
|
+
(the Qdrant claim vector store) need both: insert/refresh the
|
|
187
|
+
canonical id, and flip ``open=false`` on the closed ids.
|
|
188
|
+
|
|
189
|
+
Backwards compatibility: ``UpsertResult`` is a string subclass
|
|
190
|
+
so legacy callers using ``store.upsert(c)`` as the claim id keep
|
|
191
|
+
working (``str(result) == result.claim_id``).
|
|
192
|
+
"""
|
|
193
|
+
closed_ids: list[str] = []
|
|
194
|
+
if claim.predicate in SINGLE_VALUED_PREDICATES:
|
|
195
|
+
closed_ids = self._close_conflicting(claim)
|
|
196
|
+
|
|
197
|
+
existing_id = self._find_open_duplicate(claim)
|
|
198
|
+
if existing_id is not None:
|
|
199
|
+
self._refresh_existing(existing_id, claim)
|
|
200
|
+
self.conn.commit()
|
|
201
|
+
return UpsertResult(existing_id, closed_ids=closed_ids, was_new=False)
|
|
202
|
+
|
|
203
|
+
self.conn.execute(
|
|
204
|
+
"INSERT INTO claims("
|
|
205
|
+
"id, subject, predicate, object, polarity, confidence, "
|
|
206
|
+
"evidence_span, valid_at, valid_to, recorded_at, "
|
|
207
|
+
"head_sha, session_id, source_prompt_id, "
|
|
208
|
+
"entity_subject_id, entity_object_id) "
|
|
209
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
210
|
+
(
|
|
211
|
+
claim.id,
|
|
212
|
+
claim.subject,
|
|
213
|
+
claim.predicate,
|
|
214
|
+
claim.object,
|
|
215
|
+
1 if claim.polarity else 0,
|
|
216
|
+
claim.confidence,
|
|
217
|
+
claim.evidence_span,
|
|
218
|
+
claim.valid_at,
|
|
219
|
+
claim.valid_to,
|
|
220
|
+
claim.recorded_at,
|
|
221
|
+
claim.head_sha,
|
|
222
|
+
claim.session_id,
|
|
223
|
+
claim.source_prompt_id,
|
|
224
|
+
claim.entity_subject_id,
|
|
225
|
+
claim.entity_object_id,
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
self.conn.commit()
|
|
229
|
+
return UpsertResult(claim.id, closed_ids=closed_ids, was_new=True)
|
|
230
|
+
|
|
231
|
+
def _find_open_duplicate(self, claim: ClaimRecord) -> str | None:
|
|
232
|
+
"""Return the id of an open row identical on (s,p,o,polarity), or None."""
|
|
233
|
+
row = self.conn.execute(
|
|
234
|
+
"SELECT id FROM claims "
|
|
235
|
+
"WHERE valid_to IS NULL "
|
|
236
|
+
" AND subject = ? AND predicate = ? AND object = ? "
|
|
237
|
+
" AND polarity = ? "
|
|
238
|
+
"LIMIT 1",
|
|
239
|
+
(
|
|
240
|
+
claim.subject,
|
|
241
|
+
claim.predicate,
|
|
242
|
+
claim.object,
|
|
243
|
+
1 if claim.polarity else 0,
|
|
244
|
+
),
|
|
245
|
+
).fetchone()
|
|
246
|
+
return None if row is None else str(row[0])
|
|
247
|
+
|
|
248
|
+
def _refresh_existing(self, claim_id: str, claim: ClaimRecord) -> None:
|
|
249
|
+
"""Refresh an existing open dupe with the new claim's metadata.
|
|
250
|
+
|
|
251
|
+
Confidence is monotonic non-decreasing (keep the strongest
|
|
252
|
+
assertion seen). Evidence is overwritten only when the new
|
|
253
|
+
span is non-empty so we never erase a quote with a blank one.
|
|
254
|
+
Session/prompt-id only fill in if previously NULL, preserving
|
|
255
|
+
the first observation's provenance.
|
|
256
|
+
"""
|
|
257
|
+
self.conn.execute(
|
|
258
|
+
"UPDATE claims SET "
|
|
259
|
+
" confidence = MAX(confidence, ?), "
|
|
260
|
+
" evidence_span = CASE "
|
|
261
|
+
" WHEN ? <> '' THEN ? "
|
|
262
|
+
" ELSE evidence_span "
|
|
263
|
+
" END, "
|
|
264
|
+
" recorded_at = ?, "
|
|
265
|
+
" head_sha = COALESCE(?, head_sha), "
|
|
266
|
+
" session_id = COALESCE(session_id, ?), "
|
|
267
|
+
" source_prompt_id = COALESCE(source_prompt_id, ?), "
|
|
268
|
+
" entity_subject_id = COALESCE(entity_subject_id, ?), "
|
|
269
|
+
" entity_object_id = COALESCE(entity_object_id, ?) "
|
|
270
|
+
"WHERE id = ?",
|
|
271
|
+
(
|
|
272
|
+
claim.confidence,
|
|
273
|
+
claim.evidence_span,
|
|
274
|
+
claim.evidence_span,
|
|
275
|
+
claim.recorded_at,
|
|
276
|
+
claim.head_sha,
|
|
277
|
+
claim.session_id,
|
|
278
|
+
claim.source_prompt_id,
|
|
279
|
+
claim.entity_subject_id,
|
|
280
|
+
claim.entity_object_id,
|
|
281
|
+
claim_id,
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def upsert_many(self, claims: Iterable[ClaimRecord]) -> list[UpsertResult]:
|
|
286
|
+
return [self.upsert(c) for c in claims]
|
|
287
|
+
|
|
288
|
+
def _close_conflicting(self, claim: ClaimRecord) -> list[str]:
|
|
289
|
+
"""Close prior open assertions that conflict with the new claim.
|
|
290
|
+
|
|
291
|
+
Conflict := same (subject, predicate) but different object OR
|
|
292
|
+
polarity flip. Scope is global (not per-session) because user
|
|
293
|
+
preferences carry across sessions; restricting to one session
|
|
294
|
+
would defeat the point.
|
|
295
|
+
|
|
296
|
+
Returns the ids of the rows whose ``valid_to`` was just set, so
|
|
297
|
+
a caller maintaining a secondary index can flip their ``open``
|
|
298
|
+
flag in lockstep. Returns ``[]`` when nothing matched.
|
|
299
|
+
"""
|
|
300
|
+
rows = self.conn.execute(
|
|
301
|
+
"SELECT id FROM claims "
|
|
302
|
+
"WHERE subject = ? AND predicate = ? "
|
|
303
|
+
" AND valid_to IS NULL "
|
|
304
|
+
" AND (object <> ? OR polarity <> ?)",
|
|
305
|
+
(
|
|
306
|
+
claim.subject,
|
|
307
|
+
claim.predicate,
|
|
308
|
+
claim.object,
|
|
309
|
+
1 if claim.polarity else 0,
|
|
310
|
+
),
|
|
311
|
+
).fetchall()
|
|
312
|
+
closed_ids = [str(r[0]) for r in rows]
|
|
313
|
+
if closed_ids:
|
|
314
|
+
self.conn.execute(
|
|
315
|
+
"UPDATE claims "
|
|
316
|
+
"SET valid_to = ? "
|
|
317
|
+
"WHERE subject = ? AND predicate = ? "
|
|
318
|
+
" AND valid_to IS NULL "
|
|
319
|
+
" AND (object <> ? OR polarity <> ?)",
|
|
320
|
+
(
|
|
321
|
+
claim.valid_at,
|
|
322
|
+
claim.subject,
|
|
323
|
+
claim.predicate,
|
|
324
|
+
claim.object,
|
|
325
|
+
1 if claim.polarity else 0,
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
return closed_ids
|
|
329
|
+
|
|
330
|
+
# --------------------------------------------------------------- read
|
|
331
|
+
|
|
332
|
+
def current(self, subject: str | None = None) -> list[ClaimRecord]:
|
|
333
|
+
"""Return all currently-valid claims (``valid_to IS NULL``)."""
|
|
334
|
+
if subject is None:
|
|
335
|
+
rows = self.conn.execute(
|
|
336
|
+
_SELECT_ALL + " WHERE valid_to IS NULL ORDER BY valid_at DESC"
|
|
337
|
+
).fetchall()
|
|
338
|
+
else:
|
|
339
|
+
rows = self.conn.execute(
|
|
340
|
+
_SELECT_ALL
|
|
341
|
+
+ " WHERE valid_to IS NULL AND subject = ? "
|
|
342
|
+
"ORDER BY valid_at DESC",
|
|
343
|
+
(subject,),
|
|
344
|
+
).fetchall()
|
|
345
|
+
return [_row_to_claim(r) for r in rows]
|
|
346
|
+
|
|
347
|
+
def as_of(self, when: float, subject: str | None = None) -> list[ClaimRecord]:
|
|
348
|
+
"""Bi-temporal point query: claims valid at world-time ``when``.
|
|
349
|
+
|
|
350
|
+
A claim is valid at ``when`` iff
|
|
351
|
+
``valid_at <= when < (valid_to or +inf)``.
|
|
352
|
+
"""
|
|
353
|
+
base = (
|
|
354
|
+
_SELECT_ALL
|
|
355
|
+
+ " WHERE valid_at <= ? AND (valid_to IS NULL OR valid_to > ?)"
|
|
356
|
+
)
|
|
357
|
+
if subject is None:
|
|
358
|
+
rows = self.conn.execute(
|
|
359
|
+
base + " ORDER BY valid_at DESC", (when, when)
|
|
360
|
+
).fetchall()
|
|
361
|
+
else:
|
|
362
|
+
rows = self.conn.execute(
|
|
363
|
+
base + " AND subject = ? ORDER BY valid_at DESC",
|
|
364
|
+
(when, when, subject),
|
|
365
|
+
).fetchall()
|
|
366
|
+
return [_row_to_claim(r) for r in rows]
|
|
367
|
+
|
|
368
|
+
def by_id(self, claim_id: str) -> ClaimRecord | None:
|
|
369
|
+
row = self.conn.execute(
|
|
370
|
+
_SELECT_ALL + " WHERE id = ?", (claim_id,)
|
|
371
|
+
).fetchone()
|
|
372
|
+
return _row_to_claim(row) if row else None
|
|
373
|
+
|
|
374
|
+
def by_ids(self, ids: list[str]) -> list[ClaimRecord]:
|
|
375
|
+
"""Batch fetch by id list. Preserves caller ordering.
|
|
376
|
+
|
|
377
|
+
Mirrors :meth:`EpisodicStore.by_ids`. Used to hydrate
|
|
378
|
+
``ClaimRecord`` rows after a Qdrant semantic search returns ids.
|
|
379
|
+
Unknown ids are silently dropped — they may have been pruned
|
|
380
|
+
between the vector hit and this lookup.
|
|
381
|
+
"""
|
|
382
|
+
if not ids:
|
|
383
|
+
return []
|
|
384
|
+
placeholders = ",".join("?" for _ in ids)
|
|
385
|
+
rows = self.conn.execute(
|
|
386
|
+
_SELECT_ALL + f" WHERE id IN ({placeholders})", ids
|
|
387
|
+
).fetchall()
|
|
388
|
+
by_id = {row[0]: row for row in rows}
|
|
389
|
+
return [_row_to_claim(by_id[i]) for i in ids if i in by_id]
|
|
390
|
+
|
|
391
|
+
def count(self) -> int:
|
|
392
|
+
(n,) = self.conn.execute("SELECT COUNT(*) FROM claims").fetchone()
|
|
393
|
+
return int(n)
|
|
394
|
+
|
|
395
|
+
def close(self) -> None:
|
|
396
|
+
self.conn.close()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
_SELECT_ALL = (
|
|
400
|
+
"SELECT id, subject, predicate, object, polarity, confidence, "
|
|
401
|
+
"evidence_span, valid_at, valid_to, recorded_at, "
|
|
402
|
+
"head_sha, session_id, source_prompt_id, "
|
|
403
|
+
"entity_subject_id, entity_object_id FROM claims"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _row_to_claim(row: tuple[Any, ...]) -> ClaimRecord:
|
|
408
|
+
return ClaimRecord(
|
|
409
|
+
id=row[0],
|
|
410
|
+
subject=row[1],
|
|
411
|
+
predicate=row[2],
|
|
412
|
+
object=row[3],
|
|
413
|
+
polarity=bool(row[4]),
|
|
414
|
+
confidence=row[5],
|
|
415
|
+
evidence_span=row[6],
|
|
416
|
+
valid_at=row[7],
|
|
417
|
+
valid_to=row[8],
|
|
418
|
+
recorded_at=row[9],
|
|
419
|
+
head_sha=row[10],
|
|
420
|
+
session_id=row[11],
|
|
421
|
+
source_prompt_id=row[12],
|
|
422
|
+
entity_subject_id=row[13] if len(row) > 13 else None,
|
|
423
|
+
entity_object_id=row[14] if len(row) > 14 else None,
|
|
424
|
+
)
|