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
trace_memory/ctree.py
ADDED
|
@@ -0,0 +1,1187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CTree — Hierarchical B+Tree Conversation Memory
|
|
3
|
+
================================================
|
|
4
|
+
The core data structure of TRACE. Organises every LLM conversation
|
|
5
|
+
exchange into a tree of named topic branches, with:
|
|
6
|
+
|
|
7
|
+
• Automatic LLM-driven topic detection and branch assignment.
|
|
8
|
+
• Lazy summary generation for frozen (inactive) branches.
|
|
9
|
+
• Vector-DB integration for surgical multi-path retrieval.
|
|
10
|
+
• A four-rule-guarded self-healing reorganization pass.
|
|
11
|
+
• Soft archival of trivial leaf messages.
|
|
12
|
+
• Full JSON serialisation / deserialisation.
|
|
13
|
+
|
|
14
|
+
Typical usage
|
|
15
|
+
-------------
|
|
16
|
+
from trace import CTree, VectorDatabase, PromptSynthesizer
|
|
17
|
+
|
|
18
|
+
# 1. Boot the engine
|
|
19
|
+
tree = CTree(api_key="sk-...", model="gpt-4o-mini")
|
|
20
|
+
vdb = VectorDatabase("my_session.db")
|
|
21
|
+
tree.vdb = vdb
|
|
22
|
+
|
|
23
|
+
# 2. Wire up the embed function (any callable: text -> List[float])
|
|
24
|
+
tree.embed_fn = my_embed_function
|
|
25
|
+
|
|
26
|
+
# 3. Add exchanges after getting a response from your LLM
|
|
27
|
+
tree.add([
|
|
28
|
+
{"role": "user", "content": "Tell me about black holes."},
|
|
29
|
+
{"role": "assistant", "content": "Black holes are regions ..."},
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
# 4. Build the enriched system prompt for the next turn
|
|
33
|
+
synth = PromptSynthesizer(tree, vdb)
|
|
34
|
+
prompt = synth.synthesize_prompt(
|
|
35
|
+
user_query = "How do Hawking radiation work?",
|
|
36
|
+
query_vector = my_embed_function("How do Hawking radiation work?"),
|
|
37
|
+
active_node = tree.current_node,
|
|
38
|
+
recent_messages= tree.conversation[-6:],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# 5. Persist
|
|
42
|
+
tree.save("my_session.json", save_conversation=True)
|
|
43
|
+
|
|
44
|
+
# 6. Reload later
|
|
45
|
+
tree = CTree.load("my_session.json", api_key="sk-...")
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import json
|
|
49
|
+
import uuid
|
|
50
|
+
import time
|
|
51
|
+
import os
|
|
52
|
+
|
|
53
|
+
from typing import List, Dict, Optional, Tuple, Any, Callable
|
|
54
|
+
from dataclasses import dataclass, field
|
|
55
|
+
|
|
56
|
+
from ._llm_utils import ChatGPT_API, extract_json
|
|
57
|
+
|
|
58
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
_fallback_embedder = None
|
|
61
|
+
def _default_embed_fn(text: str) -> List[float]:
|
|
62
|
+
global _fallback_embedder
|
|
63
|
+
try:
|
|
64
|
+
from sentence_transformers import SentenceTransformer
|
|
65
|
+
except ImportError:
|
|
66
|
+
raise RuntimeError("Embedding function not provided, and 'sentence-transformers' is not installed for fallback. Please install it (e.g., pip install sentence-transformers).")
|
|
67
|
+
|
|
68
|
+
if _fallback_embedder is None:
|
|
69
|
+
import sys
|
|
70
|
+
print("\n[TRACE] Loading default local embedding model (BAAI/bge-base-en-v1.5)...", file=sys.stderr)
|
|
71
|
+
_fallback_embedder = SentenceTransformer("BAAI/bge-base-en-v1.5")
|
|
72
|
+
|
|
73
|
+
return _fallback_embedder.encode(text).tolist()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _safe_int(value, fallback):
|
|
77
|
+
if value is None:
|
|
78
|
+
return fallback
|
|
79
|
+
if isinstance(value, str) and (value.strip().upper() == "N/A" or not value.strip()):
|
|
80
|
+
return fallback
|
|
81
|
+
try:
|
|
82
|
+
return int(value)
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
return fallback
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── Node base ─────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
class Node:
|
|
90
|
+
"""
|
|
91
|
+
Abstract base for all tree nodes.
|
|
92
|
+
|
|
93
|
+
Attributes
|
|
94
|
+
----------
|
|
95
|
+
children : Direct child nodes.
|
|
96
|
+
parent : Parent node (None for root).
|
|
97
|
+
sub_node_count : Cached count of direct children.
|
|
98
|
+
created_at : Unix timestamp of node birth (set once, never changed).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, children=None, parent=None, sub_node_count=0):
|
|
102
|
+
self.children: List["Node"] = children or []
|
|
103
|
+
self.parent: Optional["Node"] = parent
|
|
104
|
+
self.sub_node_count: int = sub_node_count
|
|
105
|
+
self.created_at: float = time.time()
|
|
106
|
+
|
|
107
|
+
def is_leaf(self) -> bool:
|
|
108
|
+
return len(self.children) == 0
|
|
109
|
+
|
|
110
|
+
def to_dict(self) -> dict:
|
|
111
|
+
raise NotImplementedError("Subclasses must implement to_dict()")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── MessageNode ───────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
class MessageNode(Node):
|
|
117
|
+
"""
|
|
118
|
+
A leaf node holding one user/assistant exchange (optionally a system
|
|
119
|
+
message that preceded it).
|
|
120
|
+
|
|
121
|
+
Attributes
|
|
122
|
+
----------
|
|
123
|
+
user_message : {"role": "user", "content": "..."}
|
|
124
|
+
assistant_message : {"role": "assistant", "content": "..."}
|
|
125
|
+
system_message : {"role": "system", "content": "..."} or None
|
|
126
|
+
message_index : Index of the user message inside ``CTree.conversation``.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
user_message=None,
|
|
132
|
+
assistant_message=None,
|
|
133
|
+
system_message=None,
|
|
134
|
+
message_index: int = 0,
|
|
135
|
+
parent=None,
|
|
136
|
+
):
|
|
137
|
+
super().__init__(parent=parent)
|
|
138
|
+
self.user_message: dict = user_message or {}
|
|
139
|
+
self.assistant_message: dict = assistant_message or {}
|
|
140
|
+
self.system_message: Optional[dict] = system_message
|
|
141
|
+
self.message_index: int = message_index
|
|
142
|
+
|
|
143
|
+
def to_dict(self) -> dict:
|
|
144
|
+
result = {
|
|
145
|
+
"type": "message",
|
|
146
|
+
"message_index": self.message_index,
|
|
147
|
+
"user": self.user_message.get("content", "")[:200],
|
|
148
|
+
"assistant": self.assistant_message.get("content", "")[:200],
|
|
149
|
+
"sub_node_count": self.sub_node_count,
|
|
150
|
+
}
|
|
151
|
+
if self.system_message is not None:
|
|
152
|
+
result["system"] = self.system_message.get("content", "")[:200]
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
def __repr__(self):
|
|
156
|
+
u = self.user_message.get("content", "")[:40]
|
|
157
|
+
a = self.assistant_message.get("content", "")[:40]
|
|
158
|
+
return f"MessageNode([{self.message_index}]: U:{u!r}… A:{a!r}…)"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── TopicNode ─────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
class TopicNode(Node):
|
|
164
|
+
"""
|
|
165
|
+
An internal branch node representing a coherent conversation topic.
|
|
166
|
+
|
|
167
|
+
Attributes
|
|
168
|
+
----------
|
|
169
|
+
topic_name : Short human-readable label (e.g. "Hawking Radiation").
|
|
170
|
+
summary : LLM-generated 1–2 sentence summary (filled lazily on freeze).
|
|
171
|
+
start_index : First index in ``CTree.conversation`` that belongs to this topic.
|
|
172
|
+
end_index : Exclusive last index.
|
|
173
|
+
node_id : Stable UUID — generated once, persisted across save/load cycles.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
topic_name: str = "",
|
|
179
|
+
summary: str = "",
|
|
180
|
+
start_index: int = 0,
|
|
181
|
+
end_index: int = 0,
|
|
182
|
+
parent=None,
|
|
183
|
+
):
|
|
184
|
+
super().__init__(parent=parent)
|
|
185
|
+
self.topic_name: str = topic_name
|
|
186
|
+
self.summary: str = summary
|
|
187
|
+
self.start_index: int = start_index
|
|
188
|
+
self.end_index: int = end_index
|
|
189
|
+
self.node_id: str = str(uuid.uuid4())
|
|
190
|
+
|
|
191
|
+
def get_message_count(self) -> int:
|
|
192
|
+
if self.is_leaf():
|
|
193
|
+
return self.end_index - self.start_index
|
|
194
|
+
count = 0
|
|
195
|
+
for child in self.children:
|
|
196
|
+
if isinstance(child, MessageNode):
|
|
197
|
+
count += 1
|
|
198
|
+
elif isinstance(child, TopicNode):
|
|
199
|
+
count += child.get_message_count()
|
|
200
|
+
return count
|
|
201
|
+
|
|
202
|
+
def to_dict(self) -> dict:
|
|
203
|
+
return {
|
|
204
|
+
"type": "topic",
|
|
205
|
+
"node_id": self.node_id,
|
|
206
|
+
"topic_name": self.topic_name,
|
|
207
|
+
"summary": self.summary,
|
|
208
|
+
"start_index": self.start_index,
|
|
209
|
+
"end_index": self.end_index,
|
|
210
|
+
"sub_node_count": self.sub_node_count,
|
|
211
|
+
"created_at": self.created_at,
|
|
212
|
+
"children": [c.to_dict() for c in self.children],
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
def __repr__(self):
|
|
216
|
+
return (
|
|
217
|
+
f"TopicNode({self.topic_name!r}, "
|
|
218
|
+
f"[{self.start_index}:{self.end_index}], "
|
|
219
|
+
f"{self.sub_node_count} children)"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ── CTree ─────────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
class CTree:
|
|
226
|
+
"""
|
|
227
|
+
The TRACE hierarchical conversation memory tree.
|
|
228
|
+
|
|
229
|
+
Parameters
|
|
230
|
+
----------
|
|
231
|
+
max_children : Max child nodes before a node is split (default 5).
|
|
232
|
+
api_key : LLM API key. Falls back to OPENAI_API_KEY env var.
|
|
233
|
+
model : LLM model ID used for topic detection, summarisation, etc.
|
|
234
|
+
auto_save_path : If set, the tree auto-saves to this path after every
|
|
235
|
+
``add()`` call.
|
|
236
|
+
|
|
237
|
+
Injectable dependencies
|
|
238
|
+
-----------------------
|
|
239
|
+
tree.vdb : A ``VectorDatabase`` instance for embedding-backed recall.
|
|
240
|
+
tree.embed_fn : Callable[str, List[float]] — your embedding function.
|
|
241
|
+
|
|
242
|
+
Example
|
|
243
|
+
-------
|
|
244
|
+
import openai
|
|
245
|
+
|
|
246
|
+
client = openai.OpenAI(api_key="sk-...", base_url="http://...")
|
|
247
|
+
|
|
248
|
+
def embed(text):
|
|
249
|
+
r = client.embeddings.create(input=[text], model="nomic-embed-text")
|
|
250
|
+
return r.data[0].embedding
|
|
251
|
+
|
|
252
|
+
tree = CTree(api_key="sk-...", model="gpt-4o-mini")
|
|
253
|
+
tree.embed_fn = embed
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def __init__(
|
|
257
|
+
self,
|
|
258
|
+
max_children: int = 5,
|
|
259
|
+
api_key: str = None,
|
|
260
|
+
model: str = "gpt-4o-mini",
|
|
261
|
+
auto_save_path: str = None,
|
|
262
|
+
embed_fn: Optional[Callable[[str], List[float]]] = None,
|
|
263
|
+
):
|
|
264
|
+
self.max_children = max_children
|
|
265
|
+
self.model = model
|
|
266
|
+
self.conversation: List[dict] = []
|
|
267
|
+
self.auto_save_path: Optional[str] = auto_save_path
|
|
268
|
+
self._archived_nodes: List[MessageNode] = []
|
|
269
|
+
self.vdb = None # inject: VectorDatabase instance
|
|
270
|
+
self.embed_fn = embed_fn or _default_embed_fn # inject: callable(text) -> List[float]
|
|
271
|
+
|
|
272
|
+
# API key resolution
|
|
273
|
+
if api_key:
|
|
274
|
+
self.api_key = api_key
|
|
275
|
+
elif os.getenv("OPENAI_API_KEY"):
|
|
276
|
+
self.api_key = os.getenv("OPENAI_API_KEY")
|
|
277
|
+
else:
|
|
278
|
+
raise ValueError(
|
|
279
|
+
"API key must be provided via api_key= or the "
|
|
280
|
+
"OPENAI_API_KEY environment variable."
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
self.root: TopicNode = TopicNode(
|
|
284
|
+
topic_name="ROOT",
|
|
285
|
+
summary="Virtual root node of the conversation tree",
|
|
286
|
+
start_index=0,
|
|
287
|
+
end_index=0,
|
|
288
|
+
)
|
|
289
|
+
self.current_node: TopicNode = self.root
|
|
290
|
+
|
|
291
|
+
# ── Ancestry helpers ──────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
def get_ancestors(
|
|
294
|
+
self,
|
|
295
|
+
node,
|
|
296
|
+
include_self: bool = True,
|
|
297
|
+
exclude_root: bool = False,
|
|
298
|
+
) -> List[TopicNode]:
|
|
299
|
+
"""
|
|
300
|
+
Return the ordered list of ancestor ``TopicNode`` objects from root
|
|
301
|
+
down to *node*.
|
|
302
|
+
|
|
303
|
+
Parameters
|
|
304
|
+
----------
|
|
305
|
+
node : Starting node.
|
|
306
|
+
include_self : If True, include *node* itself in the result.
|
|
307
|
+
exclude_root : If True, omit the virtual ROOT node.
|
|
308
|
+
|
|
309
|
+
Returns
|
|
310
|
+
-------
|
|
311
|
+
List[TopicNode] in root-to-leaf order.
|
|
312
|
+
"""
|
|
313
|
+
ancestors = []
|
|
314
|
+
current = node if include_self else node.parent
|
|
315
|
+
while current is not None:
|
|
316
|
+
ancestors.insert(0, current)
|
|
317
|
+
current = current.parent
|
|
318
|
+
if exclude_root:
|
|
319
|
+
ancestors = [n for n in ancestors if n.topic_name != "ROOT"]
|
|
320
|
+
return ancestors
|
|
321
|
+
|
|
322
|
+
def _is_frozen_node(self, node) -> bool:
|
|
323
|
+
"""
|
|
324
|
+
A node is *frozen* when neither it nor any of its ancestors is the
|
|
325
|
+
currently active node. Frozen nodes are safe to summarise and merge.
|
|
326
|
+
"""
|
|
327
|
+
if node is self.current_node:
|
|
328
|
+
return False
|
|
329
|
+
ancestors = self.get_ancestors(self.current_node, include_self=False)
|
|
330
|
+
return node not in ancestors
|
|
331
|
+
|
|
332
|
+
# ── Summarisation & VDB upsert ────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
def _generate_summaries_for_frozen_nodes(self, node=None):
|
|
335
|
+
"""
|
|
336
|
+
Walk the tree and LLM-summarise every frozen TopicNode that has no
|
|
337
|
+
summary yet. Also embeds each new summary into the VDB.
|
|
338
|
+
"""
|
|
339
|
+
if node is None:
|
|
340
|
+
node = self.root
|
|
341
|
+
if isinstance(node, TopicNode):
|
|
342
|
+
if node is not self.root and self._is_frozen_node(node):
|
|
343
|
+
if not node.summary or not node.summary.strip():
|
|
344
|
+
messages = self.conversation[node.start_index : node.end_index]
|
|
345
|
+
if messages:
|
|
346
|
+
node.summary = self._llm_summarize(messages, node.topic_name)
|
|
347
|
+
self._upsert_node_to_vdb(node)
|
|
348
|
+
if hasattr(node, "children"):
|
|
349
|
+
for child in node.children:
|
|
350
|
+
self._generate_summaries_for_frozen_nodes(child)
|
|
351
|
+
|
|
352
|
+
def _upsert_node_to_vdb(self, node, depth=None):
|
|
353
|
+
"""Embed a TopicNode summary and upsert it into the VDB topic table."""
|
|
354
|
+
if self.vdb is None or self.embed_fn is None:
|
|
355
|
+
return
|
|
356
|
+
if not node.summary or not node.node_id:
|
|
357
|
+
return
|
|
358
|
+
try:
|
|
359
|
+
embedding = self.embed_fn(node.summary)
|
|
360
|
+
self.vdb.upsert_topic_summary(
|
|
361
|
+
node_id = node.node_id,
|
|
362
|
+
topic_name = node.topic_name,
|
|
363
|
+
summary = node.summary,
|
|
364
|
+
embedding = embedding,
|
|
365
|
+
start_index = node.start_index,
|
|
366
|
+
end_index = node.end_index,
|
|
367
|
+
depth = depth,
|
|
368
|
+
)
|
|
369
|
+
except Exception:
|
|
370
|
+
pass # embedding failures must never crash the tree
|
|
371
|
+
|
|
372
|
+
# ── Public: add exchange ──────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
def add(self, messages: List[dict]):
|
|
375
|
+
"""
|
|
376
|
+
Ingest one completed user/assistant exchange (and optional system
|
|
377
|
+
message) into the tree.
|
|
378
|
+
|
|
379
|
+
Parameters
|
|
380
|
+
----------
|
|
381
|
+
messages : A list of dicts, each with ``"role"`` and ``"content"``.
|
|
382
|
+
Must include at least one ``"user"`` and one
|
|
383
|
+
``"assistant"`` message.
|
|
384
|
+
|
|
385
|
+
Raises
|
|
386
|
+
------
|
|
387
|
+
ValueError : If the exchange is missing a user or assistant message.
|
|
388
|
+
|
|
389
|
+
Example
|
|
390
|
+
-------
|
|
391
|
+
tree.add([
|
|
392
|
+
{"role": "user", "content": "What is a neutron star?"},
|
|
393
|
+
{"role": "assistant", "content": "A neutron star is ..."},
|
|
394
|
+
])
|
|
395
|
+
|
|
396
|
+
# With a system message (e.g. tool-call context)
|
|
397
|
+
tree.add([
|
|
398
|
+
{"role": "system", "content": "Search result: ..."},
|
|
399
|
+
{"role": "user", "content": "Summarise that."},
|
|
400
|
+
{"role": "assistant", "content": "Here is a summary: ..."},
|
|
401
|
+
])
|
|
402
|
+
"""
|
|
403
|
+
system_msg = assistant_msg = user_msg = None
|
|
404
|
+
for m in messages:
|
|
405
|
+
role = m.get("role")
|
|
406
|
+
if role == "system":
|
|
407
|
+
system_msg = m
|
|
408
|
+
elif role == "user":
|
|
409
|
+
user_msg = m
|
|
410
|
+
elif role == "assistant":
|
|
411
|
+
assistant_msg = m
|
|
412
|
+
|
|
413
|
+
if not user_msg or not assistant_msg:
|
|
414
|
+
raise ValueError("Each exchange must contain a 'user' and 'assistant' message.")
|
|
415
|
+
|
|
416
|
+
msg_dict = {
|
|
417
|
+
"system": system_msg,
|
|
418
|
+
"user": user_msg,
|
|
419
|
+
"assistant": assistant_msg,
|
|
420
|
+
"index": len(self.conversation),
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
for msg in messages:
|
|
424
|
+
self.conversation.append(msg)
|
|
425
|
+
|
|
426
|
+
if len(self.root.children) == 0:
|
|
427
|
+
self._initialize_first_topic_with_message(msg_dict)
|
|
428
|
+
else:
|
|
429
|
+
self._add_message(msg_dict)
|
|
430
|
+
|
|
431
|
+
if self.auto_save_path:
|
|
432
|
+
self.save(self.auto_save_path, save_conversation=True)
|
|
433
|
+
|
|
434
|
+
# ── Internal: topic routing ───────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
def _initialize_first_topic_with_message(self, msg_dict):
|
|
437
|
+
topic_name = self._llm_generate_topic_from_message(
|
|
438
|
+
msg_dict["user"], msg_dict["assistant"], system_msg=msg_dict["system"]
|
|
439
|
+
)
|
|
440
|
+
msg_count = 3 if msg_dict["system"] else 2
|
|
441
|
+
first_topic = TopicNode(
|
|
442
|
+
topic_name=topic_name,
|
|
443
|
+
start_index=0,
|
|
444
|
+
end_index=msg_count,
|
|
445
|
+
parent=self.root,
|
|
446
|
+
)
|
|
447
|
+
msg_node = MessageNode(
|
|
448
|
+
user_message = msg_dict["user"],
|
|
449
|
+
assistant_message = msg_dict["assistant"],
|
|
450
|
+
system_message = msg_dict["system"],
|
|
451
|
+
message_index = msg_dict["index"],
|
|
452
|
+
parent = first_topic,
|
|
453
|
+
)
|
|
454
|
+
first_topic.children.append(msg_node)
|
|
455
|
+
self.root.children.append(first_topic)
|
|
456
|
+
self.root.end_index = msg_count
|
|
457
|
+
self.current_node = first_topic
|
|
458
|
+
|
|
459
|
+
def _add_message(self, msg_dict):
|
|
460
|
+
target_topic = self._assign_topic_for_message(
|
|
461
|
+
msg_dict["user"], msg_dict["assistant"], msg_dict["system"]
|
|
462
|
+
)
|
|
463
|
+
msg_node = MessageNode(
|
|
464
|
+
user_message = msg_dict["user"],
|
|
465
|
+
assistant_message = msg_dict["assistant"],
|
|
466
|
+
system_message = msg_dict["system"],
|
|
467
|
+
message_index = msg_dict["index"],
|
|
468
|
+
parent = target_topic,
|
|
469
|
+
)
|
|
470
|
+
target_topic.children.append(msg_node)
|
|
471
|
+
target_topic.end_index = len(self.conversation)
|
|
472
|
+
self.current_node = target_topic
|
|
473
|
+
|
|
474
|
+
def _assign_topic_for_message(self, user_msg, assistant_msg, system_msg=None):
|
|
475
|
+
candidate_nodes = self.get_ancestors(
|
|
476
|
+
self.current_node, include_self=True, exclude_root=False
|
|
477
|
+
)
|
|
478
|
+
if not candidate_nodes:
|
|
479
|
+
return self._create_new_topic_for_message(
|
|
480
|
+
user_msg, assistant_msg, self.root, "", system_msg
|
|
481
|
+
)
|
|
482
|
+
classification = self._llm_classify_message_exchange(
|
|
483
|
+
user_msg, assistant_msg, candidate_nodes, system_msg
|
|
484
|
+
)
|
|
485
|
+
if classification["belongs_to_current"]:
|
|
486
|
+
return candidate_nodes[-1]
|
|
487
|
+
parent_index = classification.get("new_topic_parent_index", len(candidate_nodes) - 1)
|
|
488
|
+
parent_node = candidate_nodes[parent_index]
|
|
489
|
+
topic_name = classification.get("new_topic_name", "")
|
|
490
|
+
return self._create_new_topic_for_message(
|
|
491
|
+
user_msg, assistant_msg, parent_node, topic_name, system_msg
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def _has_topic_children(self, node) -> bool:
|
|
495
|
+
return any(isinstance(child, TopicNode) for child in node.children)
|
|
496
|
+
|
|
497
|
+
def _create_new_topic_for_message(
|
|
498
|
+
self, user_msg, assistant_msg, parent, topic_name="", system_msg=None
|
|
499
|
+
):
|
|
500
|
+
if not topic_name or not topic_name.strip():
|
|
501
|
+
topic_name = self._llm_generate_topic_from_message(
|
|
502
|
+
user_msg, assistant_msg, parent=parent, system_msg=system_msg
|
|
503
|
+
)
|
|
504
|
+
msg_count = 3 if system_msg else 2
|
|
505
|
+
new_topic = TopicNode(
|
|
506
|
+
topic_name = topic_name,
|
|
507
|
+
start_index = len(self.conversation) - msg_count,
|
|
508
|
+
end_index = len(self.conversation),
|
|
509
|
+
parent = parent,
|
|
510
|
+
)
|
|
511
|
+
parent.children.append(new_topic)
|
|
512
|
+
return new_topic
|
|
513
|
+
|
|
514
|
+
def _create_new_node(self, message, start_index, parent, topic_name=""):
|
|
515
|
+
if not topic_name or not topic_name.strip():
|
|
516
|
+
topic_name = self._llm_generate_topic(message, parent)
|
|
517
|
+
new_topic = TopicNode(
|
|
518
|
+
topic_name=topic_name,
|
|
519
|
+
start_index=start_index,
|
|
520
|
+
end_index=start_index,
|
|
521
|
+
parent=parent,
|
|
522
|
+
)
|
|
523
|
+
parent.children.append(new_topic)
|
|
524
|
+
return new_topic
|
|
525
|
+
|
|
526
|
+
# ── LLM helpers ───────────────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
def _llm_generate_topic(self, message, parent=None) -> str:
|
|
529
|
+
content = message.get("content", "")
|
|
530
|
+
context = f"\nParent topic: {parent.topic_name}" if (parent and parent.topic_name != "ROOT") else ""
|
|
531
|
+
prompt = (
|
|
532
|
+
f"Given the following message, generate a concise topic name "
|
|
533
|
+
f"(2-5 words) that captures its main subject.{context}\n\n"
|
|
534
|
+
f"Message: {content}\n\nRespond with ONLY the topic name, nothing else."
|
|
535
|
+
)
|
|
536
|
+
try:
|
|
537
|
+
return ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.3, max_tokens=50).strip()
|
|
538
|
+
except Exception as e:
|
|
539
|
+
print(f"LLM error in topic generation: {e}")
|
|
540
|
+
return f"Topic at index {len(self.conversation)}"
|
|
541
|
+
|
|
542
|
+
def _llm_generate_topic_from_message(
|
|
543
|
+
self, user_msg, assistant_msg, parent=None, system_msg=None
|
|
544
|
+
) -> str:
|
|
545
|
+
user_content = user_msg.get("content", "")
|
|
546
|
+
assistant_content = assistant_msg.get("content", "")
|
|
547
|
+
context = f"\nParent topic: {parent.topic_name}" if (parent and parent.topic_name != "ROOT") else ""
|
|
548
|
+
system_context = f"\nSystem: {system_msg.get('content', '')}\n" if system_msg else ""
|
|
549
|
+
prompt = (
|
|
550
|
+
f"Given the following conversation exchange, generate a concise topic name "
|
|
551
|
+
f"(2-5 words) that captures its main subject.{context}\n{system_context}\n"
|
|
552
|
+
f"User: {user_content}\nAssistant: {assistant_content}\n\n"
|
|
553
|
+
f"Respond with ONLY the topic name, nothing else."
|
|
554
|
+
)
|
|
555
|
+
try:
|
|
556
|
+
return ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.3, max_tokens=50).strip()
|
|
557
|
+
except Exception as e:
|
|
558
|
+
print(f"LLM error in topic generation from message: {e}")
|
|
559
|
+
return f"Topic at message {len(self.conversation) // 2}"
|
|
560
|
+
|
|
561
|
+
def _llm_summarize(self, messages: List[dict], topic_name: str) -> str:
|
|
562
|
+
content = "\n\n".join(
|
|
563
|
+
[f"{m.get('role','unknown')}: {m.get('content','')}" for m in messages[:5]]
|
|
564
|
+
)
|
|
565
|
+
prompt = (
|
|
566
|
+
f"Summarize the following conversation segment about \"{topic_name}\" "
|
|
567
|
+
f"in 1-2 sentences.\n\n{content}\n\nSummary:"
|
|
568
|
+
)
|
|
569
|
+
try:
|
|
570
|
+
return ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.3, max_tokens=100).strip()
|
|
571
|
+
except Exception as e:
|
|
572
|
+
print(f"LLM error in summarization: {e}")
|
|
573
|
+
return f"Discussion about {topic_name}"
|
|
574
|
+
|
|
575
|
+
def _llm_classify_message_exchange(
|
|
576
|
+
self, user_msg, assistant_msg, candidate_nodes, system_msg=None
|
|
577
|
+
) -> dict:
|
|
578
|
+
user_content = user_msg.get("content", "")
|
|
579
|
+
assistant_content = assistant_msg.get("content", "")
|
|
580
|
+
current_node = candidate_nodes[-1]
|
|
581
|
+
recent_messages = self.conversation[max(0, current_node.end_index - 6) : current_node.end_index]
|
|
582
|
+
recent_content = "\n".join(
|
|
583
|
+
[f"- {m.get('role','unknown')}: {m.get('content','')[:150]}" for m in recent_messages[-6:]]
|
|
584
|
+
)
|
|
585
|
+
ancestor_list = "\n".join(
|
|
586
|
+
[f"{i}. {node.topic_name} - {node.summary[:100]}" for i, node in enumerate(candidate_nodes[:-1])]
|
|
587
|
+
)
|
|
588
|
+
system_context = f"System: {system_msg.get('content', '')[:200]}\n" if system_msg else ""
|
|
589
|
+
prompt = (
|
|
590
|
+
f"Decide if this new conversation exchange belongs to the current topic "
|
|
591
|
+
f"or starts a completely new one.\n\n"
|
|
592
|
+
f"**Current Topic:** {current_node.topic_name}\n"
|
|
593
|
+
f"**Topic Summary:** {current_node.summary}\n\n"
|
|
594
|
+
f"**Recent exchanges in current topic:**\n{recent_content}\n\n"
|
|
595
|
+
f"**New exchange:**\n{system_context}"
|
|
596
|
+
f"User: {user_content[:400]}\nAssistant: {assistant_content[:400]}\n\n"
|
|
597
|
+
f"**Available parent nodes (choose one as parent if creating a new topic):**\n{ancestor_list}\n\n"
|
|
598
|
+
f"RULES:\n"
|
|
599
|
+
f"1. If the new exchange is a natural continuation of the current topic, set belongs_to_current=true.\n"
|
|
600
|
+
f"2. If the new exchange introduces a completely different subject, set belongs_to_current=false.\n"
|
|
601
|
+
f"3. CRITICAL: When choosing new_topic_parent_index — if the new topic is completely unrelated "
|
|
602
|
+
f"to all listed ancestors, you MUST choose index 0 (the root/top-level). Only choose a deeper "
|
|
603
|
+
f"ancestor if the new topic is a genuine sub-topic of that ancestor.\n"
|
|
604
|
+
f"4. Do NOT nest an unrelated topic under the current topic just because it is the most recent.\n\n"
|
|
605
|
+
f"Respond ONLY with valid JSON:\n"
|
|
606
|
+
f"{{\"reasoning\": \"brief explanation\", \"belongs_to_current\": true|false, "
|
|
607
|
+
f"\"new_topic_name\": \"name (or N/A)\", \"new_topic_parent_index\": <index or N/A>}}\n\n"
|
|
608
|
+
f"Output ONLY the JSON, no other text."
|
|
609
|
+
)
|
|
610
|
+
try:
|
|
611
|
+
response = ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.1, max_tokens=200)
|
|
612
|
+
result = extract_json(response)
|
|
613
|
+
if "new_topic_parent_index" in result:
|
|
614
|
+
raw = result["new_topic_parent_index"]
|
|
615
|
+
is_na = raw is None or (isinstance(raw, str) and raw.strip().upper() == "N/A")
|
|
616
|
+
if not is_na:
|
|
617
|
+
result["new_topic_parent_index"] = min(
|
|
618
|
+
max(0, _safe_int(raw, len(candidate_nodes) - 1)),
|
|
619
|
+
len(candidate_nodes) - 1,
|
|
620
|
+
)
|
|
621
|
+
if "belongs_to_current" not in result:
|
|
622
|
+
result["belongs_to_current"] = True
|
|
623
|
+
return result
|
|
624
|
+
except Exception as e:
|
|
625
|
+
print(f"LLM error in message classification: {e}")
|
|
626
|
+
return {
|
|
627
|
+
"reasoning": "Error in classification, defaulting to current topic",
|
|
628
|
+
"belongs_to_current": True,
|
|
629
|
+
"new_topic_name": "",
|
|
630
|
+
"new_topic_parent_index": len(candidate_nodes) - 1,
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
def _llm_split_subtopics(self, messages: List[dict], node) -> list:
|
|
634
|
+
message_summary = [
|
|
635
|
+
f"[{i}] {m.get('role','unknown')}: {m.get('content','')[:200]}"
|
|
636
|
+
for i, m in enumerate(messages)
|
|
637
|
+
]
|
|
638
|
+
messages_text = "\n".join(message_summary)
|
|
639
|
+
prompt = (
|
|
640
|
+
f'Analyze the following conversation segment about "{node.topic_name}" '
|
|
641
|
+
f"and identify distinct subtopics.\nGroup consecutive messages into 2 coherent subtopics.\n\n"
|
|
642
|
+
f"Messages (indices 0–{len(messages) - 1}):\n{messages_text}\n\n"
|
|
643
|
+
f"Respond in JSON format with an array of subtopics:\n"
|
|
644
|
+
f'{{"subtopics": [{{"topic_name": "...", "summary": "...", '
|
|
645
|
+
f'"start_offset": <int>, "end_offset": <int>}}, ...]}}\n\n'
|
|
646
|
+
f"Ensure: subtopics cover all messages; no gaps/overlaps; each subtopic ≥ 2 messages.\n"
|
|
647
|
+
f"Output ONLY the JSON, no other text."
|
|
648
|
+
)
|
|
649
|
+
try:
|
|
650
|
+
response = ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.3, max_tokens=500)
|
|
651
|
+
result = extract_json(response)
|
|
652
|
+
if isinstance(result, list):
|
|
653
|
+
return result
|
|
654
|
+
if "subtopics" in result:
|
|
655
|
+
return result["subtopics"]
|
|
656
|
+
except Exception as e:
|
|
657
|
+
print(f"LLM error in splitting: {e}")
|
|
658
|
+
mid = len(messages) // 2
|
|
659
|
+
return [
|
|
660
|
+
{"topic_name": f"{node.topic_name} – Part 1", "summary": "First part", "start_offset": 0, "end_offset": mid},
|
|
661
|
+
{"topic_name": f"{node.topic_name} – Part 2", "summary": "Second part", "start_offset": mid, "end_offset": len(messages)},
|
|
662
|
+
]
|
|
663
|
+
|
|
664
|
+
def _llm_find_split_point(self, topic_children, parent_node) -> int:
|
|
665
|
+
children_text = "\n".join(
|
|
666
|
+
[f"[{i}] {c.topic_name} – {c.summary[:100]}" for i, c in enumerate(topic_children)]
|
|
667
|
+
)
|
|
668
|
+
prompt = (
|
|
669
|
+
f'You are analyzing topic "{parent_node.topic_name}" with {len(topic_children)} subtopics.\n'
|
|
670
|
+
f"Find the BEST SPLIT POINT (natural topic shift).\n\nSubtopics:\n{children_text}\n\n"
|
|
671
|
+
f'Respond with ONLY: {{"reasoning": "...", "split_index": <int 1–{len(topic_children)-1}>}}'
|
|
672
|
+
)
|
|
673
|
+
try:
|
|
674
|
+
response = ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.2, max_tokens=200)
|
|
675
|
+
result = extract_json(response)
|
|
676
|
+
split_index = _safe_int(result.get("split_index"), len(topic_children) // 2)
|
|
677
|
+
return max(1, min(split_index, len(topic_children) - 1))
|
|
678
|
+
except Exception:
|
|
679
|
+
return len(topic_children) // 2
|
|
680
|
+
|
|
681
|
+
def _llm_generate_topic_from_children(self, topic_children, parent_node) -> str:
|
|
682
|
+
names = [c.topic_name for c in topic_children[:5]]
|
|
683
|
+
text = ", ".join(names) + (f", and {len(topic_children) - 5} more" if len(topic_children) > 5 else "")
|
|
684
|
+
prompt = (
|
|
685
|
+
f"Generate a concise topic name (2-6 words) that captures the common "
|
|
686
|
+
f"theme of these subtopics:\n\nParent topic: {parent_node.topic_name}\n"
|
|
687
|
+
f"Subtopics: {text}\n\nRespond with ONLY the topic name."
|
|
688
|
+
)
|
|
689
|
+
try:
|
|
690
|
+
name = ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.3, max_tokens=50).strip()
|
|
691
|
+
return name.strip("\"'")
|
|
692
|
+
except Exception:
|
|
693
|
+
return f"{parent_node.topic_name} – Part"
|
|
694
|
+
|
|
695
|
+
def _llm_group_topics_into_subtopics(self, topic_children, parent_node) -> list:
|
|
696
|
+
num_topics = len(topic_children)
|
|
697
|
+
num_groups = max(2, (num_topics + self.max_children - 1) // self.max_children)
|
|
698
|
+
topics_text = "\n".join(
|
|
699
|
+
[f"[{i}] {c.topic_name} – {c.summary[:100]}" for i, c in enumerate(topic_children)]
|
|
700
|
+
)
|
|
701
|
+
prompt = (
|
|
702
|
+
f"Analyze these {num_topics} topics and group them into {num_groups} logical categories "
|
|
703
|
+
f"by theme, respecting chronological flow.\n\nTopics:\n{topics_text}\n\n"
|
|
704
|
+
f'Respond with ONLY: {{"groups": [{{"topic_name": "...", "summary": "...", '
|
|
705
|
+
f'"topic_indices": [...]}}]}}'
|
|
706
|
+
)
|
|
707
|
+
try:
|
|
708
|
+
response = ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.3, max_tokens=800)
|
|
709
|
+
result = extract_json(response)
|
|
710
|
+
if isinstance(result, dict) and "groups" in result:
|
|
711
|
+
groups = result["groups"]
|
|
712
|
+
elif isinstance(result, list):
|
|
713
|
+
groups = result
|
|
714
|
+
else:
|
|
715
|
+
return self._create_fallback_groups(topic_children, num_groups)
|
|
716
|
+
all_indices = set()
|
|
717
|
+
for g in groups:
|
|
718
|
+
all_indices.update(g.get("topic_indices", []))
|
|
719
|
+
if len(all_indices) != num_topics:
|
|
720
|
+
return self._create_fallback_groups(topic_children, num_groups)
|
|
721
|
+
return groups
|
|
722
|
+
except Exception:
|
|
723
|
+
return self._create_fallback_groups(topic_children, num_groups)
|
|
724
|
+
|
|
725
|
+
def _create_fallback_groups(self, topic_children, num_groups) -> list:
|
|
726
|
+
groups, tpg = [], len(topic_children) // num_groups
|
|
727
|
+
for i in range(num_groups):
|
|
728
|
+
s = i * tpg
|
|
729
|
+
e = s + tpg if i < num_groups - 1 else len(topic_children)
|
|
730
|
+
idx = list(range(s, e))
|
|
731
|
+
gt = [topic_children[j] for j in idx]
|
|
732
|
+
groups.append({
|
|
733
|
+
"topic_name": f"Topic Group {i + 1}",
|
|
734
|
+
"summary": f"Group from {gt[0].topic_name} to {gt[-1].topic_name}",
|
|
735
|
+
"topic_indices": idx,
|
|
736
|
+
})
|
|
737
|
+
return groups
|
|
738
|
+
|
|
739
|
+
# ── Node expansion / splitting ────────────────────────────────────────────
|
|
740
|
+
|
|
741
|
+
def _split_node(self, node):
|
|
742
|
+
if node is self.root:
|
|
743
|
+
self._expand_root_into_subtopics(node)
|
|
744
|
+
return
|
|
745
|
+
parent = node.parent
|
|
746
|
+
if parent is None:
|
|
747
|
+
return
|
|
748
|
+
topic_children = [c for c in node.children if isinstance(c, TopicNode)]
|
|
749
|
+
if len(topic_children) < 2:
|
|
750
|
+
return
|
|
751
|
+
split_point = self._llm_find_split_point(topic_children, node)
|
|
752
|
+
first_half = topic_children[:split_point]
|
|
753
|
+
second_half = topic_children[split_point:]
|
|
754
|
+
first_start = first_half[0].start_index
|
|
755
|
+
first_end = first_half[-1].end_index
|
|
756
|
+
second_start = second_half[0].start_index
|
|
757
|
+
second_end = second_half[-1].end_index
|
|
758
|
+
first_name = self._llm_generate_topic_from_children(first_half, node)
|
|
759
|
+
second_name = self._llm_generate_topic_from_children(second_half, node)
|
|
760
|
+
first_node = TopicNode(topic_name=first_name, start_index=first_start, end_index=first_end, parent=parent)
|
|
761
|
+
second_node = TopicNode(topic_name=second_name, start_index=second_start, end_index=second_end, parent=parent)
|
|
762
|
+
for t in first_half:
|
|
763
|
+
t.parent = first_node; first_node.children.append(t)
|
|
764
|
+
for t in second_half:
|
|
765
|
+
t.parent = second_node; second_node.children.append(t)
|
|
766
|
+
parent.children.remove(node)
|
|
767
|
+
parent.children.extend([first_node, second_node])
|
|
768
|
+
if self.current_node is node:
|
|
769
|
+
self.current_node = second_node
|
|
770
|
+
|
|
771
|
+
def _expand_root_into_subtopics(self, root_node):
|
|
772
|
+
topic_children = [c for c in root_node.children if isinstance(c, TopicNode)]
|
|
773
|
+
if len(topic_children) < 2:
|
|
774
|
+
return
|
|
775
|
+
groups = self._llm_group_topics_into_subtopics(topic_children, root_node)
|
|
776
|
+
root_node.children.clear()
|
|
777
|
+
last_subtopic_node = None
|
|
778
|
+
for group in groups:
|
|
779
|
+
indices = group["topic_indices"]
|
|
780
|
+
group_topics = [topic_children[i] for i in indices if i < len(topic_children)]
|
|
781
|
+
if not group_topics:
|
|
782
|
+
continue
|
|
783
|
+
g_start = min(t.start_index for t in group_topics)
|
|
784
|
+
g_end = max(t.end_index for t in group_topics)
|
|
785
|
+
sn = TopicNode(
|
|
786
|
+
topic_name = group["topic_name"],
|
|
787
|
+
summary = group.get("summary", ""),
|
|
788
|
+
start_index = g_start,
|
|
789
|
+
end_index = g_end,
|
|
790
|
+
parent = root_node,
|
|
791
|
+
)
|
|
792
|
+
for t in group_topics:
|
|
793
|
+
t.parent = sn
|
|
794
|
+
sn.children.append(t)
|
|
795
|
+
root_node.children.append(sn)
|
|
796
|
+
last_subtopic_node = sn
|
|
797
|
+
if last_subtopic_node:
|
|
798
|
+
def _in_subtree(n, target):
|
|
799
|
+
if n is target: return True
|
|
800
|
+
return any(_in_subtree(c, target) for c in n.children)
|
|
801
|
+
for sn in root_node.children:
|
|
802
|
+
if isinstance(sn, TopicNode) and _in_subtree(sn, self.current_node):
|
|
803
|
+
break
|
|
804
|
+
else:
|
|
805
|
+
self.current_node = last_subtopic_node
|
|
806
|
+
|
|
807
|
+
# ── Public utilities ──────────────────────────────────────────────────────
|
|
808
|
+
|
|
809
|
+
def generate_summaries(self):
|
|
810
|
+
"""
|
|
811
|
+
Manually trigger lazy summarisation of all frozen branches.
|
|
812
|
+
Called automatically during ``save()``.
|
|
813
|
+
"""
|
|
814
|
+
self._generate_summaries_for_frozen_nodes()
|
|
815
|
+
|
|
816
|
+
def print_tree(self, node=None, indent: int = 0, show_messages: bool = False):
|
|
817
|
+
"""
|
|
818
|
+
Pretty-print the conversation tree to stdout.
|
|
819
|
+
|
|
820
|
+
Parameters
|
|
821
|
+
----------
|
|
822
|
+
node : Start node (defaults to root).
|
|
823
|
+
indent : Current indentation level (used internally).
|
|
824
|
+
show_messages : If True, also render MessageNode contents.
|
|
825
|
+
"""
|
|
826
|
+
if node is None:
|
|
827
|
+
node = self.root
|
|
828
|
+
prefix = " " * indent
|
|
829
|
+
if isinstance(node, TopicNode):
|
|
830
|
+
msg_count = node.get_message_count()
|
|
831
|
+
if node is self.root:
|
|
832
|
+
print(f"{prefix}ROOT (sub-nodes: {node.sub_node_count})")
|
|
833
|
+
else:
|
|
834
|
+
print(f"{prefix}├─ {node.topic_name} [{node.start_index}:{node.end_index}] ({msg_count} msgs)")
|
|
835
|
+
if indent < 3:
|
|
836
|
+
print(f"{prefix} {node.summary[:100]}")
|
|
837
|
+
elif isinstance(node, MessageNode) and show_messages:
|
|
838
|
+
if node.system_message:
|
|
839
|
+
print(f"{prefix} └─ Message[{node.message_index}] (3 msgs):")
|
|
840
|
+
print(f"{prefix} System: {node.system_message.get('content','')[:50]}…")
|
|
841
|
+
else:
|
|
842
|
+
print(f"{prefix} └─ Message[{node.message_index}] (2 msgs):")
|
|
843
|
+
print(f"{prefix} User: {node.user_message.get('content','')[:50]}…")
|
|
844
|
+
print(f"{prefix} Assistant: {node.assistant_message.get('content','')[:50]}…")
|
|
845
|
+
for child in node.children:
|
|
846
|
+
if isinstance(child, TopicNode) or (isinstance(child, MessageNode) and show_messages):
|
|
847
|
+
self.print_tree(child, indent + 1, show_messages)
|
|
848
|
+
|
|
849
|
+
# ── Serialisation ─────────────────────────────────────────────────────────
|
|
850
|
+
|
|
851
|
+
def to_dict(self) -> dict:
|
|
852
|
+
return {
|
|
853
|
+
"max_children": self.max_children,
|
|
854
|
+
"total_messages": len(self.conversation),
|
|
855
|
+
"tree": self.root.to_dict(),
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
def save(self, filepath: str, save_conversation: bool = False):
|
|
859
|
+
"""
|
|
860
|
+
Persist the tree to a JSON file.
|
|
861
|
+
|
|
862
|
+
Parameters
|
|
863
|
+
----------
|
|
864
|
+
filepath : Output path (e.g. "sessions/chat_001.json").
|
|
865
|
+
save_conversation : If True, embed the raw ``conversation`` list in
|
|
866
|
+
the file so it can be fully restored.
|
|
867
|
+
|
|
868
|
+
Example
|
|
869
|
+
-------
|
|
870
|
+
tree.save("my_session.json", save_conversation=True)
|
|
871
|
+
"""
|
|
872
|
+
self._generate_summaries_for_frozen_nodes()
|
|
873
|
+
data = self.to_dict()
|
|
874
|
+
if save_conversation:
|
|
875
|
+
data["conversation"] = self.conversation
|
|
876
|
+
if self._archived_nodes:
|
|
877
|
+
data["archived"] = [n.to_dict() for n in self._archived_nodes]
|
|
878
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
879
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
880
|
+
|
|
881
|
+
@classmethod
|
|
882
|
+
def load(cls, filepath: str, api_key: str = None, model: str = None, embed_fn: Optional[Callable[[str], List[float]]] = None) -> "CTree":
|
|
883
|
+
"""
|
|
884
|
+
Reconstruct a ``CTree`` from a previously saved JSON file.
|
|
885
|
+
|
|
886
|
+
Parameters
|
|
887
|
+
----------
|
|
888
|
+
filepath : Path to the JSON file produced by ``save()``.
|
|
889
|
+
api_key : LLM API key.
|
|
890
|
+
model : Override model ID (defaults to "gpt-4o-mini").
|
|
891
|
+
|
|
892
|
+
Returns
|
|
893
|
+
-------
|
|
894
|
+
CTree — fully hydrated tree with ``conversation``, all nodes, and
|
|
895
|
+
any archived messages restored.
|
|
896
|
+
|
|
897
|
+
Example
|
|
898
|
+
-------
|
|
899
|
+
tree = CTree.load("my_session.json", api_key="sk-...")
|
|
900
|
+
"""
|
|
901
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
902
|
+
data = json.load(f)
|
|
903
|
+
tree = cls(
|
|
904
|
+
max_children = data.get("max_children", 5),
|
|
905
|
+
api_key = api_key,
|
|
906
|
+
model = model or "gpt-4o-mini",
|
|
907
|
+
embed_fn = embed_fn,
|
|
908
|
+
)
|
|
909
|
+
tree.conversation = data.get("conversation", [])
|
|
910
|
+
tree.root = tree._reconstruct_node(data["tree"], parent=None)
|
|
911
|
+
tree.current_node = tree._find_current_node(tree.root)
|
|
912
|
+
for arch_data in data.get("archived", []):
|
|
913
|
+
try:
|
|
914
|
+
tree._archived_nodes.append(tree._reconstruct_node(arch_data, parent=None))
|
|
915
|
+
except Exception:
|
|
916
|
+
pass
|
|
917
|
+
return tree
|
|
918
|
+
|
|
919
|
+
def _reconstruct_node(self, node_data: dict, parent):
|
|
920
|
+
node_type = node_data.get("type", "topic")
|
|
921
|
+
if node_type == "message":
|
|
922
|
+
msg_start_idx = node_data.get("message_index", node_data.get("pair_index", 0) * 2)
|
|
923
|
+
user_msg = assistant_msg = {}
|
|
924
|
+
system_msg = None
|
|
925
|
+
for i in range(max(0, msg_start_idx - 1), min(len(self.conversation), msg_start_idx + 4)):
|
|
926
|
+
if i < len(self.conversation) and self.conversation[i].get("role") == "user":
|
|
927
|
+
if i > 0 and self.conversation[i - 1].get("role") == "system":
|
|
928
|
+
system_msg = self.conversation[i - 1]
|
|
929
|
+
user_msg = self.conversation[i]
|
|
930
|
+
if i + 1 < len(self.conversation) and self.conversation[i + 1].get("role") == "assistant":
|
|
931
|
+
assistant_msg = self.conversation[i + 1]
|
|
932
|
+
break
|
|
933
|
+
node = MessageNode(
|
|
934
|
+
user_message = user_msg,
|
|
935
|
+
assistant_message = assistant_msg,
|
|
936
|
+
system_message = system_msg,
|
|
937
|
+
message_index = msg_start_idx,
|
|
938
|
+
parent = parent,
|
|
939
|
+
)
|
|
940
|
+
else:
|
|
941
|
+
node = TopicNode(
|
|
942
|
+
topic_name = node_data.get("topic_name", ""),
|
|
943
|
+
summary = node_data.get("summary", ""),
|
|
944
|
+
start_index = node_data.get("start_index", 0),
|
|
945
|
+
end_index = node_data.get("end_index", 0),
|
|
946
|
+
parent = parent,
|
|
947
|
+
)
|
|
948
|
+
if "node_id" in node_data:
|
|
949
|
+
node.node_id = node_data["node_id"]
|
|
950
|
+
node.created_at = node_data.get("created_at", node.created_at)
|
|
951
|
+
for child_data in node_data.get("children", []):
|
|
952
|
+
node.children.append(self._reconstruct_node(child_data, parent=node))
|
|
953
|
+
node.sub_node_count = node_data.get("sub_node_count", 0)
|
|
954
|
+
return node
|
|
955
|
+
|
|
956
|
+
def _find_current_node(self, node):
|
|
957
|
+
if isinstance(node, MessageNode):
|
|
958
|
+
return node.parent if isinstance(node.parent, TopicNode) else self.root
|
|
959
|
+
if not isinstance(node, TopicNode):
|
|
960
|
+
return self.root
|
|
961
|
+
if node.children:
|
|
962
|
+
last = node.children[-1]
|
|
963
|
+
if isinstance(last, MessageNode):
|
|
964
|
+
return node
|
|
965
|
+
if isinstance(last, TopicNode):
|
|
966
|
+
return self._find_current_node(last)
|
|
967
|
+
return node
|
|
968
|
+
|
|
969
|
+
# ── Self-healing reorganizer ──────────────────────────────────────────────
|
|
970
|
+
|
|
971
|
+
def _build_flat_topic_list(self) -> list:
|
|
972
|
+
"""Post-order traversal: returns every non-root TopicNode."""
|
|
973
|
+
result = []
|
|
974
|
+
def _post(node):
|
|
975
|
+
for c in node.children:
|
|
976
|
+
_post(c)
|
|
977
|
+
if isinstance(node, TopicNode) and node is not self.root:
|
|
978
|
+
result.append(node)
|
|
979
|
+
_post(self.root)
|
|
980
|
+
return result
|
|
981
|
+
|
|
982
|
+
def _llm_check_relatedness(self, node_a, node_b) -> dict:
|
|
983
|
+
"""Ask the LLM whether two frozen branches are related enough to merge."""
|
|
984
|
+
hint = "a_into_b means B is older" if node_a.created_at <= node_b.created_at else "b_into_a means A is older"
|
|
985
|
+
prompt = (
|
|
986
|
+
f'Topic A: "{node_a.topic_name}" — {(node_a.summary or "(no summary)")[:200]}\n'
|
|
987
|
+
f'Topic B: "{node_b.topic_name}" — {(node_b.summary or "(no summary)")[:200]}\n\n'
|
|
988
|
+
f"Are these two topics related enough to be grouped under the same parent?\n"
|
|
989
|
+
f"The cosine similarity score already pre-filtered these — lean toward yes unless they are "
|
|
990
|
+
f"clearly different domains (e.g. cooking vs programming).\n"
|
|
991
|
+
f"If yes, merge_direction: \"a_into_b\" = A becomes child of B, \"b_into_a\" = B becomes child of A.\n"
|
|
992
|
+
f"({hint})\n\n"
|
|
993
|
+
f'Reply ONLY with JSON. Keep "reason" under 10 words:\n'
|
|
994
|
+
f'{{"related": true|false, "reason": "short reason", "merge_direction": "a_into_b"|"b_into_a"|"N/A"}}'
|
|
995
|
+
)
|
|
996
|
+
try:
|
|
997
|
+
response = ChatGPT_API(self.model, prompt, api_key=self.api_key, temperature=0.1, max_tokens=300)
|
|
998
|
+
result = extract_json(response)
|
|
999
|
+
result.setdefault("related", False)
|
|
1000
|
+
result.setdefault("merge_direction", "N/A")
|
|
1001
|
+
return result
|
|
1002
|
+
except Exception:
|
|
1003
|
+
return {"related": False, "reason": "LLM error", "merge_direction": "N/A"}
|
|
1004
|
+
|
|
1005
|
+
def _merge_node_into(self, source: "TopicNode", target: "TopicNode"):
|
|
1006
|
+
"""
|
|
1007
|
+
Move *source* to become a child of *target*.
|
|
1008
|
+
No MessageNodes are deleted. VDB entry is cleared for re-indexing.
|
|
1009
|
+
"""
|
|
1010
|
+
old_parent = source.parent
|
|
1011
|
+
if old_parent is None or old_parent is target:
|
|
1012
|
+
return
|
|
1013
|
+
try:
|
|
1014
|
+
old_parent.children.remove(source)
|
|
1015
|
+
except ValueError:
|
|
1016
|
+
return
|
|
1017
|
+
source.parent = target
|
|
1018
|
+
target.children.append(source)
|
|
1019
|
+
if self.vdb is not None:
|
|
1020
|
+
try:
|
|
1021
|
+
self.vdb.delete_topic_summary(source.node_id)
|
|
1022
|
+
except Exception:
|
|
1023
|
+
pass
|
|
1024
|
+
|
|
1025
|
+
def _is_ancestor_of(self, potential_ancestor, node) -> bool:
|
|
1026
|
+
current = node.parent
|
|
1027
|
+
while current is not None:
|
|
1028
|
+
if current is potential_ancestor:
|
|
1029
|
+
return True
|
|
1030
|
+
current = current.parent
|
|
1031
|
+
return False
|
|
1032
|
+
|
|
1033
|
+
def reorganize(
|
|
1034
|
+
self,
|
|
1035
|
+
embed_fn=None,
|
|
1036
|
+
similarity_threshold: float = 0.55,
|
|
1037
|
+
prune_trivial_leaves: bool = False,
|
|
1038
|
+
) -> dict:
|
|
1039
|
+
"""
|
|
1040
|
+
Run one conservative self-healing reorganization pass over the tree.
|
|
1041
|
+
|
|
1042
|
+
This is the core of TRACE's "Axiomatic Reorganization" feature.
|
|
1043
|
+
It evaluates all *frozen* (inactive) branches for semantic similarity
|
|
1044
|
+
and, when four strict axioms are satisfied, merges related branches
|
|
1045
|
+
under a common parent — mimicking how the human brain consolidates
|
|
1046
|
+
memories during sleep.
|
|
1047
|
+
|
|
1048
|
+
The four axioms
|
|
1049
|
+
---------------
|
|
1050
|
+
1. **Chronological Guard**: The older node absorbs the newer one —
|
|
1051
|
+
never the reverse.
|
|
1052
|
+
2. **Frozen State**: Only nodes outside the currently active ancestry
|
|
1053
|
+
path may be merged. The live conversation thread is never touched.
|
|
1054
|
+
3. **Sim Threshold**: Cosine similarity between branch embeddings must
|
|
1055
|
+
exceed *similarity_threshold* (default 0.55).
|
|
1056
|
+
4. **LLM Veto**: The LLM independently confirms the merge makes
|
|
1057
|
+
semantic sense. If it disagrees, the merge is aborted.
|
|
1058
|
+
|
|
1059
|
+
Parameters
|
|
1060
|
+
----------
|
|
1061
|
+
embed_fn : Optional embedding function to override
|
|
1062
|
+
``tree.embed_fn``. Must be callable:
|
|
1063
|
+
``(text: str) -> List[float]``.
|
|
1064
|
+
similarity_threshold : Minimum cosine similarity for a pair to be
|
|
1065
|
+
considered as merge candidates. (default 0.55)
|
|
1066
|
+
prune_trivial_leaves : If True, MessageNodes with < 20 words in both
|
|
1067
|
+
sides are soft-archived (moved to
|
|
1068
|
+
``tree._archived_nodes``). Default False.
|
|
1069
|
+
|
|
1070
|
+
Returns
|
|
1071
|
+
-------
|
|
1072
|
+
dict with keys:
|
|
1073
|
+
merged (int) — number of branches merged.
|
|
1074
|
+
pruned (int) — number of trivial messages archived.
|
|
1075
|
+
skipped (int) — pairs skipped due to axiom violations.
|
|
1076
|
+
duration_secs (float) — wall-clock time for the pass.
|
|
1077
|
+
|
|
1078
|
+
Example
|
|
1079
|
+
-------
|
|
1080
|
+
stats = tree.reorganize(
|
|
1081
|
+
embed_fn=my_embed,
|
|
1082
|
+
similarity_threshold=0.60,
|
|
1083
|
+
prune_trivial_leaves=True,
|
|
1084
|
+
)
|
|
1085
|
+
print(f"Merged {stats['merged']} branches in {stats['duration_secs']:.1f}s")
|
|
1086
|
+
"""
|
|
1087
|
+
import time as _time
|
|
1088
|
+
from .vector_db import cosine_similarity as _cos
|
|
1089
|
+
|
|
1090
|
+
t0 = _time.time()
|
|
1091
|
+
merged = pruned = skipped = 0
|
|
1092
|
+
|
|
1093
|
+
# Phase 1 — collect frozen candidates
|
|
1094
|
+
all_topics = self._build_flat_topic_list()
|
|
1095
|
+
live_ancestors = set(self.get_ancestors(self.current_node, include_self=True))
|
|
1096
|
+
candidates = [n for n in all_topics if n not in live_ancestors and self._is_frozen_node(n)]
|
|
1097
|
+
|
|
1098
|
+
if len(candidates) < 2:
|
|
1099
|
+
return {"merged": 0, "pruned": 0, "skipped": 0, "duration_secs": _time.time() - t0}
|
|
1100
|
+
|
|
1101
|
+
# Phase 2 — embed candidates
|
|
1102
|
+
self._generate_summaries_for_frozen_nodes()
|
|
1103
|
+
_embed = embed_fn or self.embed_fn
|
|
1104
|
+
embeddings = {}
|
|
1105
|
+
if _embed:
|
|
1106
|
+
for node in candidates:
|
|
1107
|
+
text = (node.summary or node.topic_name).strip()
|
|
1108
|
+
if not text:
|
|
1109
|
+
for child in node.children:
|
|
1110
|
+
if isinstance(child, MessageNode):
|
|
1111
|
+
u = (child.user_message.get("content") or "").strip()
|
|
1112
|
+
a = (child.assistant_message.get("content") or "").strip()
|
|
1113
|
+
raw = f"{u} {a}".strip()
|
|
1114
|
+
if raw:
|
|
1115
|
+
text = raw[:300]
|
|
1116
|
+
if not node.topic_name:
|
|
1117
|
+
node.topic_name = self._llm_generate_topic_from_message(
|
|
1118
|
+
child.user_message, child.assistant_message
|
|
1119
|
+
)
|
|
1120
|
+
break
|
|
1121
|
+
if not text:
|
|
1122
|
+
continue
|
|
1123
|
+
try:
|
|
1124
|
+
embeddings[node.node_id] = _embed(text)
|
|
1125
|
+
except Exception as e:
|
|
1126
|
+
print(f" ⚠ Embed error for [{node.topic_name}]: {e}")
|
|
1127
|
+
|
|
1128
|
+
# Phase 3 — find high-similarity pairs and apply axioms
|
|
1129
|
+
candidate_pairs = []
|
|
1130
|
+
for i in range(len(candidates)):
|
|
1131
|
+
for j in range(i + 1, len(candidates)):
|
|
1132
|
+
na, nb = candidates[i], candidates[j]
|
|
1133
|
+
if na.node_id not in embeddings or nb.node_id not in embeddings:
|
|
1134
|
+
skipped += 1
|
|
1135
|
+
continue
|
|
1136
|
+
sim = _cos(embeddings[na.node_id], embeddings[nb.node_id])
|
|
1137
|
+
if sim < similarity_threshold:
|
|
1138
|
+
continue
|
|
1139
|
+
older, newer = (na, nb) if na.created_at <= nb.created_at else (nb, na)
|
|
1140
|
+
if self._is_ancestor_of(older, newer) or self._is_ancestor_of(newer, older):
|
|
1141
|
+
skipped += 1
|
|
1142
|
+
continue
|
|
1143
|
+
candidate_pairs.append((sim, older, newer))
|
|
1144
|
+
|
|
1145
|
+
candidate_pairs.sort(key=lambda x: x[0], reverse=True)
|
|
1146
|
+
|
|
1147
|
+
already_moved = set()
|
|
1148
|
+
for sim, older, newer in candidate_pairs:
|
|
1149
|
+
if older.node_id in already_moved or newer.node_id in already_moved:
|
|
1150
|
+
skipped += 1
|
|
1151
|
+
continue
|
|
1152
|
+
if not self._is_frozen_node(older) or not self._is_frozen_node(newer):
|
|
1153
|
+
skipped += 1
|
|
1154
|
+
continue
|
|
1155
|
+
decision = self._llm_check_relatedness(older, newer)
|
|
1156
|
+
if not decision.get("related", False):
|
|
1157
|
+
skipped += 1
|
|
1158
|
+
continue
|
|
1159
|
+
self._merge_node_into(source=newer, target=older)
|
|
1160
|
+
already_moved.add(newer.node_id)
|
|
1161
|
+
merged += 1
|
|
1162
|
+
|
|
1163
|
+
# Phase 4 — optional leaf pruning
|
|
1164
|
+
if prune_trivial_leaves:
|
|
1165
|
+
for node in self._build_flat_topic_list():
|
|
1166
|
+
if node not in live_ancestors and self._is_frozen_node(node):
|
|
1167
|
+
msg_children = [c for c in node.children if isinstance(c, MessageNode)]
|
|
1168
|
+
topic_children = [c for c in node.children if isinstance(c, TopicNode)]
|
|
1169
|
+
if topic_children:
|
|
1170
|
+
continue
|
|
1171
|
+
parent = node.parent
|
|
1172
|
+
if parent is None or not (parent.summary and parent.summary.strip()):
|
|
1173
|
+
continue
|
|
1174
|
+
for mc in msg_children:
|
|
1175
|
+
u_words = len((mc.user_message.get("content", "") or "").split())
|
|
1176
|
+
a_words = len((mc.assistant_message.get("content", "") or "").split())
|
|
1177
|
+
if u_words < 20 and a_words < 20:
|
|
1178
|
+
self._archived_nodes.append(mc)
|
|
1179
|
+
node.children.remove(mc)
|
|
1180
|
+
pruned += 1
|
|
1181
|
+
|
|
1182
|
+
return {
|
|
1183
|
+
"merged": merged,
|
|
1184
|
+
"pruned": pruned,
|
|
1185
|
+
"skipped": skipped,
|
|
1186
|
+
"duration_secs": _time.time() - t0,
|
|
1187
|
+
}
|