htmlgraph 0.27.1__py3-none-any.whl → 0.27.3__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.
- htmlgraph/__init__.py +1 -1
- htmlgraph/analytics/session_graph.py +707 -0
- htmlgraph/api/main.py +8 -8
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/query_composer.py +509 -0
- {htmlgraph-0.27.1.dist-info → htmlgraph-0.27.3.dist-info}/METADATA +2 -2
- {htmlgraph-0.27.1.dist-info → htmlgraph-0.27.3.dist-info}/RECORD +16 -11
- {htmlgraph-0.27.1.data → htmlgraph-0.27.3.data}/data/htmlgraph/dashboard.html +0 -0
- {htmlgraph-0.27.1.data → htmlgraph-0.27.3.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.27.1.data → htmlgraph-0.27.3.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.27.1.data → htmlgraph-0.27.3.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.27.1.data → htmlgraph-0.27.3.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.27.1.dist-info → htmlgraph-0.27.3.dist-info}/WHEEL +0 -0
- {htmlgraph-0.27.1.dist-info → htmlgraph-0.27.3.dist-info}/entry_points.txt +0 -0
htmlgraph/__init__.py
CHANGED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cross-Session Graph Queries - Session Relationship Index.
|
|
3
|
+
|
|
4
|
+
Provides indexed graph queries over the SQLite store for efficient
|
|
5
|
+
cross-session analytics. Replaces expensive linear scans of event logs
|
|
6
|
+
and git commands with O(log n) indexed lookups and recursive CTEs.
|
|
7
|
+
|
|
8
|
+
Key capabilities:
|
|
9
|
+
- sessions_for_feature: Find all sessions that touched a feature via index
|
|
10
|
+
- features_for_session: Find all features a session worked on
|
|
11
|
+
- delegation_chain: Follow parent_session_id links recursively
|
|
12
|
+
- handoff_path: Find path between sessions via handoffs
|
|
13
|
+
- feature_timeline: Chronological timeline of all work on a feature
|
|
14
|
+
- related_sessions: Find sessions related through shared features or delegation
|
|
15
|
+
|
|
16
|
+
Design:
|
|
17
|
+
- Uses SQLite indexes for O(log n) lookups instead of O(n) scans
|
|
18
|
+
- Recursive CTEs for delegation chain traversal
|
|
19
|
+
- Zero external dependencies (SQLite only)
|
|
20
|
+
- Works with existing HtmlGraphDB schema
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
from htmlgraph.db.schema import HtmlGraphDB
|
|
24
|
+
from htmlgraph.analytics.session_graph import SessionGraph
|
|
25
|
+
|
|
26
|
+
db = HtmlGraphDB(db_path="path/to/htmlgraph.db")
|
|
27
|
+
graph = SessionGraph(db)
|
|
28
|
+
|
|
29
|
+
# Find all sessions that worked on a feature
|
|
30
|
+
sessions = graph.sessions_for_feature("feat-abc123")
|
|
31
|
+
|
|
32
|
+
# Follow delegation chain
|
|
33
|
+
chain = graph.delegation_chain("session-xyz")
|
|
34
|
+
|
|
35
|
+
# Build feature timeline
|
|
36
|
+
timeline = graph.feature_timeline("feat-abc123")
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import logging
|
|
42
|
+
import sqlite3
|
|
43
|
+
from collections import deque
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
from datetime import datetime
|
|
46
|
+
from typing import TYPE_CHECKING
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from htmlgraph.db.schema import HtmlGraphDB
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class SessionNode:
|
|
56
|
+
"""A node in the session graph representing a single session."""
|
|
57
|
+
|
|
58
|
+
session_id: str
|
|
59
|
+
agent: str
|
|
60
|
+
status: str
|
|
61
|
+
created_at: datetime
|
|
62
|
+
features_worked_on: list[str] = field(default_factory=list)
|
|
63
|
+
parent_session_id: str | None = None
|
|
64
|
+
depth: int = 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class FeatureEvent:
|
|
69
|
+
"""A single event related to a feature across any session."""
|
|
70
|
+
|
|
71
|
+
session_id: str
|
|
72
|
+
agent: str
|
|
73
|
+
timestamp: datetime
|
|
74
|
+
event_type: str
|
|
75
|
+
tool_name: str | None = None
|
|
76
|
+
summary: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SessionGraph:
|
|
80
|
+
"""
|
|
81
|
+
Property-graph view over SQLite tables for cross-session queries.
|
|
82
|
+
|
|
83
|
+
Provides indexed lookups and recursive traversals over the session,
|
|
84
|
+
event, and handoff tables in the HtmlGraph SQLite database.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, db: HtmlGraphDB) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Initialize SessionGraph with database reference.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
db: HtmlGraphDB instance with active connection
|
|
93
|
+
"""
|
|
94
|
+
self.db = db
|
|
95
|
+
|
|
96
|
+
def ensure_indexes(self) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Create optimized indexes for cross-session graph queries.
|
|
99
|
+
|
|
100
|
+
These indexes supplement the existing schema indexes with
|
|
101
|
+
composite indexes specifically designed for graph traversal
|
|
102
|
+
patterns. Safe to call multiple times (idempotent).
|
|
103
|
+
"""
|
|
104
|
+
if not self.db.connection:
|
|
105
|
+
self.db.connect()
|
|
106
|
+
|
|
107
|
+
cursor = self.db.connection.cursor() # type: ignore[union-attr]
|
|
108
|
+
|
|
109
|
+
indexes = [
|
|
110
|
+
# Feature -> Session mapping (sessions_for_feature)
|
|
111
|
+
"CREATE INDEX IF NOT EXISTS idx_events_feature_session "
|
|
112
|
+
"ON agent_events(feature_id, session_id)",
|
|
113
|
+
# Session -> Feature mapping (features_for_session)
|
|
114
|
+
"CREATE INDEX IF NOT EXISTS idx_events_session_feature "
|
|
115
|
+
"ON agent_events(session_id, feature_id)",
|
|
116
|
+
# Delegation chain traversal
|
|
117
|
+
"CREATE INDEX IF NOT EXISTS idx_sessions_parent "
|
|
118
|
+
"ON sessions(parent_session_id)",
|
|
119
|
+
# Continuation chain traversal
|
|
120
|
+
"CREATE INDEX IF NOT EXISTS idx_sessions_continued "
|
|
121
|
+
"ON sessions(continued_from)",
|
|
122
|
+
# Handoff from-session lookups
|
|
123
|
+
"CREATE INDEX IF NOT EXISTS idx_handoff_from "
|
|
124
|
+
"ON handoff_tracking(from_session_id)",
|
|
125
|
+
# Handoff to-session lookups
|
|
126
|
+
"CREATE INDEX IF NOT EXISTS idx_handoff_to "
|
|
127
|
+
"ON handoff_tracking(to_session_id)",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
for index_sql in indexes:
|
|
131
|
+
try:
|
|
132
|
+
cursor.execute(index_sql)
|
|
133
|
+
except sqlite3.OperationalError as e:
|
|
134
|
+
logger.warning(f"Index creation warning: {e}")
|
|
135
|
+
|
|
136
|
+
self.db.connection.commit() # type: ignore[union-attr]
|
|
137
|
+
|
|
138
|
+
def sessions_for_feature(self, feature_id: str) -> list[SessionNode]:
|
|
139
|
+
"""
|
|
140
|
+
Find all sessions that touched a feature - O(log n) via index.
|
|
141
|
+
|
|
142
|
+
Uses the idx_events_feature_session index for fast lookup
|
|
143
|
+
instead of scanning all events linearly.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
feature_id: Feature ID to query
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
List of SessionNode objects for sessions that worked on this feature
|
|
150
|
+
"""
|
|
151
|
+
if not self.db.connection:
|
|
152
|
+
self.db.connect()
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
cursor = self.db.connection.cursor() # type: ignore[union-attr]
|
|
156
|
+
cursor.execute(
|
|
157
|
+
"""
|
|
158
|
+
SELECT DISTINCT
|
|
159
|
+
s.session_id,
|
|
160
|
+
s.agent_assigned,
|
|
161
|
+
s.status,
|
|
162
|
+
s.created_at,
|
|
163
|
+
s.parent_session_id,
|
|
164
|
+
s.features_worked_on
|
|
165
|
+
FROM agent_events ae
|
|
166
|
+
JOIN sessions s ON ae.session_id = s.session_id
|
|
167
|
+
WHERE ae.feature_id = ?
|
|
168
|
+
ORDER BY s.created_at ASC
|
|
169
|
+
""",
|
|
170
|
+
(feature_id,),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
nodes = []
|
|
174
|
+
for row in cursor.fetchall():
|
|
175
|
+
row_dict = dict(row)
|
|
176
|
+
features = self._parse_features_list(row_dict.get("features_worked_on"))
|
|
177
|
+
if feature_id not in features:
|
|
178
|
+
features.append(feature_id)
|
|
179
|
+
|
|
180
|
+
nodes.append(
|
|
181
|
+
SessionNode(
|
|
182
|
+
session_id=row_dict["session_id"],
|
|
183
|
+
agent=row_dict["agent_assigned"],
|
|
184
|
+
status=row_dict["status"],
|
|
185
|
+
created_at=self._parse_datetime(row_dict["created_at"]),
|
|
186
|
+
features_worked_on=features,
|
|
187
|
+
parent_session_id=row_dict.get("parent_session_id"),
|
|
188
|
+
depth=0,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return nodes
|
|
193
|
+
|
|
194
|
+
except sqlite3.Error as e:
|
|
195
|
+
logger.error(f"Error querying sessions for feature: {e}")
|
|
196
|
+
return []
|
|
197
|
+
|
|
198
|
+
def features_for_session(self, session_id: str) -> list[str]:
|
|
199
|
+
"""
|
|
200
|
+
Find all features a session worked on.
|
|
201
|
+
|
|
202
|
+
Uses the idx_events_session_feature index for fast lookup.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
session_id: Session ID to query
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Sorted list of feature IDs
|
|
209
|
+
"""
|
|
210
|
+
if not self.db.connection:
|
|
211
|
+
self.db.connect()
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
cursor = self.db.connection.cursor() # type: ignore[union-attr]
|
|
215
|
+
cursor.execute(
|
|
216
|
+
"""
|
|
217
|
+
SELECT DISTINCT feature_id
|
|
218
|
+
FROM agent_events
|
|
219
|
+
WHERE session_id = ?
|
|
220
|
+
AND feature_id IS NOT NULL
|
|
221
|
+
ORDER BY feature_id
|
|
222
|
+
""",
|
|
223
|
+
(session_id,),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return [row["feature_id"] for row in cursor.fetchall()]
|
|
227
|
+
|
|
228
|
+
except sqlite3.Error as e:
|
|
229
|
+
logger.error(f"Error querying features for session: {e}")
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
def delegation_chain(
|
|
233
|
+
self, session_id: str, max_depth: int = 10
|
|
234
|
+
) -> list[SessionNode]:
|
|
235
|
+
"""
|
|
236
|
+
Follow parent_session_id links to build delegation chain.
|
|
237
|
+
|
|
238
|
+
Uses a recursive CTE to efficiently traverse the delegation
|
|
239
|
+
tree upward from the given session to its root ancestor.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
session_id: Starting session ID
|
|
243
|
+
max_depth: Maximum depth to traverse (default 10)
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
List of SessionNode objects from the starting session
|
|
247
|
+
up to the root ancestor, ordered by depth (0 = starting session)
|
|
248
|
+
"""
|
|
249
|
+
if not self.db.connection:
|
|
250
|
+
self.db.connect()
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
cursor = self.db.connection.cursor() # type: ignore[union-attr]
|
|
254
|
+
cursor.execute(
|
|
255
|
+
"""
|
|
256
|
+
WITH RECURSIVE chain AS (
|
|
257
|
+
SELECT session_id, parent_session_id, agent_assigned,
|
|
258
|
+
status, created_at, features_worked_on, 0 as depth
|
|
259
|
+
FROM sessions
|
|
260
|
+
WHERE session_id = ?
|
|
261
|
+
|
|
262
|
+
UNION ALL
|
|
263
|
+
|
|
264
|
+
SELECT s.session_id, s.parent_session_id, s.agent_assigned,
|
|
265
|
+
s.status, s.created_at, s.features_worked_on, c.depth + 1
|
|
266
|
+
FROM sessions s
|
|
267
|
+
JOIN chain c ON s.session_id = c.parent_session_id
|
|
268
|
+
WHERE c.depth < ?
|
|
269
|
+
)
|
|
270
|
+
SELECT * FROM chain
|
|
271
|
+
ORDER BY depth ASC
|
|
272
|
+
""",
|
|
273
|
+
(session_id, max_depth),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
nodes = []
|
|
277
|
+
for row in cursor.fetchall():
|
|
278
|
+
row_dict = dict(row)
|
|
279
|
+
nodes.append(
|
|
280
|
+
SessionNode(
|
|
281
|
+
session_id=row_dict["session_id"],
|
|
282
|
+
agent=row_dict["agent_assigned"],
|
|
283
|
+
status=row_dict["status"],
|
|
284
|
+
created_at=self._parse_datetime(row_dict["created_at"]),
|
|
285
|
+
features_worked_on=self._parse_features_list(
|
|
286
|
+
row_dict.get("features_worked_on")
|
|
287
|
+
),
|
|
288
|
+
parent_session_id=row_dict.get("parent_session_id"),
|
|
289
|
+
depth=row_dict["depth"],
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return nodes
|
|
294
|
+
|
|
295
|
+
except sqlite3.Error as e:
|
|
296
|
+
logger.error(f"Error querying delegation chain: {e}")
|
|
297
|
+
return []
|
|
298
|
+
|
|
299
|
+
def handoff_path(
|
|
300
|
+
self, from_session: str, to_session: str, max_depth: int = 10
|
|
301
|
+
) -> list[SessionNode] | None:
|
|
302
|
+
"""
|
|
303
|
+
Find the path from one session to another via handoffs.
|
|
304
|
+
|
|
305
|
+
Performs a BFS over the handoff_tracking table to find the
|
|
306
|
+
shortest path between two sessions. Follows both from_session_id
|
|
307
|
+
and to_session_id links bidirectionally.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
from_session: Starting session ID
|
|
311
|
+
to_session: Target session ID
|
|
312
|
+
max_depth: Maximum search depth (default 10)
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
List of SessionNode objects forming the path, or None if no path exists
|
|
316
|
+
"""
|
|
317
|
+
if from_session == to_session:
|
|
318
|
+
node = self._get_session_node(from_session, depth=0)
|
|
319
|
+
return [node] if node else None
|
|
320
|
+
|
|
321
|
+
if not self.db.connection:
|
|
322
|
+
self.db.connect()
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
# BFS to find path through handoffs
|
|
326
|
+
visited: set[str] = set()
|
|
327
|
+
# Queue of (session_id, path_so_far)
|
|
328
|
+
queue: deque[tuple[str, list[str]]] = deque()
|
|
329
|
+
queue.append((from_session, [from_session]))
|
|
330
|
+
visited.add(from_session)
|
|
331
|
+
|
|
332
|
+
cursor = self.db.connection.cursor() # type: ignore[union-attr]
|
|
333
|
+
|
|
334
|
+
while queue:
|
|
335
|
+
current_id, path = queue.popleft()
|
|
336
|
+
|
|
337
|
+
if len(path) > max_depth + 1:
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
# Find sessions reachable via handoffs from current
|
|
341
|
+
cursor.execute(
|
|
342
|
+
"""
|
|
343
|
+
SELECT to_session_id FROM handoff_tracking
|
|
344
|
+
WHERE from_session_id = ? AND to_session_id IS NOT NULL
|
|
345
|
+
UNION
|
|
346
|
+
SELECT from_session_id FROM handoff_tracking
|
|
347
|
+
WHERE to_session_id = ?
|
|
348
|
+
""",
|
|
349
|
+
(current_id, current_id),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
for row in cursor.fetchall():
|
|
353
|
+
neighbor_id = row[0]
|
|
354
|
+
if neighbor_id in visited:
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
new_path = path + [neighbor_id]
|
|
358
|
+
|
|
359
|
+
if neighbor_id == to_session:
|
|
360
|
+
# Found the target - build SessionNode list
|
|
361
|
+
return self._build_path_nodes(new_path)
|
|
362
|
+
|
|
363
|
+
visited.add(neighbor_id)
|
|
364
|
+
queue.append((neighbor_id, new_path))
|
|
365
|
+
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
except sqlite3.Error as e:
|
|
369
|
+
logger.error(f"Error finding handoff path: {e}")
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
def feature_timeline(self, feature_id: str) -> list[FeatureEvent]:
|
|
373
|
+
"""
|
|
374
|
+
Build chronological timeline of all work on a feature across sessions.
|
|
375
|
+
|
|
376
|
+
Queries agent_events for all events associated with the given
|
|
377
|
+
feature, ordered chronologically.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
feature_id: Feature ID to query
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
List of FeatureEvent objects in chronological order
|
|
384
|
+
"""
|
|
385
|
+
if not self.db.connection:
|
|
386
|
+
self.db.connect()
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
cursor = self.db.connection.cursor() # type: ignore[union-attr]
|
|
390
|
+
cursor.execute(
|
|
391
|
+
"""
|
|
392
|
+
SELECT
|
|
393
|
+
ae.session_id,
|
|
394
|
+
s.agent_assigned,
|
|
395
|
+
ae.timestamp,
|
|
396
|
+
ae.event_type,
|
|
397
|
+
ae.tool_name,
|
|
398
|
+
ae.input_summary
|
|
399
|
+
FROM agent_events ae
|
|
400
|
+
JOIN sessions s ON ae.session_id = s.session_id
|
|
401
|
+
WHERE ae.feature_id = ?
|
|
402
|
+
ORDER BY ae.timestamp ASC
|
|
403
|
+
""",
|
|
404
|
+
(feature_id,),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
events = []
|
|
408
|
+
for row in cursor.fetchall():
|
|
409
|
+
row_dict = dict(row)
|
|
410
|
+
events.append(
|
|
411
|
+
FeatureEvent(
|
|
412
|
+
session_id=row_dict["session_id"],
|
|
413
|
+
agent=row_dict["agent_assigned"],
|
|
414
|
+
timestamp=self._parse_datetime(row_dict["timestamp"]),
|
|
415
|
+
event_type=row_dict["event_type"],
|
|
416
|
+
tool_name=row_dict.get("tool_name"),
|
|
417
|
+
summary=row_dict.get("input_summary"),
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
return events
|
|
422
|
+
|
|
423
|
+
except sqlite3.Error as e:
|
|
424
|
+
logger.error(f"Error querying feature timeline: {e}")
|
|
425
|
+
return []
|
|
426
|
+
|
|
427
|
+
def related_sessions(
|
|
428
|
+
self, session_id: str, max_depth: int = 3
|
|
429
|
+
) -> list[SessionNode]:
|
|
430
|
+
"""
|
|
431
|
+
Find sessions related through shared features or delegation.
|
|
432
|
+
|
|
433
|
+
Performs a BFS starting from the given session, expanding via:
|
|
434
|
+
1. Shared features (sessions that worked on the same features)
|
|
435
|
+
2. Delegation links (parent_session_id chains)
|
|
436
|
+
3. Continuation links (continued_from chains)
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
session_id: Starting session ID
|
|
440
|
+
max_depth: Maximum traversal depth (default 3)
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
List of related SessionNode objects (excluding the starting session),
|
|
444
|
+
ordered by depth then created_at
|
|
445
|
+
"""
|
|
446
|
+
if not self.db.connection:
|
|
447
|
+
self.db.connect()
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
visited: set[str] = {session_id}
|
|
451
|
+
result: list[SessionNode] = []
|
|
452
|
+
current_layer: set[str] = {session_id}
|
|
453
|
+
|
|
454
|
+
cursor = self.db.connection.cursor() # type: ignore[union-attr]
|
|
455
|
+
|
|
456
|
+
for depth in range(1, max_depth + 1):
|
|
457
|
+
next_layer: set[str] = set()
|
|
458
|
+
|
|
459
|
+
for current_id in current_layer:
|
|
460
|
+
# 1. Sessions sharing features
|
|
461
|
+
neighbors = self._find_feature_neighbors(cursor, current_id)
|
|
462
|
+
next_layer.update(neighbors - visited)
|
|
463
|
+
|
|
464
|
+
# 2. Delegation links (parent and children)
|
|
465
|
+
neighbors = self._find_delegation_neighbors(cursor, current_id)
|
|
466
|
+
next_layer.update(neighbors - visited)
|
|
467
|
+
|
|
468
|
+
# 3. Continuation links
|
|
469
|
+
neighbors = self._find_continuation_neighbors(cursor, current_id)
|
|
470
|
+
next_layer.update(neighbors - visited)
|
|
471
|
+
|
|
472
|
+
# Build SessionNodes for the new layer
|
|
473
|
+
for neighbor_id in next_layer:
|
|
474
|
+
node = self._get_session_node(neighbor_id, depth=depth)
|
|
475
|
+
if node:
|
|
476
|
+
result.append(node)
|
|
477
|
+
|
|
478
|
+
visited.update(next_layer)
|
|
479
|
+
current_layer = next_layer
|
|
480
|
+
|
|
481
|
+
if not next_layer:
|
|
482
|
+
break
|
|
483
|
+
|
|
484
|
+
# Sort by depth then created_at
|
|
485
|
+
result.sort(key=lambda n: (n.depth, n.created_at))
|
|
486
|
+
return result
|
|
487
|
+
|
|
488
|
+
except sqlite3.Error as e:
|
|
489
|
+
logger.error(f"Error querying related sessions: {e}")
|
|
490
|
+
return []
|
|
491
|
+
|
|
492
|
+
# === Private helper methods ===
|
|
493
|
+
|
|
494
|
+
def _get_session_node(self, session_id: str, depth: int = 0) -> SessionNode | None:
|
|
495
|
+
"""
|
|
496
|
+
Load a single session as a SessionNode.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
session_id: Session ID to load
|
|
500
|
+
depth: Depth value to assign
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
SessionNode or None if not found
|
|
504
|
+
"""
|
|
505
|
+
if not self.db.connection:
|
|
506
|
+
self.db.connect()
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
cursor = self.db.connection.cursor() # type: ignore[union-attr]
|
|
510
|
+
cursor.execute(
|
|
511
|
+
"""
|
|
512
|
+
SELECT session_id, agent_assigned, status, created_at,
|
|
513
|
+
parent_session_id, features_worked_on
|
|
514
|
+
FROM sessions
|
|
515
|
+
WHERE session_id = ?
|
|
516
|
+
""",
|
|
517
|
+
(session_id,),
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
row = cursor.fetchone()
|
|
521
|
+
if not row:
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
row_dict = dict(row)
|
|
525
|
+
features = self._parse_features_list(row_dict.get("features_worked_on"))
|
|
526
|
+
|
|
527
|
+
# Also get features from events
|
|
528
|
+
event_features = self.features_for_session(session_id)
|
|
529
|
+
for f in event_features:
|
|
530
|
+
if f not in features:
|
|
531
|
+
features.append(f)
|
|
532
|
+
|
|
533
|
+
return SessionNode(
|
|
534
|
+
session_id=row_dict["session_id"],
|
|
535
|
+
agent=row_dict["agent_assigned"],
|
|
536
|
+
status=row_dict["status"],
|
|
537
|
+
created_at=self._parse_datetime(row_dict["created_at"]),
|
|
538
|
+
features_worked_on=features,
|
|
539
|
+
parent_session_id=row_dict.get("parent_session_id"),
|
|
540
|
+
depth=depth,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
except sqlite3.Error as e:
|
|
544
|
+
logger.error(f"Error loading session node: {e}")
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
def _build_path_nodes(self, session_ids: list[str]) -> list[SessionNode]:
|
|
548
|
+
"""
|
|
549
|
+
Build a list of SessionNodes from a list of session IDs.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
session_ids: Ordered list of session IDs forming a path
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
List of SessionNode objects with depth set to position in path
|
|
556
|
+
"""
|
|
557
|
+
nodes = []
|
|
558
|
+
for i, sid in enumerate(session_ids):
|
|
559
|
+
node = self._get_session_node(sid, depth=i)
|
|
560
|
+
if node:
|
|
561
|
+
nodes.append(node)
|
|
562
|
+
return nodes
|
|
563
|
+
|
|
564
|
+
def _find_feature_neighbors(
|
|
565
|
+
self, cursor: sqlite3.Cursor, session_id: str
|
|
566
|
+
) -> set[str]:
|
|
567
|
+
"""
|
|
568
|
+
Find sessions that share features with the given session.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
cursor: SQLite cursor
|
|
572
|
+
session_id: Session to find neighbors for
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Set of neighbor session IDs
|
|
576
|
+
"""
|
|
577
|
+
cursor.execute(
|
|
578
|
+
"""
|
|
579
|
+
SELECT DISTINCT ae2.session_id
|
|
580
|
+
FROM agent_events ae1
|
|
581
|
+
JOIN agent_events ae2 ON ae1.feature_id = ae2.feature_id
|
|
582
|
+
WHERE ae1.session_id = ?
|
|
583
|
+
AND ae2.session_id != ?
|
|
584
|
+
AND ae1.feature_id IS NOT NULL
|
|
585
|
+
""",
|
|
586
|
+
(session_id, session_id),
|
|
587
|
+
)
|
|
588
|
+
return {row[0] for row in cursor.fetchall()}
|
|
589
|
+
|
|
590
|
+
def _find_delegation_neighbors(
|
|
591
|
+
self, cursor: sqlite3.Cursor, session_id: str
|
|
592
|
+
) -> set[str]:
|
|
593
|
+
"""
|
|
594
|
+
Find sessions linked via delegation (parent/child).
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
cursor: SQLite cursor
|
|
598
|
+
session_id: Session to find neighbors for
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
Set of neighbor session IDs
|
|
602
|
+
"""
|
|
603
|
+
neighbors: set[str] = set()
|
|
604
|
+
|
|
605
|
+
# Parent session
|
|
606
|
+
cursor.execute(
|
|
607
|
+
"SELECT parent_session_id FROM sessions WHERE session_id = ?",
|
|
608
|
+
(session_id,),
|
|
609
|
+
)
|
|
610
|
+
row = cursor.fetchone()
|
|
611
|
+
if row and row[0]:
|
|
612
|
+
neighbors.add(row[0])
|
|
613
|
+
|
|
614
|
+
# Child sessions
|
|
615
|
+
cursor.execute(
|
|
616
|
+
"SELECT session_id FROM sessions WHERE parent_session_id = ?",
|
|
617
|
+
(session_id,),
|
|
618
|
+
)
|
|
619
|
+
for row in cursor.fetchall():
|
|
620
|
+
neighbors.add(row[0])
|
|
621
|
+
|
|
622
|
+
return neighbors
|
|
623
|
+
|
|
624
|
+
def _find_continuation_neighbors(
|
|
625
|
+
self, cursor: sqlite3.Cursor, session_id: str
|
|
626
|
+
) -> set[str]:
|
|
627
|
+
"""
|
|
628
|
+
Find sessions linked via continuation (continued_from).
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
cursor: SQLite cursor
|
|
632
|
+
session_id: Session to find neighbors for
|
|
633
|
+
|
|
634
|
+
Returns:
|
|
635
|
+
Set of neighbor session IDs
|
|
636
|
+
"""
|
|
637
|
+
neighbors: set[str] = set()
|
|
638
|
+
|
|
639
|
+
# Session this one continued from
|
|
640
|
+
cursor.execute(
|
|
641
|
+
"SELECT continued_from FROM sessions WHERE session_id = ?",
|
|
642
|
+
(session_id,),
|
|
643
|
+
)
|
|
644
|
+
row = cursor.fetchone()
|
|
645
|
+
if row and row[0]:
|
|
646
|
+
neighbors.add(row[0])
|
|
647
|
+
|
|
648
|
+
# Sessions that continued from this one
|
|
649
|
+
cursor.execute(
|
|
650
|
+
"SELECT session_id FROM sessions WHERE continued_from = ?",
|
|
651
|
+
(session_id,),
|
|
652
|
+
)
|
|
653
|
+
for row in cursor.fetchall():
|
|
654
|
+
neighbors.add(row[0])
|
|
655
|
+
|
|
656
|
+
return neighbors
|
|
657
|
+
|
|
658
|
+
@staticmethod
|
|
659
|
+
def _parse_features_list(value: str | list[str] | None) -> list[str]:
|
|
660
|
+
"""
|
|
661
|
+
Parse features_worked_on field which may be JSON string or list.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
value: Raw value from database (JSON string, list, or None)
|
|
665
|
+
|
|
666
|
+
Returns:
|
|
667
|
+
List of feature ID strings
|
|
668
|
+
"""
|
|
669
|
+
if value is None:
|
|
670
|
+
return []
|
|
671
|
+
|
|
672
|
+
if isinstance(value, list):
|
|
673
|
+
return [str(item) for item in value]
|
|
674
|
+
|
|
675
|
+
if isinstance(value, str):
|
|
676
|
+
import json
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
parsed = json.loads(value)
|
|
680
|
+
if isinstance(parsed, list):
|
|
681
|
+
return [str(item) for item in parsed]
|
|
682
|
+
except (json.JSONDecodeError, TypeError):
|
|
683
|
+
pass
|
|
684
|
+
|
|
685
|
+
return []
|
|
686
|
+
|
|
687
|
+
@staticmethod
|
|
688
|
+
def _parse_datetime(value: str | datetime | None) -> datetime:
|
|
689
|
+
"""
|
|
690
|
+
Parse datetime from various formats.
|
|
691
|
+
|
|
692
|
+
Args:
|
|
693
|
+
value: Datetime string, datetime object, or None
|
|
694
|
+
|
|
695
|
+
Returns:
|
|
696
|
+
Parsed datetime (defaults to datetime.min if unparseable)
|
|
697
|
+
"""
|
|
698
|
+
if isinstance(value, datetime):
|
|
699
|
+
return value
|
|
700
|
+
|
|
701
|
+
if isinstance(value, str):
|
|
702
|
+
try:
|
|
703
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
704
|
+
except (ValueError, AttributeError):
|
|
705
|
+
pass
|
|
706
|
+
|
|
707
|
+
return datetime.min
|