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.
Files changed (53) hide show
  1. brainlayer/__init__.py +3 -0
  2. brainlayer/cli/__init__.py +1545 -0
  3. brainlayer/cli/wizard.py +132 -0
  4. brainlayer/cli_new.py +151 -0
  5. brainlayer/client.py +164 -0
  6. brainlayer/clustering.py +736 -0
  7. brainlayer/daemon.py +1105 -0
  8. brainlayer/dashboard/README.md +129 -0
  9. brainlayer/dashboard/__init__.py +5 -0
  10. brainlayer/dashboard/app.py +151 -0
  11. brainlayer/dashboard/search.py +229 -0
  12. brainlayer/dashboard/views.py +230 -0
  13. brainlayer/embeddings.py +131 -0
  14. brainlayer/engine.py +550 -0
  15. brainlayer/index_new.py +87 -0
  16. brainlayer/mcp/__init__.py +1558 -0
  17. brainlayer/migrate.py +205 -0
  18. brainlayer/paths.py +43 -0
  19. brainlayer/pipeline/__init__.py +47 -0
  20. brainlayer/pipeline/analyze_communication.py +508 -0
  21. brainlayer/pipeline/brain_graph.py +567 -0
  22. brainlayer/pipeline/chat_tags.py +63 -0
  23. brainlayer/pipeline/chunk.py +422 -0
  24. brainlayer/pipeline/classify.py +472 -0
  25. brainlayer/pipeline/cluster_sampling.py +73 -0
  26. brainlayer/pipeline/enrichment.py +810 -0
  27. brainlayer/pipeline/extract.py +66 -0
  28. brainlayer/pipeline/extract_claude_desktop.py +149 -0
  29. brainlayer/pipeline/extract_corrections.py +231 -0
  30. brainlayer/pipeline/extract_markdown.py +195 -0
  31. brainlayer/pipeline/extract_whatsapp.py +227 -0
  32. brainlayer/pipeline/git_overlay.py +301 -0
  33. brainlayer/pipeline/longitudinal_analyzer.py +568 -0
  34. brainlayer/pipeline/obsidian_export.py +455 -0
  35. brainlayer/pipeline/operation_grouping.py +486 -0
  36. brainlayer/pipeline/plan_linking.py +313 -0
  37. brainlayer/pipeline/sanitize.py +549 -0
  38. brainlayer/pipeline/semantic_style.py +574 -0
  39. brainlayer/pipeline/session_enrichment.py +472 -0
  40. brainlayer/pipeline/style_embed.py +67 -0
  41. brainlayer/pipeline/style_index.py +139 -0
  42. brainlayer/pipeline/temporal_chains.py +203 -0
  43. brainlayer/pipeline/time_batcher.py +248 -0
  44. brainlayer/pipeline/unified_timeline.py +569 -0
  45. brainlayer/storage.py +66 -0
  46. brainlayer/store.py +155 -0
  47. brainlayer/taxonomy.json +80 -0
  48. brainlayer/vector_store.py +1891 -0
  49. brainlayer-1.0.0.dist-info/METADATA +313 -0
  50. brainlayer-1.0.0.dist-info/RECORD +53 -0
  51. brainlayer-1.0.0.dist-info/WHEEL +4 -0
  52. brainlayer-1.0.0.dist-info/entry_points.txt +4 -0
  53. brainlayer-1.0.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,568 @@
