local-deep-research 0.4.4__py3-none-any.whl → 0.5.2__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 (220) hide show
  1. local_deep_research/__init__.py +7 -0
  2. local_deep_research/__version__.py +1 -1
  3. local_deep_research/advanced_search_system/answer_decoding/__init__.py +5 -0
  4. local_deep_research/advanced_search_system/answer_decoding/browsecomp_answer_decoder.py +421 -0
  5. local_deep_research/advanced_search_system/candidate_exploration/README.md +219 -0
  6. local_deep_research/advanced_search_system/candidate_exploration/__init__.py +25 -0
  7. local_deep_research/advanced_search_system/candidate_exploration/adaptive_explorer.py +329 -0
  8. local_deep_research/advanced_search_system/candidate_exploration/base_explorer.py +341 -0
  9. local_deep_research/advanced_search_system/candidate_exploration/constraint_guided_explorer.py +436 -0
  10. local_deep_research/advanced_search_system/candidate_exploration/diversity_explorer.py +457 -0
  11. local_deep_research/advanced_search_system/candidate_exploration/parallel_explorer.py +250 -0
  12. local_deep_research/advanced_search_system/candidate_exploration/progressive_explorer.py +255 -0
  13. local_deep_research/advanced_search_system/candidates/__init__.py +5 -0
  14. local_deep_research/advanced_search_system/candidates/base_candidate.py +59 -0
  15. local_deep_research/advanced_search_system/constraint_checking/README.md +150 -0
  16. local_deep_research/advanced_search_system/constraint_checking/__init__.py +35 -0
  17. local_deep_research/advanced_search_system/constraint_checking/base_constraint_checker.py +122 -0
  18. local_deep_research/advanced_search_system/constraint_checking/constraint_checker.py +223 -0
  19. local_deep_research/advanced_search_system/constraint_checking/constraint_satisfaction_tracker.py +387 -0
  20. local_deep_research/advanced_search_system/constraint_checking/dual_confidence_checker.py +424 -0
  21. local_deep_research/advanced_search_system/constraint_checking/evidence_analyzer.py +174 -0
  22. local_deep_research/advanced_search_system/constraint_checking/intelligent_constraint_relaxer.py +503 -0
  23. local_deep_research/advanced_search_system/constraint_checking/rejection_engine.py +143 -0
  24. local_deep_research/advanced_search_system/constraint_checking/strict_checker.py +259 -0
  25. local_deep_research/advanced_search_system/constraint_checking/threshold_checker.py +213 -0
  26. local_deep_research/advanced_search_system/constraints/__init__.py +6 -0
  27. local_deep_research/advanced_search_system/constraints/base_constraint.py +58 -0
  28. local_deep_research/advanced_search_system/constraints/constraint_analyzer.py +143 -0
  29. local_deep_research/advanced_search_system/evidence/__init__.py +12 -0
  30. local_deep_research/advanced_search_system/evidence/base_evidence.py +57 -0
  31. local_deep_research/advanced_search_system/evidence/evaluator.py +159 -0
  32. local_deep_research/advanced_search_system/evidence/requirements.py +122 -0
  33. local_deep_research/advanced_search_system/filters/base_filter.py +3 -1
  34. local_deep_research/advanced_search_system/filters/cross_engine_filter.py +8 -2
  35. local_deep_research/advanced_search_system/filters/journal_reputation_filter.py +43 -29
  36. local_deep_research/advanced_search_system/findings/repository.py +54 -17
  37. local_deep_research/advanced_search_system/knowledge/standard_knowledge.py +3 -1
  38. local_deep_research/advanced_search_system/query_generation/adaptive_query_generator.py +405 -0
  39. local_deep_research/advanced_search_system/questions/__init__.py +16 -0
  40. local_deep_research/advanced_search_system/questions/atomic_fact_question.py +171 -0
  41. local_deep_research/advanced_search_system/questions/browsecomp_question.py +287 -0
  42. local_deep_research/advanced_search_system/questions/decomposition_question.py +13 -4
  43. local_deep_research/advanced_search_system/questions/entity_aware_question.py +184 -0
  44. local_deep_research/advanced_search_system/questions/standard_question.py +9 -3
  45. local_deep_research/advanced_search_system/search_optimization/cross_constraint_manager.py +624 -0
  46. local_deep_research/advanced_search_system/source_management/diversity_manager.py +613 -0
  47. local_deep_research/advanced_search_system/strategies/__init__.py +42 -0
  48. local_deep_research/advanced_search_system/strategies/adaptive_decomposition_strategy.py +564 -0
  49. local_deep_research/advanced_search_system/strategies/base_strategy.py +4 -4
  50. local_deep_research/advanced_search_system/strategies/browsecomp_entity_strategy.py +1031 -0
  51. local_deep_research/advanced_search_system/strategies/browsecomp_optimized_strategy.py +778 -0
  52. local_deep_research/advanced_search_system/strategies/concurrent_dual_confidence_strategy.py +446 -0
  53. local_deep_research/advanced_search_system/strategies/constrained_search_strategy.py +1348 -0
  54. local_deep_research/advanced_search_system/strategies/constraint_parallel_strategy.py +522 -0
  55. local_deep_research/advanced_search_system/strategies/direct_search_strategy.py +217 -0
  56. local_deep_research/advanced_search_system/strategies/dual_confidence_strategy.py +320 -0
  57. local_deep_research/advanced_search_system/strategies/dual_confidence_with_rejection.py +219 -0
  58. local_deep_research/advanced_search_system/strategies/early_stop_constrained_strategy.py +369 -0
  59. local_deep_research/advanced_search_system/strategies/entity_aware_source_strategy.py +140 -0
  60. local_deep_research/advanced_search_system/strategies/evidence_based_strategy.py +1248 -0
  61. local_deep_research/advanced_search_system/strategies/evidence_based_strategy_v2.py +1337 -0
  62. local_deep_research/advanced_search_system/strategies/focused_iteration_strategy.py +537 -0
  63. local_deep_research/advanced_search_system/strategies/improved_evidence_based_strategy.py +782 -0
  64. local_deep_research/advanced_search_system/strategies/iterative_reasoning_strategy.py +760 -0
  65. local_deep_research/advanced_search_system/strategies/iterdrag_strategy.py +55 -21
  66. local_deep_research/advanced_search_system/strategies/llm_driven_modular_strategy.py +865 -0
  67. local_deep_research/advanced_search_system/strategies/modular_strategy.py +1142 -0
  68. local_deep_research/advanced_search_system/strategies/parallel_constrained_strategy.py +506 -0
  69. local_deep_research/advanced_search_system/strategies/parallel_search_strategy.py +34 -16
  70. local_deep_research/advanced_search_system/strategies/rapid_search_strategy.py +29 -9
  71. local_deep_research/advanced_search_system/strategies/recursive_decomposition_strategy.py +492 -0
  72. local_deep_research/advanced_search_system/strategies/smart_decomposition_strategy.py +284 -0
  73. local_deep_research/advanced_search_system/strategies/smart_query_strategy.py +515 -0
  74. local_deep_research/advanced_search_system/strategies/source_based_strategy.py +48 -24
  75. local_deep_research/advanced_search_system/strategies/standard_strategy.py +34 -14
  76. local_deep_research/advanced_search_system/tools/base_tool.py +7 -2
  77. local_deep_research/api/benchmark_functions.py +6 -2
  78. local_deep_research/api/research_functions.py +10 -4
  79. local_deep_research/benchmarks/__init__.py +9 -7
  80. local_deep_research/benchmarks/benchmark_functions.py +6 -2
  81. local_deep_research/benchmarks/cli/benchmark_commands.py +27 -10
  82. local_deep_research/benchmarks/cli.py +38 -13
  83. local_deep_research/benchmarks/comparison/__init__.py +4 -2
  84. local_deep_research/benchmarks/comparison/evaluator.py +316 -239
  85. local_deep_research/benchmarks/datasets/__init__.py +1 -1
  86. local_deep_research/benchmarks/datasets/base.py +91 -72
  87. local_deep_research/benchmarks/datasets/browsecomp.py +54 -33
  88. local_deep_research/benchmarks/datasets/custom_dataset_template.py +19 -19
  89. local_deep_research/benchmarks/datasets/simpleqa.py +14 -14
  90. local_deep_research/benchmarks/datasets/utils.py +48 -29
  91. local_deep_research/benchmarks/datasets.py +4 -11
  92. local_deep_research/benchmarks/efficiency/__init__.py +8 -4
  93. local_deep_research/benchmarks/efficiency/resource_monitor.py +223 -171
  94. local_deep_research/benchmarks/efficiency/speed_profiler.py +62 -48
  95. local_deep_research/benchmarks/evaluators/browsecomp.py +3 -1
  96. local_deep_research/benchmarks/evaluators/composite.py +6 -2
  97. local_deep_research/benchmarks/evaluators/simpleqa.py +36 -13
  98. local_deep_research/benchmarks/graders.py +32 -10
  99. local_deep_research/benchmarks/metrics/README.md +1 -1
  100. local_deep_research/benchmarks/metrics/calculation.py +25 -10
  101. local_deep_research/benchmarks/metrics/reporting.py +7 -3
  102. local_deep_research/benchmarks/metrics/visualization.py +42 -23
  103. local_deep_research/benchmarks/metrics.py +1 -1
  104. local_deep_research/benchmarks/optimization/__init__.py +3 -1
  105. local_deep_research/benchmarks/optimization/api.py +7 -1
  106. local_deep_research/benchmarks/optimization/optuna_optimizer.py +75 -26
  107. local_deep_research/benchmarks/runners.py +48 -15
  108. local_deep_research/citation_handler.py +65 -92
  109. local_deep_research/citation_handlers/__init__.py +15 -0
  110. local_deep_research/citation_handlers/base_citation_handler.py +70 -0
  111. local_deep_research/citation_handlers/forced_answer_citation_handler.py +179 -0
  112. local_deep_research/citation_handlers/precision_extraction_handler.py +550 -0
  113. local_deep_research/citation_handlers/standard_citation_handler.py +80 -0
  114. local_deep_research/config/llm_config.py +271 -169
  115. local_deep_research/config/search_config.py +14 -5
  116. local_deep_research/defaults/__init__.py +0 -1
  117. local_deep_research/metrics/__init__.py +13 -0
  118. local_deep_research/metrics/database.py +58 -0
  119. local_deep_research/metrics/db_models.py +115 -0
  120. local_deep_research/metrics/migrate_add_provider_to_token_usage.py +148 -0
  121. local_deep_research/metrics/migrate_call_stack_tracking.py +105 -0
  122. local_deep_research/metrics/migrate_enhanced_tracking.py +75 -0
  123. local_deep_research/metrics/migrate_research_ratings.py +31 -0
  124. local_deep_research/metrics/models.py +61 -0
  125. local_deep_research/metrics/pricing/__init__.py +12 -0
  126. local_deep_research/metrics/pricing/cost_calculator.py +237 -0
  127. local_deep_research/metrics/pricing/pricing_cache.py +143 -0
  128. local_deep_research/metrics/pricing/pricing_fetcher.py +240 -0
  129. local_deep_research/metrics/query_utils.py +51 -0
  130. local_deep_research/metrics/search_tracker.py +380 -0
  131. local_deep_research/metrics/token_counter.py +1078 -0
  132. local_deep_research/migrate_db.py +3 -1
  133. local_deep_research/report_generator.py +22 -8
  134. local_deep_research/search_system.py +390 -9
  135. local_deep_research/test_migration.py +15 -5
  136. local_deep_research/utilities/db_utils.py +7 -4
  137. local_deep_research/utilities/es_utils.py +115 -104
  138. local_deep_research/utilities/llm_utils.py +15 -5
  139. local_deep_research/utilities/log_utils.py +151 -0
  140. local_deep_research/utilities/search_cache.py +387 -0
  141. local_deep_research/utilities/search_utilities.py +14 -6
  142. local_deep_research/utilities/threading_utils.py +92 -0
  143. local_deep_research/utilities/url_utils.py +6 -0
  144. local_deep_research/web/api.py +347 -0
  145. local_deep_research/web/app.py +13 -17
  146. local_deep_research/web/app_factory.py +71 -66
  147. local_deep_research/web/database/migrate_to_ldr_db.py +12 -4
  148. local_deep_research/web/database/migrations.py +20 -3
  149. local_deep_research/web/database/models.py +74 -25
  150. local_deep_research/web/database/schema_upgrade.py +49 -29
  151. local_deep_research/web/models/database.py +63 -83
  152. local_deep_research/web/routes/api_routes.py +56 -22
  153. local_deep_research/web/routes/benchmark_routes.py +4 -1
  154. local_deep_research/web/routes/globals.py +22 -0
  155. local_deep_research/web/routes/history_routes.py +71 -46
  156. local_deep_research/web/routes/metrics_routes.py +1155 -0
  157. local_deep_research/web/routes/research_routes.py +192 -54
  158. local_deep_research/web/routes/settings_routes.py +156 -55
  159. local_deep_research/web/services/research_service.py +412 -251
  160. local_deep_research/web/services/resource_service.py +36 -11
  161. local_deep_research/web/services/settings_manager.py +55 -17
  162. local_deep_research/web/services/settings_service.py +12 -4
  163. local_deep_research/web/services/socket_service.py +295 -188
  164. local_deep_research/web/static/css/custom_dropdown.css +180 -0
  165. local_deep_research/web/static/css/styles.css +39 -1
  166. local_deep_research/web/static/js/components/detail.js +633 -267
  167. local_deep_research/web/static/js/components/details.js +751 -0
  168. local_deep_research/web/static/js/components/fallback/formatting.js +11 -11
  169. local_deep_research/web/static/js/components/fallback/ui.js +23 -23
  170. local_deep_research/web/static/js/components/history.js +76 -76
  171. local_deep_research/web/static/js/components/logpanel.js +61 -13
  172. local_deep_research/web/static/js/components/progress.js +13 -2
  173. local_deep_research/web/static/js/components/research.js +99 -12
  174. local_deep_research/web/static/js/components/results.js +239 -106
  175. local_deep_research/web/static/js/main.js +40 -40
  176. local_deep_research/web/static/js/services/audio.js +1 -1
  177. local_deep_research/web/static/js/services/formatting.js +11 -11
  178. local_deep_research/web/static/js/services/keyboard.js +157 -0
  179. local_deep_research/web/static/js/services/pdf.js +80 -80
  180. local_deep_research/web/static/sounds/README.md +1 -1
  181. local_deep_research/web/templates/base.html +1 -0
  182. local_deep_research/web/templates/components/log_panel.html +7 -1
  183. local_deep_research/web/templates/components/mobile_nav.html +1 -1
  184. local_deep_research/web/templates/components/sidebar.html +3 -0
  185. local_deep_research/web/templates/pages/cost_analytics.html +1245 -0
  186. local_deep_research/web/templates/pages/details.html +325 -24
  187. local_deep_research/web/templates/pages/history.html +1 -1
  188. local_deep_research/web/templates/pages/metrics.html +1929 -0
  189. local_deep_research/web/templates/pages/progress.html +2 -2
  190. local_deep_research/web/templates/pages/research.html +53 -17
  191. local_deep_research/web/templates/pages/results.html +12 -1
  192. local_deep_research/web/templates/pages/star_reviews.html +803 -0
  193. local_deep_research/web/utils/formatters.py +9 -3
  194. local_deep_research/web_search_engines/default_search_engines.py +5 -3
  195. local_deep_research/web_search_engines/engines/full_search.py +8 -2
  196. local_deep_research/web_search_engines/engines/meta_search_engine.py +59 -20
  197. local_deep_research/web_search_engines/engines/search_engine_arxiv.py +19 -6
  198. local_deep_research/web_search_engines/engines/search_engine_brave.py +6 -2
  199. local_deep_research/web_search_engines/engines/search_engine_ddg.py +3 -1
  200. local_deep_research/web_search_engines/engines/search_engine_elasticsearch.py +81 -58
  201. local_deep_research/web_search_engines/engines/search_engine_github.py +46 -15
  202. local_deep_research/web_search_engines/engines/search_engine_google_pse.py +16 -6
  203. local_deep_research/web_search_engines/engines/search_engine_guardian.py +39 -15
  204. local_deep_research/web_search_engines/engines/search_engine_local.py +58 -25
  205. local_deep_research/web_search_engines/engines/search_engine_local_all.py +15 -5
  206. local_deep_research/web_search_engines/engines/search_engine_pubmed.py +63 -21
  207. local_deep_research/web_search_engines/engines/search_engine_searxng.py +37 -11
  208. local_deep_research/web_search_engines/engines/search_engine_semantic_scholar.py +27 -9
  209. local_deep_research/web_search_engines/engines/search_engine_serpapi.py +12 -4
  210. local_deep_research/web_search_engines/engines/search_engine_wayback.py +31 -10
  211. local_deep_research/web_search_engines/engines/search_engine_wikipedia.py +12 -3
  212. local_deep_research/web_search_engines/search_engine_base.py +83 -35
  213. local_deep_research/web_search_engines/search_engine_factory.py +25 -8
  214. local_deep_research/web_search_engines/search_engines_config.py +9 -3
  215. {local_deep_research-0.4.4.dist-info → local_deep_research-0.5.2.dist-info}/METADATA +7 -1
  216. local_deep_research-0.5.2.dist-info/RECORD +265 -0
  217. local_deep_research-0.4.4.dist-info/RECORD +0 -177
  218. {local_deep_research-0.4.4.dist-info → local_deep_research-0.5.2.dist-info}/WHEEL +0 -0
  219. {local_deep_research-0.4.4.dist-info → local_deep_research-0.5.2.dist-info}/entry_points.txt +0 -0
  220. {local_deep_research-0.4.4.dist-info → local_deep_research-0.5.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1078 @@
1
+ """Token counting functionality for LLM usage tracking."""
2
+
3
+ import inspect
4
+ import json
5
+ import os
6
+ import time
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from langchain_core.callbacks import BaseCallbackHandler
10
+ from langchain_core.outputs import LLMResult
11
+ from loguru import logger
12
+ from sqlalchemy import func, text
13
+
14
+ from .database import get_metrics_db
15
+ from .db_models import ModelUsage, TokenUsage
16
+ from .query_utils import get_research_mode_condition, get_time_filter_condition
17
+
18
+
19
+ class TokenCountingCallback(BaseCallbackHandler):
20
+ """Callback handler for counting tokens across different models."""
21
+
22
+ def __init__(
23
+ self,
24
+ research_id: Optional[int] = None,
25
+ research_context: Optional[Dict[str, Any]] = None,
26
+ ):
27
+ """Initialize the token counting callback.
28
+
29
+ Args:
30
+ research_id: The ID of the research to track tokens for
31
+ research_context: Additional research context for enhanced tracking
32
+ """
33
+ super().__init__()
34
+ self.research_id = research_id
35
+ self.research_context = research_context or {}
36
+ self.current_model = None
37
+ self.current_provider = None
38
+ self.preset_model = None # Model name set during callback creation
39
+ self.preset_provider = None # Provider set during callback creation
40
+
41
+ # Phase 1 Enhancement: Track timing and context
42
+ self.start_time = None
43
+ self.response_time_ms = None
44
+ self.success_status = "success"
45
+ self.error_type = None
46
+
47
+ # Call stack tracking
48
+ self.calling_file = None
49
+ self.calling_function = None
50
+ self.call_stack = None
51
+
52
+ # Track token counts in memory
53
+ self.counts = {
54
+ "total_tokens": 0,
55
+ "total_prompt_tokens": 0,
56
+ "total_completion_tokens": 0,
57
+ "by_model": {},
58
+ }
59
+
60
+ def on_llm_start(
61
+ self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any
62
+ ) -> None:
63
+ """Called when LLM starts running."""
64
+ # Phase 1 Enhancement: Start timing
65
+ self.start_time = time.time()
66
+
67
+ # Phase 1 Enhancement: Capture call stack information
68
+ try:
69
+ stack = inspect.stack()
70
+
71
+ # Skip the first few frames (this method, langchain internals)
72
+ # Look for the first frame that's in our project directory
73
+ for frame_info in stack[1:]:
74
+ file_path = frame_info.filename
75
+ # Look for any frame containing local_deep_research project
76
+ if (
77
+ "local_deep_research" in file_path
78
+ and "site-packages" not in file_path
79
+ and "venv" not in file_path
80
+ ):
81
+ # Extract relative path from local_deep_research
82
+ if "src/local_deep_research" in file_path:
83
+ relative_path = file_path.split(
84
+ "src/local_deep_research"
85
+ )[-1].lstrip("/")
86
+ elif "local_deep_research/src" in file_path:
87
+ relative_path = file_path.split(
88
+ "local_deep_research/src"
89
+ )[-1].lstrip("/")
90
+ elif "local_deep_research" in file_path:
91
+ # Get everything after local_deep_research
92
+ relative_path = file_path.split("local_deep_research")[
93
+ -1
94
+ ].lstrip("/")
95
+ else:
96
+ relative_path = os.path.basename(file_path)
97
+
98
+ self.calling_file = relative_path
99
+ self.calling_function = frame_info.function
100
+
101
+ # Capture a simplified call stack (just the relevant frames)
102
+ call_stack_frames = []
103
+ for frame in stack[1:6]: # Limit to 5 frames
104
+ if (
105
+ "local_deep_research" in frame.filename
106
+ and "site-packages" not in frame.filename
107
+ and "venv" not in frame.filename
108
+ ):
109
+ frame_name = f"{os.path.basename(frame.filename)}:{frame.function}:{frame.lineno}"
110
+ call_stack_frames.append(frame_name)
111
+
112
+ self.call_stack = (
113
+ " -> ".join(call_stack_frames)
114
+ if call_stack_frames
115
+ else None
116
+ )
117
+ break
118
+ except Exception as e:
119
+ logger.debug(f"Error capturing call stack: {e}")
120
+ # Continue without call stack info if there's an error
121
+
122
+ # Debug logging
123
+ logger.debug(f"on_llm_start serialized: {serialized}")
124
+ logger.debug(f"on_llm_start kwargs: {kwargs}")
125
+
126
+ # First, use preset values if available
127
+ if self.preset_model:
128
+ self.current_model = self.preset_model
129
+ else:
130
+ # Try multiple locations for model name
131
+ model_name = None
132
+
133
+ # First check invocation_params
134
+ invocation_params = kwargs.get("invocation_params", {})
135
+ model_name = invocation_params.get(
136
+ "model"
137
+ ) or invocation_params.get("model_name")
138
+
139
+ # Check kwargs directly
140
+ if not model_name:
141
+ model_name = kwargs.get("model") or kwargs.get("model_name")
142
+
143
+ # Check serialized data
144
+ if not model_name and "kwargs" in serialized:
145
+ model_name = serialized["kwargs"].get("model") or serialized[
146
+ "kwargs"
147
+ ].get("model_name")
148
+
149
+ # Check for name in serialized data
150
+ if not model_name and "name" in serialized:
151
+ model_name = serialized["name"]
152
+
153
+ # If still not found and we have Ollama, try to extract from the instance
154
+ if (
155
+ not model_name
156
+ and "_type" in serialized
157
+ and "ChatOllama" in serialized["_type"]
158
+ ):
159
+ # For Ollama, the model name might be in the serialized kwargs
160
+ if "kwargs" in serialized and "model" in serialized["kwargs"]:
161
+ model_name = serialized["kwargs"]["model"]
162
+ else:
163
+ # Default to the type if we can't find the actual model
164
+ model_name = "ollama"
165
+
166
+ # Final fallback
167
+ if not model_name:
168
+ if "_type" in serialized:
169
+ model_name = serialized["_type"]
170
+ else:
171
+ model_name = "unknown"
172
+
173
+ self.current_model = model_name
174
+
175
+ # Use preset provider if available
176
+ if self.preset_provider:
177
+ self.current_provider = self.preset_provider
178
+ else:
179
+ # Extract provider from serialized type or kwargs
180
+ if "_type" in serialized:
181
+ type_str = serialized["_type"]
182
+ if "ChatOllama" in type_str:
183
+ self.current_provider = "ollama"
184
+ elif "ChatOpenAI" in type_str:
185
+ self.current_provider = "openai"
186
+ elif "ChatAnthropic" in type_str:
187
+ self.current_provider = "anthropic"
188
+ else:
189
+ self.current_provider = kwargs.get("provider", "unknown")
190
+ else:
191
+ self.current_provider = kwargs.get("provider", "unknown")
192
+
193
+ # Initialize model tracking if needed
194
+ if self.current_model not in self.counts["by_model"]:
195
+ self.counts["by_model"][self.current_model] = {
196
+ "prompt_tokens": 0,
197
+ "completion_tokens": 0,
198
+ "total_tokens": 0,
199
+ "calls": 0,
200
+ "provider": self.current_provider,
201
+ }
202
+
203
+ # Increment call count
204
+ self.counts["by_model"][self.current_model]["calls"] += 1
205
+
206
+ def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
207
+ """Called when LLM ends running."""
208
+ # Phase 1 Enhancement: Calculate response time
209
+ if self.start_time:
210
+ self.response_time_ms = int((time.time() - self.start_time) * 1000)
211
+
212
+ # Extract token usage from response
213
+ token_usage = None
214
+
215
+ # Check multiple locations for token usage
216
+ if hasattr(response, "llm_output") and response.llm_output:
217
+ token_usage = response.llm_output.get(
218
+ "token_usage"
219
+ ) or response.llm_output.get("usage", {})
220
+
221
+ # Check for usage metadata in generations (Ollama specific)
222
+ if not token_usage and hasattr(response, "generations"):
223
+ for generation_list in response.generations:
224
+ for generation in generation_list:
225
+ if hasattr(generation, "message") and hasattr(
226
+ generation.message, "usage_metadata"
227
+ ):
228
+ usage_meta = generation.message.usage_metadata
229
+ token_usage = {
230
+ "prompt_tokens": usage_meta.get("input_tokens", 0),
231
+ "completion_tokens": usage_meta.get(
232
+ "output_tokens", 0
233
+ ),
234
+ "total_tokens": usage_meta.get("total_tokens", 0),
235
+ }
236
+ break
237
+ # Also check response_metadata
238
+ elif hasattr(generation, "message") and hasattr(
239
+ generation.message, "response_metadata"
240
+ ):
241
+ resp_meta = generation.message.response_metadata
242
+ if resp_meta.get("prompt_eval_count") or resp_meta.get(
243
+ "eval_count"
244
+ ):
245
+ token_usage = {
246
+ "prompt_tokens": resp_meta.get(
247
+ "prompt_eval_count", 0
248
+ ),
249
+ "completion_tokens": resp_meta.get(
250
+ "eval_count", 0
251
+ ),
252
+ "total_tokens": resp_meta.get(
253
+ "prompt_eval_count", 0
254
+ )
255
+ + resp_meta.get("eval_count", 0),
256
+ }
257
+ break
258
+ if token_usage:
259
+ break
260
+
261
+ if token_usage:
262
+ prompt_tokens = token_usage.get("prompt_tokens", 0)
263
+ completion_tokens = token_usage.get("completion_tokens", 0)
264
+ total_tokens = token_usage.get(
265
+ "total_tokens", prompt_tokens + completion_tokens
266
+ )
267
+
268
+ # Update in-memory counts
269
+ self.counts["total_prompt_tokens"] += prompt_tokens
270
+ self.counts["total_completion_tokens"] += completion_tokens
271
+ self.counts["total_tokens"] += total_tokens
272
+
273
+ if self.current_model:
274
+ self.counts["by_model"][self.current_model][
275
+ "prompt_tokens"
276
+ ] += prompt_tokens
277
+ self.counts["by_model"][self.current_model][
278
+ "completion_tokens"
279
+ ] += completion_tokens
280
+ self.counts["by_model"][self.current_model]["total_tokens"] += (
281
+ total_tokens
282
+ )
283
+
284
+ # Save to database if we have a research_id
285
+ if self.research_id:
286
+ self._save_to_db(prompt_tokens, completion_tokens)
287
+
288
+ def on_llm_error(self, error, **kwargs: Any) -> None:
289
+ """Called when LLM encounters an error."""
290
+ # Phase 1 Enhancement: Track errors
291
+ if self.start_time:
292
+ self.response_time_ms = int((time.time() - self.start_time) * 1000)
293
+
294
+ self.success_status = "error"
295
+ self.error_type = str(type(error).__name__)
296
+
297
+ # Still save to database to track failed calls
298
+ if self.research_id:
299
+ self._save_to_db(0, 0)
300
+
301
+ def _save_to_db(self, prompt_tokens: int, completion_tokens: int):
302
+ """Save token usage to the database."""
303
+ try:
304
+ db = get_metrics_db()
305
+ with db.get_session() as session:
306
+ # Phase 1 Enhancement: Prepare additional context
307
+ research_query = self.research_context.get("research_query")
308
+ research_mode = self.research_context.get("research_mode")
309
+ research_phase = self.research_context.get("research_phase")
310
+ search_iteration = self.research_context.get("search_iteration")
311
+ search_engines_planned = self.research_context.get(
312
+ "search_engines_planned"
313
+ )
314
+ search_engine_selected = self.research_context.get(
315
+ "search_engine_selected"
316
+ )
317
+
318
+ # Debug logging for search engine context
319
+ if search_engines_planned or search_engine_selected:
320
+ logger.info(
321
+ f"Token tracking - Search context: planned={search_engines_planned}, selected={search_engine_selected}, phase={research_phase}"
322
+ )
323
+ else:
324
+ logger.debug(
325
+ f"Token tracking - No search engine context yet, phase={research_phase}"
326
+ )
327
+
328
+ # Convert list to JSON string if needed
329
+ if isinstance(search_engines_planned, list):
330
+ search_engines_planned = json.dumps(search_engines_planned)
331
+
332
+ # Add token usage record with enhanced fields
333
+ token_usage = TokenUsage(
334
+ research_id=self.research_id,
335
+ model_name=self.current_model,
336
+ provider=self.current_provider, # Added provider for accurate cost tracking
337
+ prompt_tokens=prompt_tokens,
338
+ completion_tokens=completion_tokens,
339
+ total_tokens=prompt_tokens + completion_tokens,
340
+ # Phase 1 Enhancement: Research context
341
+ research_query=research_query,
342
+ research_mode=research_mode,
343
+ research_phase=research_phase,
344
+ search_iteration=search_iteration,
345
+ # Phase 1 Enhancement: Performance metrics
346
+ response_time_ms=self.response_time_ms,
347
+ success_status=self.success_status,
348
+ error_type=self.error_type,
349
+ # Phase 1 Enhancement: Search engine context
350
+ search_engines_planned=search_engines_planned,
351
+ search_engine_selected=search_engine_selected,
352
+ # Phase 1 Enhancement: Call stack tracking
353
+ calling_file=self.calling_file,
354
+ calling_function=self.calling_function,
355
+ call_stack=self.call_stack,
356
+ )
357
+ session.add(token_usage)
358
+
359
+ # Update or create model usage statistics
360
+ model_usage = (
361
+ session.query(ModelUsage)
362
+ .filter_by(
363
+ research_id=self.research_id,
364
+ model_name=self.current_model,
365
+ )
366
+ .first()
367
+ )
368
+
369
+ if model_usage:
370
+ model_usage.prompt_tokens += prompt_tokens
371
+ model_usage.completion_tokens += completion_tokens
372
+ model_usage.total_tokens += (
373
+ prompt_tokens + completion_tokens
374
+ )
375
+ model_usage.calls += 1
376
+ else:
377
+ model_usage = ModelUsage(
378
+ research_id=self.research_id,
379
+ model_name=self.current_model,
380
+ provider=self.current_provider,
381
+ prompt_tokens=prompt_tokens,
382
+ completion_tokens=completion_tokens,
383
+ total_tokens=prompt_tokens + completion_tokens,
384
+ calls=1,
385
+ )
386
+ session.add(model_usage)
387
+
388
+ except Exception as e:
389
+ logger.exception(f"Error saving token usage to database: {e}")
390
+
391
+ def get_counts(self) -> Dict[str, Any]:
392
+ """Get the current token counts."""
393
+ return self.counts
394
+
395
+
396
+ class TokenCounter:
397
+ """Manager class for token counting across the application."""
398
+
399
+ def __init__(self):
400
+ """Initialize the token counter."""
401
+ self.db = get_metrics_db()
402
+
403
+ def create_callback(
404
+ self,
405
+ research_id: Optional[int] = None,
406
+ research_context: Optional[Dict[str, Any]] = None,
407
+ ) -> TokenCountingCallback:
408
+ """Create a new token counting callback.
409
+
410
+ Args:
411
+ research_id: The ID of the research to track tokens for
412
+ research_context: Additional research context for enhanced tracking
413
+
414
+ Returns:
415
+ A new TokenCountingCallback instance
416
+ """
417
+ return TokenCountingCallback(
418
+ research_id=research_id, research_context=research_context
419
+ )
420
+
421
+ def get_research_metrics(self, research_id: int) -> Dict[str, Any]:
422
+ """Get token metrics for a specific research.
423
+
424
+ Args:
425
+ research_id: The ID of the research
426
+
427
+ Returns:
428
+ Dictionary containing token usage metrics
429
+ """
430
+ with self.db.get_session() as session:
431
+ # Get model usage for this research
432
+ model_usages = (
433
+ session.query(ModelUsage)
434
+ .filter_by(research_id=research_id)
435
+ .order_by(ModelUsage.total_tokens.desc())
436
+ .all()
437
+ )
438
+
439
+ model_usage = []
440
+ total_tokens = 0
441
+ total_calls = 0
442
+
443
+ for usage in model_usages:
444
+ model_usage.append(
445
+ {
446
+ "model": usage.model_name,
447
+ "provider": usage.provider,
448
+ "tokens": usage.total_tokens,
449
+ "calls": usage.calls,
450
+ "prompt_tokens": usage.prompt_tokens,
451
+ "completion_tokens": usage.completion_tokens,
452
+ }
453
+ )
454
+ total_tokens += usage.total_tokens
455
+ total_calls += usage.calls
456
+
457
+ return {
458
+ "research_id": research_id,
459
+ "total_tokens": total_tokens,
460
+ "total_calls": total_calls,
461
+ "model_usage": model_usage,
462
+ }
463
+
464
+ def get_overall_metrics(
465
+ self, period: str = "30d", research_mode: str = "all"
466
+ ) -> Dict[str, Any]:
467
+ """Get overall token metrics across all researches.
468
+
469
+ Args:
470
+ period: Time period to filter by ('7d', '30d', '3m', '1y', 'all')
471
+ research_mode: Research mode to filter by ('quick', 'detailed', 'all')
472
+
473
+ Returns:
474
+ Dictionary containing overall metrics
475
+ """
476
+ with self.db.get_session() as session:
477
+ # Build base query with filters
478
+ query = session.query(TokenUsage)
479
+
480
+ # Apply time filter
481
+ time_condition = get_time_filter_condition(
482
+ period, TokenUsage.timestamp
483
+ )
484
+ if time_condition is not None:
485
+ query = query.filter(time_condition)
486
+
487
+ # Apply research mode filter
488
+ mode_condition = get_research_mode_condition(
489
+ research_mode, TokenUsage.research_mode
490
+ )
491
+ if mode_condition is not None:
492
+ query = query.filter(mode_condition)
493
+
494
+ # Total tokens and researches
495
+ total_tokens = (
496
+ query.with_entities(func.sum(TokenUsage.total_tokens)).scalar()
497
+ or 0
498
+ )
499
+ total_researches = (
500
+ query.with_entities(
501
+ func.count(func.distinct(TokenUsage.research_id))
502
+ ).scalar()
503
+ or 0
504
+ )
505
+
506
+ # Model statistics using ORM aggregation
507
+ model_stats_query = session.query(
508
+ TokenUsage.model_name,
509
+ func.sum(TokenUsage.total_tokens).label("tokens"),
510
+ func.count().label("calls"),
511
+ func.sum(TokenUsage.prompt_tokens).label("prompt_tokens"),
512
+ func.sum(TokenUsage.completion_tokens).label(
513
+ "completion_tokens"
514
+ ),
515
+ ).filter(TokenUsage.model_name.isnot(None))
516
+
517
+ # Apply same filters to model stats
518
+ if time_condition is not None:
519
+ model_stats_query = model_stats_query.filter(time_condition)
520
+ if mode_condition is not None:
521
+ model_stats_query = model_stats_query.filter(mode_condition)
522
+
523
+ model_stats = (
524
+ model_stats_query.group_by(TokenUsage.model_name)
525
+ .order_by(func.sum(TokenUsage.total_tokens).desc())
526
+ .all()
527
+ )
528
+
529
+ # Get provider info from ModelUsage table
530
+ by_model = []
531
+ for stat in model_stats:
532
+ # Try to get provider from ModelUsage table
533
+ provider_info = (
534
+ session.query(ModelUsage.provider)
535
+ .filter(ModelUsage.model_name == stat.model_name)
536
+ .first()
537
+ )
538
+ provider = (
539
+ provider_info.provider if provider_info else "unknown"
540
+ )
541
+
542
+ by_model.append(
543
+ {
544
+ "model": stat.model_name,
545
+ "provider": provider,
546
+ "tokens": stat.tokens,
547
+ "calls": stat.calls,
548
+ "prompt_tokens": stat.prompt_tokens,
549
+ "completion_tokens": stat.completion_tokens,
550
+ }
551
+ )
552
+
553
+ # Get recent researches with token usage
554
+ # Note: This requires research_history table - for now we'll use available data
555
+ recent_research_query = session.query(
556
+ TokenUsage.research_id,
557
+ func.sum(TokenUsage.total_tokens).label("token_count"),
558
+ func.max(TokenUsage.timestamp).label("latest_timestamp"),
559
+ ).filter(TokenUsage.research_id.isnot(None))
560
+
561
+ if time_condition is not None:
562
+ recent_research_query = recent_research_query.filter(
563
+ time_condition
564
+ )
565
+ if mode_condition is not None:
566
+ recent_research_query = recent_research_query.filter(
567
+ mode_condition
568
+ )
569
+
570
+ recent_research_data = (
571
+ recent_research_query.group_by(TokenUsage.research_id)
572
+ .order_by(func.max(TokenUsage.timestamp).desc())
573
+ .limit(10)
574
+ .all()
575
+ )
576
+
577
+ recent_researches = []
578
+ for research_data in recent_research_data:
579
+ # Get research query from token_usage table if available
580
+ research_query_data = (
581
+ session.query(TokenUsage.research_query)
582
+ .filter(
583
+ TokenUsage.research_id == research_data.research_id,
584
+ TokenUsage.research_query.isnot(None),
585
+ )
586
+ .first()
587
+ )
588
+
589
+ query_text = (
590
+ research_query_data.research_query
591
+ if research_query_data
592
+ else f"Research {research_data.research_id}"
593
+ )
594
+
595
+ recent_researches.append(
596
+ {
597
+ "id": research_data.research_id,
598
+ "query": query_text,
599
+ "tokens": research_data.token_count or 0,
600
+ "created_at": research_data.latest_timestamp,
601
+ }
602
+ )
603
+
604
+ # Token breakdown statistics
605
+ breakdown_query = query.with_entities(
606
+ func.sum(TokenUsage.prompt_tokens).label("total_input_tokens"),
607
+ func.sum(TokenUsage.completion_tokens).label(
608
+ "total_output_tokens"
609
+ ),
610
+ func.avg(TokenUsage.prompt_tokens).label("avg_input_tokens"),
611
+ func.avg(TokenUsage.completion_tokens).label(
612
+ "avg_output_tokens"
613
+ ),
614
+ func.avg(TokenUsage.total_tokens).label("avg_total_tokens"),
615
+ )
616
+ token_breakdown = breakdown_query.first()
617
+
618
+ return {
619
+ "total_tokens": total_tokens,
620
+ "total_researches": total_researches,
621
+ "by_model": by_model,
622
+ "recent_researches": recent_researches,
623
+ "token_breakdown": {
624
+ "total_input_tokens": int(
625
+ token_breakdown.total_input_tokens or 0
626
+ ),
627
+ "total_output_tokens": int(
628
+ token_breakdown.total_output_tokens or 0
629
+ ),
630
+ "avg_input_tokens": int(
631
+ token_breakdown.avg_input_tokens or 0
632
+ ),
633
+ "avg_output_tokens": int(
634
+ token_breakdown.avg_output_tokens or 0
635
+ ),
636
+ "avg_total_tokens": int(
637
+ token_breakdown.avg_total_tokens or 0
638
+ ),
639
+ },
640
+ }
641
+
642
+ def get_enhanced_metrics(
643
+ self, period: str = "30d", research_mode: str = "all"
644
+ ) -> Dict[str, Any]:
645
+ """Get enhanced Phase 1 tracking metrics.
646
+
647
+ Args:
648
+ period: Time period to filter by ('7d', '30d', '3m', '1y', 'all')
649
+ research_mode: Research mode to filter by ('quick', 'detailed', 'all')
650
+
651
+ Returns:
652
+ Dictionary containing enhanced metrics data including time series
653
+ """
654
+ with self.db.get_session() as session:
655
+ # Build base query with filters
656
+ query = session.query(TokenUsage)
657
+
658
+ # Apply time filter
659
+ time_condition = get_time_filter_condition(
660
+ period, TokenUsage.timestamp
661
+ )
662
+ if time_condition is not None:
663
+ query = query.filter(time_condition)
664
+
665
+ # Apply research mode filter
666
+ mode_condition = get_research_mode_condition(
667
+ research_mode, TokenUsage.research_mode
668
+ )
669
+ if mode_condition is not None:
670
+ query = query.filter(mode_condition)
671
+
672
+ # Get time series data for the chart - most important for "Token Consumption Over Time"
673
+ time_series_query = query.filter(
674
+ TokenUsage.timestamp.isnot(None), TokenUsage.total_tokens > 0
675
+ ).order_by(TokenUsage.timestamp.asc())
676
+
677
+ # Limit to recent data for performance
678
+ if period != "all":
679
+ time_series_query = time_series_query.limit(200)
680
+
681
+ time_series_data = time_series_query.all()
682
+
683
+ # Format time series data with cumulative calculations
684
+ time_series = []
685
+ cumulative_tokens = 0
686
+ cumulative_prompt_tokens = 0
687
+ cumulative_completion_tokens = 0
688
+
689
+ for usage in time_series_data:
690
+ cumulative_tokens += usage.total_tokens or 0
691
+ cumulative_prompt_tokens += usage.prompt_tokens or 0
692
+ cumulative_completion_tokens += usage.completion_tokens or 0
693
+
694
+ time_series.append(
695
+ {
696
+ "timestamp": str(usage.timestamp)
697
+ if usage.timestamp
698
+ else None,
699
+ "tokens": usage.total_tokens or 0,
700
+ "prompt_tokens": usage.prompt_tokens or 0,
701
+ "completion_tokens": usage.completion_tokens or 0,
702
+ "cumulative_tokens": cumulative_tokens,
703
+ "cumulative_prompt_tokens": cumulative_prompt_tokens,
704
+ "cumulative_completion_tokens": cumulative_completion_tokens,
705
+ "research_id": usage.research_id,
706
+ "research_query": usage.research_query,
707
+ }
708
+ )
709
+
710
+ # Basic performance stats using ORM
711
+ performance_query = query.filter(
712
+ TokenUsage.response_time_ms.isnot(None)
713
+ )
714
+ total_calls = performance_query.count()
715
+
716
+ if total_calls > 0:
717
+ avg_response_time = (
718
+ performance_query.with_entities(
719
+ func.avg(TokenUsage.response_time_ms)
720
+ ).scalar()
721
+ or 0
722
+ )
723
+ min_response_time = (
724
+ performance_query.with_entities(
725
+ func.min(TokenUsage.response_time_ms)
726
+ ).scalar()
727
+ or 0
728
+ )
729
+ max_response_time = (
730
+ performance_query.with_entities(
731
+ func.max(TokenUsage.response_time_ms)
732
+ ).scalar()
733
+ or 0
734
+ )
735
+ success_count = performance_query.filter(
736
+ TokenUsage.success_status == "success"
737
+ ).count()
738
+ error_count = performance_query.filter(
739
+ TokenUsage.success_status == "error"
740
+ ).count()
741
+
742
+ perf_stats = {
743
+ "avg_response_time": round(avg_response_time),
744
+ "min_response_time": min_response_time,
745
+ "max_response_time": max_response_time,
746
+ "success_rate": (
747
+ round((success_count / total_calls * 100), 1)
748
+ if total_calls > 0
749
+ else 0
750
+ ),
751
+ "error_rate": (
752
+ round((error_count / total_calls * 100), 1)
753
+ if total_calls > 0
754
+ else 0
755
+ ),
756
+ "total_enhanced_calls": total_calls,
757
+ }
758
+ else:
759
+ perf_stats = {
760
+ "avg_response_time": 0,
761
+ "min_response_time": 0,
762
+ "max_response_time": 0,
763
+ "success_rate": 0,
764
+ "error_rate": 0,
765
+ "total_enhanced_calls": 0,
766
+ }
767
+
768
+ # Research mode breakdown using ORM
769
+ mode_stats = (
770
+ query.filter(TokenUsage.research_mode.isnot(None))
771
+ .with_entities(
772
+ TokenUsage.research_mode,
773
+ func.count().label("count"),
774
+ func.avg(TokenUsage.total_tokens).label("avg_tokens"),
775
+ func.avg(TokenUsage.response_time_ms).label(
776
+ "avg_response_time"
777
+ ),
778
+ )
779
+ .group_by(TokenUsage.research_mode)
780
+ .all()
781
+ )
782
+
783
+ modes = [
784
+ {
785
+ "mode": stat.research_mode,
786
+ "count": stat.count,
787
+ "avg_tokens": round(stat.avg_tokens or 0),
788
+ "avg_response_time": round(stat.avg_response_time or 0),
789
+ }
790
+ for stat in mode_stats
791
+ ]
792
+
793
+ # Recent enhanced data (simplified)
794
+ recent_enhanced_query = (
795
+ query.filter(TokenUsage.research_query.isnot(None))
796
+ .order_by(TokenUsage.timestamp.desc())
797
+ .limit(50)
798
+ )
799
+
800
+ recent_enhanced_data = recent_enhanced_query.all()
801
+ recent_enhanced = [
802
+ {
803
+ "research_query": usage.research_query,
804
+ "research_mode": usage.research_mode,
805
+ "research_phase": usage.research_phase,
806
+ "search_iteration": usage.search_iteration,
807
+ "response_time_ms": usage.response_time_ms,
808
+ "success_status": usage.success_status,
809
+ "error_type": usage.error_type,
810
+ "search_engines_planned": usage.search_engines_planned,
811
+ "search_engine_selected": usage.search_engine_selected,
812
+ "total_tokens": usage.total_tokens,
813
+ "prompt_tokens": usage.prompt_tokens,
814
+ "completion_tokens": usage.completion_tokens,
815
+ "timestamp": str(usage.timestamp)
816
+ if usage.timestamp
817
+ else None,
818
+ "research_id": usage.research_id,
819
+ "calling_file": usage.calling_file,
820
+ "calling_function": usage.calling_function,
821
+ "call_stack": usage.call_stack,
822
+ }
823
+ for usage in recent_enhanced_data
824
+ ]
825
+
826
+ # Search engine breakdown using ORM
827
+ search_engine_stats = (
828
+ query.filter(TokenUsage.search_engine_selected.isnot(None))
829
+ .with_entities(
830
+ TokenUsage.search_engine_selected,
831
+ func.count().label("count"),
832
+ func.avg(TokenUsage.total_tokens).label("avg_tokens"),
833
+ func.avg(TokenUsage.response_time_ms).label(
834
+ "avg_response_time"
835
+ ),
836
+ )
837
+ .group_by(TokenUsage.search_engine_selected)
838
+ .all()
839
+ )
840
+
841
+ search_engines = [
842
+ {
843
+ "search_engine": stat.search_engine_selected,
844
+ "count": stat.count,
845
+ "avg_tokens": round(stat.avg_tokens or 0),
846
+ "avg_response_time": round(stat.avg_response_time or 0),
847
+ }
848
+ for stat in search_engine_stats
849
+ ]
850
+
851
+ # Research phase breakdown using ORM
852
+ phase_stats = (
853
+ query.filter(TokenUsage.research_phase.isnot(None))
854
+ .with_entities(
855
+ TokenUsage.research_phase,
856
+ func.count().label("count"),
857
+ func.avg(TokenUsage.total_tokens).label("avg_tokens"),
858
+ func.avg(TokenUsage.response_time_ms).label(
859
+ "avg_response_time"
860
+ ),
861
+ )
862
+ .group_by(TokenUsage.research_phase)
863
+ .all()
864
+ )
865
+
866
+ phases = [
867
+ {
868
+ "phase": stat.research_phase,
869
+ "count": stat.count,
870
+ "avg_tokens": round(stat.avg_tokens or 0),
871
+ "avg_response_time": round(stat.avg_response_time or 0),
872
+ }
873
+ for stat in phase_stats
874
+ ]
875
+
876
+ # Call stack analysis using ORM
877
+ file_stats = (
878
+ query.filter(TokenUsage.calling_file.isnot(None))
879
+ .with_entities(
880
+ TokenUsage.calling_file,
881
+ func.count().label("count"),
882
+ func.avg(TokenUsage.total_tokens).label("avg_tokens"),
883
+ )
884
+ .group_by(TokenUsage.calling_file)
885
+ .order_by(func.count().desc())
886
+ .limit(10)
887
+ .all()
888
+ )
889
+
890
+ files = [
891
+ {
892
+ "file": stat.calling_file,
893
+ "count": stat.count,
894
+ "avg_tokens": round(stat.avg_tokens or 0),
895
+ }
896
+ for stat in file_stats
897
+ ]
898
+
899
+ function_stats = (
900
+ query.filter(TokenUsage.calling_function.isnot(None))
901
+ .with_entities(
902
+ TokenUsage.calling_function,
903
+ func.count().label("count"),
904
+ func.avg(TokenUsage.total_tokens).label("avg_tokens"),
905
+ )
906
+ .group_by(TokenUsage.calling_function)
907
+ .order_by(func.count().desc())
908
+ .limit(10)
909
+ .all()
910
+ )
911
+
912
+ functions = [
913
+ {
914
+ "function": stat.calling_function,
915
+ "count": stat.count,
916
+ "avg_tokens": round(stat.avg_tokens or 0),
917
+ }
918
+ for stat in function_stats
919
+ ]
920
+
921
+ return {
922
+ "recent_enhanced_data": recent_enhanced,
923
+ "performance_stats": perf_stats,
924
+ "mode_breakdown": modes,
925
+ "search_engine_stats": search_engines,
926
+ "phase_breakdown": phases,
927
+ "time_series_data": time_series,
928
+ "call_stack_analysis": {
929
+ "by_file": files,
930
+ "by_function": functions,
931
+ },
932
+ }
933
+
934
+ def get_research_timeline_metrics(self, research_id: int) -> Dict[str, Any]:
935
+ """Get timeline metrics for a specific research.
936
+
937
+ Args:
938
+ research_id: The ID of the research
939
+
940
+ Returns:
941
+ Dictionary containing timeline metrics for the research
942
+ """
943
+ with self.db.get_session() as session:
944
+ # Get all token usage for this research ordered by time including call stack
945
+ timeline_data = session.execute(
946
+ text(
947
+ """
948
+ SELECT
949
+ timestamp,
950
+ total_tokens,
951
+ prompt_tokens,
952
+ completion_tokens,
953
+ response_time_ms,
954
+ success_status,
955
+ error_type,
956
+ research_phase,
957
+ search_iteration,
958
+ search_engine_selected,
959
+ model_name,
960
+ calling_file,
961
+ calling_function,
962
+ call_stack
963
+ FROM token_usage
964
+ WHERE research_id = :research_id
965
+ ORDER BY timestamp ASC
966
+ """
967
+ ),
968
+ {"research_id": research_id},
969
+ ).fetchall()
970
+
971
+ # Format timeline data with cumulative tokens
972
+ timeline = []
973
+ cumulative_tokens = 0
974
+ cumulative_prompt_tokens = 0
975
+ cumulative_completion_tokens = 0
976
+
977
+ for row in timeline_data:
978
+ cumulative_tokens += row[1] or 0
979
+ cumulative_prompt_tokens += row[2] or 0
980
+ cumulative_completion_tokens += row[3] or 0
981
+
982
+ timeline.append(
983
+ {
984
+ "timestamp": str(row[0]) if row[0] else None,
985
+ "tokens": row[1] or 0,
986
+ "prompt_tokens": row[2] or 0,
987
+ "completion_tokens": row[3] or 0,
988
+ "cumulative_tokens": cumulative_tokens,
989
+ "cumulative_prompt_tokens": cumulative_prompt_tokens,
990
+ "cumulative_completion_tokens": cumulative_completion_tokens,
991
+ "response_time_ms": row[4],
992
+ "success_status": row[5],
993
+ "error_type": row[6],
994
+ "research_phase": row[7],
995
+ "search_iteration": row[8],
996
+ "search_engine_selected": row[9],
997
+ "model_name": row[10],
998
+ "calling_file": row[11],
999
+ "calling_function": row[12],
1000
+ "call_stack": row[13],
1001
+ }
1002
+ )
1003
+
1004
+ # Get research basic info
1005
+ research_info = session.execute(
1006
+ text(
1007
+ """
1008
+ SELECT query, mode, status, created_at, completed_at
1009
+ FROM research_history
1010
+ WHERE id = :research_id
1011
+ """
1012
+ ),
1013
+ {"research_id": research_id},
1014
+ ).fetchone()
1015
+
1016
+ research_details = {}
1017
+ if research_info:
1018
+ research_details = {
1019
+ "query": research_info[0],
1020
+ "mode": research_info[1],
1021
+ "status": research_info[2],
1022
+ "created_at": str(research_info[3])
1023
+ if research_info[3]
1024
+ else None,
1025
+ "completed_at": str(research_info[4])
1026
+ if research_info[4]
1027
+ else None,
1028
+ }
1029
+
1030
+ # Calculate summary stats
1031
+ total_calls = len(timeline_data)
1032
+ total_tokens = cumulative_tokens
1033
+ avg_response_time = sum(row[4] or 0 for row in timeline_data) / max(
1034
+ total_calls, 1
1035
+ )
1036
+ success_rate = (
1037
+ sum(1 for row in timeline_data if row[5] == "success")
1038
+ / max(total_calls, 1)
1039
+ * 100
1040
+ )
1041
+
1042
+ # Phase breakdown for this research
1043
+ phase_stats = {}
1044
+ for row in timeline_data:
1045
+ phase = row[7] or "unknown"
1046
+ if phase not in phase_stats:
1047
+ phase_stats[phase] = {
1048
+ "count": 0,
1049
+ "tokens": 0,
1050
+ "avg_response_time": 0,
1051
+ }
1052
+ phase_stats[phase]["count"] += 1
1053
+ phase_stats[phase]["tokens"] += row[1] or 0
1054
+ if row[4]:
1055
+ phase_stats[phase]["avg_response_time"] += row[4]
1056
+
1057
+ # Calculate averages for phases
1058
+ for phase in phase_stats:
1059
+ if phase_stats[phase]["count"] > 0:
1060
+ phase_stats[phase]["avg_response_time"] = round(
1061
+ phase_stats[phase]["avg_response_time"]
1062
+ / phase_stats[phase]["count"]
1063
+ )
1064
+
1065
+ return {
1066
+ "research_id": research_id,
1067
+ "research_details": research_details,
1068
+ "timeline": timeline,
1069
+ "summary": {
1070
+ "total_calls": total_calls,
1071
+ "total_tokens": total_tokens,
1072
+ "total_prompt_tokens": cumulative_prompt_tokens,
1073
+ "total_completion_tokens": cumulative_completion_tokens,
1074
+ "avg_response_time": round(avg_response_time),
1075
+ "success_rate": round(success_rate, 1),
1076
+ },
1077
+ "phase_stats": phase_stats,
1078
+ }