brainlayer 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.
- brainlayer/__init__.py +3 -0
- brainlayer/cli/__init__.py +1545 -0
- brainlayer/cli/wizard.py +132 -0
- brainlayer/cli_new.py +151 -0
- brainlayer/client.py +164 -0
- brainlayer/clustering.py +736 -0
- brainlayer/daemon.py +1105 -0
- brainlayer/dashboard/README.md +129 -0
- brainlayer/dashboard/__init__.py +5 -0
- brainlayer/dashboard/app.py +151 -0
- brainlayer/dashboard/search.py +229 -0
- brainlayer/dashboard/views.py +230 -0
- brainlayer/embeddings.py +131 -0
- brainlayer/engine.py +550 -0
- brainlayer/index_new.py +87 -0
- brainlayer/mcp/__init__.py +1558 -0
- brainlayer/migrate.py +205 -0
- brainlayer/paths.py +43 -0
- brainlayer/pipeline/__init__.py +47 -0
- brainlayer/pipeline/analyze_communication.py +508 -0
- brainlayer/pipeline/brain_graph.py +567 -0
- brainlayer/pipeline/chat_tags.py +63 -0
- brainlayer/pipeline/chunk.py +422 -0
- brainlayer/pipeline/classify.py +472 -0
- brainlayer/pipeline/cluster_sampling.py +73 -0
- brainlayer/pipeline/enrichment.py +810 -0
- brainlayer/pipeline/extract.py +66 -0
- brainlayer/pipeline/extract_claude_desktop.py +149 -0
- brainlayer/pipeline/extract_corrections.py +231 -0
- brainlayer/pipeline/extract_markdown.py +195 -0
- brainlayer/pipeline/extract_whatsapp.py +227 -0
- brainlayer/pipeline/git_overlay.py +301 -0
- brainlayer/pipeline/longitudinal_analyzer.py +568 -0
- brainlayer/pipeline/obsidian_export.py +455 -0
- brainlayer/pipeline/operation_grouping.py +486 -0
- brainlayer/pipeline/plan_linking.py +313 -0
- brainlayer/pipeline/sanitize.py +549 -0
- brainlayer/pipeline/semantic_style.py +574 -0
- brainlayer/pipeline/session_enrichment.py +472 -0
- brainlayer/pipeline/style_embed.py +67 -0
- brainlayer/pipeline/style_index.py +139 -0
- brainlayer/pipeline/temporal_chains.py +203 -0
- brainlayer/pipeline/time_batcher.py +248 -0
- brainlayer/pipeline/unified_timeline.py +569 -0
- brainlayer/storage.py +66 -0
- brainlayer/store.py +155 -0
- brainlayer/taxonomy.json +80 -0
- brainlayer/vector_store.py +1891 -0
- brainlayer-1.0.0.dist-info/METADATA +313 -0
- brainlayer-1.0.0.dist-info/RECORD +53 -0
- brainlayer-1.0.0.dist-info/WHEEL +4 -0
- brainlayer-1.0.0.dist-info/entry_points.txt +4 -0
- brainlayer-1.0.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""Operation Grouping Pipeline — Group chunks into logical operations.
|
|
2
|
+
|
|
3
|
+
Phase 8a: Detect patterns like read→edit→test, search→read chains,
|
|
4
|
+
and user→plan→implement cycles using heuristic rules.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from brainlayer.pipeline.operation_grouping import run_operation_grouping
|
|
8
|
+
run_operation_grouping(vector_store, project="my-project")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Operation type definitions
|
|
19
|
+
OP_EDIT_CYCLE = "edit-cycle" # read → edit → test
|
|
20
|
+
OP_RESEARCH = "research" # search/grep → read multiple
|
|
21
|
+
OP_FEATURE_CYCLE = "feature-cycle" # user asks → plan → implement
|
|
22
|
+
OP_DEBUG = "debug" # error → read → try fix → test
|
|
23
|
+
OP_CONFIG = "config" # write/edit config files
|
|
24
|
+
OP_REVIEW = "review" # read multiple files, no edits
|
|
25
|
+
|
|
26
|
+
# Tool action categories (tuples for deterministic order)
|
|
27
|
+
SEARCH_TOOLS = ("Grep", "Glob")
|
|
28
|
+
READ_TOOLS = ("Read",)
|
|
29
|
+
EDIT_TOOLS = ("Edit", "Write")
|
|
30
|
+
TEST_TOOLS = ("Bash",) # detected by content patterns
|
|
31
|
+
# Ordered: Edit/Write first so they take priority over Read
|
|
32
|
+
ALL_FILE_TOOLS = EDIT_TOOLS + SEARCH_TOOLS + READ_TOOLS
|
|
33
|
+
|
|
34
|
+
# Max time gap between chunks in same operation (seconds)
|
|
35
|
+
MAX_GAP_SECONDS = 300 # 5 minutes
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_timestamp(ts: Optional[str]) -> Optional[float]:
|
|
39
|
+
"""Parse ISO timestamp to epoch seconds."""
|
|
40
|
+
if not ts:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
from datetime import datetime as dt_cls
|
|
44
|
+
|
|
45
|
+
# Handle various ISO formats
|
|
46
|
+
ts_clean = ts.replace("Z", "+00:00")
|
|
47
|
+
dt = dt_cls.fromisoformat(ts_clean)
|
|
48
|
+
return dt.timestamp()
|
|
49
|
+
except (ValueError, TypeError):
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _extract_tool_info(chunk: Dict[str, Any]) -> Dict[str, Any]:
|
|
54
|
+
"""Extract tool usage info from a chunk's content."""
|
|
55
|
+
content = chunk.get("content", "")
|
|
56
|
+
content_type = chunk.get("content_type", "")
|
|
57
|
+
metadata = chunk.get("metadata", "{}")
|
|
58
|
+
|
|
59
|
+
if isinstance(metadata, str):
|
|
60
|
+
try:
|
|
61
|
+
metadata = json.loads(metadata)
|
|
62
|
+
except (json.JSONDecodeError, TypeError):
|
|
63
|
+
metadata = {}
|
|
64
|
+
|
|
65
|
+
info = {
|
|
66
|
+
"chunk_id": chunk.get("id", ""),
|
|
67
|
+
"content_type": content_type,
|
|
68
|
+
"tool": None,
|
|
69
|
+
"action": None,
|
|
70
|
+
"file_path": None,
|
|
71
|
+
"is_test": False,
|
|
72
|
+
"is_error": False,
|
|
73
|
+
"is_user_message": content_type == "user_message",
|
|
74
|
+
"is_assistant": content_type == "assistant_text",
|
|
75
|
+
"timestamp": chunk.get("timestamp"),
|
|
76
|
+
"snippet": "", # brief context for summaries
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Extract a short snippet for summary context
|
|
80
|
+
# Take first meaningful line (skip empty, very short)
|
|
81
|
+
for line in content.split("\n"):
|
|
82
|
+
line = line.strip()
|
|
83
|
+
if len(line) > 10 and not line.startswith("#"):
|
|
84
|
+
info["snippet"] = line[:80]
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
# Extract file paths from content
|
|
88
|
+
import re
|
|
89
|
+
|
|
90
|
+
file_match = re.search(
|
|
91
|
+
r'(?:file_path|path)[=:]\s*["\']?'
|
|
92
|
+
r'([^\s"\']+\.\w+)',
|
|
93
|
+
content,
|
|
94
|
+
)
|
|
95
|
+
if file_match:
|
|
96
|
+
info["file_path"] = file_match.group(1)
|
|
97
|
+
else:
|
|
98
|
+
# Also match bare file paths
|
|
99
|
+
bare_match = re.search(
|
|
100
|
+
r"(?:^|\s)(/[^\s]+\.\w{1,5})",
|
|
101
|
+
content,
|
|
102
|
+
)
|
|
103
|
+
if bare_match:
|
|
104
|
+
info["file_path"] = bare_match.group(1)
|
|
105
|
+
|
|
106
|
+
# Detect tool calls from content patterns
|
|
107
|
+
# Use word-boundary matching to avoid false positives
|
|
108
|
+
# (e.g., "Read" matching "README")
|
|
109
|
+
has_tool_marker = "tool_use" in content or "Tool:" in content
|
|
110
|
+
if has_tool_marker or content_type == "assistant_text":
|
|
111
|
+
for tool in ALL_FILE_TOOLS:
|
|
112
|
+
# Match tool name as a whole word
|
|
113
|
+
pattern = r"\b" + re.escape(tool) + r"\b"
|
|
114
|
+
if re.search(pattern, content):
|
|
115
|
+
info["tool"] = tool
|
|
116
|
+
if tool in SEARCH_TOOLS:
|
|
117
|
+
info["action"] = "search"
|
|
118
|
+
elif tool in READ_TOOLS:
|
|
119
|
+
info["action"] = "read"
|
|
120
|
+
elif tool in EDIT_TOOLS:
|
|
121
|
+
info["action"] = "edit"
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
# Detect from content_type
|
|
125
|
+
if content_type == "ai_code":
|
|
126
|
+
info["action"] = "code"
|
|
127
|
+
elif content_type == "stack_trace":
|
|
128
|
+
info["is_error"] = True
|
|
129
|
+
info["action"] = "error"
|
|
130
|
+
|
|
131
|
+
# Detect test runs
|
|
132
|
+
if any(
|
|
133
|
+
kw in content.lower()
|
|
134
|
+
for kw in [
|
|
135
|
+
"bun test",
|
|
136
|
+
"pytest",
|
|
137
|
+
"npm test",
|
|
138
|
+
"jest",
|
|
139
|
+
"vitest",
|
|
140
|
+
"test passed",
|
|
141
|
+
"test failed",
|
|
142
|
+
"tests pass",
|
|
143
|
+
]
|
|
144
|
+
):
|
|
145
|
+
info["is_test"] = True
|
|
146
|
+
info["action"] = "test"
|
|
147
|
+
|
|
148
|
+
# Detect errors (avoid false positives from test summaries)
|
|
149
|
+
content_lower = content.lower()
|
|
150
|
+
if any(
|
|
151
|
+
kw in content_lower
|
|
152
|
+
for kw in [
|
|
153
|
+
"error:",
|
|
154
|
+
"exception:",
|
|
155
|
+
"traceback",
|
|
156
|
+
"exit code 1",
|
|
157
|
+
]
|
|
158
|
+
):
|
|
159
|
+
info["is_error"] = True
|
|
160
|
+
# "failed" only counts as error if not in test summary
|
|
161
|
+
if "failed" in content_lower and "0 fail" not in content_lower and "pass" not in content_lower:
|
|
162
|
+
info["is_error"] = True
|
|
163
|
+
|
|
164
|
+
return info
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _classify_operation(
|
|
168
|
+
steps: List[Dict[str, Any]],
|
|
169
|
+
) -> str:
|
|
170
|
+
"""Classify an operation based on its step patterns."""
|
|
171
|
+
actions = [s["action"] for s in steps if s["action"]]
|
|
172
|
+
has_search = "search" in actions
|
|
173
|
+
has_read = "read" in actions
|
|
174
|
+
has_edit = "edit" in actions
|
|
175
|
+
has_test = any(s["is_test"] for s in steps)
|
|
176
|
+
has_error = any(s["is_error"] for s in steps)
|
|
177
|
+
has_code = "code" in actions
|
|
178
|
+
has_user = any(s["is_user_message"] for s in steps)
|
|
179
|
+
|
|
180
|
+
# Debug cycle: error → investigation → fix → test
|
|
181
|
+
if has_error and (has_edit or has_code) and has_test:
|
|
182
|
+
return OP_DEBUG
|
|
183
|
+
if has_error and (has_edit or has_code):
|
|
184
|
+
return OP_DEBUG
|
|
185
|
+
|
|
186
|
+
# Edit cycle: read → edit → test
|
|
187
|
+
if has_edit and has_test:
|
|
188
|
+
return OP_EDIT_CYCLE
|
|
189
|
+
if has_edit and has_read:
|
|
190
|
+
return OP_EDIT_CYCLE
|
|
191
|
+
|
|
192
|
+
# Feature cycle: user request → implementation
|
|
193
|
+
if has_user and (has_edit or has_code):
|
|
194
|
+
return OP_FEATURE_CYCLE
|
|
195
|
+
|
|
196
|
+
# Research: search/read without edits
|
|
197
|
+
if has_search and has_read and not has_edit:
|
|
198
|
+
return OP_RESEARCH
|
|
199
|
+
if has_search and not has_edit:
|
|
200
|
+
return OP_RESEARCH
|
|
201
|
+
|
|
202
|
+
# Review: reading multiple files
|
|
203
|
+
read_count = sum(1 for s in steps if s["action"] == "read")
|
|
204
|
+
if read_count >= 3 and not has_edit:
|
|
205
|
+
return OP_REVIEW
|
|
206
|
+
|
|
207
|
+
# Config: editing config-like files
|
|
208
|
+
if has_edit:
|
|
209
|
+
return OP_EDIT_CYCLE
|
|
210
|
+
|
|
211
|
+
return OP_RESEARCH # default
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _generate_summary(
|
|
215
|
+
op_type: str,
|
|
216
|
+
steps: List[Dict[str, Any]],
|
|
217
|
+
) -> str:
|
|
218
|
+
"""Generate a brief summary of an operation."""
|
|
219
|
+
step_count = len(steps)
|
|
220
|
+
actions = [s["action"] for s in steps if s["action"]]
|
|
221
|
+
unique_actions = list(dict.fromkeys(actions)) # ordered
|
|
222
|
+
|
|
223
|
+
action_str = " -> ".join(unique_actions[:5])
|
|
224
|
+
if len(unique_actions) > 5:
|
|
225
|
+
action_str += " -> ..."
|
|
226
|
+
|
|
227
|
+
type_labels = {
|
|
228
|
+
OP_EDIT_CYCLE: "Edit cycle",
|
|
229
|
+
OP_RESEARCH: "Research",
|
|
230
|
+
OP_FEATURE_CYCLE: "Feature cycle",
|
|
231
|
+
OP_DEBUG: "Debug cycle",
|
|
232
|
+
OP_CONFIG: "Config change",
|
|
233
|
+
OP_REVIEW: "Code review",
|
|
234
|
+
}
|
|
235
|
+
label = type_labels.get(op_type, op_type)
|
|
236
|
+
|
|
237
|
+
# Extract file context from steps
|
|
238
|
+
files = []
|
|
239
|
+
for s in steps:
|
|
240
|
+
fp = s.get("file_path")
|
|
241
|
+
if fp:
|
|
242
|
+
# Extract just the filename
|
|
243
|
+
import os
|
|
244
|
+
|
|
245
|
+
basename = os.path.basename(fp)
|
|
246
|
+
if basename and basename not in files:
|
|
247
|
+
files.append(basename)
|
|
248
|
+
file_ctx = ""
|
|
249
|
+
if files:
|
|
250
|
+
shown = files[:3]
|
|
251
|
+
file_ctx = " on " + ", ".join(shown)
|
|
252
|
+
if len(files) > 3:
|
|
253
|
+
file_ctx += f" +{len(files) - 3}"
|
|
254
|
+
|
|
255
|
+
# Extract topic from first user message or snippet
|
|
256
|
+
topic = ""
|
|
257
|
+
if not file_ctx:
|
|
258
|
+
for s in steps:
|
|
259
|
+
if s.get("snippet"):
|
|
260
|
+
topic = f": {s['snippet'][:50]}"
|
|
261
|
+
break
|
|
262
|
+
|
|
263
|
+
parts = [label]
|
|
264
|
+
if action_str:
|
|
265
|
+
parts.append(f"({action_str})")
|
|
266
|
+
if file_ctx:
|
|
267
|
+
parts.append(file_ctx)
|
|
268
|
+
elif topic:
|
|
269
|
+
parts.append(topic)
|
|
270
|
+
parts.append(f"[{step_count} steps]")
|
|
271
|
+
return " ".join(parts)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def group_session_chunks(
|
|
275
|
+
chunks: List[Dict[str, Any]],
|
|
276
|
+
session_id: str,
|
|
277
|
+
max_gap: int = MAX_GAP_SECONDS,
|
|
278
|
+
) -> List[Dict[str, Any]]:
|
|
279
|
+
"""Group a session's chunks into logical operations.
|
|
280
|
+
|
|
281
|
+
Uses temporal proximity and tool-type patterns.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
chunks: List of chunk dicts (must have id, content,
|
|
285
|
+
content_type, and optionally timestamp in metadata)
|
|
286
|
+
session_id: The session identifier
|
|
287
|
+
max_gap: Max seconds between chunks in same operation
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
List of operation dicts ready for store_operations()
|
|
291
|
+
"""
|
|
292
|
+
if not chunks:
|
|
293
|
+
return []
|
|
294
|
+
|
|
295
|
+
# Extract tool info for each chunk
|
|
296
|
+
steps = []
|
|
297
|
+
for chunk in chunks:
|
|
298
|
+
info = _extract_tool_info(chunk)
|
|
299
|
+
# Try to get timestamp from chunk metadata
|
|
300
|
+
meta = chunk.get("metadata", "{}")
|
|
301
|
+
if isinstance(meta, str):
|
|
302
|
+
try:
|
|
303
|
+
meta = json.loads(meta)
|
|
304
|
+
except (json.JSONDecodeError, TypeError):
|
|
305
|
+
meta = {}
|
|
306
|
+
ts = meta.get("timestamp") or chunk.get("timestamp")
|
|
307
|
+
info["timestamp"] = ts
|
|
308
|
+
info["epoch"] = _parse_timestamp(ts)
|
|
309
|
+
steps.append(info)
|
|
310
|
+
|
|
311
|
+
# Group by temporal proximity
|
|
312
|
+
groups: List[List[Dict[str, Any]]] = []
|
|
313
|
+
current_group: List[Dict[str, Any]] = []
|
|
314
|
+
|
|
315
|
+
for step in steps:
|
|
316
|
+
if not current_group:
|
|
317
|
+
current_group.append(step)
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
# Check time gap
|
|
321
|
+
prev_epoch = current_group[-1].get("epoch")
|
|
322
|
+
curr_epoch = step.get("epoch")
|
|
323
|
+
|
|
324
|
+
if prev_epoch and curr_epoch:
|
|
325
|
+
gap = curr_epoch - prev_epoch
|
|
326
|
+
if gap > max_gap:
|
|
327
|
+
# Time gap too large — start new group
|
|
328
|
+
if len(current_group) >= 2:
|
|
329
|
+
groups.append(current_group)
|
|
330
|
+
current_group = [step]
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
# Check for natural boundaries
|
|
334
|
+
# A new user message often starts a new operation
|
|
335
|
+
if step["is_user_message"] and len(current_group) >= 3:
|
|
336
|
+
groups.append(current_group)
|
|
337
|
+
current_group = [step]
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
current_group.append(step)
|
|
341
|
+
|
|
342
|
+
# Cap group size
|
|
343
|
+
if len(current_group) >= 50:
|
|
344
|
+
groups.append(current_group)
|
|
345
|
+
current_group = []
|
|
346
|
+
|
|
347
|
+
# Don't forget the last group
|
|
348
|
+
if len(current_group) >= 2:
|
|
349
|
+
groups.append(current_group)
|
|
350
|
+
|
|
351
|
+
# Convert groups to operation dicts
|
|
352
|
+
operations = []
|
|
353
|
+
for group in groups:
|
|
354
|
+
op_type = _classify_operation(group)
|
|
355
|
+
chunk_ids = [s["chunk_id"] for s in group if s["chunk_id"]]
|
|
356
|
+
|
|
357
|
+
timestamps = [s["timestamp"] for s in group if s.get("timestamp")]
|
|
358
|
+
started = timestamps[0] if timestamps else None
|
|
359
|
+
ended = timestamps[-1] if timestamps else None
|
|
360
|
+
|
|
361
|
+
# Determine outcome
|
|
362
|
+
has_error = any(s["is_error"] for s in group)
|
|
363
|
+
has_test_pass = any(s["is_test"] and not s["is_error"] for s in group)
|
|
364
|
+
if has_test_pass:
|
|
365
|
+
outcome = "success"
|
|
366
|
+
elif has_error:
|
|
367
|
+
outcome = "failure"
|
|
368
|
+
else:
|
|
369
|
+
outcome = "unknown"
|
|
370
|
+
|
|
371
|
+
operations.append(
|
|
372
|
+
{
|
|
373
|
+
"id": str(uuid.uuid4()),
|
|
374
|
+
"session_id": session_id,
|
|
375
|
+
"operation_type": op_type,
|
|
376
|
+
"chunk_ids": chunk_ids,
|
|
377
|
+
"summary": _generate_summary(op_type, group),
|
|
378
|
+
"outcome": outcome,
|
|
379
|
+
"started_at": started,
|
|
380
|
+
"ended_at": ended,
|
|
381
|
+
"step_count": len(group),
|
|
382
|
+
}
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return operations
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def run_operation_grouping(
|
|
389
|
+
vector_store: Any,
|
|
390
|
+
project: Optional[str] = None,
|
|
391
|
+
force: bool = False,
|
|
392
|
+
max_sessions: int = 0,
|
|
393
|
+
) -> Dict[str, int]:
|
|
394
|
+
"""Run operation grouping on indexed sessions.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
vector_store: VectorStore instance
|
|
398
|
+
project: Filter to specific project name
|
|
399
|
+
force: Re-process sessions with existing operations
|
|
400
|
+
max_sessions: Limit number of sessions (0 = all)
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Dict with counts: sessions_processed, operations_added
|
|
404
|
+
"""
|
|
405
|
+
stats = {
|
|
406
|
+
"sessions_processed": 0,
|
|
407
|
+
"operations_added": 0,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
# Get all sessions from session_context table
|
|
411
|
+
cursor = vector_store.conn.cursor()
|
|
412
|
+
query = "SELECT session_id, project FROM session_context"
|
|
413
|
+
params: list = []
|
|
414
|
+
if project:
|
|
415
|
+
query += " WHERE project = ?"
|
|
416
|
+
params.append(project)
|
|
417
|
+
query += " ORDER BY started_at"
|
|
418
|
+
|
|
419
|
+
sessions = list(cursor.execute(query, params))
|
|
420
|
+
|
|
421
|
+
processed = 0
|
|
422
|
+
for session_id, proj in sessions:
|
|
423
|
+
if max_sessions and processed >= max_sessions:
|
|
424
|
+
break
|
|
425
|
+
|
|
426
|
+
# Skip if already has operations (unless force)
|
|
427
|
+
if not force:
|
|
428
|
+
existing = vector_store.get_session_operations(session_id)
|
|
429
|
+
if existing:
|
|
430
|
+
continue
|
|
431
|
+
else:
|
|
432
|
+
# Clear existing operations for re-processing
|
|
433
|
+
vector_store.clear_session_operations(session_id)
|
|
434
|
+
|
|
435
|
+
# Get chunks for this session
|
|
436
|
+
source_file_pattern = f"%{session_id}%"
|
|
437
|
+
chunk_rows = list(
|
|
438
|
+
cursor.execute(
|
|
439
|
+
"""SELECT id, content, content_type, metadata
|
|
440
|
+
FROM chunks
|
|
441
|
+
WHERE source_file LIKE ?
|
|
442
|
+
ORDER BY ROWID""",
|
|
443
|
+
(source_file_pattern,),
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
if not chunk_rows:
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
chunks = []
|
|
451
|
+
for row in chunk_rows:
|
|
452
|
+
meta = row[3] or "{}"
|
|
453
|
+
if isinstance(meta, str):
|
|
454
|
+
try:
|
|
455
|
+
meta_dict = json.loads(meta)
|
|
456
|
+
except (json.JSONDecodeError, TypeError):
|
|
457
|
+
meta_dict = {}
|
|
458
|
+
else:
|
|
459
|
+
meta_dict = meta
|
|
460
|
+
|
|
461
|
+
chunks.append(
|
|
462
|
+
{
|
|
463
|
+
"id": row[0],
|
|
464
|
+
"content": row[1],
|
|
465
|
+
"content_type": row[2],
|
|
466
|
+
"metadata": meta_dict,
|
|
467
|
+
}
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# Group into operations
|
|
471
|
+
operations = group_session_chunks(chunks, session_id)
|
|
472
|
+
|
|
473
|
+
if operations:
|
|
474
|
+
count = vector_store.store_operations(operations)
|
|
475
|
+
stats["operations_added"] += count
|
|
476
|
+
logger.info(
|
|
477
|
+
"Session %s: %d operations from %d chunks",
|
|
478
|
+
session_id[:8],
|
|
479
|
+
count,
|
|
480
|
+
len(chunks),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
stats["sessions_processed"] += 1
|
|
484
|
+
processed += 1
|
|
485
|
+
|
|
486
|
+
return stats
|