superlocalmemory 3.2.1 → 3.2.3
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.
- package/CHANGELOG.md +23 -1
- package/README.md +61 -1
- package/package.json +1 -1
- package/pyproject.toml +26 -1
- package/src/superlocalmemory/attribution/signer.py +6 -1
- package/src/superlocalmemory/core/config.py +113 -1
- package/src/superlocalmemory/core/consolidation_engine.py +595 -0
- package/src/superlocalmemory/core/embeddings.py +0 -1
- package/src/superlocalmemory/core/engine.py +164 -674
- package/src/superlocalmemory/core/engine_wiring.py +474 -0
- package/src/superlocalmemory/core/graph_analyzer.py +199 -0
- package/src/superlocalmemory/core/recall_pipeline.py +247 -0
- package/src/superlocalmemory/core/store_pipeline.py +483 -0
- package/src/superlocalmemory/core/worker_pool.py +35 -12
- package/src/superlocalmemory/encoding/auto_linker.py +308 -0
- package/src/superlocalmemory/encoding/context_generator.py +175 -0
- package/src/superlocalmemory/encoding/temporal_validator.py +513 -0
- package/src/superlocalmemory/hooks/auto_invoker.py +484 -0
- package/src/superlocalmemory/retrieval/channel_registry.py +154 -0
- package/src/superlocalmemory/retrieval/engine.py +12 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +87 -3
- package/src/superlocalmemory/retrieval/spreading_activation.py +311 -0
- package/src/superlocalmemory/retrieval/strategy.py +6 -6
- package/src/superlocalmemory/retrieval/vector_store.py +386 -0
- package/src/superlocalmemory/server/routes/v3_api.py +576 -0
- package/src/superlocalmemory/storage/access_log.py +169 -0
- package/src/superlocalmemory/storage/database.py +288 -0
- package/src/superlocalmemory/storage/schema.py +10 -0
- package/src/superlocalmemory/storage/schema_v32.py +252 -0
- package/src/superlocalmemory/storage/v2_migrator.py +24 -2
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under the MIT License - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3
|
|
4
|
+
|
|
5
|
+
"""A-MEM-inspired automatic edge creation between related facts.
|
|
6
|
+
|
|
7
|
+
Creates association_edges ONLY (never writes to graph_edges -- Rule 13).
|
|
8
|
+
Disabled in Mode A without embedding provider.
|
|
9
|
+
|
|
10
|
+
Responsibilities:
|
|
11
|
+
1. link_new_fact(): Find similar facts via VectorStore, create edges
|
|
12
|
+
2. evolve_linked_facts(): Update contextual_description of linked old facts
|
|
13
|
+
3. strengthen_co_access(): Hebbian +0.05 for co-accessed pairs
|
|
14
|
+
4. decay_unused(): Weight decay for stale edges
|
|
15
|
+
|
|
16
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
17
|
+
License: MIT
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import math
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from superlocalmemory.storage.models import AtomicFact, _new_id
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AutoLinker:
|
|
34
|
+
"""A-MEM automatic edge creation between related facts.
|
|
35
|
+
|
|
36
|
+
Creates association_edges ONLY (never graph_edges -- Rule 13).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
SIMILARITY_THRESHOLD: float = 0.7 # A-MEM auto-link threshold
|
|
40
|
+
HEBBIAN_INCREMENT: float = 0.05 # Per co-access weight boost
|
|
41
|
+
MAX_LINKS_PER_FACT: int = 10 # Cap auto-links per new fact
|
|
42
|
+
DECAY_RATE: float = 0.01 # Per-day weight decay
|
|
43
|
+
DECAY_MIN_WEIGHT: float = 0.05 # Minimum weight before deletion
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
db: Any,
|
|
48
|
+
vector_store: Any,
|
|
49
|
+
context_generator: Any | None = None,
|
|
50
|
+
config: Any | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._db = db
|
|
53
|
+
self._vector_store = vector_store
|
|
54
|
+
self._context_gen = context_generator
|
|
55
|
+
self._config = config
|
|
56
|
+
self._mode = config.mode.value if config else "a"
|
|
57
|
+
|
|
58
|
+
def link_new_fact(
|
|
59
|
+
self, new_fact: AtomicFact, profile_id: str,
|
|
60
|
+
) -> list[str]:
|
|
61
|
+
"""Find similar facts via VectorStore KNN, create association_edges.
|
|
62
|
+
|
|
63
|
+
Called in store_pipeline AFTER GraphBuilder (which writes graph_edges).
|
|
64
|
+
Returns: list of linked fact_ids.
|
|
65
|
+
"""
|
|
66
|
+
if new_fact.embedding is None:
|
|
67
|
+
logger.debug(
|
|
68
|
+
"AutoLinker: link_new_fact skipped (no embedding) "
|
|
69
|
+
"for fact %s",
|
|
70
|
+
new_fact.fact_id,
|
|
71
|
+
)
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
if not self._vector_store or not self._vector_store.available:
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
query_vec = np.asarray(new_fact.embedding, dtype=np.float32)
|
|
79
|
+
candidates = self._vector_store.search(
|
|
80
|
+
query_vec.tolist(),
|
|
81
|
+
top_k=self.MAX_LINKS_PER_FACT + 1,
|
|
82
|
+
)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
logger.debug("AutoLinker: VectorStore search failed: %s", exc)
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
linked_ids: list[str] = []
|
|
88
|
+
for fact_id, score in candidates:
|
|
89
|
+
if fact_id == new_fact.fact_id:
|
|
90
|
+
continue
|
|
91
|
+
if score < self.SIMILARITY_THRESHOLD:
|
|
92
|
+
continue
|
|
93
|
+
if len(linked_ids) >= self.MAX_LINKS_PER_FACT:
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
# Create bidirectional edge (INSERT OR IGNORE for idempotency)
|
|
97
|
+
self._create_edge(
|
|
98
|
+
profile_id,
|
|
99
|
+
new_fact.fact_id,
|
|
100
|
+
fact_id,
|
|
101
|
+
association_type="auto_link",
|
|
102
|
+
weight=round(float(score), 4),
|
|
103
|
+
)
|
|
104
|
+
linked_ids.append(fact_id)
|
|
105
|
+
|
|
106
|
+
# Memory evolution: update context of linked old facts
|
|
107
|
+
if linked_ids and self._context_gen:
|
|
108
|
+
self.evolve_linked_facts(
|
|
109
|
+
new_fact.fact_id, linked_ids, profile_id,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return linked_ids
|
|
113
|
+
|
|
114
|
+
def evolve_linked_facts(
|
|
115
|
+
self,
|
|
116
|
+
new_fact_id: str,
|
|
117
|
+
linked_fact_ids: list[str],
|
|
118
|
+
profile_id: str,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Update contextual_description of linked old facts.
|
|
121
|
+
|
|
122
|
+
A-MEM memory evolution: old facts gain new context from new links.
|
|
123
|
+
Updates fact_context table ONLY (immutability preserved -- Rule 17).
|
|
124
|
+
Non-blocking, fire-and-forget. Errors logged, never raised (Rule 19).
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
new_fact = self._db.get_fact(new_fact_id)
|
|
128
|
+
if not new_fact:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
for old_fact_id in linked_fact_ids:
|
|
132
|
+
old_fact = self._db.get_fact(old_fact_id)
|
|
133
|
+
if not old_fact:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if self._mode == "a":
|
|
137
|
+
self._append_keyword(old_fact_id, new_fact, profile_id)
|
|
138
|
+
else:
|
|
139
|
+
if self._context_gen:
|
|
140
|
+
self._regenerate_context(
|
|
141
|
+
old_fact_id, old_fact, new_fact, profile_id,
|
|
142
|
+
)
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
logger.debug(
|
|
145
|
+
"evolve_linked_facts failed (non-fatal): %s", exc,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def strengthen_co_access(
|
|
149
|
+
self, fact_ids: list[str], profile_id: str,
|
|
150
|
+
) -> int:
|
|
151
|
+
"""Hebbian strengthening: +0.05 weight for facts recalled together.
|
|
152
|
+
|
|
153
|
+
For each pair (i, j) in the recalled set: if an association_edge
|
|
154
|
+
exists, increment weight by HEBBIAN_INCREMENT (capped at 1.0).
|
|
155
|
+
Returns: number of edges strengthened.
|
|
156
|
+
"""
|
|
157
|
+
if len(fact_ids) < 2:
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
strengthened = 0
|
|
161
|
+
for i in range(len(fact_ids)):
|
|
162
|
+
for j in range(i + 1, len(fact_ids)):
|
|
163
|
+
rows = self._db.execute(
|
|
164
|
+
"SELECT edge_id, weight, co_access_count "
|
|
165
|
+
"FROM association_edges "
|
|
166
|
+
"WHERE profile_id = ? "
|
|
167
|
+
"AND source_fact_id = ? AND target_fact_id = ? "
|
|
168
|
+
"UNION ALL "
|
|
169
|
+
"SELECT edge_id, weight, co_access_count "
|
|
170
|
+
"FROM association_edges "
|
|
171
|
+
"WHERE profile_id = ? "
|
|
172
|
+
"AND source_fact_id = ? AND target_fact_id = ?",
|
|
173
|
+
(profile_id, fact_ids[i], fact_ids[j],
|
|
174
|
+
profile_id, fact_ids[j], fact_ids[i]),
|
|
175
|
+
)
|
|
176
|
+
for row in rows:
|
|
177
|
+
d = dict(row)
|
|
178
|
+
new_weight = min(
|
|
179
|
+
1.0, d["weight"] + self.HEBBIAN_INCREMENT,
|
|
180
|
+
)
|
|
181
|
+
new_count = d["co_access_count"] + 1
|
|
182
|
+
self._db.execute(
|
|
183
|
+
"UPDATE association_edges "
|
|
184
|
+
"SET weight = ?, co_access_count = ?, "
|
|
185
|
+
" last_strengthened = datetime('now') "
|
|
186
|
+
"WHERE edge_id = ?",
|
|
187
|
+
(new_weight, new_count, d["edge_id"]),
|
|
188
|
+
)
|
|
189
|
+
strengthened += 1
|
|
190
|
+
|
|
191
|
+
return strengthened
|
|
192
|
+
|
|
193
|
+
def decay_unused(
|
|
194
|
+
self, profile_id: str, days_threshold: int = 30,
|
|
195
|
+
) -> int:
|
|
196
|
+
"""Decay weights of association_edges not used in N days.
|
|
197
|
+
|
|
198
|
+
Formula: new_weight = weight * exp(-DECAY_RATE * days)
|
|
199
|
+
Edges below DECAY_MIN_WEIGHT are deleted.
|
|
200
|
+
Returns: number of edges decayed or deleted.
|
|
201
|
+
"""
|
|
202
|
+
rows = self._db.execute(
|
|
203
|
+
"SELECT edge_id, weight, last_strengthened, created_at "
|
|
204
|
+
"FROM association_edges "
|
|
205
|
+
"WHERE profile_id = ? AND ("
|
|
206
|
+
" last_strengthened IS NULL "
|
|
207
|
+
" AND created_at < datetime('now', ?)"
|
|
208
|
+
" OR last_strengthened < datetime('now', ?)"
|
|
209
|
+
")",
|
|
210
|
+
(profile_id,
|
|
211
|
+
f'-{days_threshold} days',
|
|
212
|
+
f'-{days_threshold} days'),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
affected = 0
|
|
216
|
+
for row in rows:
|
|
217
|
+
d = dict(row)
|
|
218
|
+
days_inactive = days_threshold # Conservative estimate
|
|
219
|
+
new_weight = d["weight"] * math.exp(
|
|
220
|
+
-self.DECAY_RATE * days_inactive,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if new_weight < self.DECAY_MIN_WEIGHT:
|
|
224
|
+
self._db.execute(
|
|
225
|
+
"DELETE FROM association_edges WHERE edge_id = ?",
|
|
226
|
+
(d["edge_id"],),
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
self._db.execute(
|
|
230
|
+
"UPDATE association_edges SET weight = ? "
|
|
231
|
+
"WHERE edge_id = ?",
|
|
232
|
+
(round(new_weight, 4), d["edge_id"]),
|
|
233
|
+
)
|
|
234
|
+
affected += 1
|
|
235
|
+
|
|
236
|
+
return affected
|
|
237
|
+
|
|
238
|
+
# --- Private helpers ---
|
|
239
|
+
|
|
240
|
+
def _create_edge(
|
|
241
|
+
self,
|
|
242
|
+
profile_id: str,
|
|
243
|
+
source_id: str,
|
|
244
|
+
target_id: str,
|
|
245
|
+
association_type: str,
|
|
246
|
+
weight: float,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Insert a single association_edge. Idempotent via UNIQUE index."""
|
|
249
|
+
self._db.execute(
|
|
250
|
+
"INSERT OR IGNORE INTO association_edges "
|
|
251
|
+
"(edge_id, profile_id, source_fact_id, target_fact_id, "
|
|
252
|
+
" association_type, weight, co_access_count, created_at) "
|
|
253
|
+
"VALUES (?, ?, ?, ?, ?, ?, 0, datetime('now'))",
|
|
254
|
+
(_new_id(), profile_id, source_id, target_id,
|
|
255
|
+
association_type, weight),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def _append_keyword(
|
|
259
|
+
self, fact_id: str, new_fact: AtomicFact, profile_id: str,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Mode A: append new fact's first entity as keyword."""
|
|
262
|
+
keyword = ""
|
|
263
|
+
if new_fact.canonical_entities:
|
|
264
|
+
keyword = new_fact.canonical_entities[0]
|
|
265
|
+
elif new_fact.content:
|
|
266
|
+
words = new_fact.content.split()
|
|
267
|
+
keyword = words[0] if words else ""
|
|
268
|
+
if not keyword:
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
rows = self._db.execute(
|
|
273
|
+
"SELECT keywords FROM fact_context "
|
|
274
|
+
"WHERE fact_id = ? AND profile_id = ?",
|
|
275
|
+
(fact_id, profile_id),
|
|
276
|
+
)
|
|
277
|
+
if rows:
|
|
278
|
+
existing = dict(rows[0]).get("keywords", "") or ""
|
|
279
|
+
if keyword.lower() not in existing.lower():
|
|
280
|
+
updated = (
|
|
281
|
+
(existing + ", " + keyword) if existing else keyword
|
|
282
|
+
)
|
|
283
|
+
self._db.execute(
|
|
284
|
+
"UPDATE fact_context SET keywords = ? "
|
|
285
|
+
"WHERE fact_id = ? AND profile_id = ?",
|
|
286
|
+
(updated, fact_id, profile_id),
|
|
287
|
+
)
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
logger.debug("_append_keyword failed: %s", exc)
|
|
290
|
+
|
|
291
|
+
def _regenerate_context(
|
|
292
|
+
self,
|
|
293
|
+
old_fact_id: str,
|
|
294
|
+
old_fact: AtomicFact,
|
|
295
|
+
new_fact: AtomicFact,
|
|
296
|
+
profile_id: str,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Mode B/C: regenerate contextual_description for old fact."""
|
|
299
|
+
try:
|
|
300
|
+
if self._context_gen:
|
|
301
|
+
new_desc = self._context_gen.generate(old_fact, self._mode)
|
|
302
|
+
self._db.execute(
|
|
303
|
+
"UPDATE fact_context SET contextual_description = ? "
|
|
304
|
+
"WHERE fact_id = ? AND profile_id = ?",
|
|
305
|
+
(new_desc, old_fact_id, profile_id),
|
|
306
|
+
)
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
logger.debug("_regenerate_context failed: %s", exc)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under the MIT License - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3.2 | https://qualixar.com
|
|
4
|
+
|
|
5
|
+
"""Generate contextual descriptions for facts (A-MEM pattern).
|
|
6
|
+
|
|
7
|
+
WHY a memory matters, not just WHAT it says. Enriches retrieval by
|
|
8
|
+
providing a secondary searchable signal and helps consolidation
|
|
9
|
+
understand memory relationships.
|
|
10
|
+
|
|
11
|
+
NEVER imports core/engine.py (Rule 06).
|
|
12
|
+
Receives LLM backbone via __init__, not engine.
|
|
13
|
+
|
|
14
|
+
References:
|
|
15
|
+
- A-MEM (agiresearch/A-mem-sys): contextual_description field
|
|
16
|
+
- Zep/Graphiti: relationship summaries on temporal edges
|
|
17
|
+
|
|
18
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
from superlocalmemory.storage.models import AtomicFact, FactType, SignalType
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ContextResult:
|
|
34
|
+
"""Immutable result of context generation."""
|
|
35
|
+
|
|
36
|
+
description: str
|
|
37
|
+
keywords: list[str]
|
|
38
|
+
generated_by: str # "rules" | "ollama" | "cloud"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ContextGenerator:
|
|
42
|
+
"""Generates WHY a memory matters, not just WHAT it says.
|
|
43
|
+
|
|
44
|
+
Mode A: deterministic template-based (zero LLM).
|
|
45
|
+
Mode B: Ollama LLM one-sentence generation.
|
|
46
|
+
Mode C: Cloud LLM high-quality generation.
|
|
47
|
+
|
|
48
|
+
NEVER imports core/engine.py (Rule 06).
|
|
49
|
+
Receives LLM backbone via __init__, not engine.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, llm=None) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Args:
|
|
55
|
+
llm: LLMBackbone instance or None (Mode A).
|
|
56
|
+
Must implement .generate(prompt, system) -> str
|
|
57
|
+
and .is_available() -> bool.
|
|
58
|
+
"""
|
|
59
|
+
self._llm = llm
|
|
60
|
+
|
|
61
|
+
def generate(self, fact: AtomicFact, mode: str = "a") -> ContextResult:
|
|
62
|
+
"""Generate contextual description for a fact.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
fact: The AtomicFact to generate context for.
|
|
66
|
+
mode: "a" (rules), "b" (Ollama), "c" (cloud).
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
ContextResult with description, keywords, and generator type.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
if mode == "a" or self._llm is None or not self._llm.is_available():
|
|
73
|
+
return self._rules_based(fact)
|
|
74
|
+
return self._llm_based(fact, mode)
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
logger.debug("Context generation failed, falling back to rules: %s", exc)
|
|
77
|
+
return self._rules_based(fact)
|
|
78
|
+
|
|
79
|
+
def _rules_based(self, fact: AtomicFact) -> ContextResult:
|
|
80
|
+
"""Deterministic template for Mode A (zero LLM).
|
|
81
|
+
|
|
82
|
+
Template:
|
|
83
|
+
'This {fact_type} about {entities} records {signal_type}
|
|
84
|
+
observed on {observation_date} regarding {topic}.'
|
|
85
|
+
|
|
86
|
+
Keywords extracted from entities + content tokens.
|
|
87
|
+
"""
|
|
88
|
+
entities_str = (
|
|
89
|
+
", ".join(fact.canonical_entities[:5])
|
|
90
|
+
if fact.canonical_entities
|
|
91
|
+
else "general knowledge"
|
|
92
|
+
)
|
|
93
|
+
date_str = fact.observation_date or fact.created_at[:10]
|
|
94
|
+
topic = fact.content[:60].rstrip(".")
|
|
95
|
+
|
|
96
|
+
# Map fact_type to human-readable category
|
|
97
|
+
type_labels = {
|
|
98
|
+
FactType.EPISODIC: "episodic event",
|
|
99
|
+
FactType.SEMANTIC: "semantic knowledge",
|
|
100
|
+
FactType.OPINION: "opinion or preference",
|
|
101
|
+
FactType.TEMPORAL: "time-bounded event",
|
|
102
|
+
}
|
|
103
|
+
type_label = type_labels.get(fact.fact_type, "memory")
|
|
104
|
+
|
|
105
|
+
# Map signal_type to human-readable signal description
|
|
106
|
+
signal_labels = {
|
|
107
|
+
SignalType.FACTUAL: "factual information",
|
|
108
|
+
SignalType.EMOTIONAL: "emotional context",
|
|
109
|
+
SignalType.TEMPORAL: "temporal relationship",
|
|
110
|
+
SignalType.OPINION: "subjective judgment",
|
|
111
|
+
SignalType.REQUEST: "a request or action item",
|
|
112
|
+
SignalType.SOCIAL: "social interaction",
|
|
113
|
+
}
|
|
114
|
+
signal_label = signal_labels.get(fact.signal_type, "an observation")
|
|
115
|
+
|
|
116
|
+
description = (
|
|
117
|
+
f"This {type_label} about {entities_str} records {signal_label} "
|
|
118
|
+
f"observed on {date_str} regarding {topic}."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Extract keywords from entities + content significant words
|
|
122
|
+
keywords = list(fact.canonical_entities[:5])
|
|
123
|
+
stop = {
|
|
124
|
+
"this", "that", "with", "from", "about",
|
|
125
|
+
"which", "their", "there", "would", "could", "should",
|
|
126
|
+
}
|
|
127
|
+
content_words = [
|
|
128
|
+
w.lower().strip(".,!?;:\"'()[]")
|
|
129
|
+
for w in fact.content.split()
|
|
130
|
+
if len(w) > 4
|
|
131
|
+
]
|
|
132
|
+
for w in content_words[:10]:
|
|
133
|
+
if w not in stop and w not in keywords:
|
|
134
|
+
keywords.append(w)
|
|
135
|
+
|
|
136
|
+
return ContextResult(
|
|
137
|
+
description=description,
|
|
138
|
+
keywords=keywords[:10],
|
|
139
|
+
generated_by="rules",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _llm_based(self, fact: AtomicFact, mode: str) -> ContextResult:
|
|
143
|
+
"""LLM-generated contextual description for Mode B/C.
|
|
144
|
+
|
|
145
|
+
Prompt follows A-MEM pattern: explain WHY this memory
|
|
146
|
+
would be useful in the future.
|
|
147
|
+
"""
|
|
148
|
+
prompt = (
|
|
149
|
+
"You are a memory analyst. Given a memory fact, write ONE sentence "
|
|
150
|
+
"explaining WHY this memory would be useful to recall in the future. "
|
|
151
|
+
"Focus on what it reveals about the person, project, or decision -- "
|
|
152
|
+
"not what it literally says.\n\n"
|
|
153
|
+
f"Memory: {fact.content}\n"
|
|
154
|
+
f"Type: {fact.fact_type.value}\n"
|
|
155
|
+
f"Entities: {', '.join(fact.canonical_entities[:5])}\n\n"
|
|
156
|
+
"Also extract 3-5 keywords that would help find this memory later.\n\n"
|
|
157
|
+
"Respond in this exact JSON format:\n"
|
|
158
|
+
'{"description": "...", "keywords": ["...", "..."]}'
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
response = self._llm.generate(
|
|
162
|
+
prompt,
|
|
163
|
+
system="You are a precise memory analyst. Respond ONLY with valid JSON.",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
parsed = json.loads(response)
|
|
168
|
+
return ContextResult(
|
|
169
|
+
description=parsed.get("description", "")[:500],
|
|
170
|
+
keywords=parsed.get("keywords", [])[:10],
|
|
171
|
+
generated_by="ollama" if mode == "b" else "cloud",
|
|
172
|
+
)
|
|
173
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
174
|
+
logger.debug("LLM context response unparseable, falling back to rules")
|
|
175
|
+
return self._rules_based(fact)
|