memtrust-cli 0.3.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.
- memtrust/__init__.py +11 -0
- memtrust/adapters/__init__.py +62 -0
- memtrust/adapters/base.py +2020 -0
- memtrust/adapters/mem0_adapter.py +456 -0
- memtrust/adapters/mem0_direct_adapter.py +1217 -0
- memtrust/adapters/mempalace_adapter.py +1166 -0
- memtrust/adapters/openviking_adapter.py +570 -0
- memtrust/adapters/zep_graphiti_adapter.py +181 -0
- memtrust/adapters/zep_graphiti_selfhosted_adapter.py +882 -0
- memtrust/cli.py +1201 -0
- memtrust/evals/__init__.py +5 -0
- memtrust/evals/compression.py +235 -0
- memtrust/evals/contradiction.py +451 -0
- memtrust/evals/crash_recovery.py +290 -0
- memtrust/evals/embedder_cost.py +163 -0
- memtrust/evals/embedding_drift.py +273 -0
- memtrust/evals/episode_temporal_leak.py +182 -0
- memtrust/evals/extraction_quality.py +373 -0
- memtrust/evals/filter_injection.py +371 -0
- memtrust/evals/language_degradation.py +189 -0
- memtrust/evals/lock_contention.py +263 -0
- memtrust/evals/locomo.py +392 -0
- memtrust/evals/longmemeval.py +249 -0
- memtrust/evals/mempalace_metadata_scale.py +494 -0
- memtrust/evals/migration_rollback.py +276 -0
- memtrust/evals/orphan_cleanup.py +235 -0
- memtrust/evals/ranking_quality.py +303 -0
- memtrust/evals/resource_sync_safety.py +336 -0
- memtrust/evals/result_consistency.py +239 -0
- memtrust/evals/scale_fixtures.py +189 -0
- memtrust/evals/scale_stress.py +502 -0
- memtrust/evals/stats_accuracy.py +240 -0
- memtrust/evals/temporal_kg_boundary.py +281 -0
- memtrust/receipt.py +318 -0
- memtrust/scoring/__init__.py +1 -0
- memtrust/scoring/cost_tracker.py +199 -0
- memtrust/scoring/llm_judge.py +161 -0
- memtrust_cli-0.3.0.dist-info/METADATA +624 -0
- memtrust_cli-0.3.0.dist-info/RECORD +42 -0
- memtrust_cli-0.3.0.dist-info/WHEEL +4 -0
- memtrust_cli-0.3.0.dist-info/entry_points.txt +2 -0
- memtrust_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""Adapter for OpenViking (https://github.com/volcengine/OpenViking).
|
|
2
|
+
|
|
3
|
+
Confidence: MEDIUM on architecture, LOW on exact memory-write/query
|
|
4
|
+
endpoint paths.
|
|
5
|
+
|
|
6
|
+
OpenViking (ByteDance/Volcengine) is confirmed to organize agent context
|
|
7
|
+
as a virtual filesystem addressed by `viking://` URIs, with a REST server
|
|
8
|
+
mode listening on port 1933 and documented Python client classes
|
|
9
|
+
(`OpenViking` for embedded/local mode, `SyncHTTPClient`/`AsyncHTTPClient`
|
|
10
|
+
for remote server mode). The fetched API docs during this build covered
|
|
11
|
+
resource/skill ingestion (`add_resource`, `add_skill`) in detail but did
|
|
12
|
+
not surface a confirmed method name for writing or querying a
|
|
13
|
+
conversational *memory* entry specifically (as opposed to a resource or
|
|
14
|
+
skill file) -- OpenViking's memory layer is described as an automatic,
|
|
15
|
+
session-derived extraction process rather than a direct "store this fact"
|
|
16
|
+
call in the documentation surfaced here.
|
|
17
|
+
|
|
18
|
+
This adapter is written best-effort against the confirmed `viking://`
|
|
19
|
+
filesystem paradigm: store() writes a file under a session-scoped path,
|
|
20
|
+
query() greps/searches that path, update() overwrites the file at the
|
|
21
|
+
same path. If OpenViking's real memory API differs materially from a
|
|
22
|
+
filesystem write/search, this adapter's behavior should be corrected by
|
|
23
|
+
whoever verifies it against a live instance -- flagged explicitly here
|
|
24
|
+
and in docs/methodology.md rather than presented as confirmed.
|
|
25
|
+
|
|
26
|
+
list_resource_paths()/trigger_resync() (supports_resource_sync = True)
|
|
27
|
+
are written against the same `viking://` filesystem paradigm, guessing a
|
|
28
|
+
`/v1/fs/list` listing endpoint and a `/v1/fs/resync` resync-trigger
|
|
29
|
+
endpoint by analogy with the confirmed `/v1/fs/write` and `/v1/search`
|
|
30
|
+
paths above. Neither endpoint was confirmed in this build's research
|
|
31
|
+
pass -- confidence is LOW on the exact paths, same flag as store's
|
|
32
|
+
memory-write endpoint above. This is the capability that lets the
|
|
33
|
+
resource-sync-safety eval (evals/resource_sync_safety.py) exercise
|
|
34
|
+
OpenViking at all; it exists specifically because OpenViking's Feishu
|
|
35
|
+
resync mechanism has a reported data-loss bug (a resync silently
|
|
36
|
+
deleting user-owned files the ingestion watcher did not generate --
|
|
37
|
+
volcengine/OpenViking#3029) that the store/query/update model alone
|
|
38
|
+
cannot observe.
|
|
39
|
+
|
|
40
|
+
store() honors a `resource_path` metadata key (e.g.
|
|
41
|
+
"entities/people/jordan-lee.md") when the caller supplies one, writing
|
|
42
|
+
to that real nested `viking://` path instead of always falling back to
|
|
43
|
+
the flat `memory/{session_id}/{sha256(content)[:16]}` single-level
|
|
44
|
+
filename. Without `resource_path` in metadata, the flat-hash behavior
|
|
45
|
+
is unchanged (backward compatible for every existing caller). This
|
|
46
|
+
closes a structural gap found validating volcengine/OpenViking#1703
|
|
47
|
+
(index_resource() in OpenViking's embedding_utils.py skipped every
|
|
48
|
+
subdirectory during reindex, so nested-directory content was never
|
|
49
|
+
vectorized and searches over it silently returned nothing): before this
|
|
50
|
+
change, memtrust's own store() never actually constructed a real nested
|
|
51
|
+
directory tree against OpenViking, so a directory-indexing bug like
|
|
52
|
+
#1703 was structurally unreachable by this harness regardless of how
|
|
53
|
+
good the eval classification logic was. list_resource_paths() now does
|
|
54
|
+
a real recursive tree walk (bounded by an optional `max_depth`) so
|
|
55
|
+
nested paths a listing response reports as directories are actually
|
|
56
|
+
descended into and returned as leaf file paths, not just whatever a
|
|
57
|
+
single flat response happened to contain. See docs/methodology.md for
|
|
58
|
+
the honest scope of what this closes: it makes the #1703 bug class
|
|
59
|
+
reachable by this harness's storage layer, it does not reproduce
|
|
60
|
+
OpenViking's real server-side reindex bug without a live instance.
|
|
61
|
+
|
|
62
|
+
Gated on OPENVIKING_API_KEY, matching the project's hosted "OpenViking
|
|
63
|
+
Studio" offering; OPENVIKING_BASE_URL may override the default host to
|
|
64
|
+
point at a self-hosted server instead.
|
|
65
|
+
|
|
66
|
+
On volcengine/OpenViking#1523 (contributor A0nameless0man: an embedder
|
|
67
|
+
migration silently degrades search quality mid-migration -- switching
|
|
68
|
+
embedding models overwrites vectors in place with no dimension/model
|
|
69
|
+
validation): the `/v1/search` response shape documented and confirmed
|
|
70
|
+
during this build (see the top of this docstring) surfaces `path`,
|
|
71
|
+
`content`/`snippet`, `score`, `updated_at`, and `metadata` per result --
|
|
72
|
+
nothing that identifies which embedding model or vector dimensionality
|
|
73
|
+
produced a given record. `query()` above therefore cannot report
|
|
74
|
+
`MemoryRecord.embedding_model`/`embedding_dims` (see adapters/base.py) for
|
|
75
|
+
real OpenViking responses; both stay at their default `None`. This is why
|
|
76
|
+
#1523 is scored at the harness level instead, against fake adapters
|
|
77
|
+
engineered to reproduce its exact bug shape -- see
|
|
78
|
+
evals/embedding_drift.py and docs/methodology.md for the eval and its
|
|
79
|
+
honest scope.
|
|
80
|
+
|
|
81
|
+
`supports_crash_recovery_simulation` is NOT set on this adapter (stays at
|
|
82
|
+
the base class default, False). volcengine/OpenViking#2644 (contributor
|
|
83
|
+
yeyitech) reports a local vectordb backend's `_recover()` silently
|
|
84
|
+
skipping index rebuild on server-process restart when index files are
|
|
85
|
+
missing but store data exists -- a real, cited bug this project's
|
|
86
|
+
crash-recovery eval (evals/crash_recovery.py) exists to catch. This
|
|
87
|
+
adapter is a pure HTTP client with no ability to start, kill, or restart
|
|
88
|
+
a live OpenViking server process, and no confirmed endpoint that reads
|
|
89
|
+
raw stored data bypassing the search/index layer `query()` above goes
|
|
90
|
+
through -- both are required for that eval to run against a real
|
|
91
|
+
backend. Until this adapter (or a future one) gains real
|
|
92
|
+
process-lifecycle control, the crash-recovery eval only ever runs
|
|
93
|
+
against a purpose-built fake adapter that models #2644's shape; see
|
|
94
|
+
evals/crash_recovery.py's module docstring and docs/methodology.md for
|
|
95
|
+
exactly what that does and does not prove.
|
|
96
|
+
|
|
97
|
+
`supports_stats = True`: get_stats() hits `GET /api/v1/stats/memories`
|
|
98
|
+
and reads its `total_memories` field. Confidence HIGH on this specific
|
|
99
|
+
endpoint and response shape -- both are quoted verbatim in volcengine/
|
|
100
|
+
OpenViking#1255's bug report (contributor SeeYangZhi), which is also the
|
|
101
|
+
motivating case: the endpoint silently returns an all-zero count even
|
|
102
|
+
when filesystem listing and `/v1/search` both independently confirm real
|
|
103
|
+
memories exist. See evals/stats_accuracy.py, the eval this feeds.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
from __future__ import annotations
|
|
107
|
+
|
|
108
|
+
import json
|
|
109
|
+
import os
|
|
110
|
+
|
|
111
|
+
import httpx
|
|
112
|
+
|
|
113
|
+
from memtrust.adapters.base import (
|
|
114
|
+
BackendAPIError,
|
|
115
|
+
BackendNotConfiguredError,
|
|
116
|
+
ConflictSignal,
|
|
117
|
+
CrashSignal,
|
|
118
|
+
DeletePrefixResult,
|
|
119
|
+
DeleteResult,
|
|
120
|
+
ExtractionSignal,
|
|
121
|
+
MemoryBackendAdapter,
|
|
122
|
+
MemoryRecord,
|
|
123
|
+
QueryResult,
|
|
124
|
+
RankingSignal,
|
|
125
|
+
StatsResult,
|
|
126
|
+
StoreResult,
|
|
127
|
+
UpdateResult,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
DEFAULT_BASE_URL = "http://localhost:1933"
|
|
131
|
+
|
|
132
|
+
#: Approximate reranker batch-token budget a typical local reranker (e.g.
|
|
133
|
+
#: qwen3-reranker-0.6B) enforces, per hhspiny's own measured report on
|
|
134
|
+
#: volcengine/OpenViking#2880 (a `QMD_RERANK_CONTEXT_SIZE`-style default).
|
|
135
|
+
#: OpenViking's own rerank-provider config is not confirmed to expose this
|
|
136
|
+
#: value through any endpoint this adapter calls, so this is a
|
|
137
|
+
#: conservative, documented approximation -- not a value read from a live
|
|
138
|
+
#: OpenViking config. See `_rerank_fallback_risk()` below and
|
|
139
|
+
#: RankingSignal.RERANK_FALLBACK in base.py.
|
|
140
|
+
MAX_RERANK_TOKENS = 4096
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _error_detail(exc: httpx.HTTPError) -> str:
|
|
144
|
+
"""Build a BackendAPIError detail that includes the real response body
|
|
145
|
+
when one is available, not just the generic status-line `str(exc)`.
|
|
146
|
+
|
|
147
|
+
Motivating case: volcengine/OpenViking#1227, where a server-side
|
|
148
|
+
Pydantic validation error (e.g. `"id" extra_forbidden`) was silently
|
|
149
|
+
swallowed down to a useless status-line-only message like
|
|
150
|
+
"Client error '400 Bad Request' for url ..." -- the actual validation
|
|
151
|
+
detail explaining WHAT failed was discarded entirely. `exc.response`
|
|
152
|
+
is `None` for request-level failures (a connection error, a timeout)
|
|
153
|
+
that never got a response back at all, not just for
|
|
154
|
+
`httpx.HTTPStatusError` -- handled explicitly rather than assumed
|
|
155
|
+
present, since not every `httpx.HTTPError` subclass carries one.
|
|
156
|
+
"""
|
|
157
|
+
response = getattr(exc, "response", None)
|
|
158
|
+
if response is None:
|
|
159
|
+
return str(exc)
|
|
160
|
+
body = response.text
|
|
161
|
+
if not body:
|
|
162
|
+
return str(exc)
|
|
163
|
+
return f"{exc} -- response body: {body}"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _estimate_tokens(text: str) -> int:
|
|
167
|
+
"""Cheap, tokenizer-free token-count approximation (~4 chars/token, the
|
|
168
|
+
same rough heuristic widely used for English text when no real
|
|
169
|
+
tokenizer is available). Not a substitute for the reranker's own
|
|
170
|
+
tokenizer -- see MAX_RERANK_TOKENS's docstring for the honesty caveat
|
|
171
|
+
this approximation carries."""
|
|
172
|
+
return max(1, len(text) // 4)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _rerank_fallback_risk(records: list[MemoryRecord]) -> bool:
|
|
176
|
+
"""Whether this response's own candidate set carries the same input
|
|
177
|
+
shape volcengine/OpenViking#1737 (an empty-string document) or
|
|
178
|
+
#2739/#2880 (a batch exceeding the reranker's real token budget)
|
|
179
|
+
report as silently degrading to a raw vector-score fallback with no
|
|
180
|
+
caller-visible signal.
|
|
181
|
+
|
|
182
|
+
This is a heuristic classification computed entirely from records this
|
|
183
|
+
adapter already has in hand -- it cannot intercept OpenViking's actual
|
|
184
|
+
server-side rerank-batch construction, so it flags the SHAPE a
|
|
185
|
+
response's candidates carry (an empty document, or a total estimated
|
|
186
|
+
token count over budget), not confirmed proof this specific live call
|
|
187
|
+
fell back server-side. See RankingSignal.RERANK_FALLBACK in base.py
|
|
188
|
+
for the full honesty caveat and upstream-fix citations.
|
|
189
|
+
"""
|
|
190
|
+
if any(record.content == "" for record in records):
|
|
191
|
+
return True
|
|
192
|
+
total_tokens = sum(_estimate_tokens(record.content) for record in records)
|
|
193
|
+
return total_tokens > MAX_RERANK_TOKENS
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class OpenVikingAdapter(MemoryBackendAdapter):
|
|
197
|
+
name = "openviking"
|
|
198
|
+
env_var = "OPENVIKING_API_KEY"
|
|
199
|
+
supports_update = True
|
|
200
|
+
supports_resource_sync = True
|
|
201
|
+
supports_stats = True
|
|
202
|
+
supports_prefix_delete = True
|
|
203
|
+
|
|
204
|
+
def __init__(self, base_url: str | None = None, timeout: float = 30.0) -> None:
|
|
205
|
+
api_key = os.environ.get(self.env_var)
|
|
206
|
+
if not api_key:
|
|
207
|
+
raise BackendNotConfiguredError(self.name, self.env_var)
|
|
208
|
+
resolved_base_url = base_url or os.environ.get("OPENVIKING_BASE_URL") or DEFAULT_BASE_URL
|
|
209
|
+
self._http = httpx.Client(
|
|
210
|
+
base_url=resolved_base_url,
|
|
211
|
+
headers={
|
|
212
|
+
"Authorization": f"Bearer {api_key}",
|
|
213
|
+
"Content-Type": "application/json",
|
|
214
|
+
},
|
|
215
|
+
timeout=timeout,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _path(self, session_id: str, key: str) -> str:
|
|
219
|
+
return f"memory/{session_id}/{key}"
|
|
220
|
+
|
|
221
|
+
def store(
|
|
222
|
+
self,
|
|
223
|
+
session_id: str,
|
|
224
|
+
content: str,
|
|
225
|
+
metadata: dict[str, str] | None = None,
|
|
226
|
+
mode: str | None = None,
|
|
227
|
+
) -> StoreResult:
|
|
228
|
+
# OpenViking's filesystem paradigm has no documented operating-mode
|
|
229
|
+
# variant -- accepted and ignored (no-op); see
|
|
230
|
+
# MemoryBackendAdapter.supported_modes.
|
|
231
|
+
del mode
|
|
232
|
+
timer = self._timed()
|
|
233
|
+
metadata = metadata or {}
|
|
234
|
+
# A caller (e.g. evals/resource_sync_safety.py) that knows the real
|
|
235
|
+
# nested path a piece of content belongs under -- "entities/people/
|
|
236
|
+
# jordan-lee.md", "preferences/user-482/notification-settings.md" --
|
|
237
|
+
# passes it via the `resource_path` metadata key, and store()
|
|
238
|
+
# writes to that real path instead of flattening every write to a
|
|
239
|
+
# single-level content-hash filename. Falls back to the flat hash
|
|
240
|
+
# when no resource_path is supplied, so every existing caller that
|
|
241
|
+
# never sets this key keeps its current behavior unchanged. See the
|
|
242
|
+
# module docstring for why this matters (volcengine/OpenViking#1703).
|
|
243
|
+
resource_path = metadata.get("resource_path")
|
|
244
|
+
memory_key = resource_path.strip("/") if resource_path else _slugify(content)
|
|
245
|
+
payload: dict[str, object] = {
|
|
246
|
+
"path": f"viking://{self._path(session_id, memory_key)}",
|
|
247
|
+
"content": content,
|
|
248
|
+
"metadata": metadata,
|
|
249
|
+
}
|
|
250
|
+
try:
|
|
251
|
+
resp = self._http.post("/v1/fs/write", json=payload)
|
|
252
|
+
resp.raise_for_status()
|
|
253
|
+
data = resp.json()
|
|
254
|
+
except httpx.HTTPError as exc:
|
|
255
|
+
raise BackendAPIError(self.name, _error_detail(exc)) from exc
|
|
256
|
+
except json.JSONDecodeError as exc:
|
|
257
|
+
# volcengine/OpenViking#2966: a legacy uint16-length-truncated
|
|
258
|
+
# record's `fields` JSON crashes internal delta-list conversion
|
|
259
|
+
# (_convert_delta_list_for_index -> convert_fields_for_index ->
|
|
260
|
+
# a bare json.loads(fields_json)) on the write/upsert path this
|
|
261
|
+
# call exercises when it overwrites an existing key. This
|
|
262
|
+
# adapter's own `resp.json()` call would otherwise let that raw
|
|
263
|
+
# JSONDecodeError escape as an unclassified bare exception,
|
|
264
|
+
# violating the "always raise BackendAPIError" contract every
|
|
265
|
+
# adapter must honor -- see CrashSignal
|
|
266
|
+
# .LEGACY_CORRUPT_RECORD_UNDELETABLE in base.py for the full
|
|
267
|
+
# honesty caveat on what this classifies.
|
|
268
|
+
raise BackendAPIError(
|
|
269
|
+
self.name,
|
|
270
|
+
f"response body is not valid JSON: {exc}",
|
|
271
|
+
crash_signal=CrashSignal.LEGACY_CORRUPT_RECORD_UNDELETABLE,
|
|
272
|
+
) from exc
|
|
273
|
+
memory_id = str(data.get("path", payload["path"]))
|
|
274
|
+
# See ExtractionSignal in base.py -- volcengine/OpenViking#2751: a
|
|
275
|
+
# store() call that hits OpenViking's own session-based extraction
|
|
276
|
+
# pipeline can complete without raising and still report zero
|
|
277
|
+
# memories actually extracted (the OpenAI VLM backend's hardcoded
|
|
278
|
+
# max_tokens=32768 exceeds gpt-4o-mini's real 16384 cap; the
|
|
279
|
+
# resulting API 400 gets swallowed inside compressor_v2, and the
|
|
280
|
+
# write commit still returns 200/accepted with total_memories
|
|
281
|
+
# staying 0, silent). This adapter's store() targets the
|
|
282
|
+
# /v1/fs/write filesystem endpoint (see module docstring for the
|
|
283
|
+
# confidence caveat on the real memory-extraction endpoint), which
|
|
284
|
+
# is not confirmed to echo back a `total_memories`/
|
|
285
|
+
# `memories_extracted` count on every deployment/version -- when
|
|
286
|
+
# present and explicitly 0, that is the identical "response looks
|
|
287
|
+
# like a normal 200 but nothing was extracted" shape #2751 reports.
|
|
288
|
+
# Absent entirely, this defaults to FACTS_EXTRACTED whenever a
|
|
289
|
+
# usable path/memory_id came back, the same "presence of an id is
|
|
290
|
+
# a successful store" assumption store() already made before this
|
|
291
|
+
# change -- see docs/methodology.md for the confidence caveat.
|
|
292
|
+
total_memories = data.get("total_memories", data.get("memories_extracted"))
|
|
293
|
+
if total_memories is not None and total_memories == 0:
|
|
294
|
+
extraction_signal = ExtractionSignal.EMPTY_EXTRACTION
|
|
295
|
+
elif memory_id:
|
|
296
|
+
extraction_signal = ExtractionSignal.FACTS_EXTRACTED
|
|
297
|
+
else:
|
|
298
|
+
extraction_signal = ExtractionSignal.EMPTY_EXTRACTION
|
|
299
|
+
return StoreResult(
|
|
300
|
+
memory_id=memory_id,
|
|
301
|
+
latency_ms=timer.elapsed_ms(),
|
|
302
|
+
raw=data,
|
|
303
|
+
extraction_signal=extraction_signal,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def query(
|
|
307
|
+
self, session_id: str, query: str, top_k: int = 5, mode: str | None = None
|
|
308
|
+
) -> QueryResult:
|
|
309
|
+
del mode # no-op, see store() above
|
|
310
|
+
timer = self._timed()
|
|
311
|
+
payload = {"path_prefix": f"viking://memory/{session_id}/", "query": query, "limit": top_k}
|
|
312
|
+
try:
|
|
313
|
+
resp = self._http.post("/v1/search", json=payload)
|
|
314
|
+
resp.raise_for_status()
|
|
315
|
+
data = resp.json()
|
|
316
|
+
except httpx.HTTPError as exc:
|
|
317
|
+
raise BackendAPIError(self.name, _error_detail(exc)) from exc
|
|
318
|
+
|
|
319
|
+
items = data.get("results", data.get("matches", []))
|
|
320
|
+
records = [
|
|
321
|
+
MemoryRecord(
|
|
322
|
+
memory_id=str(item.get("path", "")),
|
|
323
|
+
content=str(item.get("content", item.get("snippet", ""))),
|
|
324
|
+
score=item.get("score"),
|
|
325
|
+
created_at=item.get("updated_at"),
|
|
326
|
+
metadata=item.get("metadata") or {},
|
|
327
|
+
raw=item,
|
|
328
|
+
)
|
|
329
|
+
for item in items
|
|
330
|
+
]
|
|
331
|
+
# OpenViking's filesystem paradigm has no documented conflict-
|
|
332
|
+
# marker field surfaced in this build's research pass -- a
|
|
333
|
+
# contradicting fact written to the same path simply overwrites
|
|
334
|
+
# the file's content with no version history exposed through the
|
|
335
|
+
# search API as documented. Recorded as NOT_APPLICABLE rather than
|
|
336
|
+
# guessed as FLAGGED or SILENT_OVERWRITE; see docs/methodology.md.
|
|
337
|
+
#
|
|
338
|
+
# Rerank-awareness (volcengine/OpenViking#1737, #2739, #2880): a
|
|
339
|
+
# multi-candidate response carrying an empty-string document or an
|
|
340
|
+
# oversized total token budget matches the shape both cited bug
|
|
341
|
+
# reports describe as silently falling back to raw vector scores
|
|
342
|
+
# -- see _rerank_fallback_risk()'s and RankingSignal
|
|
343
|
+
# .RERANK_FALLBACK's own docstrings for the full honesty caveat
|
|
344
|
+
# (both bugs are already fixed upstream; this flags the SHAPE, not
|
|
345
|
+
# confirmed live server-side fallback).
|
|
346
|
+
ranking_signal = (
|
|
347
|
+
RankingSignal.RERANK_FALLBACK
|
|
348
|
+
if len(records) > 1 and _rerank_fallback_risk(records)
|
|
349
|
+
else RankingSignal.NOT_APPLICABLE
|
|
350
|
+
)
|
|
351
|
+
return QueryResult(
|
|
352
|
+
records=records,
|
|
353
|
+
conflict_signal=ConflictSignal.NOT_APPLICABLE,
|
|
354
|
+
latency_ms=timer.elapsed_ms(),
|
|
355
|
+
ranking_signal=ranking_signal,
|
|
356
|
+
raw=data,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def update(self, session_id: str, memory_id: str, content: str) -> UpdateResult:
|
|
360
|
+
timer = self._timed()
|
|
361
|
+
payload = {"path": memory_id, "content": content}
|
|
362
|
+
try:
|
|
363
|
+
resp = self._http.post("/v1/fs/write", json=payload)
|
|
364
|
+
resp.raise_for_status()
|
|
365
|
+
data = resp.json()
|
|
366
|
+
except httpx.HTTPError as exc:
|
|
367
|
+
raise BackendAPIError(self.name, _error_detail(exc)) from exc
|
|
368
|
+
except json.JSONDecodeError as exc:
|
|
369
|
+
# Same volcengine/OpenViking#2966 shape as store() above --
|
|
370
|
+
# update()'s /v1/fs/write call is this adapter's own upsert
|
|
371
|
+
# path (it overwrites an existing key), the exact "upsert_data()"
|
|
372
|
+
# write path #2966 reports as crashing on a legacy-corrupt
|
|
373
|
+
# record. See CrashSignal.LEGACY_CORRUPT_RECORD_UNDELETABLE.
|
|
374
|
+
raise BackendAPIError(
|
|
375
|
+
self.name,
|
|
376
|
+
f"response body is not valid JSON: {exc}",
|
|
377
|
+
crash_signal=CrashSignal.LEGACY_CORRUPT_RECORD_UNDELETABLE,
|
|
378
|
+
) from exc
|
|
379
|
+
return UpdateResult(
|
|
380
|
+
memory_id=memory_id, acknowledged=True, latency_ms=timer.elapsed_ms(), raw=data
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def delete(self, memory_id: str) -> DeleteResult:
|
|
384
|
+
# Best-effort reconstruction against the same viking:// filesystem
|
|
385
|
+
# paradigm store()/update() above are written against: this build's
|
|
386
|
+
# research pass did not surface a confirmed "delete a memory"
|
|
387
|
+
# method name (see module docstring), so this targets the plain
|
|
388
|
+
# filesystem-delete symmetrical with /v1/fs/write. Whoever verifies
|
|
389
|
+
# this adapter against a live instance should correct the path if
|
|
390
|
+
# OpenViking's real client exposes something else.
|
|
391
|
+
timer = self._timed()
|
|
392
|
+
payload = {"path": memory_id}
|
|
393
|
+
try:
|
|
394
|
+
resp = self._http.post("/v1/fs/delete", json=payload)
|
|
395
|
+
resp.raise_for_status()
|
|
396
|
+
data = resp.json() if resp.content else {}
|
|
397
|
+
except httpx.HTTPError as exc:
|
|
398
|
+
raise BackendAPIError(self.name, _error_detail(exc)) from exc
|
|
399
|
+
except json.JSONDecodeError as exc:
|
|
400
|
+
# volcengine/OpenViking#2966: LocalIndex.delete_data() runs the
|
|
401
|
+
# same _convert_delta_list_for_index() -> bare
|
|
402
|
+
# json.loads(fields_json) path store()/update() above hit --
|
|
403
|
+
# this is the literal "delete()" half of "delete/upsert" the
|
|
404
|
+
# issue names. See CrashSignal
|
|
405
|
+
# .LEGACY_CORRUPT_RECORD_UNDELETABLE for the full citation.
|
|
406
|
+
raise BackendAPIError(
|
|
407
|
+
self.name,
|
|
408
|
+
f"response body is not valid JSON: {exc}",
|
|
409
|
+
crash_signal=CrashSignal.LEGACY_CORRUPT_RECORD_UNDELETABLE,
|
|
410
|
+
) from exc
|
|
411
|
+
return DeleteResult(
|
|
412
|
+
success=True, memory_id=memory_id, latency_ms=timer.elapsed_ms(), raw=data
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def list_resource_paths(self, prefix: str, max_depth: int = 8) -> list[str]:
|
|
416
|
+
"""Recursively list every leaf file path under `prefix`.
|
|
417
|
+
|
|
418
|
+
A single `/v1/fs/list` response is not assumed to already contain
|
|
419
|
+
every nested file -- each returned entry is inspected, and any
|
|
420
|
+
entry the listing endpoint reports as a directory (a dict with
|
|
421
|
+
`is_dir`/`type: "directory"`, or a bare path string ending in `/`)
|
|
422
|
+
is itself descended into with a follow-up `/v1/fs/list` call,
|
|
423
|
+
rather than being dropped or treated as a leaf. `max_depth` bounds
|
|
424
|
+
that recursion (default 8) so a misbehaving or cyclic listing
|
|
425
|
+
response cannot recurse unboundedly.
|
|
426
|
+
|
|
427
|
+
This exists because a flat, non-recursive read of whatever the
|
|
428
|
+
first response happened to contain would make nested content
|
|
429
|
+
structurally invisible to this harness regardless of what
|
|
430
|
+
store() actually wrote -- see the module docstring and
|
|
431
|
+
volcengine/OpenViking#1703.
|
|
432
|
+
"""
|
|
433
|
+
return self._list_resource_paths_recursive(prefix, depth=0, max_depth=max_depth)
|
|
434
|
+
|
|
435
|
+
def _list_resource_paths_recursive(self, prefix: str, depth: int, max_depth: int) -> list[str]:
|
|
436
|
+
payload = {"path_prefix": f"viking://{prefix}"}
|
|
437
|
+
try:
|
|
438
|
+
resp = self._http.post("/v1/fs/list", json=payload)
|
|
439
|
+
resp.raise_for_status()
|
|
440
|
+
data = resp.json()
|
|
441
|
+
except httpx.HTTPError as exc:
|
|
442
|
+
raise BackendAPIError(self.name, _error_detail(exc)) from exc
|
|
443
|
+
entries = data.get("paths", data.get("entries", []))
|
|
444
|
+
paths: list[str] = []
|
|
445
|
+
for entry in entries:
|
|
446
|
+
if isinstance(entry, dict):
|
|
447
|
+
entry_path = str(entry.get("path", ""))
|
|
448
|
+
is_dir = bool(entry.get("is_dir") or entry.get("type") == "directory")
|
|
449
|
+
else:
|
|
450
|
+
entry_path = str(entry)
|
|
451
|
+
is_dir = entry_path.endswith("/")
|
|
452
|
+
if not entry_path:
|
|
453
|
+
continue
|
|
454
|
+
if is_dir:
|
|
455
|
+
if depth >= max_depth:
|
|
456
|
+
continue
|
|
457
|
+
sub_prefix = entry_path
|
|
458
|
+
if sub_prefix.startswith("viking://"):
|
|
459
|
+
sub_prefix = sub_prefix[len("viking://") :]
|
|
460
|
+
sub_prefix = sub_prefix.rstrip("/")
|
|
461
|
+
if not sub_prefix or sub_prefix == prefix.rstrip("/"):
|
|
462
|
+
# Guard against a listing entry that just echoes the
|
|
463
|
+
# queried prefix back as a "directory" -- recursing on
|
|
464
|
+
# it would loop forever at constant depth.
|
|
465
|
+
continue
|
|
466
|
+
paths.extend(self._list_resource_paths_recursive(sub_prefix, depth + 1, max_depth))
|
|
467
|
+
else:
|
|
468
|
+
paths.append(entry_path)
|
|
469
|
+
return paths
|
|
470
|
+
|
|
471
|
+
def trigger_resync(self, prefix: str) -> None:
|
|
472
|
+
payload = {"path_prefix": f"viking://{prefix}"}
|
|
473
|
+
try:
|
|
474
|
+
resp = self._http.post("/v1/fs/resync", json=payload)
|
|
475
|
+
resp.raise_for_status()
|
|
476
|
+
except httpx.HTTPError as exc:
|
|
477
|
+
raise BackendAPIError(self.name, _error_detail(exc)) from exc
|
|
478
|
+
|
|
479
|
+
def get_stats(self, session_id: str | None = None) -> StatsResult:
|
|
480
|
+
"""Read OpenViking's dedicated stats/dashboard endpoint.
|
|
481
|
+
|
|
482
|
+
Confidence: HIGH on the endpoint path and response shape --
|
|
483
|
+
`GET /api/v1/stats/memories` and its `total_memories` field are
|
|
484
|
+
confirmed verbatim from volcengine/OpenViking#1255's real bug
|
|
485
|
+
report (contributor SeeYangZhi), which pastes the exact JSON
|
|
486
|
+
response shape this parses. Distinct from the LOW-confidence
|
|
487
|
+
guessed `/v1/fs/write`/`/v1/search` endpoints elsewhere in this
|
|
488
|
+
adapter (see the module docstring): this path was never confirmed
|
|
489
|
+
against a live instance either, but the exact response shape comes
|
|
490
|
+
from a real, reproduced report rather than being inferred by
|
|
491
|
+
analogy.
|
|
492
|
+
|
|
493
|
+
`session_id` is accepted for interface compatibility with
|
|
494
|
+
MemoryBackendAdapter.get_stats() but ignored -- the real endpoint
|
|
495
|
+
this targets is a tenant-wide/global counter (per #1255's report),
|
|
496
|
+
not session-scoped, so there is nothing to pass it as.
|
|
497
|
+
"""
|
|
498
|
+
del session_id
|
|
499
|
+
timer = self._timed()
|
|
500
|
+
try:
|
|
501
|
+
resp = self._http.get("/api/v1/stats/memories")
|
|
502
|
+
resp.raise_for_status()
|
|
503
|
+
data = resp.json()
|
|
504
|
+
except httpx.HTTPError as exc:
|
|
505
|
+
raise BackendAPIError(self.name, _error_detail(exc)) from exc
|
|
506
|
+
total = data.get("total_memories")
|
|
507
|
+
total_memories = int(total) if isinstance(total, int | float) else None
|
|
508
|
+
return StatsResult(total_memories=total_memories, latency_ms=timer.elapsed_ms(), raw=data)
|
|
509
|
+
|
|
510
|
+
def delete_prefix(self, prefix: str, recursive: bool = True) -> DeletePrefixResult:
|
|
511
|
+
"""Recursively delete every resource path under `prefix`.
|
|
512
|
+
|
|
513
|
+
Best-effort reconstruction against volcengine/OpenViking#3064's
|
|
514
|
+
recommended fix direction: discover every child path via
|
|
515
|
+
list_resource_paths() (this adapter's own recursive tree walk,
|
|
516
|
+
see that method's docstring) rather than a single-level listing,
|
|
517
|
+
then delete() each discovered path plus the prefix root itself.
|
|
518
|
+
|
|
519
|
+
Honesty caveat (see VectorIntegritySignal in base.py and
|
|
520
|
+
evals/orphan_cleanup.py for the eval this feeds): list_resource_paths()
|
|
521
|
+
goes through the same `/v1/fs/list` AGFS-directory-listing endpoint
|
|
522
|
+
#3064's root cause lives in. If a live OpenViking server still has
|
|
523
|
+
the bug this issue reports -- `_ls_entries()` raising when the
|
|
524
|
+
parent directory no longer exists in AGFS, silently swallowed by a
|
|
525
|
+
bare `except: pass` -- this adapter's own listing call would
|
|
526
|
+
suffer the identical limitation: it would enumerate zero children,
|
|
527
|
+
this method would only delete the prefix root, and any child
|
|
528
|
+
vector-index entries would survive undetected by anything that
|
|
529
|
+
only checks list_resource_paths() again afterward. That is
|
|
530
|
+
precisely why evals/orphan_cleanup.py's classification also
|
|
531
|
+
re-queries for seeded content, not just re-lists paths -- see
|
|
532
|
+
VectorIntegritySignal.ORPHANED_VECTOR_ENTRY.
|
|
533
|
+
"""
|
|
534
|
+
timer = self._timed()
|
|
535
|
+
child_paths: list[str] = []
|
|
536
|
+
if recursive:
|
|
537
|
+
try:
|
|
538
|
+
child_paths = self.list_resource_paths(prefix)
|
|
539
|
+
except BackendAPIError:
|
|
540
|
+
child_paths = []
|
|
541
|
+
deleted: list[str] = []
|
|
542
|
+
failed: list[str] = []
|
|
543
|
+
for path in child_paths:
|
|
544
|
+
try:
|
|
545
|
+
result = self.delete(path)
|
|
546
|
+
except BackendAPIError:
|
|
547
|
+
failed.append(path)
|
|
548
|
+
continue
|
|
549
|
+
(deleted if result.success else failed).append(path)
|
|
550
|
+
root_uri = f"viking://{prefix.strip('/')}"
|
|
551
|
+
try:
|
|
552
|
+
root_result = self.delete(root_uri)
|
|
553
|
+
(deleted if root_result.success else failed).append(root_uri)
|
|
554
|
+
except BackendAPIError:
|
|
555
|
+
failed.append(root_uri)
|
|
556
|
+
return DeletePrefixResult(
|
|
557
|
+
prefix=prefix,
|
|
558
|
+
deleted_paths=deleted,
|
|
559
|
+
failed_paths=failed,
|
|
560
|
+
latency_ms=timer.elapsed_ms(),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
def close(self) -> None:
|
|
564
|
+
self._http.close()
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _slugify(content: str) -> str:
|
|
568
|
+
import hashlib
|
|
569
|
+
|
|
570
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
|