trace-memory 1.0.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.
- trace_memory/__init__.py +43 -0
- trace_memory/_llm_utils.py +139 -0
- trace_memory/ctree.py +1187 -0
- trace_memory/prompt_synthesizer.py +256 -0
- trace_memory/vector_db.py +191 -0
- trace_memory-1.0.0.dist-info/METADATA +715 -0
- trace_memory-1.0.0.dist-info/RECORD +11 -0
- trace_memory-1.0.0.dist-info/WHEEL +5 -0
- trace_memory-1.0.0.dist-info/licenses/LICENSE +202 -0
- trace_memory-1.0.0.dist-info/licenses/NOTICE +8 -0
- trace_memory-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PromptSynthesizer — TRACE's Multi-Path RAG Prompt Builder
|
|
3
|
+
==========================================================
|
|
4
|
+
Assembles the enriched system prompt that is injected before every LLM
|
|
5
|
+
call, weaving together:
|
|
6
|
+
|
|
7
|
+
1. **Surgical Memory Context** — Cosine-searched topic summaries from the
|
|
8
|
+
B+Tree. All nodes above the base similarity threshold are collected,
|
|
9
|
+
ancestors are deduplicated, then the top-3 highest-scoring paths are
|
|
10
|
+
surfaced for multi-path cross-branch retrieval.
|
|
11
|
+
2. **Cross-Thread Conversation Recall** — Semantically similar past
|
|
12
|
+
messages retrieved from the conversation vector table.
|
|
13
|
+
3. **Verbatim Recent Log** — The last N raw messages for immediate context.
|
|
14
|
+
|
|
15
|
+
Usage
|
|
16
|
+
-----
|
|
17
|
+
from trace_memory import CTree, VectorDatabase, PromptSynthesizer
|
|
18
|
+
|
|
19
|
+
tree = CTree(api_key="sk-...", model="gpt-4o-mini")
|
|
20
|
+
vdb = VectorDatabase("session.db")
|
|
21
|
+
synth = PromptSynthesizer(tree, vdb)
|
|
22
|
+
|
|
23
|
+
system_prompt = synth.synthesize_prompt(
|
|
24
|
+
user_query = "How do neutron stars form?",
|
|
25
|
+
query_vector = embed("How do neutron stars form?"),
|
|
26
|
+
active_node = tree.current_node,
|
|
27
|
+
recent_messages = tree.conversation[-6:],
|
|
28
|
+
)
|
|
29
|
+
# Pass system_prompt as the system message to your LLM.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import time
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
|
|
35
|
+
from .ctree import CTree, TopicNode, MessageNode
|
|
36
|
+
from .vector_db import VectorDatabase, ConversationVector
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PromptSynthesizer:
|
|
40
|
+
"""
|
|
41
|
+
Builds an enriched, RAG-grounded system prompt for every LLM turn.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
ctree : A live ``CTree`` instance.
|
|
46
|
+
vector_db : The ``VectorDatabase`` associated with the session.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, ctree: CTree, vector_db: VectorDatabase):
|
|
50
|
+
self.tree = ctree
|
|
51
|
+
self.vdb = vector_db
|
|
52
|
+
|
|
53
|
+
# ── Internal: full-tree narrative (fallback) ──────────────────────────────
|
|
54
|
+
|
|
55
|
+
def _traverse(self, node, depth: int = 0):
|
|
56
|
+
lines = []
|
|
57
|
+
indent = " " * depth
|
|
58
|
+
if isinstance(node, TopicNode) and node.topic_name != "ROOT":
|
|
59
|
+
summary = (node.summary or "Active discussion in progress.").strip()
|
|
60
|
+
lines.append(f"{indent}• {node.topic_name} [msgs {node.start_index}–{node.end_index}]")
|
|
61
|
+
lines.append(f"{indent} ↳ {summary}")
|
|
62
|
+
for child in node.children:
|
|
63
|
+
if isinstance(child, TopicNode):
|
|
64
|
+
lines.extend(self._traverse(child, depth + 1))
|
|
65
|
+
return lines
|
|
66
|
+
|
|
67
|
+
def _get_active_narrative_context(self, active_node) -> str:
|
|
68
|
+
blocks = ["── GLOBAL CONVERSATION MEMORY INDEX ──"]
|
|
69
|
+
summaries = self._traverse(self.tree.root)
|
|
70
|
+
blocks.extend(summaries if summaries else [" (No topics indexed yet)"])
|
|
71
|
+
blocks.append("\n── ACTIVE THEMATIC CONTEXT PATH ──")
|
|
72
|
+
ancestors = self.tree.get_ancestors(active_node, include_self=True, exclude_root=True)
|
|
73
|
+
if ancestors:
|
|
74
|
+
path = " → ".join(n.topic_name for n in ancestors)
|
|
75
|
+
blocks.append(f"Thread: {path}")
|
|
76
|
+
for n in ancestors:
|
|
77
|
+
s = (n.summary or "Expanding context…").strip()
|
|
78
|
+
blocks.append(f" • {n.topic_name}: {s}")
|
|
79
|
+
else:
|
|
80
|
+
blocks.append(" Thread: ROOT (first topic in progress)")
|
|
81
|
+
return "\n".join(blocks)
|
|
82
|
+
|
|
83
|
+
# ── Internal: surgical multi-path retrieval ───────────────────────────────
|
|
84
|
+
|
|
85
|
+
def _build_node_id_map(self) -> dict:
|
|
86
|
+
result = {}
|
|
87
|
+
def _walk(node):
|
|
88
|
+
if isinstance(node, TopicNode) and hasattr(node, "node_id"):
|
|
89
|
+
result[node.node_id] = node
|
|
90
|
+
for child in node.children:
|
|
91
|
+
_walk(child)
|
|
92
|
+
_walk(self.tree.root)
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
def _get_surgical_context(self, query_vector) -> str:
|
|
96
|
+
"""
|
|
97
|
+
Multi-path surgical retrieval (the heart of TRACE):
|
|
98
|
+
|
|
99
|
+
1. Cosine-search the VDB for ALL topic summaries above the base threshold.
|
|
100
|
+
2. Walk full ancestry for every qualifying node.
|
|
101
|
+
3. Deduplicate shared ancestor nodes across all paths.
|
|
102
|
+
4. Rank deduplicated paths by similarity and take the top-3.
|
|
103
|
+
5. Format a compact merged multi-path context block.
|
|
104
|
+
|
|
105
|
+
Falls back to full-tree narrative if no hits qualify.
|
|
106
|
+
"""
|
|
107
|
+
if not query_vector or self.vdb is None:
|
|
108
|
+
return self._get_active_narrative_context(self.tree.current_node)
|
|
109
|
+
try:
|
|
110
|
+
# Fetch all nodes above the base threshold (large top_k to capture everything)
|
|
111
|
+
hits = self.vdb.search_topic_summaries(query_vector, top_k=50, min_similarity=0.30)
|
|
112
|
+
except Exception:
|
|
113
|
+
return self._get_active_narrative_context(self.tree.current_node)
|
|
114
|
+
if not hits:
|
|
115
|
+
return self._get_active_narrative_context(self.tree.current_node)
|
|
116
|
+
|
|
117
|
+
node_map = self._build_node_id_map()
|
|
118
|
+
all_ancestor_sets = [] # [(similarity, [TopicNode, ...])]
|
|
119
|
+
for hit in hits:
|
|
120
|
+
node = node_map.get(hit["node_id"])
|
|
121
|
+
if node is None:
|
|
122
|
+
continue
|
|
123
|
+
ancestors = self.tree.get_ancestors(node, include_self=True, exclude_root=True)
|
|
124
|
+
if ancestors:
|
|
125
|
+
all_ancestor_sets.append((hit["similarity"], ancestors))
|
|
126
|
+
|
|
127
|
+
if not all_ancestor_sets:
|
|
128
|
+
return self._get_active_narrative_context(self.tree.current_node)
|
|
129
|
+
|
|
130
|
+
# Deduplicate shared ancestors across ALL qualifying paths
|
|
131
|
+
seen_ids = set()
|
|
132
|
+
unique_nodes_ordered = []
|
|
133
|
+
for similarity, ancestors in all_ancestor_sets:
|
|
134
|
+
for node in ancestors:
|
|
135
|
+
nid = getattr(node, "node_id", id(node))
|
|
136
|
+
if nid not in seen_ids:
|
|
137
|
+
seen_ids.add(nid)
|
|
138
|
+
unique_nodes_ordered.append((node, similarity))
|
|
139
|
+
|
|
140
|
+
# Now rank paths by similarity and take the top-3
|
|
141
|
+
all_ancestor_sets_ranked = sorted(all_ancestor_sets, key=lambda x: x[0], reverse=True)[:3]
|
|
142
|
+
|
|
143
|
+
blocks = ["── SURGICAL MEMORY CONTEXT (top matched topics + ancestry) ──"]
|
|
144
|
+
for i, (similarity, ancestors) in enumerate(all_ancestor_sets_ranked, 1):
|
|
145
|
+
path = " → ".join(n.topic_name for n in ancestors)
|
|
146
|
+
blocks.append(f"Match {i} (confidence {similarity * 100:.0f}%): {path}")
|
|
147
|
+
blocks.append("")
|
|
148
|
+
blocks.append("── ANCESTRY DETAIL ──")
|
|
149
|
+
for node, _ in unique_nodes_ordered:
|
|
150
|
+
s = (node.summary or "Context expanding…").strip()
|
|
151
|
+
blocks.append(f" • {node.topic_name}: {s}")
|
|
152
|
+
return "\n".join(blocks)
|
|
153
|
+
|
|
154
|
+
# ── Public: synthesize_prompt ─────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
def synthesize_prompt(
|
|
157
|
+
self,
|
|
158
|
+
user_query: str,
|
|
159
|
+
query_vector: list,
|
|
160
|
+
active_node,
|
|
161
|
+
recent_messages: list,
|
|
162
|
+
top_k_history: int = 2,
|
|
163
|
+
min_history_similarity: float = 0.50,
|
|
164
|
+
) -> str:
|
|
165
|
+
"""
|
|
166
|
+
Assemble the full enriched system prompt for the next LLM turn.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
user_query : The raw text of the user's current message.
|
|
171
|
+
query_vector : Embedding of *user_query*.
|
|
172
|
+
active_node : ``tree.current_node``.
|
|
173
|
+
recent_messages : Tail of ``tree.conversation`` (e.g. last 6 msgs).
|
|
174
|
+
top_k_history : Max past conversation messages to recall.
|
|
175
|
+
min_history_similarity : Minimum cosine score for conversation recall.
|
|
176
|
+
|
|
177
|
+
Returns
|
|
178
|
+
-------
|
|
179
|
+
str — A ready-to-use system prompt string. Pass this as the
|
|
180
|
+
``system`` role message to your LLM.
|
|
181
|
+
|
|
182
|
+
Example
|
|
183
|
+
-------
|
|
184
|
+
prompt = synth.synthesize_prompt(
|
|
185
|
+
user_query = "Is the cake safe for Sarah?",
|
|
186
|
+
query_vector = embed("Is the cake safe for Sarah?"),
|
|
187
|
+
active_node = tree.current_node,
|
|
188
|
+
recent_messages = tree.conversation[-6:],
|
|
189
|
+
)
|
|
190
|
+
response = openai_client.chat.completions.create(
|
|
191
|
+
model = "gpt-4o",
|
|
192
|
+
messages = [
|
|
193
|
+
{"role": "system", "content": prompt},
|
|
194
|
+
{"role": "user", "content": "Is the cake safe for Sarah?"},
|
|
195
|
+
],
|
|
196
|
+
)
|
|
197
|
+
"""
|
|
198
|
+
# 1. Surgical multi-path memory context
|
|
199
|
+
narrative_block = self._get_surgical_context(query_vector)
|
|
200
|
+
|
|
201
|
+
# 2. Cross-thread conversation recall
|
|
202
|
+
history_matches = self.vdb.search_conversation(
|
|
203
|
+
query_vector = query_vector,
|
|
204
|
+
top_k = top_k_history,
|
|
205
|
+
min_similarity = min_history_similarity,
|
|
206
|
+
)
|
|
207
|
+
recall_block = ""
|
|
208
|
+
valid_recalls = [h for h in history_matches if h.text.strip() != user_query.strip()]
|
|
209
|
+
if valid_recalls:
|
|
210
|
+
recall_block = "\n── SUBCONSCIOUS CROSS-THREAD CONVERSATION RECALL ──\n"
|
|
211
|
+
for msg in valid_recalls:
|
|
212
|
+
time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(msg.timestamp))
|
|
213
|
+
recall_block += (
|
|
214
|
+
f"Branch Path: {msg.thread_path} (Recall Strength: {msg.similarity * 100:.1f}%)\n"
|
|
215
|
+
f"Context [Role: {msg.role.upper()} | Time: {time_str}]:\n"
|
|
216
|
+
f"\"{msg.text.strip()}\"\n"
|
|
217
|
+
+ "─" * 50 + "\n"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# 3. Verbatim recent log
|
|
221
|
+
recent_block = "── RECENT CONVERSATION LOGS (VERBATIM) ──\n"
|
|
222
|
+
if recent_messages:
|
|
223
|
+
for msg in recent_messages:
|
|
224
|
+
role = msg.get("role", "unknown").upper()
|
|
225
|
+
content = msg.get("content", "")
|
|
226
|
+
if isinstance(content, str) and len(content) > 600:
|
|
227
|
+
content = content[:600] + "… [truncated]"
|
|
228
|
+
elif isinstance(content, list):
|
|
229
|
+
content = "[multimodal message]"
|
|
230
|
+
recent_block += f"[{role}]: {content}\n"
|
|
231
|
+
else:
|
|
232
|
+
recent_block += " (No messages yet)\n"
|
|
233
|
+
|
|
234
|
+
# Assemble final prompt
|
|
235
|
+
now_str = datetime.now().strftime("%A, %d %B %Y, %I:%M %p")
|
|
236
|
+
base = (
|
|
237
|
+
f"Today's date and time: {now_str}\n"
|
|
238
|
+
"Your training data may be outdated — always treat today's date above as ground truth.\n"
|
|
239
|
+
"If web search results are provided, treat them as the most current and accurate source.\n\n"
|
|
240
|
+
"You are a sharp, knowledgeable AI assistant. "
|
|
241
|
+
"Answer the user's current message directly and helpfully.\n\n"
|
|
242
|
+
"STRICT RULES (never break these):\n"
|
|
243
|
+
"- NEVER mention topics, branches, threads, memory trees, or any internal system.\n"
|
|
244
|
+
"- NEVER ask the user if they want to switch topics or continue a previous topic.\n"
|
|
245
|
+
"- If the user asks about something new, just answer it.\n"
|
|
246
|
+
"- Use the context below ONLY as silent background knowledge to stay coherent.\n"
|
|
247
|
+
"- Stay on what the user actually asked. No preamble.\n"
|
|
248
|
+
"- Provide detailed, comprehensive answers.\n\n"
|
|
249
|
+
)
|
|
250
|
+
return (
|
|
251
|
+
base
|
|
252
|
+
+ narrative_block + "\n\n"
|
|
253
|
+
+ recall_block
|
|
254
|
+
+ recent_block + "\n"
|
|
255
|
+
+ "Now answer the user's latest message directly, as a knowledgeable friend would."
|
|
256
|
+
)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import struct
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConversationVector:
|
|
10
|
+
|
|
11
|
+
def __init__(self, message_id, message_index, role, text, embedding, timestamp, thread_path, similarity=0.0):
|
|
12
|
+
self.message_id = message_id
|
|
13
|
+
self.message_index = message_index
|
|
14
|
+
self.role = role
|
|
15
|
+
self.text = text
|
|
16
|
+
self.embedding = embedding
|
|
17
|
+
self.timestamp = timestamp
|
|
18
|
+
self.thread_path = thread_path
|
|
19
|
+
self.similarity = similarity
|
|
20
|
+
|
|
21
|
+
def pack_float_vector(vector):
|
|
22
|
+
if not vector:
|
|
23
|
+
return b''
|
|
24
|
+
format_string = '<' + str(len(vector)) + 'f'
|
|
25
|
+
return struct.pack(format_string, *vector)
|
|
26
|
+
|
|
27
|
+
def unpack_float_vector(blob):
|
|
28
|
+
if not blob:
|
|
29
|
+
return []
|
|
30
|
+
num_floats = len(blob) // 4
|
|
31
|
+
format_string = '<' + str(num_floats) + 'f'
|
|
32
|
+
unpacked = struct.unpack(format_string, blob)
|
|
33
|
+
result = []
|
|
34
|
+
for val in unpacked:
|
|
35
|
+
result.append(val)
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
def cosine_similarity(v1, v2):
|
|
39
|
+
if not v1 or not v2 or len(v1) != len(v2):
|
|
40
|
+
return 0.0
|
|
41
|
+
vec1 = np.array(v1, dtype=np.float32)
|
|
42
|
+
vec2 = np.array(v2, dtype=np.float32)
|
|
43
|
+
norm_v1 = float(np.linalg.norm(vec1))
|
|
44
|
+
norm_v2 = float(np.linalg.norm(vec2))
|
|
45
|
+
if norm_v1 == 0.0 or norm_v2 == 0.0:
|
|
46
|
+
return 0.0
|
|
47
|
+
return float(np.dot(vec1, vec2) / (norm_v1 * norm_v2))
|
|
48
|
+
|
|
49
|
+
class VectorDatabase:
|
|
50
|
+
|
|
51
|
+
def __init__(self, db_path):
|
|
52
|
+
self.db_path = db_path
|
|
53
|
+
self._initialize_database()
|
|
54
|
+
|
|
55
|
+
def _initialize_database(self):
|
|
56
|
+
conn = sqlite3.connect(self.db_path)
|
|
57
|
+
cursor = conn.cursor()
|
|
58
|
+
|
|
59
|
+
cursor.execute("""
|
|
60
|
+
CREATE TABLE IF NOT EXISTS conversation_vectors (
|
|
61
|
+
message_id TEXT PRIMARY KEY,
|
|
62
|
+
message_index INTEGER NOT NULL,
|
|
63
|
+
role TEXT NOT NULL,
|
|
64
|
+
content TEXT NOT NULL,
|
|
65
|
+
embedding BLOB NOT NULL,
|
|
66
|
+
timestamp REAL NOT NULL,
|
|
67
|
+
thread_path TEXT NOT NULL
|
|
68
|
+
)
|
|
69
|
+
""")
|
|
70
|
+
cursor.execute("""
|
|
71
|
+
CREATE TABLE IF NOT EXISTS topic_summaries (
|
|
72
|
+
node_id TEXT PRIMARY KEY,
|
|
73
|
+
topic_name TEXT NOT NULL,
|
|
74
|
+
summary TEXT NOT NULL,
|
|
75
|
+
embedding BLOB NOT NULL,
|
|
76
|
+
start_index INTEGER,
|
|
77
|
+
end_index INTEGER,
|
|
78
|
+
depth INTEGER
|
|
79
|
+
)
|
|
80
|
+
""")
|
|
81
|
+
conn.commit()
|
|
82
|
+
conn.close()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def add_conversation_message(self, msg):
|
|
88
|
+
packed_vector = pack_float_vector(msg.embedding)
|
|
89
|
+
conn = sqlite3.connect(self.db_path)
|
|
90
|
+
cursor = conn.cursor()
|
|
91
|
+
cursor.execute('\n INSERT OR REPLACE INTO conversation_vectors (\n message_id, message_index, role, content, embedding, timestamp, thread_path\n ) VALUES (?, ?, ?, ?, ?, ?, ?)\n ', (msg.message_id, msg.message_index, msg.role, msg.text, packed_vector, msg.timestamp, msg.thread_path))
|
|
92
|
+
conn.commit()
|
|
93
|
+
conn.close()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def search_conversation(self, query_vector, top_k=2, min_similarity=0.5):
|
|
98
|
+
if not query_vector:
|
|
99
|
+
return []
|
|
100
|
+
conn = sqlite3.connect(self.db_path)
|
|
101
|
+
cursor = conn.cursor()
|
|
102
|
+
cursor.execute('\n SELECT message_id, message_index, role, content, embedding, timestamp, thread_path \n FROM conversation_vectors\n ')
|
|
103
|
+
rows = cursor.fetchall()
|
|
104
|
+
conn.close()
|
|
105
|
+
scored_msgs = []
|
|
106
|
+
for row in rows:
|
|
107
|
+
msg_id = row[0]
|
|
108
|
+
msg_idx = row[1]
|
|
109
|
+
role = row[2]
|
|
110
|
+
content = row[3]
|
|
111
|
+
raw_embed = row[4]
|
|
112
|
+
timestamp = row[5]
|
|
113
|
+
thread_path = row[6]
|
|
114
|
+
stored_vector = unpack_float_vector(raw_embed)
|
|
115
|
+
sim = cosine_similarity(query_vector, stored_vector)
|
|
116
|
+
if sim >= min_similarity:
|
|
117
|
+
data_tuple = (msg_id, msg_idx, role, content, timestamp, thread_path)
|
|
118
|
+
scored_msgs.append((sim, data_tuple))
|
|
119
|
+
scored_msgs.sort(key=lambda x: x[0], reverse=True)
|
|
120
|
+
top_matches = []
|
|
121
|
+
for i in range(min(top_k, len(scored_msgs))):
|
|
122
|
+
top_matches.append(scored_msgs[i])
|
|
123
|
+
results = []
|
|
124
|
+
for match in top_matches:
|
|
125
|
+
sim = match[0]
|
|
126
|
+
data = match[1]
|
|
127
|
+
msg_id = data[0]
|
|
128
|
+
msg_idx = data[1]
|
|
129
|
+
role = data[2]
|
|
130
|
+
content = data[3]
|
|
131
|
+
timestamp = data[4]
|
|
132
|
+
thread_path = data[5]
|
|
133
|
+
msg = ConversationVector(message_id=msg_id, message_index=msg_idx, role=role, text=content, embedding=[], timestamp=timestamp, thread_path=thread_path, similarity=sim)
|
|
134
|
+
results.append(msg)
|
|
135
|
+
return results
|
|
136
|
+
|
|
137
|
+
# ── Topic Summary Methods (Change 1: Surgical Retrieval) ─────────────────
|
|
138
|
+
|
|
139
|
+
def upsert_topic_summary(self, node_id, topic_name, summary, embedding,
|
|
140
|
+
start_index=None, end_index=None, depth=None):
|
|
141
|
+
"""Insert or replace a topic embedding row. Called when a node is frozen & summarised."""
|
|
142
|
+
packed = pack_float_vector(embedding)
|
|
143
|
+
conn = sqlite3.connect(self.db_path)
|
|
144
|
+
cursor = conn.cursor()
|
|
145
|
+
cursor.execute(
|
|
146
|
+
'INSERT OR REPLACE INTO topic_summaries '
|
|
147
|
+
'(node_id, topic_name, summary, embedding, start_index, end_index, depth) '
|
|
148
|
+
'VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
149
|
+
(node_id, topic_name, summary, packed, start_index, end_index, depth)
|
|
150
|
+
)
|
|
151
|
+
conn.commit()
|
|
152
|
+
conn.close()
|
|
153
|
+
|
|
154
|
+
def search_topic_summaries(self, query_vector, top_k=3, min_similarity=0.40):
|
|
155
|
+
"""Cosine-search the topic_summaries table.
|
|
156
|
+
Returns list of dicts: [{node_id, topic_name, summary, similarity}]
|
|
157
|
+
"""
|
|
158
|
+
if not query_vector:
|
|
159
|
+
return []
|
|
160
|
+
conn = sqlite3.connect(self.db_path)
|
|
161
|
+
cursor = conn.cursor()
|
|
162
|
+
cursor.execute(
|
|
163
|
+
'SELECT node_id, topic_name, summary, embedding FROM topic_summaries'
|
|
164
|
+
)
|
|
165
|
+
rows = cursor.fetchall()
|
|
166
|
+
conn.close()
|
|
167
|
+
scored = []
|
|
168
|
+
for row in rows:
|
|
169
|
+
node_id, topic_name, summary, raw_embed = row
|
|
170
|
+
stored_vec = unpack_float_vector(raw_embed)
|
|
171
|
+
sim = cosine_similarity(query_vector, stored_vec)
|
|
172
|
+
if sim >= min_similarity:
|
|
173
|
+
scored.append((sim, node_id, topic_name, summary))
|
|
174
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
175
|
+
results = []
|
|
176
|
+
for sim, node_id, topic_name, summary in scored[:top_k]:
|
|
177
|
+
results.append({
|
|
178
|
+
'node_id': node_id,
|
|
179
|
+
'topic_name': topic_name,
|
|
180
|
+
'summary': summary,
|
|
181
|
+
'similarity': sim,
|
|
182
|
+
})
|
|
183
|
+
return results
|
|
184
|
+
|
|
185
|
+
def delete_topic_summary(self, node_id):
|
|
186
|
+
"""Remove a topic summary row by node_id (used by reorganizer on merge)."""
|
|
187
|
+
conn = sqlite3.connect(self.db_path)
|
|
188
|
+
cursor = conn.cursor()
|
|
189
|
+
cursor.execute('DELETE FROM topic_summaries WHERE node_id = ?', (node_id,))
|
|
190
|
+
conn.commit()
|
|
191
|
+
conn.close()
|