Skip to content

Commit ca49392

Browse files
author
Wentai Zhang
committed
feat(insights): add concurrent LLM analysis with ThreadPoolExecutor
- extract_facets() now runs sessions in parallel via ThreadPoolExecutor - run_aggregate_analysis() runs 7 prompts concurrently - Default workers: min(cpu_count, 8), override with --concurrency N - Thread-safe via lock for cache writes and progress reporting - All 344 existing tests pass unchanged
1 parent a214e3f commit ca49392

5 files changed

Lines changed: 109 additions & 49 deletions

File tree

src/opencode_usage/cli.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,13 @@ def _build_parser() -> argparse.ArgumentParser:
120120
dest="force",
121121
help="Force re-analysis, ignoring cache",
122122
)
123-
123+
p.add_argument(
124+
"--concurrency",
125+
type=int,
126+
default=None,
127+
metavar="N",
128+
help="Max parallel LLM workers (default: min(cpu_count, 8))",
129+
)
124130
p.add_argument(
125131
"--output",
126132
default="./opencode-insights.html",

src/opencode_usage/insights/analyze.py

Lines changed: 97 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
from __future__ import annotations
44

55
import json
6+
import os
67
import subprocess
8+
import threading
79
import time
810
import warnings
911
from collections import defaultdict
12+
from concurrent.futures import ThreadPoolExecutor, as_completed
1013
from pathlib import Path
1114
from typing import TYPE_CHECKING
1215

@@ -142,6 +145,18 @@ def run_llm(
142145

143146

144147
_MAX_NEW_SESSIONS = 50
148+
_MAX_CONCURRENCY = 8
149+
150+
151+
def _default_concurrency(override: int | None = None) -> int:
152+
"""Return worker count: override > 0, else min(cpu_count, _MAX_CONCURRENCY)."""
153+
if override is not None and override > 0:
154+
return override
155+
try:
156+
cpu = os.cpu_count() or 4
157+
except Exception:
158+
cpu = 4
159+
return min(cpu, _MAX_CONCURRENCY)
145160

146161

147162
def _count_outcomes(facets: dict[str, SessionFacet]) -> dict[str, int]:
@@ -182,14 +197,50 @@ def _count_goal_categories(facets: dict[str, SessionFacet]) -> dict[str, int]:
182197
return dict(counts)
183198

184199

200+
def _extract_single_facet(
201+
db_path: Path | str,
202+
sid: str,
203+
model: str,
204+
) -> SessionFacet:
205+
"""Extract facets for a single session (thread-safe)."""
206+
transcript = reconstruct_transcript(db_path, sid)
207+
meta = extract_session_meta(db_path, sid)
208+
meta_summary = (
209+
f"Title: {meta.title}\n"
210+
f"Duration: {meta.duration_minutes:.1f} min\n"
211+
f"User messages: {meta.user_msg_count}\n"
212+
f"Assistant messages: {meta.assistant_msg_count}\n"
213+
f"Total tokens: {meta.total_tokens}\n"
214+
f"Cost: ${meta.cost:.4f}\n"
215+
f"Tools: {', '.join(meta.tool_counts) if meta.tool_counts else 'none'}\n"
216+
f"Languages: {', '.join(meta.languages) if meta.languages else 'none'}\n"
217+
f"Agents: {', '.join(meta.agent_counts) if meta.agent_counts else 'none'}"
218+
)
219+
prompt = build_facet_prompt(transcript, meta_summary)
220+
llm_result = run_llm(prompt, model=model)
221+
return SessionFacet(
222+
session_id=sid,
223+
underlying_goal=llm_result.get("underlying_goal", ""),
224+
goal_categories=llm_result.get("goal_categories", {}),
225+
outcome=llm_result.get("outcome", ""),
226+
satisfaction=llm_result.get("satisfaction", {}),
227+
helpfulness=llm_result.get("helpfulness", ""),
228+
session_type=llm_result.get("session_type", ""),
229+
friction_counts=llm_result.get("friction_counts", {}),
230+
friction_detail=llm_result.get("friction_detail", ""),
231+
primary_success=llm_result.get("primary_success", ""),
232+
brief_summary=llm_result.get("brief_summary", ""),
233+
)
234+
235+
185236
def extract_facets(
186237
db_path: Path | str,
187238
session_ids: list[str],
188239
config: InsightsConfig,
189240
cache: FacetCache | None = None,
190241
on_progress: Callable[[int, int], None] | None = None,
191242
) -> dict[str, SessionFacet]:
192-
"""Extract per-session facets with caching."""
243+
"""Extract per-session facets with caching and concurrent LLM calls."""
193244
if cache is None:
194245
cache = FacetCache()
195246

@@ -213,46 +264,39 @@ def extract_facets(
213264
uncached = uncached[:_MAX_NEW_SESSIONS]
214265

215266
total = len(uncached)
216-
for i, sid in enumerate(uncached):
217-
if on_progress is not None:
218-
on_progress(i + 1, total)
219-
try:
220-
transcript = reconstruct_transcript(db_path, sid)
221-
meta = extract_session_meta(db_path, sid)
222-
meta_summary = (
223-
f"Title: {meta.title}\n"
224-
f"Duration: {meta.duration_minutes:.1f} min\n"
225-
f"User messages: {meta.user_msg_count}\n"
226-
f"Assistant messages: {meta.assistant_msg_count}\n"
227-
f"Total tokens: {meta.total_tokens}\n"
228-
f"Cost: ${meta.cost:.4f}\n"
229-
f"Tools: {', '.join(meta.tool_counts) if meta.tool_counts else 'none'}\n"
230-
f"Languages: {', '.join(meta.languages) if meta.languages else 'none'}\n"
231-
f"Agents: {', '.join(meta.agent_counts) if meta.agent_counts else 'none'}"
232-
)
233-
prompt = build_facet_prompt(transcript, meta_summary)
234-
llm_result = run_llm(prompt, model=config.model)
235-
facet = SessionFacet(
236-
session_id=sid,
237-
underlying_goal=llm_result.get("underlying_goal", ""),
238-
goal_categories=llm_result.get("goal_categories", {}),
239-
outcome=llm_result.get("outcome", ""),
240-
satisfaction=llm_result.get("satisfaction", {}),
241-
helpfulness=llm_result.get("helpfulness", ""),
242-
session_type=llm_result.get("session_type", ""),
243-
friction_counts=llm_result.get("friction_counts", {}),
244-
friction_detail=llm_result.get("friction_detail", ""),
245-
primary_success=llm_result.get("primary_success", ""),
246-
brief_summary=llm_result.get("brief_summary", ""),
247-
)
248-
cache.put(sid, facet)
249-
result[sid] = facet
250-
except Exception:
251-
warnings.warn(
252-
f"Failed to extract facets for session {sid}",
253-
stacklevel=2,
254-
)
255-
continue
267+
if total == 0:
268+
return result
269+
270+
workers = _default_concurrency(config.concurrency)
271+
completed = 0
272+
lock = threading.Lock()
273+
274+
def _on_done(sid: str, facet: SessionFacet | None) -> None:
275+
nonlocal completed
276+
with lock:
277+
completed += 1
278+
if facet is not None:
279+
cache.put(sid, facet)
280+
result[sid] = facet
281+
if on_progress is not None:
282+
on_progress(completed, total)
283+
284+
with ThreadPoolExecutor(max_workers=workers) as executor:
285+
futures = {
286+
executor.submit(_extract_single_facet, db_path, sid, config.model): sid
287+
for sid in uncached
288+
}
289+
for future in as_completed(futures):
290+
sid = futures[future]
291+
try:
292+
facet = future.result()
293+
_on_done(sid, facet)
294+
except Exception:
295+
warnings.warn(
296+
f"Failed to extract facets for session {sid}",
297+
stacklevel=2,
298+
)
299+
_on_done(sid, None)
256300

257301
return result
258302

@@ -288,12 +332,20 @@ def run_aggregate_analysis(
288332
]
289333

290334
results: dict[str, dict] = {}
291-
for key, builder in prompts:
335+
workers = min(_default_concurrency(config.concurrency), len(prompts))
336+
337+
def _run_prompt(key: str, builder) -> tuple[str, dict]:
292338
try:
293-
prompt = builder(aggregated_data)
294-
results[key] = run_llm(prompt, model=config.model)
339+
prompt_text = builder(aggregated_data)
340+
return key, run_llm(prompt_text, model=config.model)
295341
except Exception:
296-
results[key] = {}
342+
return key, {}
343+
344+
with ThreadPoolExecutor(max_workers=workers) as executor:
345+
futures = [executor.submit(_run_prompt, key, builder) for key, builder in prompts]
346+
for future in as_completed(futures):
347+
key, val = future.result()
348+
results[key] = val
297349

298350
return results
299351

src/opencode_usage/insights/extract.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ def extract_agent_stats(
320320
finally:
321321
conn.close()
322322

323-
result: dict[str, dict] = {}
323+
result: dict[str, dict[str, Any]] = {}
324324
for r in rows:
325325
agent = r["agent"]
326326
if not agent:
@@ -368,7 +368,7 @@ def extract_model_stats(
368368
finally:
369369
conn.close()
370370

371-
result: dict[str, dict] = {}
371+
result: dict[str, dict[str, Any]] = {}
372372
for r in rows:
373373
model = r["model"]
374374
if not model:
@@ -413,7 +413,7 @@ def extract_tool_stats(
413413
finally:
414414
conn.close()
415415

416-
result: dict[str, dict] = {}
416+
result: dict[str, dict[str, int]] = {}
417417
for r in rows:
418418
tool = r["tool"]
419419
if not tool:

src/opencode_usage/insights/orchestrator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def run_insights(args: argparse.Namespace) -> None:
3232
since=getattr(args, "since", None),
3333
force=getattr(args, "force", False),
3434
output_path=getattr(args, "output", "./opencode-insights.html"),
35+
concurrency=getattr(args, "concurrency", None),
3536
)
3637

3738
db_path = getattr(args, "db", None) or _default_db_path()

src/opencode_usage/insights/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,4 @@ class InsightsConfig:
7272
since: datetime | None = None
7373
force: bool = False
7474
output_path: str = "./opencode-insights.html"
75+
concurrency: int | None = None

0 commit comments

Comments
 (0)