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,203 @@
|
|
|
1
|
+
"""Temporal Chains Pipeline — Link sessions by shared files.
|
|
2
|
+
|
|
3
|
+
Phase 8d: Build topic chains across sessions and enable
|
|
4
|
+
regression detection queries.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from brainlayer.pipeline.temporal_chains import run_temporal_chains
|
|
8
|
+
run_temporal_chains(vector_store, project="my-project")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_timestamp(ts: Optional[str]) -> Optional[float]:
|
|
19
|
+
"""Parse ISO timestamp to epoch seconds."""
|
|
20
|
+
if not ts:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
ts_clean = ts.replace("Z", "+00:00")
|
|
26
|
+
dt = datetime.fromisoformat(ts_clean)
|
|
27
|
+
return dt.timestamp()
|
|
28
|
+
except (ValueError, TypeError):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_file_session_map(
|
|
33
|
+
vector_store: Any,
|
|
34
|
+
project: Optional[str] = None,
|
|
35
|
+
) -> Dict[str, List[Dict[str, Any]]]:
|
|
36
|
+
"""Build a map of file → sessions that touched it.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Dict mapping file_path to list of session info dicts
|
|
40
|
+
(session_id, actions, first_timestamp, last_timestamp)
|
|
41
|
+
"""
|
|
42
|
+
cursor = vector_store.conn.cursor()
|
|
43
|
+
query = """
|
|
44
|
+
SELECT fi.file_path, fi.session_id,
|
|
45
|
+
fi.action, fi.timestamp
|
|
46
|
+
FROM file_interactions fi
|
|
47
|
+
"""
|
|
48
|
+
params: list = []
|
|
49
|
+
if project:
|
|
50
|
+
query += " WHERE fi.project = ?"
|
|
51
|
+
params.append(project)
|
|
52
|
+
query += " ORDER BY fi.file_path, fi.timestamp"
|
|
53
|
+
|
|
54
|
+
rows = list(cursor.execute(query, params))
|
|
55
|
+
|
|
56
|
+
# Group by file, then by session
|
|
57
|
+
file_sessions: Dict[str, Dict[str, Dict[str, Any]]] = defaultdict(
|
|
58
|
+
lambda: defaultdict(
|
|
59
|
+
lambda: {
|
|
60
|
+
"actions": [],
|
|
61
|
+
"first_ts": None,
|
|
62
|
+
"last_ts": None,
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
for row in rows:
|
|
68
|
+
fp, sid, action, ts = row[0], row[1], row[2], row[3]
|
|
69
|
+
entry = file_sessions[fp][sid]
|
|
70
|
+
if action:
|
|
71
|
+
entry["actions"].append(action)
|
|
72
|
+
if ts:
|
|
73
|
+
if not entry["first_ts"] or ts < entry["first_ts"]:
|
|
74
|
+
entry["first_ts"] = ts
|
|
75
|
+
if not entry["last_ts"] or ts > entry["last_ts"]:
|
|
76
|
+
entry["last_ts"] = ts
|
|
77
|
+
|
|
78
|
+
# Convert to list format
|
|
79
|
+
result: Dict[str, List[Dict[str, Any]]] = {}
|
|
80
|
+
for fp, sessions in file_sessions.items():
|
|
81
|
+
session_list = []
|
|
82
|
+
for sid, info in sessions.items():
|
|
83
|
+
session_list.append(
|
|
84
|
+
{
|
|
85
|
+
"session_id": sid,
|
|
86
|
+
"action_count": len(info["actions"]),
|
|
87
|
+
"actions": list(set(info["actions"])),
|
|
88
|
+
"first_ts": info["first_ts"],
|
|
89
|
+
"last_ts": info["last_ts"],
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
# Sort by first timestamp
|
|
93
|
+
session_list.sort(key=lambda x: x.get("first_ts") or "")
|
|
94
|
+
result[fp] = session_list
|
|
95
|
+
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_topic_chains(
|
|
100
|
+
file_session_map: Dict[str, List[Dict[str, Any]]],
|
|
101
|
+
project: Optional[str] = None,
|
|
102
|
+
min_sessions: int = 2,
|
|
103
|
+
) -> List[Dict[str, Any]]:
|
|
104
|
+
"""Build topic chains from file→session map.
|
|
105
|
+
|
|
106
|
+
A topic chain links consecutive sessions that touched
|
|
107
|
+
the same file. This reveals how work on a file evolves
|
|
108
|
+
across sessions.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
file_session_map: Output from build_file_session_map
|
|
112
|
+
project: Project name for metadata
|
|
113
|
+
min_sessions: Min sessions per file to create chains
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
List of chain dicts for store_topic_chains()
|
|
117
|
+
"""
|
|
118
|
+
chains = []
|
|
119
|
+
|
|
120
|
+
for fp, sessions in file_session_map.items():
|
|
121
|
+
if len(sessions) < min_sessions:
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
# Link consecutive sessions
|
|
125
|
+
for i in range(len(sessions) - 1):
|
|
126
|
+
sa = sessions[i]
|
|
127
|
+
sb = sessions[i + 1]
|
|
128
|
+
|
|
129
|
+
# Calculate time delta
|
|
130
|
+
ta = _parse_timestamp(sa.get("last_ts"))
|
|
131
|
+
tb = _parse_timestamp(sb.get("first_ts"))
|
|
132
|
+
delta_hours = None
|
|
133
|
+
if ta and tb:
|
|
134
|
+
delta_hours = round((tb - ta) / 3600, 2)
|
|
135
|
+
|
|
136
|
+
# Count shared actions
|
|
137
|
+
shared = len(set(sa["actions"]) & set(sb["actions"]))
|
|
138
|
+
|
|
139
|
+
chains.append(
|
|
140
|
+
{
|
|
141
|
+
"file_path": fp,
|
|
142
|
+
"session_a": sa["session_id"],
|
|
143
|
+
"session_b": sb["session_id"],
|
|
144
|
+
"shared_actions": shared,
|
|
145
|
+
"time_delta_hours": delta_hours,
|
|
146
|
+
"project": project,
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return chains
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run_temporal_chains(
|
|
154
|
+
vector_store: Any,
|
|
155
|
+
project: Optional[str] = None,
|
|
156
|
+
force: bool = False,
|
|
157
|
+
) -> Dict[str, int]:
|
|
158
|
+
"""Run temporal chain building.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
vector_store: VectorStore instance
|
|
162
|
+
project: Filter to specific project
|
|
163
|
+
force: Clear existing chains first
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Dict with counts: files_analyzed, chains_created
|
|
167
|
+
"""
|
|
168
|
+
if force:
|
|
169
|
+
vector_store.clear_topic_chains(project)
|
|
170
|
+
|
|
171
|
+
# Check if chains already exist
|
|
172
|
+
stats = vector_store.get_topic_chain_stats()
|
|
173
|
+
if stats["total_chains"] > 0 and not force:
|
|
174
|
+
logger.info(
|
|
175
|
+
"Topic chains already exist (%d chains). Use --force to rebuild.",
|
|
176
|
+
stats["total_chains"],
|
|
177
|
+
)
|
|
178
|
+
return {
|
|
179
|
+
"files_analyzed": 0,
|
|
180
|
+
"chains_created": 0,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Build file→session map
|
|
184
|
+
file_map = build_file_session_map(vector_store, project=project)
|
|
185
|
+
logger.info(
|
|
186
|
+
"Built file map: %d files across sessions",
|
|
187
|
+
len(file_map),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Build chains
|
|
191
|
+
chains = build_topic_chains(file_map, project=project)
|
|
192
|
+
logger.info("Found %d topic chains", len(chains))
|
|
193
|
+
|
|
194
|
+
# Store
|
|
195
|
+
if chains:
|
|
196
|
+
count = vector_store.store_topic_chains(chains)
|
|
197
|
+
else:
|
|
198
|
+
count = 0
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
"files_analyzed": len(file_map),
|
|
202
|
+
"chains_created": count,
|
|
203
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Time-based batching for longitudinal analysis.
|
|
2
|
+
|
|
3
|
+
Groups messages by time periods (half-year by default) for evolution tracking.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from .unified_timeline import UnifiedMessage, UnifiedTimeline
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class TimeBatch:
|
|
16
|
+
"""A batch of messages from a specific time period."""
|
|
17
|
+
|
|
18
|
+
period: str # e.g., "2024-H1", "2025-H2"
|
|
19
|
+
start_date: datetime
|
|
20
|
+
end_date: datetime
|
|
21
|
+
messages: list[UnifiedMessage]
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def count(self) -> int:
|
|
25
|
+
return len(self.messages)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def hebrew_messages(self) -> list[UnifiedMessage]:
|
|
29
|
+
return [m for m in self.messages if m.language == "hebrew"]
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def english_messages(self) -> list[UnifiedMessage]:
|
|
33
|
+
return [m for m in self.messages if m.language == "english"]
|
|
34
|
+
|
|
35
|
+
def get_relationship_context(self) -> str:
|
|
36
|
+
"""Get relationship mix for prompt enrichment (e.g. '~60% family, ~30% friends')."""
|
|
37
|
+
tagged = [m for m in self.messages if m.relationship_tag]
|
|
38
|
+
if not tagged:
|
|
39
|
+
return ""
|
|
40
|
+
from collections import Counter
|
|
41
|
+
|
|
42
|
+
counts = Counter(m.relationship_tag for m in tagged)
|
|
43
|
+
total = len(tagged)
|
|
44
|
+
parts = [f"~{100 * c // total}% {tag}" for tag, c in counts.most_common(5)]
|
|
45
|
+
contacts = list({m.contact_name for m in tagged if m.contact_name})[:8]
|
|
46
|
+
return f"Relationship context: {', '.join(parts)}. Contacts: {', '.join(contacts)}."
|
|
47
|
+
|
|
48
|
+
def get_stats(self) -> dict:
|
|
49
|
+
"""Get statistics for this batch."""
|
|
50
|
+
if not self.messages:
|
|
51
|
+
return {
|
|
52
|
+
"period": self.period,
|
|
53
|
+
"total": 0,
|
|
54
|
+
"hebrew": 0,
|
|
55
|
+
"english": 0,
|
|
56
|
+
"avg_length": 0,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
total_length = sum(len(m.text) for m in self.messages)
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
"period": self.period,
|
|
63
|
+
"total": self.count,
|
|
64
|
+
"hebrew": len(self.hebrew_messages),
|
|
65
|
+
"english": len(self.english_messages),
|
|
66
|
+
"avg_length": total_length / self.count,
|
|
67
|
+
"date_range": f"{self.start_date.date()} to {self.end_date.date()}",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_period_key(timestamp: datetime, granularity: Literal["year", "half", "quarter", "month"] = "half") -> str:
|
|
72
|
+
"""
|
|
73
|
+
Get the period key for a timestamp.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
timestamp: The datetime to categorize
|
|
77
|
+
granularity: How to group periods
|
|
78
|
+
- "year": "2024", "2025"
|
|
79
|
+
- "half": "2024-H1", "2024-H2"
|
|
80
|
+
- "quarter": "2024-Q1", "2024-Q2", "2024-Q3", "2024-Q4"
|
|
81
|
+
- "month": "2024-01", "2024-02"
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Period key string
|
|
85
|
+
"""
|
|
86
|
+
year = timestamp.year
|
|
87
|
+
month = timestamp.month
|
|
88
|
+
|
|
89
|
+
if granularity == "year":
|
|
90
|
+
return str(year)
|
|
91
|
+
elif granularity == "half":
|
|
92
|
+
half = "H1" if month <= 6 else "H2"
|
|
93
|
+
return f"{year}-{half}"
|
|
94
|
+
elif granularity == "quarter":
|
|
95
|
+
quarter = (month - 1) // 3 + 1
|
|
96
|
+
return f"{year}-Q{quarter}"
|
|
97
|
+
elif granularity == "month":
|
|
98
|
+
return f"{year}-{month:02d}"
|
|
99
|
+
else:
|
|
100
|
+
raise ValueError(f"Unknown granularity: {granularity}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_period_dates(
|
|
104
|
+
period: str, granularity: Literal["year", "half", "quarter", "month"] = "half"
|
|
105
|
+
) -> tuple[datetime, datetime]:
|
|
106
|
+
"""
|
|
107
|
+
Get the start and end dates for a period.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
(start_date, end_date) tuple
|
|
111
|
+
"""
|
|
112
|
+
if granularity == "year":
|
|
113
|
+
year = int(period)
|
|
114
|
+
return (datetime(year, 1, 1), datetime(year, 12, 31, 23, 59, 59))
|
|
115
|
+
elif granularity == "half":
|
|
116
|
+
year, half = period.split("-")
|
|
117
|
+
year = int(year)
|
|
118
|
+
if half == "H1":
|
|
119
|
+
return (datetime(year, 1, 1), datetime(year, 6, 30, 23, 59, 59))
|
|
120
|
+
else:
|
|
121
|
+
return (datetime(year, 7, 1), datetime(year, 12, 31, 23, 59, 59))
|
|
122
|
+
elif granularity == "quarter":
|
|
123
|
+
year, quarter = period.split("-Q")
|
|
124
|
+
year = int(year)
|
|
125
|
+
quarter = int(quarter)
|
|
126
|
+
start_month = (quarter - 1) * 3 + 1
|
|
127
|
+
end_month = quarter * 3
|
|
128
|
+
if end_month == 12:
|
|
129
|
+
end_day = 31
|
|
130
|
+
elif end_month in [4, 6, 9, 11]:
|
|
131
|
+
end_day = 30
|
|
132
|
+
else:
|
|
133
|
+
end_day = 31 # Simplified
|
|
134
|
+
return (datetime(year, start_month, 1), datetime(year, end_month, end_day, 23, 59, 59))
|
|
135
|
+
elif granularity == "month":
|
|
136
|
+
year, month = period.split("-")
|
|
137
|
+
year = int(year)
|
|
138
|
+
month = int(month)
|
|
139
|
+
# Last day of month (simplified)
|
|
140
|
+
if month == 12:
|
|
141
|
+
end_date = datetime(year + 1, 1, 1) - timedelta(seconds=1)
|
|
142
|
+
else:
|
|
143
|
+
end_date = datetime(year, month + 1, 1) - timedelta(seconds=1)
|
|
144
|
+
return (datetime(year, month, 1), end_date)
|
|
145
|
+
else:
|
|
146
|
+
raise ValueError(f"Unknown granularity: {granularity}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
from datetime import timedelta
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def create_time_batches(
|
|
153
|
+
timeline: UnifiedTimeline,
|
|
154
|
+
granularity: Literal["year", "half", "quarter", "month"] = "half",
|
|
155
|
+
min_messages: int = 10,
|
|
156
|
+
) -> list[TimeBatch]:
|
|
157
|
+
"""
|
|
158
|
+
Create time batches from a unified timeline.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
timeline: The unified timeline to batch
|
|
162
|
+
granularity: How to group periods
|
|
163
|
+
min_messages: Minimum messages required for a batch (otherwise merged)
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
List of TimeBatch objects sorted by period
|
|
167
|
+
"""
|
|
168
|
+
# Group messages by period
|
|
169
|
+
period_messages: dict[str, list[UnifiedMessage]] = defaultdict(list)
|
|
170
|
+
|
|
171
|
+
for msg in timeline.messages:
|
|
172
|
+
period = get_period_key(msg.timestamp, granularity)
|
|
173
|
+
period_messages[period].append(msg)
|
|
174
|
+
|
|
175
|
+
# Create batches
|
|
176
|
+
batches = []
|
|
177
|
+
for period in sorted(period_messages.keys()):
|
|
178
|
+
messages = period_messages[period]
|
|
179
|
+
start_date, end_date = get_period_dates(period, granularity)
|
|
180
|
+
|
|
181
|
+
batches.append(
|
|
182
|
+
TimeBatch(
|
|
183
|
+
period=period,
|
|
184
|
+
start_date=start_date,
|
|
185
|
+
end_date=end_date,
|
|
186
|
+
messages=messages,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Optionally merge small batches (skip for now, can add later)
|
|
191
|
+
# if min_messages > 0:
|
|
192
|
+
# batches = merge_small_batches(batches, min_messages)
|
|
193
|
+
|
|
194
|
+
return batches
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_period_weight(period: str, current_year: int = None) -> float:
|
|
198
|
+
"""
|
|
199
|
+
Get weight for a period (more recent = higher weight).
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
period: Period string like "2024-H1"
|
|
203
|
+
current_year: Current year for reference (default: now)
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Weight between 0.4 and 1.0
|
|
207
|
+
"""
|
|
208
|
+
if current_year is None:
|
|
209
|
+
current_year = datetime.now().year
|
|
210
|
+
|
|
211
|
+
# Extract year from period
|
|
212
|
+
try:
|
|
213
|
+
year = int(period.split("-")[0])
|
|
214
|
+
except (ValueError, IndexError):
|
|
215
|
+
return 0.5 # Default weight
|
|
216
|
+
|
|
217
|
+
years_ago = current_year - year
|
|
218
|
+
|
|
219
|
+
if years_ago <= 0:
|
|
220
|
+
return 1.0 # Current year
|
|
221
|
+
elif years_ago == 1:
|
|
222
|
+
return 0.8
|
|
223
|
+
elif years_ago == 2:
|
|
224
|
+
return 0.6
|
|
225
|
+
else:
|
|
226
|
+
return 0.4 # Older
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def format_batches_summary(batches: list[TimeBatch]) -> str:
|
|
230
|
+
"""Format a summary of all batches."""
|
|
231
|
+
lines = ["# Time Batches Summary\n"]
|
|
232
|
+
|
|
233
|
+
total_messages = sum(b.count for b in batches)
|
|
234
|
+
lines.append(f"Total batches: {len(batches)}")
|
|
235
|
+
lines.append(f"Total messages: {total_messages:,}")
|
|
236
|
+
lines.append("")
|
|
237
|
+
lines.append("| Period | Messages | Hebrew | English | Avg Length |")
|
|
238
|
+
lines.append("|--------|----------|--------|---------|------------|")
|
|
239
|
+
|
|
240
|
+
for batch in batches:
|
|
241
|
+
stats = batch.get_stats()
|
|
242
|
+
lines.append(
|
|
243
|
+
f"| {stats['period']} | {stats['total']:,} | "
|
|
244
|
+
f"{stats['hebrew']:,} | {stats['english']:,} | "
|
|
245
|
+
f"{stats['avg_length']:.0f} |"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return "\n".join(lines)
|