1
+ """Longitudinal communication style analyzer.
2
+
3
+ Analyzes communication patterns over time periods using LLM,
4
+ tracks evolution, and generates style rules.
5
+ """
6
+
7
+ import json
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ try:
14
+ import ollama
15
+
16
+ HAS_OLLAMA = True
17
+ except ImportError:
18
+ HAS_OLLAMA = False
19
+
20
+ from .time_batcher import TimeBatch, get_period_weight
21
+
22
+
23
+ @dataclass
24
+ class PeriodAnalysis:
25
+ """Analysis results for a single time period."""
26
+
27
+ period: str
28
+ language: str # "hebrew", "english", or "all"
29
+ message_count: int
30
+ avg_length: float
31
+ formality_score: float # 0-10
32
+ emoji_rate: float
33
+ common_phrases: list[str]
34
+ tone_description: str
35
+ key_characteristics: list[str]
36
+ raw_analysis: str # Full LLM output
37
+
38
+ def to_dict(self) -> dict:
39
+ return {
40
+ "period": self.period,
41
+ "language": self.language,
42
+ "message_count": self.message_count,
43
+ "avg_length": self.avg_length,
44
+ "formality_score": self.formality_score,
45
+ "emoji_rate": self.emoji_rate,
46
+ "common_phrases": self.common_phrases,
47
+ "tone_description": self.tone_description,
48
+ "key_characteristics": self.key_characteristics,
49
+ "raw_analysis": self.raw_analysis,
50
+ }
51
+
52
+
53
+ def analyze_batch_with_llm(
54
+ batch: TimeBatch,
55
+ language: str = "all",
56
+ model: str = "qwen3-coder-64k",
57
+ max_samples: int = 250,
58
+ style_collection=None,
59
+ ) -> PeriodAnalysis:
60
+ """
61
+ Analyze a time batch using LLM.
62
+
63
+ Args:
64
+ batch: The time batch to analyze
65
+ language: "hebrew", "english", or "all"
66
+ model: Ollama model to use
67
+ max_samples: Maximum messages to include in prompt
68
+
69
+ Returns:
70
+ PeriodAnalysis with results
71
+ """
72
+ if not HAS_OLLAMA:
73
+ raise ImportError("ollama package required: pip install ollama")
74
+
75
+ # Filter by language if specified
76
+ if language == "hebrew":
77
+ messages = batch.hebrew_messages
78
+ elif language == "english":
79
+ messages = batch.english_messages
80
+ else:
81
+ messages = batch.messages
82
+
83
+ if not messages:
84
+ return PeriodAnalysis(
85
+ period=batch.period,
86
+ language=language,
87
+ message_count=0,
88
+ avg_length=0,
89
+ formality_score=5.0,
90
+ emoji_rate=0,
91
+ common_phrases=[],
92
+ tone_description="No messages in this period",
93
+ key_characteristics=[],
94
+ raw_analysis="",
95
+ )
96
+
97
+ # Sample messages for prompt: cluster-based if style_collection, else first N
98
+ if style_collection:
99
+ from .cluster_sampling import cluster_sample_messages
100
+ from .style_index import get_embeddings_for_batch
101
+
102
+ start_epoch = batch.start_date.timestamp()
103
+ end_epoch = batch.end_date.timestamp()
104
+ embs, docs = get_embeddings_for_batch(style_collection, start_epoch, end_epoch, language)
105
+ if embs and docs:
106
+
107
+ class _TextDoc:
108
+ def __init__(self, text):
109
+ self.text = text
110
+
111
+ items = [_TextDoc(d) for d in docs]
112
+ samples = cluster_sample_messages(items, embs, max_total=max_samples)
113
+ samples_text = "\n".join([f"- {m.text[:200]}" for m in samples])
114
+ else:
115
+ samples = messages[:max_samples]
116
+ samples_text = "\n".join([f"- {m.text[:200]}" for m in samples])
117
+ else:
118
+ samples = messages[:max_samples]
119
+ samples_text = "\n".join([f"- {m.text[:200]}" for m in samples])
120
+
121
+ # Calculate basic metrics
122
+ total_length = sum(len(m.text) for m in messages)
123
+ avg_length = total_length / len(messages)
124
+
125
+ # Count emojis (simple pattern)
126
+ import re
127
+
128
+ emoji_pattern = re.compile(
129
+ r"[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF\U0001F1E0-\U0001F1FF]"
130
+ )
131
+ emoji_count = sum(len(emoji_pattern.findall(m.text)) for m in messages)
132
+ emoji_rate = emoji_count / len(messages)
133
+
134
+ relationship_ctx = batch.get_relationship_context()
135
+ relationship_block = (
136
+ f"\n{relationship_ctx}\nConsider how tone may shift by relationship.\n" if relationship_ctx else ""
137
+ )
138
+
139
+ prompt = f"""Analyze this person's communication style from {len(samples)} messages in the period {batch.period}.
140
+
141
+ Language focus: {language.upper()}
142
+ {relationship_block}
143
+ CRITICAL RULES:
144
+ - Every phrase, example, and quote MUST appear verbatim in the sample messages below. Do NOT invent or paraphrase.
145
+ - Be specific. Cite exact text from the messages.
146
+ - If a phrase appears only once, note it as "occasional" not "frequent."
147
+
148
+ Sample messages:
149
+ {samples_text}
150
+
151
+ Provide a structured analysis:
152
+
153
+ ## 1. FORMALITY SCORE (0-10)
154
+ Rate from 0 (very casual) to 10 (very formal). Provide the number and brief justification. Reference specific messages.
155
+
156
+ ## 2. TONE DESCRIPTION
157
+ Describe the overall tone in 1-2 sentences. Base this on actual message content.
158
+
159
+ ## 3. KEY CHARACTERISTICS
160
+ List 3-5 distinct characteristics. Each must be different—no overlap (e.g., don't list both "short messages" and "concise" as separate items).
161
+
162
+ ## 4. COMMON PHRASES
163
+ List 5-10 phrases they use. Copy them EXACTLY from the messages—character for character. Include nothing invented.
164
+
165
+ ## 5. COMMUNICATION PATTERNS
166
+ - How do they start messages? (quote real examples)
167
+ - How do they make requests? (quote real examples)
168
+ - How do they express emotions? (quote real examples)
169
+ - Use of punctuation and capitalization
170
+ """
171
+
172
+ response = ollama.generate(
173
+ model=model,
174
+ prompt=prompt,
175
+ options={
176
+ "num_ctx": 16000,
177
+ "temperature": 0.1,
178
+ },
179
+ )
180
+
181
+ raw_analysis = response["response"]
182
+
183
+ # Parse formality score from response (simple extraction)
184
+ formality_score = 5.0 # Default
185
+ import re
186
+
187
+ score_match = re.search(r"(?:score|rating)[:\s]*(\d+(?:\.\d+)?)", raw_analysis.lower())
188
+ if score_match:
189
+ try:
190
+ formality_score = float(score_match.group(1))
191
+ except ValueError:
192
+ pass
193
+
194
+ # Extract key characteristics (lines starting with - or *)
195
+ char_pattern = re.compile(r"^[\-\*]\s*(.+)$", re.MULTILINE)
196
+ characteristics = char_pattern.findall(raw_analysis)[:10]
197
+
198
+ # Extract common phrases (quoted text)
199
+ phrase_pattern = re.compile(r'"([^"]+)"')
200
+ phrases = phrase_pattern.findall(raw_analysis)[:10]
201
+
202
+ # Extract tone description (first sentence after "tone" keyword)
203
+ tone_desc = "Professional and direct" # Default
204
+ tone_match = re.search(r"tone[:\s]+([^.]+\.)", raw_analysis.lower())
205
+ if tone_match:
206
+ tone_desc = tone_match.group(1).strip().capitalize()
207
+
208
+ return PeriodAnalysis(
209
+ period=batch.period,
210
+ language=language,
211
+ message_count=len(messages),
212
+ avg_length=avg_length,
213
+ formality_score=formality_score,
214
+ emoji_rate=emoji_rate,
215
+ common_phrases=phrases,
216
+ tone_description=tone_desc,
217
+ key_characteristics=characteristics,
218
+ raw_analysis=raw_analysis,
219
+ )
220
+
221
+
222
+ def analyze_evolution(
223
+ analyses: list[PeriodAnalysis],
224
+ model: str = "qwen3-coder-64k",
225
+ ) -> str:
226
+ """
227
+ Analyze how communication style evolved across periods.
228
+
229
+ Args:
230
+ analyses: List of period analyses in chronological order
231
+ model: Ollama model to use
232
+
233
+ Returns:
234
+ Evolution analysis as markdown
235
+ """
236
+ if not HAS_OLLAMA:
237
+ raise ImportError("ollama package required: pip install ollama")
238
+
239
+ if len(analyses) < 2:
240
+ return "Not enough periods to analyze evolution."
241
+
242
+ # Build summary of each period
243
+ summaries = []
244
+ for a in analyses:
245
+ summaries.append(f"""
246
+ ### {a.period} ({a.language})
247
+ - Messages: {a.message_count}
248
+ - Formality: {a.formality_score}/10
249
+ - Avg length: {a.avg_length:.0f} chars
250
+ - Emoji rate: {a.emoji_rate:.2f}
251
+ - Tone: {a.tone_description}
252
+ - Key traits: {", ".join(a.key_characteristics[:3])}
253
+ """)
254
+
255
+ prompt = f"""Analyze how this person's communication style evolved over time.
256
+
257
+ Period summaries:
258
+ {"".join(summaries)}
259
+
260
+ Provide an evolution analysis:
261
+
262
+ ## 1. OVERALL TRAJECTORY
263
+ How has their style changed from earliest to latest period?
264
+
265
+ ## 2. FORMALITY TREND
266
+ Are they becoming more or less formal? Why might this be?
267
+
268
+ ## 3. NOTABLE CHANGES
269
+ What are the most significant changes between periods?
270
+
271
+ ## 4. CONSISTENCY
272
+ What aspects of their style have remained consistent?
273
+
274
+ ## 5. PREDICTIONS
275
+ Based on the trajectory, how might their style continue to evolve?
276
+
277
+ Be specific and reference the data from each period.
278
+ """
279
+
280
+ response = ollama.generate(
281
+ model=model,
282
+ prompt=prompt,
283
+ options={
284
+ "num_ctx": 16000,
285
+ "temperature": 0.1,
286
+ },
287
+ )
288
+
289
+ return response["response"]
290
+
291
+
292
+ def _collect_grounded_phrases(analyses: list[PeriodAnalysis]) -> list[str]:
293
+ """Collect unique phrases from analyses (recent-weighted) for grounded examples."""
294
+ seen = set()
295
+ phrases = []
296
+ current_year = datetime.now().year
297
+ for a in reversed(analyses): # Recent first
298
+ weight = get_period_weight(a.period, current_year)
299
+ for p in a.common_phrases:
300
+ key = p.strip().lower()[:50]
301
+ if key and key not in seen and len(p) > 2:
302
+ seen.add(key)
303
+ phrases.append(p)
304
+ if len(phrases) >= 30:
305
+ return phrases
306
+ return phrases
307
+
308
+
309
+ def generate_weighted_master_guide(
310
+ analyses: list[PeriodAnalysis],
311
+ evolution_analysis: str,
312
+ model: str = "qwen3-coder-64k",
313
+ ) -> str:
314
+ """
315
+ Generate a master style guide using two-pass approach:
316
+ Pass 1: Extract raw rules (grounded in actual phrases)
317
+ Pass 2: Consolidate, deduplicate, validate
318
+ """
319
+ if not HAS_OLLAMA:
320
+ raise ImportError("ollama package required: pip install ollama")
321
+
322
+ current_year = datetime.now().year
323
+ grounded_phrases = _collect_grounded_phrases(analyses)
324
+ phrases_block = "\n".join(f'- "{p}"' for p in grounded_phrases[:25])
325
+
326
+ weighted_summaries = []
327
+ for a in analyses:
328
+ weight = get_period_weight(a.period, current_year)
329
+ weighted_summaries.append(f"""
330
+ ### {a.period} (weight: {weight:.0%})
331
+ - Formality: {a.formality_score}/10
332
+ - Tone: {a.tone_description}
333
+ - Key traits: {", ".join(a.key_characteristics[:5])}
334
+ - Common phrases: {", ".join(a.common_phrases[:5])}
335
+ """)
336
+
337
+ # Pass 1: Extract with strict grounding
338
+ pass1_prompt = f"""Create a communication style guide based on this person's writing patterns.
339
+
340
+ PHRASES you MAY use as examples (quote exactly—do not invent):
341
+ {phrases_block}
342
+
343
+ Period analyses:
344
+ {"".join(weighted_summaries)}
345
+
346
+ Evolution context:
347
+ {evolution_analysis[:1500]}
348
+
349
+ Generate a MASTER STYLE GUIDE. CRITICAL RULES:
350
+ 1. Every example in DO's and DON'Ts MUST be a direct quote from the phrases above or clearly derived from the period analyses. No invented examples.
351
+ 2. Each DO rule must be DISTINCT—if two rules say the same thing, merge them.
352
+ 3. Each DON'T rule must be DISTINCT—same principle.
353
+ 4. Aim for 8-10 DO's and 8-10 DON'Ts. Quality over quantity.
354
+
355
+ Structure:
356
+ ## 1. CURRENT VOICE (Who They Are Now)
357
+ ## 2. DO's - Rules for AI to Follow (with real examples)
358
+ ## 3. DON'Ts - What to Avoid (with real counter-examples)
359
+ ## 4. LANGUAGE-SPECIFIC NOTES
360
+ ## 5. EXAMPLE TRANSFORMATIONS (use phrases from the list above)
361
+ """
362
+
363
+ response1 = ollama.generate(model=model, prompt=pass1_prompt, options={"num_ctx": 24000, "temperature": 0.15})
364
+ raw_guide = response1["response"]
365
+
366
+ # Pass 2: Consolidate and validate
367
+ pass2_prompt = f"""You have a draft style guide. Your task: consolidate and validate it.
368
+
369
+ GROUNDED PHRASES (these are real—use only these for examples):
370
+ {phrases_block}
371
+
372
+ DRAFT GUIDE:
373
+ {raw_guide}
374
+
375
+ REVISION RULES:
376
+ 1. Merge any DO rules that convey the same principle (e.g., "short messages" + "be concise" = one rule).
377
+ 2. Merge any DON'T rules that overlap.
378
+ 3. Replace any invented example with a phrase from the GROUNDED PHRASES list. If no match exists, remove the example.
379
+ 4. Ensure each rule is actionable and distinct.
380
+ 5. Remove redundancy—every rule should add new information.
381
+ 6. Keep the same structure (CURRENT VOICE, DO's, DON'Ts, LANGUAGE-SPECIFIC, TRANSFORMATIONS).
382
+ 7. Final output: 8-10 DO's, 8-10 DON'Ts.
383
+
384
+ Output the revised, consolidated guide. No preamble.
385
+ """
386
+
387
+ response2 = ollama.generate(model=model, prompt=pass2_prompt, options={"num_ctx": 24000, "temperature": 0.1})
388
+
389
+ return response2["response"]
390
+
391
+
392
+ def generate_human_summary(
393
+ analyses: list[PeriodAnalysis],
394
+ evolution_analysis: str,
395
+ ) -> str:
396
+ """
397
+ Generate a short, human-readable summary.
398
+
399
+ Args:
400
+ analyses: List of period analyses
401
+ evolution_analysis: The evolution analysis text
402
+
403
+ Returns:
404
+ Human-friendly summary as markdown
405
+ """
406
+ if not analyses:
407
+ return "# No Data\n\nNo communication data available for analysis."
408
+
409
+ # Get most recent analysis
410
+ recent = analyses[-1] if analyses else None
411
+
412
+ # Calculate averages
413
+ avg_formality = sum(a.formality_score for a in analyses) / len(analyses)
414
+ avg_length = sum(a.avg_length for a in analyses) / len(analyses)
415
+ avg_emoji = sum(a.emoji_rate for a in analyses) / len(analyses)
416
+
417
+ # Determine trends
418
+ if len(analyses) >= 2:
419
+ formality_trend = analyses[-1].formality_score - analyses[0].formality_score
420
+ if formality_trend > 1:
421
+ formality_direction = "becoming more formal"
422
+ elif formality_trend < -1:
423
+ formality_direction = "becoming more casual"
424
+ else:
425
+ formality_direction = "staying consistent"
426
+ else:
427
+ formality_direction = "not enough data for trend"
428
+
429
+ summary = f"""# Your Communication Style - Summary
430
+
431
+ *Generated: {datetime.now().strftime("%Y-%m-%d")}*
432
+ *Based on {sum(a.message_count for a in analyses):,} messages across {len(analyses)} time periods*
433
+
434
+ ---
435
+
436
+ ## Who You Are Now ({recent.period if recent else "N/A"})
437
+
438
+ - **Formality**: {recent.formality_score if recent else "N/A"}/10
439
+ - **Average message length**: {(recent.avg_length if recent else 0):.0f} characters
440
+ - **Emoji usage**: {(recent.emoji_rate if recent else 0):.2f} per message
441
+ - **Tone**: {recent.tone_description if recent else "Unknown"}
442
+
443
+ ## Key Characteristics
444
+
445
+ {chr(10).join(f"- {c}" for c in (recent.key_characteristics[:5] if recent else []))}
446
+
447
+ ## How You've Changed
448
+
449
+ - **Overall**: {formality_direction}
450
+ - **Average formality**: {avg_formality:.1f}/10
451
+ - **Average message length**: {avg_length:.0f} chars
452
+
453
+ ## Common Phrases You Use
454
+
455
+ {chr(10).join(f'- "{p}"' for p in (recent.common_phrases[:5] if recent else []))}
456
+
457
+ ## Quick Rules for AI
458
+
459
+ 1. Match formality level: {recent.formality_score if recent else 5}/10
460
+ 2. Keep messages around {(recent.avg_length if recent else 100):.0f} characters
461
+ 3. {"Use emojis sparingly" if (recent and recent.emoji_rate < 0.5) else "Emojis are acceptable"}
462
+ 4. Be direct and get to the point
463
+ 5. Match the {recent.tone_description.lower() if recent else "neutral"} tone
464
+
465
+ ---
466
+
467
+ *For detailed rules, see master-style-guide.md*
468
+ """
469
+
470
+ return summary
471
+
472
+
473
+ def run_full_analysis(
474
+ batches: list[TimeBatch],
475
+ output_dir: Path,
476
+ languages: list[str] = ["hebrew", "english"],
477
+ model: str = "qwen3-coder-64k",
478
+ progress_callback: Optional[callable] = None,
479
+ style_collection=None,
480
+ ) -> dict:
481
+ """
482
+ Run complete longitudinal analysis and save all outputs.
483
+
484
+ Args:
485
+ batches: List of time batches
486
+ output_dir: Directory to save output files
487
+ languages: Languages to analyze
488
+ model: Ollama model to use
489
+ progress_callback: Optional callback for progress updates
490
+
491
+ Returns:
492
+ Dictionary with paths to generated files
493
+ """
494
+ output_dir = Path(output_dir)
495
+ output_dir.mkdir(parents=True, exist_ok=True)
496
+
497
+ per_period_dir = output_dir / "per-period"
498
+ per_period_dir.mkdir(exist_ok=True)
499
+
500
+ all_analyses = []
501
+
502
+ # Analyze each batch
503
+ for i, batch in enumerate(batches):
504
+ if progress_callback:
505
+ progress_callback(f"Analyzing {batch.period}...", i, len(batches))
506
+
507
+ for lang in languages:
508
+ # Check if there are messages in this language
509
+ if lang == "hebrew" and not batch.hebrew_messages:
510
+ continue
511
+ if lang == "english" and not batch.english_messages:
512
+ continue
513
+
514
+ analysis = analyze_batch_with_llm(batch, language=lang, model=model, style_collection=style_collection)
515
+ all_analyses.append(analysis)
516
+
517
+ # Save individual period analysis
518
+ period_file = per_period_dir / f"{batch.period}-{lang}-style.md"
519
+ with open(period_file, "w") as f:
520
+ f.write(f"# {batch.period} - {lang.title()} Style\n\n")
521
+ f.write(analysis.raw_analysis)
522
+
523
+ if progress_callback:
524
+ progress_callback("Analyzing evolution...", len(batches), len(batches))
525
+
526
+ # Analyze evolution
527
+ evolution = analyze_evolution(all_analyses, model=model)
528
+ evolution_file = output_dir / "evolution-analysis.md"
529
+ with open(evolution_file, "w") as f:
530
+ f.write("# Communication Style Evolution\n\n")
531
+ f.write(evolution)
532
+
533
+ if progress_callback:
534
+ progress_callback("Generating master guide...", len(batches), len(batches))
535
+
536
+ # Generate master guide
537
+ master_guide = generate_weighted_master_guide(all_analyses, evolution, model=model)
538
+ master_file = output_dir / "master-style-guide.md"
539
+ with open(master_file, "w") as f:
540
+ f.write("# Master Communication Style Guide\n\n")
541
+ f.write(master_guide)
542
+
543
+ # Generate human summary
544
+ human_summary = generate_human_summary(all_analyses, evolution)
545
+ summary_file = output_dir / "human-summary.md"
546
+ with open(summary_file, "w") as f:
547
+ f.write(human_summary)
548
+
549
+ # Save raw analysis data
550
+ data_file = output_dir / "analysis-data.json"
551
+ with open(data_file, "w") as f:
552
+ json.dump(
553
+ {
554
+ "analyses": [a.to_dict() for a in all_analyses],
555
+ "evolution": evolution,
556
+ "generated_at": datetime.now().isoformat(),
557
+ },
558
+ f,
559
+ indent=2,
560
+ )
561
+
562
+ return {
563
+ "per_period_dir": str(per_period_dir),
564
+ "evolution_file": str(evolution_file),
565
+ "master_file": str(master_file),
566
+ "summary_file": str(summary_file),
567
+ "data_file": str(data_file),
568
+ }