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,472 @@
|
|
|
1
|
+
"""Session-level enrichment pipeline — analyze full conversations, not just chunks.
|
|
2
|
+
|
|
3
|
+
Processes sessions through local LLM (Ollama/MLX) to extract:
|
|
4
|
+
- Session summary + primary intent + outcome
|
|
5
|
+
- Decisions made (with rationale)
|
|
6
|
+
- Corrections (what the user corrected)
|
|
7
|
+
- Learnings (new knowledge gained)
|
|
8
|
+
- Mistakes (what went wrong)
|
|
9
|
+
- Patterns (recurring behaviors)
|
|
10
|
+
- Tool usage statistics
|
|
11
|
+
- Quality scores
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from brainlayer.pipeline.session_enrichment import enrich_session
|
|
15
|
+
result = enrich_session(store, session_id, call_llm_fn)
|
|
16
|
+
|
|
17
|
+
CLI:
|
|
18
|
+
brainlayer enrich-sessions
|
|
19
|
+
brainlayer enrich-sessions --project my-project --since 2026-01-01
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
from ..vector_store import VectorStore
|
|
27
|
+
|
|
28
|
+
# Valid values for structured fields
|
|
29
|
+
VALID_INTENTS = [
|
|
30
|
+
"debugging",
|
|
31
|
+
"designing",
|
|
32
|
+
"configuring",
|
|
33
|
+
"discussing",
|
|
34
|
+
"deciding",
|
|
35
|
+
"implementing",
|
|
36
|
+
"reviewing",
|
|
37
|
+
"refactoring",
|
|
38
|
+
"deploying",
|
|
39
|
+
"testing",
|
|
40
|
+
]
|
|
41
|
+
VALID_OUTCOMES = ["success", "partial_success", "failure", "abandoned", "ongoing"]
|
|
42
|
+
|
|
43
|
+
# Maximum conversation length to send to LLM (in characters)
|
|
44
|
+
MAX_CONVERSATION_CHARS = 12_000
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def reconstruct_session(store: VectorStore, session_id: str) -> Dict[str, Any]:
|
|
48
|
+
"""Reassemble ordered chunks from a session into a coherent conversation.
|
|
49
|
+
|
|
50
|
+
Chunks are identified by source_file matching the session_id pattern.
|
|
51
|
+
Returns a dict with conversation text, message counts, timing, and metadata.
|
|
52
|
+
"""
|
|
53
|
+
cursor = store.conn.cursor()
|
|
54
|
+
|
|
55
|
+
# Find chunks belonging to this session, ordered by creation time
|
|
56
|
+
# Session ID maps to source_file (the JSONL filename stem) or conversation_id
|
|
57
|
+
rows = list(
|
|
58
|
+
cursor.execute(
|
|
59
|
+
"""SELECT id, content, content_type, source_file, created_at,
|
|
60
|
+
char_count, source, conversation_id
|
|
61
|
+
FROM chunks
|
|
62
|
+
WHERE (source_file LIKE ? OR conversation_id = ?)
|
|
63
|
+
ORDER BY created_at, rowid""",
|
|
64
|
+
(f"%{session_id}%", session_id),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if not rows:
|
|
69
|
+
return {"chunks": [], "conversation": "", "message_count": 0}
|
|
70
|
+
|
|
71
|
+
chunks = []
|
|
72
|
+
user_count = 0
|
|
73
|
+
assistant_count = 0
|
|
74
|
+
tool_count = 0
|
|
75
|
+
first_time = None
|
|
76
|
+
last_time = None
|
|
77
|
+
|
|
78
|
+
for row in rows:
|
|
79
|
+
chunk = {
|
|
80
|
+
"id": row[0],
|
|
81
|
+
"content": row[1],
|
|
82
|
+
"content_type": row[2],
|
|
83
|
+
"source_file": row[3],
|
|
84
|
+
"created_at": row[4],
|
|
85
|
+
"char_count": row[5],
|
|
86
|
+
"source": row[6],
|
|
87
|
+
"conversation_id": row[7],
|
|
88
|
+
}
|
|
89
|
+
chunks.append(chunk)
|
|
90
|
+
|
|
91
|
+
# Count message types
|
|
92
|
+
ct = chunk["content_type"] or ""
|
|
93
|
+
if ct == "user_message":
|
|
94
|
+
user_count += 1
|
|
95
|
+
elif ct in ("assistant_text", "ai_code"):
|
|
96
|
+
assistant_count += 1
|
|
97
|
+
elif ct in ("tool_result", "tool_use"):
|
|
98
|
+
tool_count += 1
|
|
99
|
+
|
|
100
|
+
# Track timing
|
|
101
|
+
if chunk["created_at"]:
|
|
102
|
+
if first_time is None:
|
|
103
|
+
first_time = chunk["created_at"]
|
|
104
|
+
last_time = chunk["created_at"]
|
|
105
|
+
|
|
106
|
+
# Build conversation text for LLM analysis
|
|
107
|
+
conversation_parts = []
|
|
108
|
+
total_chars = 0
|
|
109
|
+
|
|
110
|
+
for chunk in chunks:
|
|
111
|
+
ct = chunk["content_type"] or "unknown"
|
|
112
|
+
content = chunk["content"] or ""
|
|
113
|
+
|
|
114
|
+
# Skip noise types
|
|
115
|
+
if ct in ("noise", "dir_listing", "build_log", "queue-operation"):
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# Truncate very long chunks (file reads, large code blocks)
|
|
119
|
+
if len(content) > 2000:
|
|
120
|
+
content = content[:2000] + "\n[... truncated]"
|
|
121
|
+
|
|
122
|
+
# Format based on type
|
|
123
|
+
if ct == "user_message":
|
|
124
|
+
conversation_parts.append(f"USER: {content}")
|
|
125
|
+
elif ct == "assistant_text":
|
|
126
|
+
conversation_parts.append(f"ASSISTANT: {content}")
|
|
127
|
+
elif ct == "ai_code":
|
|
128
|
+
conversation_parts.append(f"ASSISTANT [code]: {content}")
|
|
129
|
+
elif ct == "stack_trace":
|
|
130
|
+
conversation_parts.append(f"ERROR: {content}")
|
|
131
|
+
elif ct == "git_diff":
|
|
132
|
+
conversation_parts.append(f"DIFF: {content}")
|
|
133
|
+
elif ct == "file_read":
|
|
134
|
+
# Summarize file reads (often very long)
|
|
135
|
+
lines = content.split("\n")
|
|
136
|
+
conversation_parts.append(f"FILE_READ ({len(lines)} lines): {content[:500]}")
|
|
137
|
+
else:
|
|
138
|
+
conversation_parts.append(f"[{ct}]: {content[:500]}")
|
|
139
|
+
|
|
140
|
+
total_chars += len(conversation_parts[-1])
|
|
141
|
+
|
|
142
|
+
# Stop if we've exceeded the character limit
|
|
143
|
+
if total_chars > MAX_CONVERSATION_CHARS:
|
|
144
|
+
conversation_parts.append(f"\n[... {len(chunks) - len(conversation_parts)} more chunks truncated]")
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
conversation = "\n\n".join(conversation_parts)
|
|
148
|
+
|
|
149
|
+
# Calculate duration
|
|
150
|
+
duration_seconds = None
|
|
151
|
+
if first_time and last_time:
|
|
152
|
+
try:
|
|
153
|
+
t1 = datetime.fromisoformat(first_time.replace("Z", "+00:00"))
|
|
154
|
+
t2 = datetime.fromisoformat(last_time.replace("Z", "+00:00"))
|
|
155
|
+
duration_seconds = int((t2 - t1).total_seconds())
|
|
156
|
+
except (ValueError, TypeError):
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"chunks": chunks,
|
|
161
|
+
"conversation": conversation,
|
|
162
|
+
"message_count": len(chunks),
|
|
163
|
+
"user_message_count": user_count,
|
|
164
|
+
"assistant_message_count": assistant_count,
|
|
165
|
+
"tool_call_count": tool_count,
|
|
166
|
+
"session_start_time": first_time,
|
|
167
|
+
"session_end_time": last_time,
|
|
168
|
+
"duration_seconds": duration_seconds,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Session analysis prompt — single-pass for local LLM efficiency
|
|
173
|
+
SESSION_ANALYSIS_PROMPT = """You are a session analysis assistant. Analyze this Claude Code conversation and return ONLY a JSON object.
|
|
174
|
+
|
|
175
|
+
CONVERSATION (session from project: {project}):
|
|
176
|
+
---
|
|
177
|
+
{conversation}
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
Return this exact JSON structure:
|
|
181
|
+
{{
|
|
182
|
+
"session_summary": "<2-3 sentence summary of what happened in this session>",
|
|
183
|
+
"primary_intent": "<one of: debugging, designing, configuring, discussing, deciding, implementing, reviewing, refactoring, deploying, testing>",
|
|
184
|
+
"outcome": "<one of: success, partial_success, failure, abandoned, ongoing>",
|
|
185
|
+
"complexity_score": <1-10 integer>,
|
|
186
|
+
"session_quality_score": <1-10 integer>,
|
|
187
|
+
"decisions_made": [
|
|
188
|
+
{{"decision": "<what was decided>", "rationale": "<why>"}}
|
|
189
|
+
],
|
|
190
|
+
"corrections": [
|
|
191
|
+
{{"what_was_wrong": "<what the AI did wrong>", "what_user_wanted": "<correct behavior>"}}
|
|
192
|
+
],
|
|
193
|
+
"learnings": [
|
|
194
|
+
"<new knowledge or insight gained during this session>"
|
|
195
|
+
],
|
|
196
|
+
"mistakes": [
|
|
197
|
+
"<what went wrong and how it was resolved>"
|
|
198
|
+
],
|
|
199
|
+
"patterns": [
|
|
200
|
+
"<recurring behaviors or approaches observed>"
|
|
201
|
+
],
|
|
202
|
+
"topic_tags": ["<tag1>", "<tag2>"],
|
|
203
|
+
"tool_usage_stats": [
|
|
204
|
+
{{"tool": "<tool name>", "count": <number>}}
|
|
205
|
+
],
|
|
206
|
+
"what_worked": "<what went well in this session>",
|
|
207
|
+
"what_failed": "<what didn't work or caused problems>"
|
|
208
|
+
}}
|
|
209
|
+
|
|
210
|
+
SCORING RULES:
|
|
211
|
+
- complexity_score: 1-3 trivial (quick fix), 4-6 moderate (feature work), 7-9 complex (architecture), 10 critical
|
|
212
|
+
- session_quality_score: 1-3 poor (many errors, user frustrated), 4-6 average, 7-9 good (smooth), 10 exceptional
|
|
213
|
+
|
|
214
|
+
EXTRACTION RULES:
|
|
215
|
+
- decisions: Only extract REAL decisions — "we chose X over Y because Z"
|
|
216
|
+
- corrections: Only when the user explicitly corrected the AI's approach
|
|
217
|
+
- learnings: Concrete knowledge, not vague observations
|
|
218
|
+
- mistakes: What actually failed, not hypothetical risks
|
|
219
|
+
- topic_tags: lowercase, hyphenated (e.g., "bug-fix", "api-design", "typescript")
|
|
220
|
+
- tool_usage_stats: List tools used (Read, Write, Edit, Bash, etc.) with approximate counts
|
|
221
|
+
- Empty arrays [] are fine when nothing matches a category
|
|
222
|
+
|
|
223
|
+
Return ONLY the JSON object, no other text."""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def build_session_prompt(conversation: str, project: str) -> str:
|
|
227
|
+
"""Build the session analysis prompt."""
|
|
228
|
+
# Escape braces in conversation to avoid str.format() crash
|
|
229
|
+
safe_conversation = conversation.replace("{", "{{").replace("}", "}}")
|
|
230
|
+
return SESSION_ANALYSIS_PROMPT.format(
|
|
231
|
+
project=project or "unknown",
|
|
232
|
+
conversation=safe_conversation,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def parse_session_enrichment(text: str) -> Optional[Dict[str, Any]]:
|
|
237
|
+
"""Parse LLM's JSON response into session enrichment data."""
|
|
238
|
+
if not text:
|
|
239
|
+
return None
|
|
240
|
+
try:
|
|
241
|
+
# Find JSON in response (handle LLM wrapping in markdown etc.)
|
|
242
|
+
match = None
|
|
243
|
+
for start in range(len(text)):
|
|
244
|
+
if text[start] == "{":
|
|
245
|
+
for end in range(len(text) - 1, start, -1):
|
|
246
|
+
if text[end] == "}":
|
|
247
|
+
try:
|
|
248
|
+
match = json.loads(text[start : end + 1])
|
|
249
|
+
break
|
|
250
|
+
except json.JSONDecodeError:
|
|
251
|
+
continue
|
|
252
|
+
if match:
|
|
253
|
+
break
|
|
254
|
+
|
|
255
|
+
if not match:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
result: Dict[str, Any] = {}
|
|
259
|
+
|
|
260
|
+
# Required: session_summary
|
|
261
|
+
summary = match.get("session_summary", "")
|
|
262
|
+
if isinstance(summary, str) and len(summary) > 10:
|
|
263
|
+
result["session_summary"] = summary[:1000]
|
|
264
|
+
else:
|
|
265
|
+
return None # Summary is required
|
|
266
|
+
|
|
267
|
+
# Intent
|
|
268
|
+
intent = match.get("primary_intent", "")
|
|
269
|
+
if isinstance(intent, str) and intent.lower().strip() in VALID_INTENTS:
|
|
270
|
+
result["primary_intent"] = intent.lower().strip()
|
|
271
|
+
|
|
272
|
+
# Outcome
|
|
273
|
+
outcome = match.get("outcome", "")
|
|
274
|
+
if isinstance(outcome, str) and outcome.lower().strip() in VALID_OUTCOMES:
|
|
275
|
+
result["outcome"] = outcome.lower().strip()
|
|
276
|
+
|
|
277
|
+
# Scores
|
|
278
|
+
for score_field in ("complexity_score", "session_quality_score"):
|
|
279
|
+
val = match.get(score_field)
|
|
280
|
+
if isinstance(val, (int, float)):
|
|
281
|
+
result[score_field] = max(1, min(10, int(val)))
|
|
282
|
+
|
|
283
|
+
# JSON array fields
|
|
284
|
+
for field in ("decisions_made", "corrections", "learnings", "mistakes", "patterns"):
|
|
285
|
+
val = match.get(field, [])
|
|
286
|
+
if isinstance(val, list):
|
|
287
|
+
result[field] = val[:20] # Cap at 20 items
|
|
288
|
+
|
|
289
|
+
# Topic tags
|
|
290
|
+
tags = match.get("topic_tags", [])
|
|
291
|
+
if isinstance(tags, list):
|
|
292
|
+
result["topic_tags"] = [str(t).lower().strip() for t in tags if isinstance(t, str)][:15]
|
|
293
|
+
|
|
294
|
+
# Tool usage
|
|
295
|
+
tool_stats = match.get("tool_usage_stats", [])
|
|
296
|
+
if isinstance(tool_stats, list):
|
|
297
|
+
result["tool_usage_stats"] = tool_stats[:20]
|
|
298
|
+
|
|
299
|
+
# Narratives
|
|
300
|
+
for field in ("what_worked", "what_failed"):
|
|
301
|
+
val = match.get(field)
|
|
302
|
+
if isinstance(val, str) and val.strip():
|
|
303
|
+
result[field] = val.strip()[:500]
|
|
304
|
+
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
except Exception:
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def list_sessions_for_enrichment(
|
|
312
|
+
store: VectorStore,
|
|
313
|
+
project: Optional[str] = None,
|
|
314
|
+
since: Optional[str] = None,
|
|
315
|
+
) -> List[Tuple[str, str, int]]:
|
|
316
|
+
"""List session IDs available for enrichment.
|
|
317
|
+
|
|
318
|
+
Returns list of (session_id, project, chunk_count) tuples.
|
|
319
|
+
Sessions come from:
|
|
320
|
+
1. session_context table (sessions with git overlay data)
|
|
321
|
+
2. Distinct source_file values in chunks table (all sessions)
|
|
322
|
+
"""
|
|
323
|
+
cursor = store.conn.cursor()
|
|
324
|
+
already_enriched = set(store.list_enriched_sessions())
|
|
325
|
+
|
|
326
|
+
sessions = []
|
|
327
|
+
|
|
328
|
+
# Method 1: session_context table (has richer metadata)
|
|
329
|
+
query = "SELECT session_id, project FROM session_context"
|
|
330
|
+
params: list = []
|
|
331
|
+
if project:
|
|
332
|
+
query += " WHERE project = ?"
|
|
333
|
+
params.append(project)
|
|
334
|
+
for row in cursor.execute(query, params):
|
|
335
|
+
sid, proj = row[0], row[1]
|
|
336
|
+
if sid not in already_enriched:
|
|
337
|
+
# Apply 'since' filter if provided
|
|
338
|
+
if since:
|
|
339
|
+
first_time = list(
|
|
340
|
+
cursor.execute(
|
|
341
|
+
"SELECT MIN(created_at) FROM chunks WHERE source_file LIKE ?",
|
|
342
|
+
(f"%{sid}%",),
|
|
343
|
+
)
|
|
344
|
+
)[0][0]
|
|
345
|
+
if first_time and first_time < since:
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
# Count chunks for this session
|
|
349
|
+
count = list(
|
|
350
|
+
cursor.execute(
|
|
351
|
+
"SELECT COUNT(*) FROM chunks WHERE source_file LIKE ?",
|
|
352
|
+
(f"%{sid}%",),
|
|
353
|
+
)
|
|
354
|
+
)[0][0]
|
|
355
|
+
if count > 0:
|
|
356
|
+
sessions.append((sid, proj or "", count))
|
|
357
|
+
already_enriched.add(sid)
|
|
358
|
+
|
|
359
|
+
# Method 2: Distinct source_files from chunks (catches sessions without git overlay)
|
|
360
|
+
source_query = """
|
|
361
|
+
SELECT DISTINCT source_file, project, COUNT(*) as cnt
|
|
362
|
+
FROM chunks
|
|
363
|
+
WHERE source IS NULL OR source = 'claude_code'
|
|
364
|
+
GROUP BY source_file
|
|
365
|
+
HAVING cnt >= 3
|
|
366
|
+
"""
|
|
367
|
+
for row in cursor.execute(source_query):
|
|
368
|
+
source_file = row[0] or ""
|
|
369
|
+
proj = row[1] or ""
|
|
370
|
+
|
|
371
|
+
if project and proj != project:
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
# Extract session ID from source_file path
|
|
375
|
+
# Typical format: /path/to/.claude/projects/-Users-janedev-Gits-project/abc123.jsonl
|
|
376
|
+
import os
|
|
377
|
+
|
|
378
|
+
sid = os.path.splitext(os.path.basename(source_file))[0] if source_file else ""
|
|
379
|
+
if not sid or sid in already_enriched:
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
# Apply 'since' filter if provided
|
|
383
|
+
if since:
|
|
384
|
+
first_time = list(
|
|
385
|
+
cursor.execute(
|
|
386
|
+
"SELECT MIN(created_at) FROM chunks WHERE source_file = ?",
|
|
387
|
+
(source_file,),
|
|
388
|
+
)
|
|
389
|
+
)[0][0]
|
|
390
|
+
if first_time and first_time < since:
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
sessions.append((sid, proj, row[2]))
|
|
394
|
+
already_enriched.add(sid)
|
|
395
|
+
|
|
396
|
+
return sessions
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def enrich_session(
|
|
400
|
+
store: VectorStore,
|
|
401
|
+
session_id: str,
|
|
402
|
+
call_llm_fn: Callable[[str], Optional[str]],
|
|
403
|
+
project: Optional[str] = None,
|
|
404
|
+
embed_fn: Optional[Callable[[str], List[float]]] = None,
|
|
405
|
+
model_name: str = "",
|
|
406
|
+
) -> Optional[Dict[str, Any]]:
|
|
407
|
+
"""Enrich a single session with LLM analysis.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
store: VectorStore instance.
|
|
411
|
+
session_id: Session to enrich.
|
|
412
|
+
call_llm_fn: Function that takes a prompt string and returns LLM response.
|
|
413
|
+
project: Optional project name override.
|
|
414
|
+
embed_fn: Optional embedding function for session summary.
|
|
415
|
+
model_name: Name of the model used for enrichment tracking.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Enrichment dict on success, None on failure.
|
|
419
|
+
"""
|
|
420
|
+
# Step 1: Reconstruct conversation
|
|
421
|
+
session_data = reconstruct_session(store, session_id)
|
|
422
|
+
if not session_data["chunks"]:
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
conversation = session_data["conversation"]
|
|
426
|
+
if not conversation or len(conversation) < 50:
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
# Determine project from chunks if not provided
|
|
430
|
+
if not project:
|
|
431
|
+
# Try to get project from session_context
|
|
432
|
+
ctx = store.get_session_context(session_id)
|
|
433
|
+
if ctx:
|
|
434
|
+
project = ctx.get("project", "")
|
|
435
|
+
|
|
436
|
+
# Step 2: Build prompt and call LLM
|
|
437
|
+
prompt = build_session_prompt(conversation, project or "unknown")
|
|
438
|
+
response = call_llm_fn(prompt)
|
|
439
|
+
|
|
440
|
+
# Step 3: Parse response
|
|
441
|
+
enrichment = parse_session_enrichment(response)
|
|
442
|
+
if not enrichment:
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
# Step 4: Add session metadata from reconstruction
|
|
446
|
+
enrichment["session_id"] = session_id
|
|
447
|
+
enrichment["file_path"] = session_data["chunks"][0].get("source_file") if session_data["chunks"] else None
|
|
448
|
+
enrichment["message_count"] = session_data["message_count"]
|
|
449
|
+
enrichment["user_message_count"] = session_data["user_message_count"]
|
|
450
|
+
enrichment["assistant_message_count"] = session_data["assistant_message_count"]
|
|
451
|
+
enrichment["tool_call_count"] = session_data["tool_call_count"]
|
|
452
|
+
enrichment["session_start_time"] = session_data["session_start_time"]
|
|
453
|
+
enrichment["session_end_time"] = session_data["session_end_time"]
|
|
454
|
+
enrichment["duration_seconds"] = session_data["duration_seconds"]
|
|
455
|
+
enrichment["enrichment_model"] = model_name
|
|
456
|
+
enrichment["enrichment_version"] = "1.0"
|
|
457
|
+
|
|
458
|
+
# Step 5: Generate summary embedding if embed_fn provided
|
|
459
|
+
if embed_fn and enrichment.get("session_summary"):
|
|
460
|
+
try:
|
|
461
|
+
from ..vector_store import serialize_f32
|
|
462
|
+
|
|
463
|
+
embedding = embed_fn(enrichment["session_summary"])
|
|
464
|
+
enrichment["summary_embedding"] = serialize_f32(embedding)
|
|
465
|
+
except Exception:
|
|
466
|
+
pass # Don't fail enrichment over embedding issues
|
|
467
|
+
|
|
468
|
+
# Step 6: Store in database
|
|
469
|
+
store.upsert_session_enrichment(enrichment)
|
|
470
|
+
|
|
471
|
+
# Return the stored version (with JSON fields properly deserialized)
|
|
472
|
+
return store.get_session_enrichment(session_id)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Embeddings for communication style analysis.
|
|
2
|
+
|
|
3
|
+
Uses StyleDistance (HuggingFace) — the best model for style-aware clustering.
|
|
4
|
+
General embeddings (Qwen3, bge-m3) cluster by topic, not style; StyleDistance
|
|
5
|
+
clusters by how you write (formality, emoji, punctuation, phrasing).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from sentence_transformers import SentenceTransformer
|
|
12
|
+
|
|
13
|
+
HAS_SENTENCE_TRANSFORMERS = True
|
|
14
|
+
except ImportError:
|
|
15
|
+
HAS_SENTENCE_TRANSFORMERS = False
|
|
16
|
+
|
|
17
|
+
from .unified_timeline import UnifiedMessage
|
|
18
|
+
|
|
19
|
+
# Best for style analysis: clusters by writing style, not content
|
|
20
|
+
STYLE_MODEL = "StyleDistance/mstyledistance"
|
|
21
|
+
MAX_EMBEDDING_CHARS = 8000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_model() -> "SentenceTransformer":
|
|
25
|
+
"""Load mStyleDistance. Multilingual (Hebrew+English)."""
|
|
26
|
+
if not HAS_SENTENCE_TRANSFORMERS:
|
|
27
|
+
raise ImportError("sentence-transformers required. Install: pip install sentence-transformers")
|
|
28
|
+
return SentenceTransformer(STYLE_MODEL)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def embed_message(text: str) -> list[float]:
|
|
32
|
+
"""Embed a single message for style clustering."""
|
|
33
|
+
content = text[:MAX_EMBEDDING_CHARS] if len(text) > MAX_EMBEDDING_CHARS else text
|
|
34
|
+
encoder = _get_model()
|
|
35
|
+
emb = encoder.encode(content, convert_to_numpy=True)
|
|
36
|
+
return emb.tolist()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def embed_messages(
|
|
40
|
+
messages: list[UnifiedMessage],
|
|
41
|
+
batch_size: int = 32,
|
|
42
|
+
on_progress: Optional[callable] = None,
|
|
43
|
+
) -> list[tuple[UnifiedMessage, list[float]]]:
|
|
44
|
+
"""
|
|
45
|
+
Embed messages for style-aware cluster sampling.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of (message, embedding) tuples.
|
|
49
|
+
"""
|
|
50
|
+
total = len(messages)
|
|
51
|
+
encoder = _get_model()
|
|
52
|
+
texts = [m.text[:MAX_EMBEDDING_CHARS] if len(m.text) > MAX_EMBEDDING_CHARS else m.text for m in messages]
|
|
53
|
+
embeddings = encoder.encode(
|
|
54
|
+
texts,
|
|
55
|
+
batch_size=batch_size,
|
|
56
|
+
show_progress_bar=False,
|
|
57
|
+
convert_to_numpy=True,
|
|
58
|
+
)
|
|
59
|
+
results = [(msg, emb.tolist()) for msg, emb in zip(messages, embeddings)]
|
|
60
|
+
if on_progress:
|
|
61
|
+
on_progress(total, total)
|
|
62
|
+
return results
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def ensure_model() -> None:
|
|
66
|
+
"""Ensure StyleDistance is available. Downloads on first use."""
|
|
67
|
+
_get_model()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Vector index for style message embeddings (ChromaDB)."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import chromadb
|
|
8
|
+
from chromadb.config import Settings
|
|
9
|
+
|
|
10
|
+
from .unified_timeline import UnifiedMessage
|
|
11
|
+
|
|
12
|
+
DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "brainlayer" / "chromadb"
|
|
13
|
+
STYLE_COLLECTION = "style_messages"
|
|
14
|
+
CHROMADB_BATCH_SIZE = 1000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _msg_id(msg: UnifiedMessage, idx: int) -> str:
|
|
18
|
+
"""Deterministic ID for a message."""
|
|
19
|
+
content = f"{msg.timestamp.isoformat()}|{msg.source}|{msg.text[:100]}"
|
|
20
|
+
h = hashlib.sha256(content.encode()).hexdigest()[:16]
|
|
21
|
+
return f"style_{idx}_{h}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _timestamp_epoch(msg: UnifiedMessage) -> float:
|
|
25
|
+
"""Unix epoch for ChromaDB numeric filter."""
|
|
26
|
+
return msg.timestamp.timestamp()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_style_client(db_path: Path = DEFAULT_DB_PATH) -> chromadb.Client:
|
|
30
|
+
"""Get ChromaDB client for style index."""
|
|
31
|
+
db_path.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
return chromadb.PersistentClient(
|
|
33
|
+
path=str(db_path),
|
|
34
|
+
settings=Settings(anonymized_telemetry=False, allow_reset=True),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_style_collection(client: chromadb.Client) -> chromadb.Collection:
|
|
39
|
+
"""Get or create the style messages collection."""
|
|
40
|
+
return client.get_or_create_collection(
|
|
41
|
+
name=STYLE_COLLECTION,
|
|
42
|
+
metadata={"hnsw:space": "cosine"},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def index_style_messages(
|
|
47
|
+
messages_with_embeddings: list[tuple[UnifiedMessage, list[float]]],
|
|
48
|
+
collection: chromadb.Collection,
|
|
49
|
+
) -> int:
|
|
50
|
+
"""Index style messages with embeddings. Replaces existing data."""
|
|
51
|
+
if not messages_with_embeddings:
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
ids = []
|
|
55
|
+
embeddings = []
|
|
56
|
+
documents = []
|
|
57
|
+
metadatas = []
|
|
58
|
+
|
|
59
|
+
for i, (msg, emb) in enumerate(messages_with_embeddings):
|
|
60
|
+
ids.append(_msg_id(msg, i))
|
|
61
|
+
embeddings.append(emb)
|
|
62
|
+
documents.append(msg.text[:2000])
|
|
63
|
+
metadatas.append(
|
|
64
|
+
{
|
|
65
|
+
"ts_epoch": _timestamp_epoch(msg),
|
|
66
|
+
"timestamp": msg.timestamp.isoformat(),
|
|
67
|
+
"source": msg.source,
|
|
68
|
+
"language": msg.language,
|
|
69
|
+
"chat_id": (msg.chat_id or "")[:200],
|
|
70
|
+
"contact_name": (msg.contact_name or "")[:200],
|
|
71
|
+
"relationship_tag": msg.relationship_tag or "unlabeled",
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
for i in range(0, len(ids), CHROMADB_BATCH_SIZE):
|
|
76
|
+
end = min(i + CHROMADB_BATCH_SIZE, len(ids))
|
|
77
|
+
collection.upsert(
|
|
78
|
+
ids=ids[i:end],
|
|
79
|
+
embeddings=embeddings[i:end],
|
|
80
|
+
documents=documents[i:end],
|
|
81
|
+
metadatas=metadatas[i:end],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return len(ids)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def clear_style_collection(collection: chromadb.Collection) -> None:
|
|
88
|
+
"""Clear all documents from the style collection."""
|
|
89
|
+
# ChromaDB delete requires IDs; get all and delete
|
|
90
|
+
result = collection.get(include=[])
|
|
91
|
+
if result["ids"]:
|
|
92
|
+
collection.delete(ids=result["ids"])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def query_style_messages(
|
|
96
|
+
collection: chromadb.Collection,
|
|
97
|
+
query_embeddings: list[list[float]],
|
|
98
|
+
n_results: int = 100,
|
|
99
|
+
where: Optional[dict] = None,
|
|
100
|
+
) -> dict:
|
|
101
|
+
"""Query style messages by embedding. Returns documents and metadatas."""
|
|
102
|
+
return collection.query(
|
|
103
|
+
query_embeddings=query_embeddings,
|
|
104
|
+
n_results=n_results,
|
|
105
|
+
where=where,
|
|
106
|
+
include=["documents", "metadatas", "distances"],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_embeddings_for_batch(
|
|
111
|
+
collection: chromadb.Collection,
|
|
112
|
+
start_epoch: float,
|
|
113
|
+
end_epoch: float,
|
|
114
|
+
language: Optional[str] = None,
|
|
115
|
+
) -> tuple[list[list[float]], list[str]]:
|
|
116
|
+
"""
|
|
117
|
+
Get all embeddings and documents for a time range (and optionally language).
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
(embeddings, documents) for use in cluster sampling.
|
|
121
|
+
"""
|
|
122
|
+
where: dict = {
|
|
123
|
+
"$and": [
|
|
124
|
+
{"ts_epoch": {"$gte": start_epoch}},
|
|
125
|
+
{"ts_epoch": {"$lte": end_epoch}},
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
if language and language != "all":
|
|
129
|
+
where["$and"].append({"language": language})
|
|
130
|
+
|
|
131
|
+
# Get all - no query_embeddings, use get with where
|
|
132
|
+
result = collection.get(
|
|
133
|
+
where=where,
|
|
134
|
+
limit=10000,
|
|
135
|
+
include=["embeddings", "documents"],
|
|
136
|
+
)
|
|
137
|
+
embs = result.get("embeddings") or []
|
|
138
|
+
docs = result.get("documents") or []
|
|
139
|
+
return (embs, docs)
|