contextos-memory-runtime 1.0.0rc2__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.
- contextos/__init__.py +3 -0
- contextos/__main__.py +6 -0
- contextos/api/__init__.py +1 -0
- contextos/api/routes/__init__.py +1 -0
- contextos/api/routes/desktop.py +322 -0
- contextos/api/routes/ingest.py +17 -0
- contextos/api/routes/memories.py +84 -0
- contextos/api/routes/models.py +81 -0
- contextos/api/routes/retrieval.py +89 -0
- contextos/api/routes/system.py +216 -0
- contextos/api/server.py +195 -0
- contextos/benchmarks/__init__.py +1 -0
- contextos/benchmarks/compilation.py +245 -0
- contextos/benchmarks/connectors.py +423 -0
- contextos/benchmarks/explainability.py +103 -0
- contextos/benchmarks/final.py +406 -0
- contextos/benchmarks/graph.py +310 -0
- contextos/benchmarks/graph_adversarial.py +525 -0
- contextos/benchmarks/mcp.py +324 -0
- contextos/benchmarks/model_routing.py +203 -0
- contextos/benchmarks/optimization.py +305 -0
- contextos/benchmarks/rescue_integration.py +127 -0
- contextos/benchmarks/retrieval.py +266 -0
- contextos/benchmarks/temporal.py +377 -0
- contextos/benchmarks/temporal_hotpath.py +76 -0
- contextos/benchmarks/terminal.py +62 -0
- contextos/cli/__init__.py +1 -0
- contextos/cli/app.py +932 -0
- contextos/cli/dashboard.py +174 -0
- contextos/cli/formatters.py +299 -0
- contextos/config/__init__.py +1 -0
- contextos/config/settings.py +160 -0
- contextos/connectors/__init__.py +6 -0
- contextos/connectors/fake.py +11 -0
- contextos/connectors/json_import.py +125 -0
- contextos/connectors/local_files.py +102 -0
- contextos/connectors/manager.py +293 -0
- contextos/connectors/models.py +62 -0
- contextos/connectors/protocols.py +11 -0
- contextos/core/__init__.py +103 -0
- contextos/core/enums.py +489 -0
- contextos/core/exceptions.py +293 -0
- contextos/core/models.py +1147 -0
- contextos/core/protocols.py +549 -0
- contextos/daemon/__init__.py +1 -0
- contextos/daemon/manager.py +510 -0
- contextos/daemon/state.py +127 -0
- contextos/daemon/wiring.py +296 -0
- contextos/demo.py +217 -0
- contextos/embedding/__init__.py +1 -0
- contextos/embedding/deterministic.py +76 -0
- contextos/embedding/sentence_transformers.py +80 -0
- contextos/mcp/__init__.py +5 -0
- contextos/mcp/server.py +269 -0
- contextos/providers/__init__.py +13 -0
- contextos/providers/fake.py +217 -0
- contextos/providers/ollama.py +297 -0
- contextos/providers/openai_compatible.py +337 -0
- contextos/services/__init__.py +1 -0
- contextos/services/compilation.py +535 -0
- contextos/services/explainability.py +553 -0
- contextos/services/extraction.py +311 -0
- contextos/services/graph.py +524 -0
- contextos/services/graph_retrieval.py +143 -0
- contextos/services/ingestion.py +143 -0
- contextos/services/inspection.py +174 -0
- contextos/services/memory.py +291 -0
- contextos/services/model_service.py +409 -0
- contextos/services/optimization.py +426 -0
- contextos/services/privacy.py +331 -0
- contextos/services/retrieval.py +302 -0
- contextos/services/retrieval_index.py +88 -0
- contextos/services/router.py +302 -0
- contextos/services/secret_scanner.py +207 -0
- contextos/services/telemetry_query.py +102 -0
- contextos/services/temporal.py +500 -0
- contextos/services/token_counter.py +222 -0
- contextos/storage/__init__.py +1 -0
- contextos/storage/connector_repo.py +67 -0
- contextos/storage/database.py +497 -0
- contextos/storage/event_repo.py +137 -0
- contextos/storage/graph_repo.py +228 -0
- contextos/storage/lexical/__init__.py +1 -0
- contextos/storage/lexical/bm25.py +134 -0
- contextos/storage/memory_repo.py +589 -0
- contextos/storage/relation_repo.py +80 -0
- contextos/storage/telemetry_repo.py +481 -0
- contextos/storage/vector/__init__.py +1 -0
- contextos/storage/vector/in_memory.py +162 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Two-stage local privacy gate for raw input and extracted candidates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import secrets
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from contextos.core.enums import (
|
|
14
|
+
PrivacyClassification,
|
|
15
|
+
PrivacyDecision,
|
|
16
|
+
PrivacySeverity,
|
|
17
|
+
SecretDetectionMode,
|
|
18
|
+
SecretType,
|
|
19
|
+
SourceRole,
|
|
20
|
+
SourceTrust,
|
|
21
|
+
)
|
|
22
|
+
from contextos.core.models import (
|
|
23
|
+
CandidateMemory,
|
|
24
|
+
PrivacyAssessment,
|
|
25
|
+
PrivacyFinding,
|
|
26
|
+
SecretMatch,
|
|
27
|
+
)
|
|
28
|
+
from contextos.core.protocols import SecretScanner
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_CRITICAL_TYPES = {
|
|
32
|
+
SecretType.PRIVATE_KEY,
|
|
33
|
+
SecretType.SSH_PRIVATE_KEY,
|
|
34
|
+
SecretType.PASSWORD,
|
|
35
|
+
SecretType.CONNECTION_STRING,
|
|
36
|
+
SecretType.AUTHORIZATION_HEADER,
|
|
37
|
+
SecretType.SESSION_COOKIE,
|
|
38
|
+
SecretType.ACCESS_TOKEN,
|
|
39
|
+
}
|
|
40
|
+
_CREDENTIAL_LANGUAGE = re.compile(
|
|
41
|
+
r"\b(?:api key|password|passwd|otp|pin|token|authorization|cookie|session|"
|
|
42
|
+
r"private key|secret key|connection string)\b",
|
|
43
|
+
re.I,
|
|
44
|
+
)
|
|
45
|
+
_UNTRUSTED_INSTRUCTION = re.compile(
|
|
46
|
+
r"\b(?:ignore (?:all |the )?(?:previous|prior) instructions|system instruction|"
|
|
47
|
+
r"developer instruction|save .{0,40}(?:password|token|secret).{0,20}(?:memory|forever)|"
|
|
48
|
+
r"permanently remember)\b",
|
|
49
|
+
re.I,
|
|
50
|
+
)
|
|
51
|
+
_FINGERPRINT_KEY = secrets.token_bytes(32)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class GatedInput:
|
|
56
|
+
assessment: PrivacyAssessment
|
|
57
|
+
content: str
|
|
58
|
+
source_type: str
|
|
59
|
+
source_uri: str | None
|
|
60
|
+
tags: list[str]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _hash(value: str) -> str:
|
|
64
|
+
return hmac.new(_FINGERPRINT_KEY, value.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def classify_source(source_type: str, source_role: SourceRole) -> SourceTrust:
|
|
68
|
+
"""Classify source trust as metadata, never as proof that content is true."""
|
|
69
|
+
normalized = source_type.casefold().replace("-", "_")
|
|
70
|
+
if source_role in {SourceRole.ASSISTANT, SourceRole.SYSTEM}:
|
|
71
|
+
return SourceTrust.MODEL_OUTPUT
|
|
72
|
+
if any(marker in normalized for marker in ("web", "browser", "url")):
|
|
73
|
+
return SourceTrust.EXTERNAL_WEBPAGE
|
|
74
|
+
if any(marker in normalized for marker in ("document", "file", "import")):
|
|
75
|
+
return SourceTrust.IMPORTED_DOCUMENT
|
|
76
|
+
if "tool" in normalized:
|
|
77
|
+
return SourceTrust.TOOL_OUTPUT
|
|
78
|
+
if "connector" in normalized:
|
|
79
|
+
return SourceTrust.LOCAL_TRUSTED_CONNECTOR
|
|
80
|
+
return SourceTrust.DIRECT_USER
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PrivacyGate:
|
|
84
|
+
"""Deterministic pre-ingest and post-extraction security policy."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, scanner: SecretScanner) -> None:
|
|
87
|
+
self._scanner = scanner
|
|
88
|
+
|
|
89
|
+
def gate_input(
|
|
90
|
+
self,
|
|
91
|
+
content: str,
|
|
92
|
+
*,
|
|
93
|
+
source_type: str,
|
|
94
|
+
source_uri: str | None,
|
|
95
|
+
tags: list[str],
|
|
96
|
+
source_role: SourceRole,
|
|
97
|
+
mode: SecretDetectionMode,
|
|
98
|
+
) -> GatedInput:
|
|
99
|
+
safe_content, findings = self._scan_text(content, "content")
|
|
100
|
+
safe_source_type, source_type_findings = self._scan_text(
|
|
101
|
+
source_type, "source_type"
|
|
102
|
+
)
|
|
103
|
+
findings.extend(source_type_findings)
|
|
104
|
+
safe_source_uri: str | None = None
|
|
105
|
+
if source_uri is not None:
|
|
106
|
+
safe_source_uri, source_uri_findings = self._scan_text(
|
|
107
|
+
source_uri, "source_uri"
|
|
108
|
+
)
|
|
109
|
+
findings.extend(source_uri_findings)
|
|
110
|
+
safe_tags: list[str] = []
|
|
111
|
+
for index, tag in enumerate(tags):
|
|
112
|
+
safe_tag, tag_findings = self._scan_text(tag, f"tags[{index}]")
|
|
113
|
+
safe_tags.append(safe_tag)
|
|
114
|
+
findings.extend(tag_findings)
|
|
115
|
+
|
|
116
|
+
trust = classify_source(safe_source_type, source_role)
|
|
117
|
+
untrusted_instruction = (
|
|
118
|
+
trust not in {SourceTrust.DIRECT_USER, SourceTrust.LOCAL_TRUSTED_CONNECTOR}
|
|
119
|
+
and bool(_UNTRUSTED_INSTRUCTION.search(safe_content))
|
|
120
|
+
)
|
|
121
|
+
decision, classification = self._policy(findings, mode)
|
|
122
|
+
raw_envelope = json.dumps(
|
|
123
|
+
[content, source_type, source_uri, tags], ensure_ascii=False, separators=(",", ":")
|
|
124
|
+
)
|
|
125
|
+
assessment = PrivacyAssessment(
|
|
126
|
+
decision=decision,
|
|
127
|
+
classification=classification,
|
|
128
|
+
source_trust=trust,
|
|
129
|
+
findings=findings,
|
|
130
|
+
sanitized_text=safe_content,
|
|
131
|
+
scanned_length=sum(
|
|
132
|
+
len(value) for value in [content, source_type, source_uri or "", *tags]
|
|
133
|
+
),
|
|
134
|
+
input_hash=_hash(raw_envelope),
|
|
135
|
+
untrusted_instruction_detected=untrusted_instruction,
|
|
136
|
+
)
|
|
137
|
+
return GatedInput(
|
|
138
|
+
assessment=assessment,
|
|
139
|
+
content=safe_content,
|
|
140
|
+
source_type=safe_source_type,
|
|
141
|
+
source_uri=safe_source_uri,
|
|
142
|
+
tags=safe_tags,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def assess_input(
|
|
146
|
+
self,
|
|
147
|
+
text: str,
|
|
148
|
+
*,
|
|
149
|
+
source_type: str,
|
|
150
|
+
source_role: SourceRole,
|
|
151
|
+
mode: SecretDetectionMode,
|
|
152
|
+
) -> PrivacyAssessment:
|
|
153
|
+
"""Compatibility helper for callers assessing only a content string."""
|
|
154
|
+
return self.gate_input(
|
|
155
|
+
text,
|
|
156
|
+
source_type=source_type,
|
|
157
|
+
source_uri=None,
|
|
158
|
+
tags=[],
|
|
159
|
+
source_role=source_role,
|
|
160
|
+
mode=mode,
|
|
161
|
+
).assessment
|
|
162
|
+
|
|
163
|
+
def assess_candidate(
|
|
164
|
+
self,
|
|
165
|
+
candidate: CandidateMemory,
|
|
166
|
+
*,
|
|
167
|
+
source_trust: SourceTrust,
|
|
168
|
+
) -> tuple[CandidateMemory | None, PrivacyAssessment]:
|
|
169
|
+
content, findings = self._scan_text(candidate.content, "candidate.content")
|
|
170
|
+
evidence, evidence_findings = self._scan_text(
|
|
171
|
+
candidate.evidence, "candidate.evidence"
|
|
172
|
+
)
|
|
173
|
+
findings.extend(evidence_findings)
|
|
174
|
+
source_type, source_type_findings = self._scan_text(
|
|
175
|
+
candidate.source_type, "candidate.source_type"
|
|
176
|
+
)
|
|
177
|
+
findings.extend(source_type_findings)
|
|
178
|
+
source_uri = None
|
|
179
|
+
if candidate.source_uri is not None:
|
|
180
|
+
source_uri, source_uri_findings = self._scan_text(
|
|
181
|
+
candidate.source_uri, "candidate.source_uri"
|
|
182
|
+
)
|
|
183
|
+
findings.extend(source_uri_findings)
|
|
184
|
+
tags, tag_findings = self._sanitize_structure(candidate.tags, "candidate.tags")
|
|
185
|
+
metadata, metadata_findings = self._sanitize_structure(
|
|
186
|
+
candidate.metadata, "candidate.metadata"
|
|
187
|
+
)
|
|
188
|
+
findings.extend(tag_findings)
|
|
189
|
+
findings.extend(metadata_findings)
|
|
190
|
+
|
|
191
|
+
contains_redaction = "[REDACTED:" in content or "[REDACTED:" in evidence
|
|
192
|
+
credential_only = contains_redaction and bool(_CREDENTIAL_LANGUAGE.search(content))
|
|
193
|
+
combined = f"{content}\n{evidence}"
|
|
194
|
+
untrusted_instruction = (
|
|
195
|
+
source_trust not in {
|
|
196
|
+
SourceTrust.DIRECT_USER,
|
|
197
|
+
SourceTrust.LOCAL_TRUSTED_CONNECTOR,
|
|
198
|
+
}
|
|
199
|
+
and bool(_UNTRUSTED_INSTRUCTION.search(combined))
|
|
200
|
+
)
|
|
201
|
+
blocked = credential_only or untrusted_instruction
|
|
202
|
+
assessment = PrivacyAssessment(
|
|
203
|
+
decision=PrivacyDecision.REJECT if blocked else (
|
|
204
|
+
PrivacyDecision.REDACT if findings else PrivacyDecision.ALLOW
|
|
205
|
+
),
|
|
206
|
+
classification=PrivacyClassification.BLOCKED if blocked else (
|
|
207
|
+
PrivacyClassification.SECRET if findings else PrivacyClassification.SAFE
|
|
208
|
+
),
|
|
209
|
+
source_trust=source_trust,
|
|
210
|
+
findings=findings,
|
|
211
|
+
sanitized_text=combined,
|
|
212
|
+
scanned_length=len(combined),
|
|
213
|
+
input_hash=_hash(json.dumps(
|
|
214
|
+
candidate.model_dump(mode="json", exclude={"privacy_assessment"}),
|
|
215
|
+
sort_keys=True,
|
|
216
|
+
separators=(",", ":"),
|
|
217
|
+
)),
|
|
218
|
+
untrusted_instruction_detected=untrusted_instruction,
|
|
219
|
+
)
|
|
220
|
+
if blocked:
|
|
221
|
+
return None, assessment
|
|
222
|
+
safe_candidate = candidate.model_copy(update={
|
|
223
|
+
"content": content,
|
|
224
|
+
"evidence": evidence,
|
|
225
|
+
"source_type": source_type,
|
|
226
|
+
"source_uri": source_uri,
|
|
227
|
+
"tags": tags,
|
|
228
|
+
"metadata": metadata,
|
|
229
|
+
"privacy_assessment": assessment,
|
|
230
|
+
})
|
|
231
|
+
return safe_candidate, assessment
|
|
232
|
+
|
|
233
|
+
def gate_candidates(
|
|
234
|
+
self,
|
|
235
|
+
candidates: list[CandidateMemory],
|
|
236
|
+
*,
|
|
237
|
+
source_trust: SourceTrust,
|
|
238
|
+
) -> tuple[list[CandidateMemory], list[PrivacyAssessment]]:
|
|
239
|
+
safe: list[CandidateMemory] = []
|
|
240
|
+
blocked: list[PrivacyAssessment] = []
|
|
241
|
+
for candidate in candidates:
|
|
242
|
+
gated, assessment = self.assess_candidate(candidate, source_trust=source_trust)
|
|
243
|
+
if gated is None:
|
|
244
|
+
blocked.append(assessment)
|
|
245
|
+
else:
|
|
246
|
+
safe.append(gated)
|
|
247
|
+
return safe, blocked
|
|
248
|
+
|
|
249
|
+
def _scan_text(self, text: str, location: str) -> tuple[str, list[PrivacyFinding]]:
|
|
250
|
+
scan = self._scanner.scan(text)
|
|
251
|
+
findings = self._safe_findings(text, scan.matches, location)
|
|
252
|
+
return self._redact_from_findings(text, findings), findings
|
|
253
|
+
|
|
254
|
+
def _sanitize_structure(
|
|
255
|
+
self, value: Any, location: str
|
|
256
|
+
) -> tuple[Any, list[PrivacyFinding]]:
|
|
257
|
+
if isinstance(value, str):
|
|
258
|
+
return self._scan_text(value, location)
|
|
259
|
+
if isinstance(value, list):
|
|
260
|
+
safe_list: list[Any] = []
|
|
261
|
+
findings: list[PrivacyFinding] = []
|
|
262
|
+
for index, item in enumerate(value):
|
|
263
|
+
safe_item, item_findings = self._sanitize_structure(
|
|
264
|
+
item, f"{location}[{index}]"
|
|
265
|
+
)
|
|
266
|
+
safe_list.append(safe_item)
|
|
267
|
+
findings.extend(item_findings)
|
|
268
|
+
return safe_list, findings
|
|
269
|
+
if isinstance(value, dict):
|
|
270
|
+
safe_dict: dict[str, Any] = {}
|
|
271
|
+
findings = []
|
|
272
|
+
for key, item in value.items():
|
|
273
|
+
safe_key, key_findings = self._scan_text(str(key), f"{location}.key")
|
|
274
|
+
safe_item, item_findings = self._sanitize_structure(
|
|
275
|
+
item, f"{location}.{safe_key}"
|
|
276
|
+
)
|
|
277
|
+
safe_dict[safe_key] = safe_item
|
|
278
|
+
findings.extend(key_findings)
|
|
279
|
+
findings.extend(item_findings)
|
|
280
|
+
return safe_dict, findings
|
|
281
|
+
return value, []
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _policy(
|
|
285
|
+
findings: list[PrivacyFinding], mode: SecretDetectionMode
|
|
286
|
+
) -> tuple[PrivacyDecision, PrivacyClassification]:
|
|
287
|
+
if not findings:
|
|
288
|
+
return PrivacyDecision.ALLOW, PrivacyClassification.SAFE
|
|
289
|
+
if mode == SecretDetectionMode.STRICT:
|
|
290
|
+
return PrivacyDecision.REJECT, PrivacyClassification.BLOCKED
|
|
291
|
+
if mode == SecretDetectionMode.WARN:
|
|
292
|
+
return PrivacyDecision.QUARANTINE, PrivacyClassification.BLOCKED
|
|
293
|
+
return PrivacyDecision.REDACT, PrivacyClassification.SECRET
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _safe_findings(
|
|
297
|
+
text: str, matches: list[SecretMatch], location: str
|
|
298
|
+
) -> list[PrivacyFinding]:
|
|
299
|
+
findings: list[PrivacyFinding] = []
|
|
300
|
+
for match in matches:
|
|
301
|
+
value = text[match.start:match.end]
|
|
302
|
+
severity = (
|
|
303
|
+
PrivacySeverity.CRITICAL
|
|
304
|
+
if match.secret_type in _CRITICAL_TYPES
|
|
305
|
+
else PrivacySeverity.HIGH
|
|
306
|
+
if match.secret_type != SecretType.HIGH_ENTROPY
|
|
307
|
+
else PrivacySeverity.MEDIUM
|
|
308
|
+
)
|
|
309
|
+
findings.append(PrivacyFinding(
|
|
310
|
+
category=match.secret_type,
|
|
311
|
+
severity=severity,
|
|
312
|
+
start=match.start,
|
|
313
|
+
end=match.end,
|
|
314
|
+
detector="pattern-secret-scanner",
|
|
315
|
+
location=location,
|
|
316
|
+
confidence=match.confidence,
|
|
317
|
+
safe_preview=f"[REDACTED:{match.secret_type.value}]",
|
|
318
|
+
fingerprint=_hash(value),
|
|
319
|
+
))
|
|
320
|
+
return findings
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _redact_from_findings(text: str, findings: list[PrivacyFinding]) -> str:
|
|
324
|
+
redacted = text
|
|
325
|
+
for finding in sorted(findings, key=lambda item: item.start, reverse=True):
|
|
326
|
+
redacted = (
|
|
327
|
+
redacted[:finding.start]
|
|
328
|
+
+ finding.safe_preview
|
|
329
|
+
+ redacted[finding.end:]
|
|
330
|
+
)
|
|
331
|
+
return redacted
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Side-effect-free lexical, dense, and hybrid memory retrieval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from contextos.core.enums import (
|
|
10
|
+
CandidateTemporalStatus,
|
|
11
|
+
MemoryStatus,
|
|
12
|
+
RetrievalMode,
|
|
13
|
+
TemporalScope,
|
|
14
|
+
)
|
|
15
|
+
from contextos.core.models import (
|
|
16
|
+
LexicalResult,
|
|
17
|
+
Memory,
|
|
18
|
+
RetrievalConfig,
|
|
19
|
+
RetrievalQuery,
|
|
20
|
+
RetrievalResult,
|
|
21
|
+
RetrievalTrace,
|
|
22
|
+
ScoredMemory,
|
|
23
|
+
StageTrace,
|
|
24
|
+
VectorResult,
|
|
25
|
+
)
|
|
26
|
+
from contextos.core.protocols import EmbeddingService, LexicalIndex, MemoryRepository, VectorStore
|
|
27
|
+
from contextos.services.retrieval_index import RetrievalIndexSynchronizer
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HybridRetrievalEngine:
|
|
31
|
+
"""Retrieve with BM25, cosine similarity, or Reciprocal Rank Fusion.
|
|
32
|
+
|
|
33
|
+
RRF combines ranks rather than adding incomparable BM25 and cosine scores.
|
|
34
|
+
A bounded metadata factor can increase the relevance score by at most 5%.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
memory_repo: MemoryRepository,
|
|
41
|
+
vector_store: VectorStore,
|
|
42
|
+
lexical_index: LexicalIndex,
|
|
43
|
+
embedding_service: EmbeddingService,
|
|
44
|
+
index_synchronizer: RetrievalIndexSynchronizer | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._memory_repo = memory_repo
|
|
47
|
+
self._vector_store = vector_store
|
|
48
|
+
self._lexical_index = lexical_index
|
|
49
|
+
self._embedding_service = embedding_service
|
|
50
|
+
self._index_synchronizer = index_synchronizer
|
|
51
|
+
|
|
52
|
+
async def retrieve(
|
|
53
|
+
self,
|
|
54
|
+
query: str | RetrievalQuery,
|
|
55
|
+
config: RetrievalConfig | None = None,
|
|
56
|
+
) -> RetrievalResult:
|
|
57
|
+
request = self._coerce_query(query, config)
|
|
58
|
+
started = time.perf_counter()
|
|
59
|
+
stages: list[StageTrace] = []
|
|
60
|
+
|
|
61
|
+
if self._index_synchronizer is not None:
|
|
62
|
+
sync_started = time.perf_counter()
|
|
63
|
+
rebuilt = await self._index_synchronizer.ensure_current()
|
|
64
|
+
stages.append(self._stage(
|
|
65
|
+
"index_sync", 0, 0, sync_started, {"rebuilt": rebuilt}
|
|
66
|
+
))
|
|
67
|
+
|
|
68
|
+
corpus_size = max(
|
|
69
|
+
await self._lexical_index.count(),
|
|
70
|
+
await self._vector_store.count(),
|
|
71
|
+
)
|
|
72
|
+
candidate_limit = max(corpus_size, request.k)
|
|
73
|
+
lexical: list[LexicalResult] = []
|
|
74
|
+
dense: list[VectorResult] = []
|
|
75
|
+
|
|
76
|
+
if request.mode in {
|
|
77
|
+
RetrievalMode.LEXICAL,
|
|
78
|
+
RetrievalMode.HYBRID,
|
|
79
|
+
RetrievalMode.GRAPH,
|
|
80
|
+
RetrievalMode.HYBRID_GRAPH,
|
|
81
|
+
}:
|
|
82
|
+
stage_started = time.perf_counter()
|
|
83
|
+
lexical = await self._lexical_index.search(request.text, candidate_limit)
|
|
84
|
+
stages.append(self._stage(
|
|
85
|
+
"lexical_search", 1, len(lexical), stage_started, {"top_k": candidate_limit}
|
|
86
|
+
))
|
|
87
|
+
|
|
88
|
+
if request.mode in {
|
|
89
|
+
RetrievalMode.DENSE,
|
|
90
|
+
RetrievalMode.HYBRID,
|
|
91
|
+
RetrievalMode.GRAPH,
|
|
92
|
+
RetrievalMode.HYBRID_GRAPH,
|
|
93
|
+
}:
|
|
94
|
+
stage_started = time.perf_counter()
|
|
95
|
+
vector = await self._embedding_service.embed_query(request.text)
|
|
96
|
+
dense = await self._vector_store.search(vector, candidate_limit)
|
|
97
|
+
stages.append(self._stage(
|
|
98
|
+
"dense_search", 1, len(dense), stage_started, {"top_k": candidate_limit}
|
|
99
|
+
))
|
|
100
|
+
|
|
101
|
+
resolution_started = time.perf_counter()
|
|
102
|
+
ids = {item.id for item in lexical} | {item.id for item in dense}
|
|
103
|
+
memories = await self._eligible_memories(ids, request)
|
|
104
|
+
lexical = [item for item in lexical if item.id in memories]
|
|
105
|
+
dense = [item for item in dense if item.id in memories]
|
|
106
|
+
stages.append(self._stage(
|
|
107
|
+
"eligibility_filter",
|
|
108
|
+
len(ids),
|
|
109
|
+
len(memories),
|
|
110
|
+
resolution_started,
|
|
111
|
+
{"temporal_scope": request.temporal_scope.value},
|
|
112
|
+
))
|
|
113
|
+
|
|
114
|
+
rank_started = time.perf_counter()
|
|
115
|
+
scored = self._rank(request, memories, lexical, dense, rrf_k=(config.rrf_k if config else 60))
|
|
116
|
+
minimum = config.min_score if config else 0.0
|
|
117
|
+
pre_limit_scored = [item for item in scored if item.final_score >= minimum]
|
|
118
|
+
pre_limit_ids = [str(item.memory.id) for item in pre_limit_scored]
|
|
119
|
+
scored = pre_limit_scored[:request.k]
|
|
120
|
+
for rank, item in enumerate(scored, 1):
|
|
121
|
+
item.rank = rank
|
|
122
|
+
stages.append(self._stage(
|
|
123
|
+
"fusion_rerank",
|
|
124
|
+
len(set(item.id for item in lexical) | set(item.id for item in dense)),
|
|
125
|
+
len(scored),
|
|
126
|
+
rank_started,
|
|
127
|
+
{"mode": request.mode.value, "method": "rrf", "metadata_cap": 0.05},
|
|
128
|
+
))
|
|
129
|
+
|
|
130
|
+
total_latency = (time.perf_counter() - started) * 1000
|
|
131
|
+
trace = RetrievalTrace(
|
|
132
|
+
stages=stages if request.include_trace else [],
|
|
133
|
+
total_latency_ms=total_latency,
|
|
134
|
+
total_candidates=len(ids),
|
|
135
|
+
total_results=len(scored),
|
|
136
|
+
lexical_candidate_ids=[str(item.id) for item in lexical[:100]],
|
|
137
|
+
dense_candidate_ids=[str(item.id) for item in dense[:100]],
|
|
138
|
+
pre_limit_candidate_ids=pre_limit_ids[:200],
|
|
139
|
+
channel_candidates_truncated=len(lexical) > 100 or len(dense) > 100,
|
|
140
|
+
pre_limit_candidates_truncated=len(pre_limit_ids) > 200,
|
|
141
|
+
)
|
|
142
|
+
strategy_results = {
|
|
143
|
+
name: [item for item in scored if name in item.retrieval_sources]
|
|
144
|
+
for name in ("lexical", "dense")
|
|
145
|
+
if any(name in item.retrieval_sources for item in scored)
|
|
146
|
+
}
|
|
147
|
+
return RetrievalResult(
|
|
148
|
+
query=request.text,
|
|
149
|
+
memories=scored,
|
|
150
|
+
strategy_results=strategy_results,
|
|
151
|
+
trace=trace,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _coerce_query(
|
|
156
|
+
query: str | RetrievalQuery, config: RetrievalConfig | None
|
|
157
|
+
) -> RetrievalQuery:
|
|
158
|
+
if isinstance(query, RetrievalQuery):
|
|
159
|
+
return query
|
|
160
|
+
if config is None:
|
|
161
|
+
return RetrievalQuery(text=query)
|
|
162
|
+
statuses = {MemoryStatus.ACTIVE}
|
|
163
|
+
if config.include_superseded:
|
|
164
|
+
statuses.add(MemoryStatus.SUPERSEDED)
|
|
165
|
+
if config.include_contradicted:
|
|
166
|
+
statuses.add(MemoryStatus.CONTRADICTED)
|
|
167
|
+
if config.include_expired:
|
|
168
|
+
statuses.add(MemoryStatus.EXPIRED)
|
|
169
|
+
return RetrievalQuery(text=query, k=config.max_results, allowed_statuses=statuses)
|
|
170
|
+
|
|
171
|
+
async def _eligible_memories(
|
|
172
|
+
self, ids: set[str], request: RetrievalQuery
|
|
173
|
+
) -> dict[str, Memory]:
|
|
174
|
+
get_many = getattr(self._memory_repo, "get_many", None)
|
|
175
|
+
if callable(get_many):
|
|
176
|
+
found = await get_many(ids)
|
|
177
|
+
return {
|
|
178
|
+
raw_id: memory for raw_id, memory in found.items()
|
|
179
|
+
if self._eligible(memory, request)
|
|
180
|
+
}
|
|
181
|
+
eligible: dict[str, Memory] = {}
|
|
182
|
+
for raw_id in sorted(ids):
|
|
183
|
+
try:
|
|
184
|
+
memory = await self._memory_repo.get(UUID(raw_id))
|
|
185
|
+
except ValueError:
|
|
186
|
+
continue
|
|
187
|
+
if memory is not None and self._eligible(memory, request):
|
|
188
|
+
eligible[raw_id] = memory
|
|
189
|
+
return eligible
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _eligible(memory: Memory, query: RetrievalQuery) -> bool:
|
|
193
|
+
if memory.status in {MemoryStatus.DELETED, MemoryStatus.PURGED, MemoryStatus.MERGED}:
|
|
194
|
+
return False
|
|
195
|
+
if query.allowed_statuses is not None:
|
|
196
|
+
if memory.status not in query.allowed_statuses:
|
|
197
|
+
return False
|
|
198
|
+
else:
|
|
199
|
+
statuses = {
|
|
200
|
+
TemporalScope.CURRENT: {MemoryStatus.ACTIVE},
|
|
201
|
+
TemporalScope.HISTORICAL: {
|
|
202
|
+
MemoryStatus.HISTORICAL,
|
|
203
|
+
MemoryStatus.SUPERSEDED,
|
|
204
|
+
},
|
|
205
|
+
TemporalScope.ALL: {
|
|
206
|
+
MemoryStatus.ACTIVE,
|
|
207
|
+
MemoryStatus.HISTORICAL,
|
|
208
|
+
MemoryStatus.SUPERSEDED,
|
|
209
|
+
MemoryStatus.CONTRADICTED,
|
|
210
|
+
MemoryStatus.EXPIRED,
|
|
211
|
+
},
|
|
212
|
+
}[query.temporal_scope]
|
|
213
|
+
if memory.status not in statuses:
|
|
214
|
+
return False
|
|
215
|
+
if query.allowed_statuses is None:
|
|
216
|
+
if (
|
|
217
|
+
query.temporal_scope == TemporalScope.CURRENT
|
|
218
|
+
and memory.temporal_status in {
|
|
219
|
+
CandidateTemporalStatus.FUTURE,
|
|
220
|
+
CandidateTemporalStatus.HISTORICAL,
|
|
221
|
+
}
|
|
222
|
+
):
|
|
223
|
+
return False
|
|
224
|
+
if (
|
|
225
|
+
query.temporal_scope == TemporalScope.HISTORICAL
|
|
226
|
+
and memory.temporal_status == CandidateTemporalStatus.FUTURE
|
|
227
|
+
):
|
|
228
|
+
return False
|
|
229
|
+
if query.allowed_memory_types and memory.type not in query.allowed_memory_types:
|
|
230
|
+
return False
|
|
231
|
+
if query.source_types and memory.source_type not in query.source_types:
|
|
232
|
+
return False
|
|
233
|
+
if query.tags and not query.tags.issubset(memory.tags):
|
|
234
|
+
return False
|
|
235
|
+
if query.created_after and memory.created_at < query.created_after:
|
|
236
|
+
return False
|
|
237
|
+
if query.created_before and memory.created_at > query.created_before:
|
|
238
|
+
return False
|
|
239
|
+
if query.min_confidence is not None and memory.confidence < query.min_confidence:
|
|
240
|
+
return False
|
|
241
|
+
return not (
|
|
242
|
+
query.min_importance is not None and memory.importance < query.min_importance
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _rank(
|
|
247
|
+
query: RetrievalQuery,
|
|
248
|
+
memories: dict[str, Memory],
|
|
249
|
+
lexical: Sequence[LexicalResult],
|
|
250
|
+
dense: Sequence[VectorResult],
|
|
251
|
+
*,
|
|
252
|
+
rrf_k: int,
|
|
253
|
+
) -> list[ScoredMemory]:
|
|
254
|
+
lexical_ranks = {item.id: rank for rank, item in enumerate(lexical, 1)}
|
|
255
|
+
dense_ranks = {item.id: rank for rank, item in enumerate(dense, 1)}
|
|
256
|
+
lexical_scores = {item.id: item.score for item in lexical}
|
|
257
|
+
dense_scores = {item.id: item.score for item in dense}
|
|
258
|
+
identifiers = set(lexical_ranks) | set(dense_ranks)
|
|
259
|
+
results: list[ScoredMemory] = []
|
|
260
|
+
for identifier in identifiers:
|
|
261
|
+
sources: list[str] = []
|
|
262
|
+
base_score = 0.0
|
|
263
|
+
if identifier in lexical_ranks:
|
|
264
|
+
sources.append("lexical")
|
|
265
|
+
base_score += 1.0 / (rrf_k + lexical_ranks[identifier])
|
|
266
|
+
if identifier in dense_ranks:
|
|
267
|
+
sources.append("dense")
|
|
268
|
+
base_score += 1.0 / (rrf_k + dense_ranks[identifier])
|
|
269
|
+
memory = memories[identifier]
|
|
270
|
+
adjustment = (
|
|
271
|
+
base_score * 0.05 * ((memory.confidence + memory.importance) / 2.0)
|
|
272
|
+
if query.apply_metadata_rerank
|
|
273
|
+
else 0.0
|
|
274
|
+
)
|
|
275
|
+
results.append(ScoredMemory(
|
|
276
|
+
memory=memory,
|
|
277
|
+
final_score=base_score + adjustment,
|
|
278
|
+
vector_score=dense_scores.get(identifier),
|
|
279
|
+
bm25_score=lexical_scores.get(identifier),
|
|
280
|
+
lexical_rank=lexical_ranks.get(identifier),
|
|
281
|
+
dense_rank=dense_ranks.get(identifier),
|
|
282
|
+
metadata_adjustment=adjustment,
|
|
283
|
+
retrieval_sources=sources,
|
|
284
|
+
))
|
|
285
|
+
results.sort(key=lambda item: (-item.final_score, str(item.memory.id)))
|
|
286
|
+
return results
|
|
287
|
+
|
|
288
|
+
@staticmethod
|
|
289
|
+
def _stage(
|
|
290
|
+
name: str,
|
|
291
|
+
input_count: int,
|
|
292
|
+
output_count: int,
|
|
293
|
+
started: float,
|
|
294
|
+
metadata: dict[str, object] | None = None,
|
|
295
|
+
) -> StageTrace:
|
|
296
|
+
return StageTrace(
|
|
297
|
+
stage_name=name,
|
|
298
|
+
input_count=input_count,
|
|
299
|
+
output_count=output_count,
|
|
300
|
+
latency_ms=(time.perf_counter() - started) * 1000,
|
|
301
|
+
metadata=metadata or {},
|
|
302
|
+
)
|