33from __future__ import annotations
44
55import json
6+ import os
67import subprocess
8+ import threading
79import time
810import warnings
911from collections import defaultdict
12+ from concurrent .futures import ThreadPoolExecutor , as_completed
1013from pathlib import Path
1114from 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
147162def _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+
185236def 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
0 commit comments