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,1155 @@
1
+ """Routes for metrics dashboard."""
2
+
3
+ from datetime import datetime, timedelta
4
+
5
+ from flask import Blueprint, jsonify, request
6
+ from loguru import logger
7
+ from sqlalchemy import case, func
8
+
9
+ from ...metrics import TokenCounter
10
+ from ...metrics.db_models import ResearchRating, TokenUsage
11
+ from ...metrics.query_utils import get_time_filter_condition
12
+ from ...metrics.search_tracker import get_search_tracker
13
+ from ...utilities.db_utils import get_db_session
14
+ from ..database.models import Research, ResearchStrategy
15
+ from ..utils.templates import render_template_with_defaults
16
+
17
+ # Create a Blueprint for metrics
18
+ metrics_bp = Blueprint("metrics", __name__, url_prefix="/metrics")
19
+
20
+
21
+ def get_rating_analytics(period="30d", research_mode="all"):
22
+ """Get rating analytics for the specified period and research mode."""
23
+ try:
24
+ from ...metrics.database import get_metrics_db
25
+ from ...metrics.db_models import ResearchRating
26
+
27
+ db = get_metrics_db()
28
+
29
+ # Calculate date range
30
+ days_map = {"7d": 7, "30d": 30, "90d": 90, "365d": 365, "all": None}
31
+
32
+ days = days_map.get(period, 30)
33
+
34
+ with db.get_session() as session:
35
+ query = session.query(ResearchRating)
36
+
37
+ # Apply time filter
38
+ if days:
39
+ cutoff_date = datetime.now() - timedelta(days=days)
40
+ query = query.filter(ResearchRating.rated_at >= cutoff_date)
41
+
42
+ # Get all ratings
43
+ ratings = query.all()
44
+
45
+ if not ratings:
46
+ return {
47
+ "rating_analytics": {
48
+ "avg_rating": None,
49
+ "total_ratings": 0,
50
+ "rating_distribution": {},
51
+ "satisfaction_stats": {
52
+ "very_satisfied": 0, # 5 stars
53
+ "satisfied": 0, # 4 stars
54
+ "neutral": 0, # 3 stars
55
+ "dissatisfied": 0, # 2 stars
56
+ "very_dissatisfied": 0, # 1 star
57
+ },
58
+ }
59
+ }
60
+
61
+ # Calculate statistics
62
+ rating_values = [r.rating for r in ratings]
63
+ avg_rating = sum(rating_values) / len(rating_values)
64
+
65
+ # Rating distribution
66
+ rating_counts = {}
67
+ for i in range(1, 6):
68
+ rating_counts[str(i)] = rating_values.count(i)
69
+
70
+ # Satisfaction categories
71
+ satisfaction_stats = {
72
+ "very_satisfied": rating_values.count(5),
73
+ "satisfied": rating_values.count(4),
74
+ "neutral": rating_values.count(3),
75
+ "dissatisfied": rating_values.count(2),
76
+ "very_dissatisfied": rating_values.count(1),
77
+ }
78
+
79
+ return {
80
+ "rating_analytics": {
81
+ "avg_rating": round(avg_rating, 1),
82
+ "total_ratings": len(ratings),
83
+ "rating_distribution": rating_counts,
84
+ "satisfaction_stats": satisfaction_stats,
85
+ }
86
+ }
87
+
88
+ except Exception as e:
89
+ logger.exception(f"Error getting rating analytics: {e}")
90
+ return {
91
+ "rating_analytics": {
92
+ "avg_rating": None,
93
+ "total_ratings": 0,
94
+ "rating_distribution": {},
95
+ "satisfaction_stats": {
96
+ "very_satisfied": 0,
97
+ "satisfied": 0,
98
+ "neutral": 0,
99
+ "dissatisfied": 0,
100
+ "very_dissatisfied": 0,
101
+ },
102
+ }
103
+ }
104
+
105
+
106
+ def get_available_strategies():
107
+ """Get list of all available search strategies from the search system."""
108
+ # This list comes from the AdvancedSearchSystem.__init__ method
109
+ strategies = [
110
+ {"name": "standard", "description": "Basic iterative search strategy"},
111
+ {
112
+ "name": "iterdrag",
113
+ "description": "Iterative Dense Retrieval Augmented Generation",
114
+ },
115
+ {
116
+ "name": "source-based",
117
+ "description": "Focuses on finding and extracting from sources",
118
+ },
119
+ {
120
+ "name": "parallel",
121
+ "description": "Runs multiple search queries in parallel",
122
+ },
123
+ {"name": "rapid", "description": "Quick single-pass search"},
124
+ {
125
+ "name": "recursive",
126
+ "description": "Recursive decomposition of complex queries",
127
+ },
128
+ {
129
+ "name": "iterative",
130
+ "description": "Loop-based reasoning with persistent knowledge",
131
+ },
132
+ {"name": "adaptive", "description": "Adaptive step-by-step reasoning"},
133
+ {
134
+ "name": "smart",
135
+ "description": "Automatically chooses best strategy based on query",
136
+ },
137
+ {
138
+ "name": "browsecomp",
139
+ "description": "Optimized for BrowseComp-style puzzle queries",
140
+ },
141
+ {
142
+ "name": "evidence",
143
+ "description": "Enhanced evidence-based verification with improved candidate discovery",
144
+ },
145
+ {
146
+ "name": "constrained",
147
+ "description": "Progressive constraint-based search that narrows candidates step by step",
148
+ },
149
+ {
150
+ "name": "parallel-constrained",
151
+ "description": "Parallel constraint-based search with combined constraint execution",
152
+ },
153
+ {
154
+ "name": "early-stop-constrained",
155
+ "description": "Parallel constraint search with immediate evaluation and early stopping at 99% confidence",
156
+ },
157
+ {
158
+ "name": "smart-query",
159
+ "description": "Smart query generation strategy",
160
+ },
161
+ {
162
+ "name": "dual-confidence",
163
+ "description": "Dual confidence scoring with positive/negative/uncertainty",
164
+ },
165
+ {
166
+ "name": "dual-confidence-with-rejection",
167
+ "description": "Dual confidence with early rejection of poor candidates",
168
+ },
169
+ {
170
+ "name": "concurrent-dual-confidence",
171
+ "description": "Concurrent search & evaluation with progressive constraint relaxation",
172
+ },
173
+ {
174
+ "name": "modular",
175
+ "description": "Modular architecture using constraint checking and candidate exploration modules",
176
+ },
177
+ {
178
+ "name": "modular-parallel",
179
+ "description": "Modular strategy with parallel exploration",
180
+ },
181
+ {
182
+ "name": "focused-iteration",
183
+ "description": "Focused iteration strategy optimized for accuracy",
184
+ },
185
+ {
186
+ "name": "browsecomp-entity",
187
+ "description": "Entity-focused search for BrowseComp questions with knowledge graph building",
188
+ },
189
+ ]
190
+ return strategies
191
+
192
+
193
+ def get_strategy_analytics(period="30d"):
194
+ """Get strategy usage analytics for the specified period."""
195
+ try:
196
+ # Calculate date range
197
+ days_map = {"7d": 7, "30d": 30, "90d": 90, "365d": 365, "all": None}
198
+ days = days_map.get(period, 30)
199
+
200
+ session = get_db_session()
201
+
202
+ try:
203
+ # Check if we have any ResearchStrategy records
204
+ strategy_count = session.query(ResearchStrategy).count()
205
+
206
+ if strategy_count == 0:
207
+ logger.warning("No research strategies found in database")
208
+ return {
209
+ "strategy_analytics": {
210
+ "total_research_with_strategy": 0,
211
+ "total_research": 0,
212
+ "most_popular_strategy": None,
213
+ "strategy_usage": [],
214
+ "strategy_distribution": {},
215
+ "available_strategies": get_available_strategies(),
216
+ "message": "Strategy tracking not yet available - run a research to start tracking",
217
+ }
218
+ }
219
+
220
+ # Base query for strategy usage (no JOIN needed since we just want strategy counts)
221
+ query = session.query(
222
+ ResearchStrategy.strategy_name,
223
+ func.count(ResearchStrategy.id).label("usage_count"),
224
+ )
225
+
226
+ # Apply time filter if specified
227
+ if days:
228
+ cutoff_date = datetime.now() - timedelta(days=days)
229
+ query = query.filter(ResearchStrategy.created_at >= cutoff_date)
230
+
231
+ # Group by strategy and order by usage
232
+ strategy_results = (
233
+ query.group_by(ResearchStrategy.strategy_name)
234
+ .order_by(func.count(ResearchStrategy.id).desc())
235
+ .all()
236
+ )
237
+
238
+ # Get total strategy count for percentage calculation
239
+ total_query = session.query(ResearchStrategy)
240
+ if days:
241
+ total_query = total_query.filter(
242
+ ResearchStrategy.created_at >= cutoff_date
243
+ )
244
+ total_research = total_query.count()
245
+
246
+ finally:
247
+ session.close()
248
+
249
+ # Format strategy data
250
+ strategy_usage = []
251
+ strategy_distribution = {}
252
+
253
+ for strategy_name, usage_count in strategy_results:
254
+ percentage = (
255
+ (usage_count / total_research * 100)
256
+ if total_research > 0
257
+ else 0
258
+ )
259
+ strategy_usage.append(
260
+ {
261
+ "strategy": strategy_name,
262
+ "count": usage_count,
263
+ "percentage": round(percentage, 1),
264
+ }
265
+ )
266
+ strategy_distribution[strategy_name] = usage_count
267
+
268
+ # Find most popular strategy
269
+ most_popular = strategy_usage[0]["strategy"] if strategy_usage else None
270
+
271
+ return {
272
+ "strategy_analytics": {
273
+ "total_research_with_strategy": sum(
274
+ item["count"] for item in strategy_usage
275
+ ),
276
+ "total_research": total_research,
277
+ "most_popular_strategy": most_popular,
278
+ "strategy_usage": strategy_usage,
279
+ "strategy_distribution": strategy_distribution,
280
+ "available_strategies": get_available_strategies(),
281
+ }
282
+ }
283
+
284
+ except Exception as e:
285
+ logger.exception(f"Error getting strategy analytics: {e}")
286
+ return {
287
+ "strategy_analytics": {
288
+ "total_research_with_strategy": 0,
289
+ "total_research": 0,
290
+ "most_popular_strategy": None,
291
+ "strategy_usage": [],
292
+ "strategy_distribution": {},
293
+ "available_strategies": get_available_strategies(),
294
+ "error": str(e),
295
+ }
296
+ }
297
+
298
+
299
+ @metrics_bp.route("/")
300
+ def metrics_dashboard():
301
+ """Render the metrics dashboard page."""
302
+ return render_template_with_defaults("pages/metrics.html")
303
+
304
+
305
+ @metrics_bp.route("/api/metrics")
306
+ def api_metrics():
307
+ """Get overall metrics data."""
308
+ try:
309
+ # Get time period and research mode from query parameters
310
+ period = request.args.get("period", "30d")
311
+ research_mode = request.args.get("mode", "all")
312
+
313
+ token_counter = TokenCounter()
314
+ search_tracker = get_search_tracker()
315
+
316
+ # Get both token and search metrics
317
+ token_metrics = token_counter.get_overall_metrics(
318
+ period=period, research_mode=research_mode
319
+ )
320
+ search_metrics = search_tracker.get_search_metrics(
321
+ period=period, research_mode=research_mode
322
+ )
323
+
324
+ # Get user satisfaction rating data
325
+ try:
326
+ from sqlalchemy import func
327
+
328
+ from ...metrics.db_models import ResearchRating
329
+
330
+ with get_db_session() as session:
331
+ # Build base query with time filter
332
+ ratings_query = session.query(ResearchRating)
333
+ time_condition = get_time_filter_condition(
334
+ period, ResearchRating.rated_at
335
+ )
336
+ if time_condition is not None:
337
+ ratings_query = ratings_query.filter(time_condition)
338
+
339
+ # Get average rating
340
+ avg_rating = ratings_query.with_entities(
341
+ func.avg(ResearchRating.rating).label("avg_rating")
342
+ ).scalar()
343
+
344
+ # Get total rating count
345
+ total_ratings = ratings_query.count()
346
+
347
+ user_satisfaction = {
348
+ "avg_rating": round(avg_rating, 1) if avg_rating else None,
349
+ "total_ratings": total_ratings,
350
+ }
351
+ except Exception as e:
352
+ logger.warning(f"Error getting user satisfaction data: {e}")
353
+ user_satisfaction = {"avg_rating": None, "total_ratings": 0}
354
+
355
+ # Get strategy analytics
356
+ strategy_data = get_strategy_analytics(period)
357
+
358
+ # Combine metrics
359
+ combined_metrics = {
360
+ **token_metrics,
361
+ **search_metrics,
362
+ **strategy_data,
363
+ "user_satisfaction": user_satisfaction,
364
+ }
365
+
366
+ return jsonify(
367
+ {
368
+ "status": "success",
369
+ "metrics": combined_metrics,
370
+ "period": period,
371
+ "research_mode": research_mode,
372
+ }
373
+ )
374
+ except Exception as e:
375
+ logger.exception(f"Error getting metrics: {e}")
376
+ return (
377
+ jsonify(
378
+ {
379
+ "status": "error",
380
+ "message": "An internal error occurred. Please try again later.",
381
+ }
382
+ ),
383
+ 500,
384
+ )
385
+
386
+
387
+ @metrics_bp.route("/api/metrics/research/<int:research_id>")
388
+ def api_research_metrics(research_id):
389
+ """Get metrics for a specific research."""
390
+ try:
391
+ token_counter = TokenCounter()
392
+ metrics = token_counter.get_research_metrics(research_id)
393
+ return jsonify({"status": "success", "metrics": metrics})
394
+ except Exception as e:
395
+ logger.exception(f"Error getting research metrics: {e}")
396
+ return (
397
+ jsonify(
398
+ {
399
+ "status": "error",
400
+ "message": "An internal error occurred. Please try again later.",
401
+ }
402
+ ),
403
+ 500,
404
+ )
405
+
406
+
407
+ @metrics_bp.route("/api/metrics/research/<int:research_id>/timeline")
408
+ def api_research_timeline_metrics(research_id):
409
+ """Get timeline metrics for a specific research."""
410
+ try:
411
+ token_counter = TokenCounter()
412
+ timeline_metrics = token_counter.get_research_timeline_metrics(
413
+ research_id
414
+ )
415
+ return jsonify({"status": "success", "metrics": timeline_metrics})
416
+ except Exception as e:
417
+ logger.exception(f"Error getting research timeline metrics: {e}")
418
+ return (
419
+ jsonify(
420
+ {
421
+ "status": "error",
422
+ "message": "An internal error occurred. Please try again later.",
423
+ }
424
+ ),
425
+ 500,
426
+ )
427
+
428
+
429
+ @metrics_bp.route("/api/metrics/research/<int:research_id>/search")
430
+ def api_research_search_metrics(research_id):
431
+ """Get search metrics for a specific research."""
432
+ try:
433
+ search_tracker = get_search_tracker()
434
+ search_metrics = search_tracker.get_research_search_metrics(research_id)
435
+ return jsonify({"status": "success", "metrics": search_metrics})
436
+ except Exception as e:
437
+ logger.exception(f"Error getting research search metrics: {e}")
438
+ return (
439
+ jsonify(
440
+ {
441
+ "status": "error",
442
+ "message": "An internal error occurred. Please try again later.",
443
+ }
444
+ ),
445
+ 500,
446
+ )
447
+
448
+
449
+ @metrics_bp.route("/api/metrics/enhanced")
450
+ def api_enhanced_metrics():
451
+ """Get enhanced Phase 1 tracking metrics."""
452
+ try:
453
+ # Get time period and research mode from query parameters
454
+ period = request.args.get("period", "30d")
455
+ research_mode = request.args.get("mode", "all")
456
+
457
+ token_counter = TokenCounter()
458
+ search_tracker = get_search_tracker()
459
+
460
+ enhanced_metrics = token_counter.get_enhanced_metrics(
461
+ period=period, research_mode=research_mode
462
+ )
463
+
464
+ # Add search time series data for the chart
465
+ search_time_series = search_tracker.get_search_time_series(
466
+ period=period, research_mode=research_mode
467
+ )
468
+ enhanced_metrics["search_time_series"] = search_time_series
469
+
470
+ # Add rating analytics
471
+ rating_analytics = get_rating_analytics(period, research_mode)
472
+ enhanced_metrics.update(rating_analytics)
473
+
474
+ return jsonify(
475
+ {
476
+ "status": "success",
477
+ "metrics": enhanced_metrics,
478
+ "period": period,
479
+ "research_mode": research_mode,
480
+ }
481
+ )
482
+ except Exception as e:
483
+ logger.exception(f"Error getting enhanced metrics: {e}")
484
+ return (
485
+ jsonify(
486
+ {
487
+ "status": "error",
488
+ "message": "An internal error occurred. Please try again later.",
489
+ }
490
+ ),
491
+ 500,
492
+ )
493
+
494
+
495
+ @metrics_bp.route("/api/ratings/<int:research_id>", methods=["GET"])
496
+ def api_get_research_rating(research_id):
497
+ """Get rating for a specific research session."""
498
+ try:
499
+ from ...metrics.database import get_metrics_db
500
+ from ...metrics.db_models import ResearchRating
501
+
502
+ db = get_metrics_db()
503
+ with db.get_session() as session:
504
+ rating = (
505
+ session.query(ResearchRating)
506
+ .filter_by(research_id=research_id)
507
+ .first()
508
+ )
509
+
510
+ if rating:
511
+ return jsonify(
512
+ {
513
+ "status": "success",
514
+ "rating": rating.rating,
515
+ "rated_at": rating.rated_at.isoformat(),
516
+ "updated_at": rating.updated_at.isoformat(),
517
+ }
518
+ )
519
+ else:
520
+ return jsonify({"status": "success", "rating": None})
521
+
522
+ except Exception as e:
523
+ logger.exception(f"Error getting research rating: {e}")
524
+ return (
525
+ jsonify(
526
+ {
527
+ "status": "error",
528
+ "message": "An internal error occurred. Please try again later.",
529
+ }
530
+ ),
531
+ 500,
532
+ )
533
+
534
+
535
+ @metrics_bp.route("/api/ratings/<int:research_id>", methods=["POST"])
536
+ def api_save_research_rating(research_id):
537
+ """Save or update rating for a specific research session."""
538
+ try:
539
+ from sqlalchemy import func
540
+
541
+ from ...metrics.database import get_metrics_db
542
+ from ...metrics.db_models import ResearchRating
543
+
544
+ data = request.get_json()
545
+ rating_value = data.get("rating")
546
+
547
+ if (
548
+ not rating_value
549
+ or not isinstance(rating_value, int)
550
+ or rating_value < 1
551
+ or rating_value > 5
552
+ ):
553
+ return (
554
+ jsonify(
555
+ {
556
+ "status": "error",
557
+ "message": "Rating must be an integer between 1 and 5",
558
+ }
559
+ ),
560
+ 400,
561
+ )
562
+
563
+ db = get_metrics_db()
564
+ with db.get_session() as session:
565
+ # Check if rating already exists
566
+ existing_rating = (
567
+ session.query(ResearchRating)
568
+ .filter_by(research_id=research_id)
569
+ .first()
570
+ )
571
+
572
+ if existing_rating:
573
+ # Update existing rating
574
+ existing_rating.rating = rating_value
575
+ existing_rating.updated_at = func.now()
576
+ else:
577
+ # Create new rating
578
+ new_rating = ResearchRating(
579
+ research_id=research_id, rating=rating_value
580
+ )
581
+ session.add(new_rating)
582
+
583
+ session.commit()
584
+
585
+ return jsonify(
586
+ {
587
+ "status": "success",
588
+ "message": "Rating saved successfully",
589
+ "rating": rating_value,
590
+ }
591
+ )
592
+
593
+ except Exception as e:
594
+ logger.exception(f"Error saving research rating: {e}")
595
+ return (
596
+ jsonify(
597
+ {
598
+ "status": "error",
599
+ "message": "An internal error occurred. Please try again later.",
600
+ }
601
+ ),
602
+ 500,
603
+ )
604
+
605
+
606
+ @metrics_bp.route("/star-reviews")
607
+ def star_reviews():
608
+ """Display star reviews metrics page."""
609
+ return render_template_with_defaults("pages/star_reviews.html")
610
+
611
+
612
+ @metrics_bp.route("/costs")
613
+ def cost_analytics():
614
+ """Display cost analytics page."""
615
+ return render_template_with_defaults("pages/cost_analytics.html")
616
+
617
+
618
+ @metrics_bp.route("/api/star-reviews")
619
+ def api_star_reviews():
620
+ """Get star reviews analytics data."""
621
+ try:
622
+ period = request.args.get("period", "30d")
623
+
624
+ with get_db_session() as session:
625
+ # Build base query with time filter
626
+ base_query = session.query(ResearchRating)
627
+ time_condition = get_time_filter_condition(
628
+ period, ResearchRating.rated_at
629
+ )
630
+ if time_condition is not None:
631
+ base_query = base_query.filter(time_condition)
632
+
633
+ # Overall rating statistics
634
+ overall_stats = session.query(
635
+ func.avg(ResearchRating.rating).label("avg_rating"),
636
+ func.count(ResearchRating.rating).label("total_ratings"),
637
+ func.sum(case((ResearchRating.rating == 5, 1), else_=0)).label(
638
+ "five_star"
639
+ ),
640
+ func.sum(case((ResearchRating.rating == 4, 1), else_=0)).label(
641
+ "four_star"
642
+ ),
643
+ func.sum(case((ResearchRating.rating == 3, 1), else_=0)).label(
644
+ "three_star"
645
+ ),
646
+ func.sum(case((ResearchRating.rating == 2, 1), else_=0)).label(
647
+ "two_star"
648
+ ),
649
+ func.sum(case((ResearchRating.rating == 1, 1), else_=0)).label(
650
+ "one_star"
651
+ ),
652
+ )
653
+
654
+ if time_condition is not None:
655
+ overall_stats = overall_stats.filter(time_condition)
656
+
657
+ overall_stats = overall_stats.first()
658
+
659
+ # Ratings by LLM model (get from token_usage since Research doesn't have model field)
660
+ llm_ratings_query = session.query(
661
+ func.coalesce(TokenUsage.model_name, "Unknown").label("model"),
662
+ func.avg(ResearchRating.rating).label("avg_rating"),
663
+ func.count(ResearchRating.rating).label("rating_count"),
664
+ func.sum(case((ResearchRating.rating >= 4, 1), else_=0)).label(
665
+ "positive_ratings"
666
+ ),
667
+ ).outerjoin(
668
+ TokenUsage, ResearchRating.research_id == TokenUsage.research_id
669
+ )
670
+
671
+ if time_condition is not None:
672
+ llm_ratings_query = llm_ratings_query.filter(time_condition)
673
+
674
+ llm_ratings = (
675
+ llm_ratings_query.group_by(TokenUsage.model_name)
676
+ .order_by(func.avg(ResearchRating.rating).desc())
677
+ .all()
678
+ )
679
+
680
+ # Ratings by search engine (join with token_usage to get search engine info)
681
+ search_engine_ratings_query = session.query(
682
+ func.coalesce(
683
+ TokenUsage.search_engine_selected, "Unknown"
684
+ ).label("search_engine"),
685
+ func.avg(ResearchRating.rating).label("avg_rating"),
686
+ func.count(ResearchRating.rating).label("rating_count"),
687
+ func.sum(case((ResearchRating.rating >= 4, 1), else_=0)).label(
688
+ "positive_ratings"
689
+ ),
690
+ ).outerjoin(
691
+ TokenUsage, ResearchRating.research_id == TokenUsage.research_id
692
+ )
693
+
694
+ if time_condition is not None:
695
+ search_engine_ratings_query = (
696
+ search_engine_ratings_query.filter(time_condition)
697
+ )
698
+
699
+ search_engine_ratings = (
700
+ search_engine_ratings_query.group_by(
701
+ TokenUsage.search_engine_selected
702
+ )
703
+ .having(func.count(ResearchRating.rating) > 0)
704
+ .order_by(func.avg(ResearchRating.rating).desc())
705
+ .all()
706
+ )
707
+
708
+ # Rating trends over time
709
+ rating_trends_query = session.query(
710
+ func.date(ResearchRating.rated_at).label("date"),
711
+ func.avg(ResearchRating.rating).label("avg_rating"),
712
+ func.count(ResearchRating.rating).label("daily_count"),
713
+ )
714
+
715
+ if time_condition is not None:
716
+ rating_trends_query = rating_trends_query.filter(time_condition)
717
+
718
+ rating_trends = (
719
+ rating_trends_query.group_by(func.date(ResearchRating.rated_at))
720
+ .order_by("date")
721
+ .all()
722
+ )
723
+
724
+ # Recent ratings with research details
725
+ recent_ratings_query = (
726
+ session.query(
727
+ ResearchRating.rating,
728
+ ResearchRating.rated_at,
729
+ ResearchRating.research_id,
730
+ Research.query,
731
+ Research.mode,
732
+ TokenUsage.model_name,
733
+ Research.created_at,
734
+ )
735
+ .outerjoin(Research, ResearchRating.research_id == Research.id)
736
+ .outerjoin(
737
+ TokenUsage,
738
+ ResearchRating.research_id == TokenUsage.research_id,
739
+ )
740
+ )
741
+
742
+ if time_condition is not None:
743
+ recent_ratings_query = recent_ratings_query.filter(
744
+ time_condition
745
+ )
746
+
747
+ recent_ratings = (
748
+ recent_ratings_query.order_by(ResearchRating.rated_at.desc())
749
+ .limit(20)
750
+ .all()
751
+ )
752
+
753
+ return jsonify(
754
+ {
755
+ "overall_stats": {
756
+ "avg_rating": round(overall_stats.avg_rating or 0, 2),
757
+ "total_ratings": overall_stats.total_ratings or 0,
758
+ "rating_distribution": {
759
+ "5": overall_stats.five_star or 0,
760
+ "4": overall_stats.four_star or 0,
761
+ "3": overall_stats.three_star or 0,
762
+ "2": overall_stats.two_star or 0,
763
+ "1": overall_stats.one_star or 0,
764
+ },
765
+ },
766
+ "llm_ratings": [
767
+ {
768
+ "model": rating.model,
769
+ "avg_rating": round(rating.avg_rating or 0, 2),
770
+ "rating_count": rating.rating_count or 0,
771
+ "positive_ratings": rating.positive_ratings or 0,
772
+ "satisfaction_rate": round(
773
+ (rating.positive_ratings or 0)
774
+ / max(rating.rating_count or 1, 1)
775
+ * 100,
776
+ 1,
777
+ ),
778
+ }
779
+ for rating in llm_ratings
780
+ ],
781
+ "search_engine_ratings": [
782
+ {
783
+ "search_engine": rating.search_engine,
784
+ "avg_rating": round(rating.avg_rating or 0, 2),
785
+ "rating_count": rating.rating_count or 0,
786
+ "positive_ratings": rating.positive_ratings or 0,
787
+ "satisfaction_rate": round(
788
+ (rating.positive_ratings or 0)
789
+ / max(rating.rating_count or 1, 1)
790
+ * 100,
791
+ 1,
792
+ ),
793
+ }
794
+ for rating in search_engine_ratings
795
+ ],
796
+ "rating_trends": [
797
+ {
798
+ "date": str(trend.date),
799
+ "avg_rating": round(trend.avg_rating or 0, 2),
800
+ "count": trend.daily_count or 0,
801
+ }
802
+ for trend in rating_trends
803
+ ],
804
+ "recent_ratings": [
805
+ {
806
+ "rating": rating.rating,
807
+ "rated_at": str(rating.rated_at),
808
+ "research_id": rating.research_id,
809
+ "query": (
810
+ rating.query
811
+ if rating.query
812
+ else f"Research Session #{rating.research_id}"
813
+ ),
814
+ "mode": rating.mode
815
+ if rating.mode
816
+ else "Standard Research",
817
+ "llm_model": (
818
+ rating.model_name
819
+ if rating.model_name
820
+ else "LLM Model"
821
+ ),
822
+ "created_at": (
823
+ str(rating.created_at)
824
+ if rating.created_at
825
+ else str(rating.rated_at)
826
+ ),
827
+ }
828
+ for rating in recent_ratings
829
+ ],
830
+ }
831
+ )
832
+
833
+ except Exception:
834
+ logger.exception("Error getting star reviews data")
835
+ return (
836
+ jsonify(
837
+ {"error": "An internal error occurred. Please try again later."}
838
+ ),
839
+ 500,
840
+ )
841
+
842
+
843
+ @metrics_bp.route("/api/pricing")
844
+ def api_pricing():
845
+ """Get current LLM pricing data."""
846
+ try:
847
+ from ...metrics.pricing.pricing_fetcher import PricingFetcher
848
+
849
+ # Use static pricing data instead of async
850
+ fetcher = PricingFetcher()
851
+ pricing_data = fetcher.static_pricing
852
+
853
+ return jsonify(
854
+ {
855
+ "status": "success",
856
+ "pricing": pricing_data,
857
+ "last_updated": datetime.now().isoformat(),
858
+ "note": "Pricing data is from static configuration. Real-time APIs not available for most providers.",
859
+ }
860
+ )
861
+
862
+ except Exception:
863
+ logger.exception("Error fetching pricing data")
864
+ return jsonify({"error": "Internal Server Error"}), 500
865
+
866
+
867
+ @metrics_bp.route("/api/pricing/<model_name>")
868
+ def api_model_pricing(model_name):
869
+ """Get pricing for a specific model."""
870
+ try:
871
+ # Optional provider parameter
872
+ provider = request.args.get("provider")
873
+
874
+ from ...metrics.pricing.cost_calculator import CostCalculator
875
+
876
+ # Use synchronous approach with cached/static pricing
877
+ calculator = CostCalculator()
878
+ pricing = calculator.cache.get_model_pricing(
879
+ model_name
880
+ ) or calculator.calculate_cost_sync(model_name, 1000, 1000).get(
881
+ "pricing_used", {}
882
+ )
883
+
884
+ return jsonify(
885
+ {
886
+ "status": "success",
887
+ "model": model_name,
888
+ "provider": provider,
889
+ "pricing": pricing,
890
+ "last_updated": datetime.now().isoformat(),
891
+ }
892
+ )
893
+
894
+ except Exception as e:
895
+ logger.error(f"Error getting pricing for {model_name}: {e}")
896
+ return jsonify({"error": "An internal error occurred"}), 500
897
+
898
+
899
+ @metrics_bp.route("/api/cost-calculation", methods=["POST"])
900
+ def api_cost_calculation():
901
+ """Calculate cost for token usage."""
902
+ try:
903
+ data = request.get_json()
904
+
905
+ if not data:
906
+ return jsonify({"error": "No data provided"}), 400
907
+
908
+ model_name = data.get("model_name")
909
+ provider = data.get("provider") # Optional provider parameter
910
+ prompt_tokens = data.get("prompt_tokens", 0)
911
+ completion_tokens = data.get("completion_tokens", 0)
912
+
913
+ if not model_name:
914
+ return jsonify({"error": "model_name is required"}), 400
915
+
916
+ from ...metrics.pricing.cost_calculator import CostCalculator
917
+
918
+ # Use synchronous cost calculation
919
+ calculator = CostCalculator()
920
+ cost_data = calculator.calculate_cost_sync(
921
+ model_name, prompt_tokens, completion_tokens
922
+ )
923
+
924
+ return jsonify(
925
+ {
926
+ "status": "success",
927
+ "model_name": model_name,
928
+ "provider": provider,
929
+ "prompt_tokens": prompt_tokens,
930
+ "completion_tokens": completion_tokens,
931
+ "total_tokens": prompt_tokens + completion_tokens,
932
+ **cost_data,
933
+ }
934
+ )
935
+
936
+ except Exception as e:
937
+ logger.error(f"Error calculating cost: {e}")
938
+ return jsonify({"error": "An internal error occurred"}), 500
939
+
940
+
941
+ @metrics_bp.route("/api/research-costs/<int:research_id>")
942
+ def api_research_costs(research_id):
943
+ """Get cost analysis for a specific research session."""
944
+ try:
945
+ with get_db_session() as session:
946
+ # Get token usage records for this research
947
+ usage_records = (
948
+ session.query(TokenUsage)
949
+ .filter(TokenUsage.research_id == research_id)
950
+ .all()
951
+ )
952
+
953
+ if not usage_records:
954
+ return jsonify(
955
+ {
956
+ "status": "success",
957
+ "research_id": research_id,
958
+ "total_cost": 0.0,
959
+ "message": "No token usage data found for this research session",
960
+ }
961
+ )
962
+
963
+ # Convert to dict format for cost calculation
964
+ usage_data = []
965
+ for record in usage_records:
966
+ usage_data.append(
967
+ {
968
+ "model_name": record.model_name,
969
+ "provider": getattr(
970
+ record, "provider", None
971
+ ), # Handle both old and new records
972
+ "prompt_tokens": record.prompt_tokens,
973
+ "completion_tokens": record.completion_tokens,
974
+ "timestamp": record.timestamp,
975
+ }
976
+ )
977
+
978
+ from ...metrics.pricing.cost_calculator import CostCalculator
979
+
980
+ # Use synchronous calculation for research costs
981
+ calculator = CostCalculator()
982
+ costs = []
983
+ for record in usage_data:
984
+ cost_data = calculator.calculate_cost_sync(
985
+ record["model_name"],
986
+ record["prompt_tokens"],
987
+ record["completion_tokens"],
988
+ )
989
+ costs.append({**record, **cost_data})
990
+
991
+ total_cost = sum(c["total_cost"] for c in costs)
992
+ total_prompt_tokens = sum(r["prompt_tokens"] for r in usage_data)
993
+ total_completion_tokens = sum(
994
+ r["completion_tokens"] for r in usage_data
995
+ )
996
+
997
+ cost_summary = {
998
+ "total_cost": round(total_cost, 6),
999
+ "total_tokens": total_prompt_tokens + total_completion_tokens,
1000
+ "prompt_tokens": total_prompt_tokens,
1001
+ "completion_tokens": total_completion_tokens,
1002
+ }
1003
+
1004
+ return jsonify(
1005
+ {
1006
+ "status": "success",
1007
+ "research_id": research_id,
1008
+ **cost_summary,
1009
+ }
1010
+ )
1011
+
1012
+ except Exception as e:
1013
+ logger.error(f"Error getting research costs for {research_id}: {e}")
1014
+ return jsonify({"error": "An internal error occurred"}), 500
1015
+
1016
+
1017
+ @metrics_bp.route("/api/cost-analytics")
1018
+ def api_cost_analytics():
1019
+ """Get cost analytics across all research sessions."""
1020
+ try:
1021
+ period = request.args.get("period", "30d")
1022
+
1023
+ # Add error handling for empty data
1024
+ with get_db_session() as session:
1025
+ # Get token usage for the period
1026
+ query = session.query(TokenUsage)
1027
+ time_condition = get_time_filter_condition(
1028
+ period, TokenUsage.timestamp
1029
+ )
1030
+ if time_condition is not None:
1031
+ query = query.filter(time_condition)
1032
+
1033
+ usage_records = query.all()
1034
+
1035
+ if not usage_records:
1036
+ return jsonify(
1037
+ {
1038
+ "status": "success",
1039
+ "period": period,
1040
+ "total_cost": 0.0,
1041
+ "message": "No token usage data found for this period",
1042
+ }
1043
+ )
1044
+
1045
+ # Convert to dict format
1046
+ usage_data = []
1047
+ for record in usage_records:
1048
+ usage_data.append(
1049
+ {
1050
+ "model_name": record.model_name,
1051
+ "provider": getattr(
1052
+ record, "provider", None
1053
+ ), # Handle both old and new records
1054
+ "prompt_tokens": record.prompt_tokens,
1055
+ "completion_tokens": record.completion_tokens,
1056
+ "research_id": record.research_id,
1057
+ "timestamp": record.timestamp,
1058
+ }
1059
+ )
1060
+
1061
+ from ...metrics.pricing.cost_calculator import CostCalculator
1062
+
1063
+ # Use synchronous calculation
1064
+ calculator = CostCalculator()
1065
+
1066
+ # Calculate overall costs
1067
+ costs = []
1068
+ for record in usage_data:
1069
+ cost_data = calculator.calculate_cost_sync(
1070
+ record["model_name"],
1071
+ record["prompt_tokens"],
1072
+ record["completion_tokens"],
1073
+ )
1074
+ costs.append({**record, **cost_data})
1075
+
1076
+ total_cost = sum(c["total_cost"] for c in costs)
1077
+ total_prompt_tokens = sum(r["prompt_tokens"] for r in usage_data)
1078
+ total_completion_tokens = sum(
1079
+ r["completion_tokens"] for r in usage_data
1080
+ )
1081
+
1082
+ cost_summary = {
1083
+ "total_cost": round(total_cost, 6),
1084
+ "total_tokens": total_prompt_tokens + total_completion_tokens,
1085
+ "prompt_tokens": total_prompt_tokens,
1086
+ "completion_tokens": total_completion_tokens,
1087
+ }
1088
+
1089
+ # Group by research_id for per-research costs
1090
+ research_costs = {}
1091
+ for record in usage_data:
1092
+ rid = record["research_id"]
1093
+ if rid not in research_costs:
1094
+ research_costs[rid] = []
1095
+ research_costs[rid].append(record)
1096
+
1097
+ # Calculate cost per research
1098
+ research_summaries = {}
1099
+ for rid, records in research_costs.items():
1100
+ research_total = 0
1101
+ for record in records:
1102
+ cost_data = calculator.calculate_cost_sync(
1103
+ record["model_name"],
1104
+ record["prompt_tokens"],
1105
+ record["completion_tokens"],
1106
+ )
1107
+ research_total += cost_data["total_cost"]
1108
+ research_summaries[rid] = {
1109
+ "total_cost": round(research_total, 6)
1110
+ }
1111
+
1112
+ # Top expensive research sessions
1113
+ top_expensive = sorted(
1114
+ [
1115
+ (rid, data["total_cost"])
1116
+ for rid, data in research_summaries.items()
1117
+ ],
1118
+ key=lambda x: x[1],
1119
+ reverse=True,
1120
+ )[:10]
1121
+
1122
+ return jsonify(
1123
+ {
1124
+ "status": "success",
1125
+ "period": period,
1126
+ "overview": cost_summary,
1127
+ "top_expensive_research": [
1128
+ {"research_id": rid, "total_cost": cost}
1129
+ for rid, cost in top_expensive
1130
+ ],
1131
+ "research_count": len(research_summaries),
1132
+ }
1133
+ )
1134
+
1135
+ except Exception as e:
1136
+ logger.exception(f"Error getting cost analytics: {e}")
1137
+ # Return a more graceful error response
1138
+ return (
1139
+ jsonify(
1140
+ {
1141
+ "status": "success",
1142
+ "period": period,
1143
+ "overview": {
1144
+ "total_cost": 0.0,
1145
+ "total_tokens": 0,
1146
+ "prompt_tokens": 0,
1147
+ "completion_tokens": 0,
1148
+ },
1149
+ "top_expensive_research": [],
1150
+ "research_count": 0,
1151
+ "error": "Cost analytics temporarily unavailable",
1152
+ }
1153
+ ),
1154
+ 200,
1155
+ ) # Return 200 to avoid breaking the UI