Coverage for src/local_deep_research/web/routers/metrics.py: 89%

902 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1"""Routes for metrics dashboard.""" 

2 

3import asyncio 

4 

5from fastapi import APIRouter, Depends, Request 

6from fastapi.responses import JSONResponse 

7from ..dependencies.auth import require_auth 

8from ..dependencies.threadpool import run_db_sync 

9from ..dependencies.rate_limit import limiter, _user_key 

10from ..template_config import templates 

11 

12from datetime import datetime, timedelta, UTC 

13from typing import Any, Annotated 

14from urllib.parse import urlparse 

15 

16from loguru import logger 

17from sqlalchemy import case, func 

18 

19from ...database.models import ( 

20 Journal, 

21 Paper, 

22 PaperAppearance, 

23 RateLimitEstimate, 

24 ResearchHistory, 

25 ResearchRating, 

26 ResearchResource, 

27 ResearchStrategy, 

28 TokenUsage, 

29) 

30from ...constants import ( 

31 get_available_strategies as get_central_available_strategies, 

32) 

33from ...domain_classifier import DomainClassifier, DomainClassification 

34from ...database.session_context import get_user_db_session 

35from ...metrics import TokenCounter 

36from ...metrics.query_utils import ( 

37 get_context_overflow_truncation_summary, 

38 get_period_days, 

39 get_time_filter_condition, 

40) 

41from ...metrics.search_tracker import get_search_tracker 

42from ..dependencies.json_body import json_body_error 

43 

44# Create the router for metrics 

45router = APIRouter(prefix="/metrics", tags=["metrics"]) 

46 

47# NOTE: Routes use username (not .get()) intentionally. 

48# Depends(require_auth) guarantees the key exists; direct access fails 

49# fast if the dependency is ever removed. 

50 

51 

52def _extract_domain(url): 

53 """Extract normalized domain from URL, stripping www. prefix.""" 

54 try: 

55 parsed = urlparse(url) 

56 domain = parsed.netloc.lower() 

57 if domain.startswith("www."): 

58 domain = domain[4:] 

59 return domain if domain else None 

60 except (ValueError, AttributeError, TypeError): 

61 return None 

62 

63 

64def get_rating_analytics(period="30d", research_mode="all", username=None): 

65 """Get rating analytics for the specified period and research mode.""" 

66 try: 

67 if not username: 

68 return { 

69 "rating_analytics": { 

70 "avg_rating": None, 

71 "total_ratings": 0, 

72 "rating_distribution": {}, 

73 "satisfaction_stats": { 

74 "very_satisfied": 0, 

75 "satisfied": 0, 

76 "neutral": 0, 

77 "dissatisfied": 0, 

78 "very_dissatisfied": 0, 

79 }, 

80 "error": "No user session", 

81 } 

82 } 

83 

84 # Calculate date range 

85 days = get_period_days(period) 

86 

87 with get_user_db_session(username) as session: 

88 query = session.query(ResearchRating) 

89 

90 # Apply time filter 

91 if days: 

92 cutoff_date = datetime.now(UTC) - timedelta(days=days) 

93 query = query.filter(ResearchRating.created_at >= cutoff_date) 

94 

95 # Get all ratings 

96 ratings = query.all() 

97 

98 if not ratings: 

99 return { 

100 "rating_analytics": { 

101 "avg_rating": None, 

102 "total_ratings": 0, 

103 "rating_distribution": {}, 

104 "satisfaction_stats": { 

105 "very_satisfied": 0, 

106 "satisfied": 0, 

107 "neutral": 0, 

108 "dissatisfied": 0, 

109 "very_dissatisfied": 0, 

110 }, 

111 } 

112 } 

113 

114 # Calculate statistics 

115 rating_values = [r.rating for r in ratings] 

116 avg_rating = sum(rating_values) / len(rating_values) 

117 

118 # Rating distribution 

119 rating_counts = {} 

120 for i in range(1, 6): 

121 rating_counts[str(i)] = rating_values.count(i) 

122 

123 # Satisfaction categories 

124 satisfaction_stats = { 

125 "very_satisfied": rating_values.count(5), 

126 "satisfied": rating_values.count(4), 

127 "neutral": rating_values.count(3), 

128 "dissatisfied": rating_values.count(2), 

129 "very_dissatisfied": rating_values.count(1), 

130 } 

131 

132 return { 

133 "rating_analytics": { 

134 "avg_rating": round(avg_rating, 1), 

135 "total_ratings": len(ratings), 

136 "rating_distribution": rating_counts, 

137 "satisfaction_stats": satisfaction_stats, 

138 } 

139 } 

140 

141 except Exception: 

142 logger.exception("Error getting rating analytics") 

143 return { 

144 "rating_analytics": { 

145 "avg_rating": None, 

146 "total_ratings": 0, 

147 "rating_distribution": {}, 

148 "satisfaction_stats": { 

149 "very_satisfied": 0, 

150 "satisfied": 0, 

151 "neutral": 0, 

152 "dissatisfied": 0, 

153 "very_dissatisfied": 0, 

154 }, 

155 } 

156 } 

157 

158 

159def get_link_analytics(period="30d", username=None): 

160 """Get link analytics from research resources.""" 

161 try: 

162 if not username: 

163 return { 

164 "link_analytics": { 

165 "top_domains": [], 

166 "total_unique_domains": 0, 

167 "avg_links_per_research": 0, 

168 "domain_distribution": {}, 

169 "source_type_analysis": {}, 

170 "academic_vs_general": {}, 

171 "total_links": 0, 

172 "error": "No user session", 

173 } 

174 } 

175 

176 # Calculate date range 

177 days = get_period_days(period) 

178 

179 with get_user_db_session(username) as session: 

180 # Project only the columns the analytics loop reads (url / 

181 # research_id / created_at / source_type / title) plus a SQL-level 

182 # boolean for whether content_preview is present. Loading full 

183 # ``ResearchResource`` entities would materialize the 

184 # ``content_preview`` Text column for every row on this whole-table 

185 # scan — which runs unfiltered when ``period=all`` (#4560). 

186 has_preview = ( 

187 ResearchResource.content_preview.isnot(None) 

188 & (ResearchResource.content_preview != "") 

189 ).label("has_preview") 

190 query = session.query( 

191 ResearchResource.url, 

192 ResearchResource.research_id, 

193 ResearchResource.created_at, 

194 ResearchResource.source_type, 

195 ResearchResource.title, 

196 has_preview, 

197 ) 

198 

199 # Apply time filter 

200 if days: 

201 cutoff_date = datetime.now(UTC) - timedelta(days=days) 

202 query = query.filter( 

203 ResearchResource.created_at >= cutoff_date.isoformat() 

204 ) 

205 

206 # Get all resources 

207 resources = query.all() 

208 

209 if not resources: 

210 return { 

211 "link_analytics": { 

212 "top_domains": [], 

213 "total_unique_domains": 0, 

214 "avg_links_per_research": 0, 

215 "domain_distribution": {}, 

216 "source_type_analysis": {}, 

217 "academic_vs_general": {}, 

218 "total_links": 0, 

219 } 

220 } 

221 

222 # Extract domains from URLs 

223 domain_counts: dict[str, Any] = {} 

224 domain_researches: dict[ 

225 str, Any 

226 ] = {} # Track which researches used each domain 

227 source_types: dict[str, Any] = {} 

228 temporal_data: dict[str, Any] = {} # Track links over time 

229 domain_connections: dict[ 

230 str, Any 

231 ] = {} # Track domain co-occurrences 

232 

233 # Generic category counting from LLM classifications 

234 category_counts: dict[str, Any] = {} 

235 

236 quality_metrics = { 

237 "with_title": 0, 

238 "with_preview": 0, 

239 "with_both": 0, 

240 "total": 0, 

241 } 

242 

243 # First pass: collect all domains from resources 

244 all_domains = set() 

245 for resource in resources: 

246 if resource.url: 

247 domain = _extract_domain(resource.url) 

248 if domain: 

249 all_domains.add(domain) 

250 

251 # Batch load all domain classifications in one query (fix N+1) 

252 domain_classifications_map = {} 

253 if all_domains: 253 ↛ 265line 253 didn't jump to line 265 because the condition on line 253 was always true

254 all_classifications = ( 

255 session.query(DomainClassification) 

256 .filter(DomainClassification.domain.in_(all_domains)) 

257 .all() 

258 ) 

259 for classification in all_classifications: 

260 domain_classifications_map[classification.domain] = ( 

261 classification 

262 ) 

263 

264 # Second pass: process resources with pre-loaded classifications 

265 for resource in resources: 

266 if resource.url: 

267 try: 

268 domain = _extract_domain(resource.url) 

269 if not domain: 

270 continue 

271 

272 # Count domains 

273 domain_counts[domain] = domain_counts.get(domain, 0) + 1 

274 

275 # Track research IDs for each domain 

276 if domain not in domain_researches: 

277 domain_researches[domain] = set() 

278 domain_researches[domain].add(resource.research_id) 

279 

280 # Track temporal data (daily counts) 

281 if resource.created_at: 281 ↛ 290line 281 didn't jump to line 290 because the condition on line 281 was always true

282 date_str = resource.created_at[ 

283 :10 

284 ] # Extract YYYY-MM-DD 

285 temporal_data[date_str] = ( 

286 temporal_data.get(date_str, 0) + 1 

287 ) 

288 

289 # Count categories from pre-loaded classifications (no N+1) 

290 classification = domain_classifications_map.get(domain) 

291 if classification: 

292 category = classification.category 

293 category_counts[category] = ( 

294 category_counts.get(category, 0) + 1 

295 ) 

296 else: 

297 category_counts["Unclassified"] = ( 

298 category_counts.get("Unclassified", 0) + 1 

299 ) 

300 

301 # Track source type from metadata if available 

302 if resource.source_type: 

303 source_types[resource.source_type] = ( 

304 source_types.get(resource.source_type, 0) + 1 

305 ) 

306 

307 # Track quality metrics 

308 quality_metrics["total"] += 1 

309 if resource.title: 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true

310 quality_metrics["with_title"] += 1 

311 if resource.has_preview: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true

312 quality_metrics["with_preview"] += 1 

313 if resource.title and resource.has_preview: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true

314 quality_metrics["with_both"] += 1 

315 

316 # Track domain co-occurrences for network visualization 

317 research_id = resource.research_id 

318 if research_id not in domain_connections: 

319 domain_connections[research_id] = [] 

320 domain_connections[research_id].append(domain) 

321 

322 except Exception: 

323 logger.exception(f"Error parsing URL {resource.url}") 

324 

325 # Sort domains by count and get top 10 

326 sorted_domains = sorted( 

327 domain_counts.items(), key=lambda x: x[1], reverse=True 

328 ) 

329 top_10_domains = sorted_domains[:10] 

330 

331 # Calculate domain distribution (top domains vs others) 

332 top_10_count = sum(count for _, count in top_10_domains) 

333 others_count = len(resources) - top_10_count 

334 

335 # Get unique research IDs to calculate average 

336 unique_research_ids = {r.research_id for r in resources} 

337 avg_links = ( 

338 len(resources) / len(unique_research_ids) 

339 if unique_research_ids 

340 else 0 

341 ) 

342 

343 # Prepare temporal trend data (sorted by date) 

344 temporal_trend = sorted( 

345 [ 

346 {"date": date, "count": count} 

347 for date, count in temporal_data.items() 

348 ], 

349 key=lambda x: x["date"], 

350 ) 

351 

352 # Get most recent research for each top domain and classifications 

353 domain_recent_research = {} 

354 # Build domain_classifications dict from pre-loaded data 

355 domain_classifications = { 

356 domain: { 

357 "category": classification.category, 

358 "subcategory": classification.subcategory, 

359 "confidence": classification.confidence, 

360 } 

361 for domain, classification in domain_classifications_map.items() 

362 } 

363 

364 # Batch-load research details for top domains (fix N+1 query) 

365 all_research_ids = [] 

366 domain_research_id_lists = {} 

367 for domain, _ in top_10_domains: 

368 if domain in domain_researches: 368 ↛ 367line 368 didn't jump to line 367 because the condition on line 368 was always true

369 ids = list(domain_researches[domain])[:3] 

370 domain_research_id_lists[domain] = ids 

371 all_research_ids.extend(ids) 

372 

373 research_by_id = {} 

374 if all_research_ids: 374 ↛ 382line 374 didn't jump to line 382 because the condition on line 374 was always true

375 researches = ( 

376 session.query(ResearchHistory) 

377 .filter(ResearchHistory.id.in_(all_research_ids)) 

378 .all() 

379 ) 

380 research_by_id = {r.id: r for r in researches} 

381 

382 for domain, ids in domain_research_id_lists.items(): 

383 domain_recent_research[domain] = [ 

384 { 

385 "id": r_id, 

386 "query": research_by_id[r_id].query[:50] 

387 if research_by_id.get(r_id) 

388 and research_by_id[r_id].query 

389 else "Research", 

390 } 

391 for r_id in ids 

392 if r_id in research_by_id 

393 ] 

394 

395 return { 

396 "link_analytics": { 

397 "top_domains": [ 

398 { 

399 "domain": domain, 

400 "count": count, 

401 "percentage": round( 

402 count / len(resources) * 100, 1 

403 ), 

404 "research_count": len( 

405 domain_researches.get(domain, set()) 

406 ), 

407 "recent_researches": domain_recent_research.get( 

408 domain, [] 

409 ), 

410 "classification": domain_classifications.get( 

411 domain, None 

412 ), 

413 } 

414 for domain, count in top_10_domains 

415 ], 

416 "total_unique_domains": len(domain_counts), 

417 "avg_links_per_research": round(avg_links, 1), 

418 "domain_distribution": { 

419 "top_10": top_10_count, 

420 "others": others_count, 

421 }, 

422 "source_type_analysis": source_types, 

423 "category_distribution": category_counts, 

424 # Generic pie chart data - use whatever LLM classifier outputs 

425 "domain_categories": category_counts, 

426 "total_links": len(resources), 

427 "total_researches": len(unique_research_ids), 

428 "temporal_trend": temporal_trend, 

429 "domain_metrics": { 

430 domain: { 

431 "usage_count": count, 

432 "usage_percentage": round( 

433 count / len(resources) * 100, 1 

434 ), 

435 "research_diversity": len( 

436 domain_researches.get(domain, set()) 

437 ), 

438 "frequency_rank": rank + 1, 

439 } 

440 for rank, (domain, count) in enumerate(top_10_domains) 

441 }, 

442 } 

443 } 

444 

445 except Exception: 

446 logger.exception("Error getting link analytics") 

447 return { 

448 "link_analytics": { 

449 "top_domains": [], 

450 "total_unique_domains": 0, 

451 "avg_links_per_research": 0, 

452 "domain_distribution": {}, 

453 "source_type_analysis": {}, 

454 "academic_vs_general": {}, 

455 "total_links": 0, 

456 "error": "Failed to retrieve link analytics", 

457 } 

458 } 

459 

460 

461def get_available_strategies(): 

462 """Get list of all available search strategies, for the analytics UI. 

463 

464 Derived from ``constants.AVAILABLE_STRATEGIES`` -- the single 

465 app-wide source of truth for UI-facing strategies (also consumed by 

466 ``search_system_factory.create_strategy``, the settings UI, the news 

467 subscription form, and the MCP server). This function only adapts the 

468 shape: it keeps this endpoint's existing ``{"name", "description"}`` 

469 wire contract by dropping the ``"label"`` field that the central list 

470 carries for dropdown widgets, so response consumers are unaffected. 

471 

472 Do not hand-maintain a separate strategy list here -- add new 

473 strategies to ``AVAILABLE_STRATEGIES`` in constants.py instead. 

474 """ 

475 return [ 

476 {"name": strategy["name"], "description": strategy["description"]} 

477 for strategy in get_central_available_strategies() 

478 ] 

479 

480 

481def get_strategy_analytics(period="30d", username=None): 

482 """Get strategy usage analytics for the specified period.""" 

483 try: 

484 if not username: 

485 return { 

486 "strategy_analytics": { 

487 "total_research_with_strategy": 0, 

488 "total_research": 0, 

489 "most_popular_strategy": None, 

490 "strategy_usage": [], 

491 "strategy_distribution": {}, 

492 "available_strategies": get_available_strategies(), 

493 "error": "No user session", 

494 } 

495 } 

496 

497 # Calculate date range 

498 days = get_period_days(period) 

499 

500 with get_user_db_session(username) as session: 

501 # Check if we have any ResearchStrategy records 

502 strategy_count = session.query(ResearchStrategy).count() 

503 

504 if strategy_count == 0: 

505 logger.warning("No research strategies found in database") 

506 return { 

507 "strategy_analytics": { 

508 "total_research_with_strategy": 0, 

509 "total_research": 0, 

510 "most_popular_strategy": None, 

511 "strategy_usage": [], 

512 "strategy_distribution": {}, 

513 "available_strategies": get_available_strategies(), 

514 "message": "Strategy tracking not yet available - run a research to start tracking", 

515 } 

516 } 

517 

518 # Base query for strategy usage (no JOIN needed since we just want strategy counts) 

519 query = session.query( 

520 ResearchStrategy.strategy_name, 

521 func.count(ResearchStrategy.id).label("usage_count"), 

522 ) 

523 

524 # Apply time filter if specified 

525 if days: 

526 cutoff_date = datetime.now(UTC) - timedelta(days=days) 

527 query = query.filter(ResearchStrategy.created_at >= cutoff_date) 

528 

529 # Group by strategy and order by usage 

530 strategy_results = ( 

531 query.group_by(ResearchStrategy.strategy_name) 

532 .order_by(func.count(ResearchStrategy.id).desc()) 

533 .all() 

534 ) 

535 

536 # Get total strategy count for percentage calculation 

537 total_query = session.query(ResearchStrategy) 

538 if days: 

539 total_query = total_query.filter( 

540 ResearchStrategy.created_at >= cutoff_date 

541 ) 

542 total_research = total_query.count() 

543 

544 # Format strategy data 

545 strategy_usage = [] 

546 strategy_distribution = {} 

547 

548 for strategy_name, usage_count in strategy_results: 

549 percentage = ( 

550 (usage_count / total_research * 100) 

551 if total_research > 0 

552 else 0 

553 ) 

554 strategy_usage.append( 

555 { 

556 "strategy": strategy_name, 

557 "count": usage_count, 

558 "percentage": round(percentage, 1), 

559 } 

560 ) 

561 strategy_distribution[strategy_name] = usage_count 

562 

563 # Find most popular strategy 

564 most_popular = ( 

565 strategy_usage[0]["strategy"] if strategy_usage else None 

566 ) 

567 

568 return { 

569 "strategy_analytics": { 

570 "total_research_with_strategy": sum( 

571 item["count"] for item in strategy_usage 

572 ), 

573 "total_research": total_research, 

574 "most_popular_strategy": most_popular, 

575 "strategy_usage": strategy_usage, 

576 "strategy_distribution": strategy_distribution, 

577 "available_strategies": get_available_strategies(), 

578 } 

579 } 

580 

581 except Exception: 

582 logger.exception("Error getting strategy analytics") 

583 return { 

584 "strategy_analytics": { 

585 "total_research_with_strategy": 0, 

586 "total_research": 0, 

587 "most_popular_strategy": None, 

588 "strategy_usage": [], 

589 "strategy_distribution": {}, 

590 "available_strategies": get_available_strategies(), 

591 "error": "Failed to retrieve strategy data", 

592 } 

593 } 

594 

595 

596def get_rate_limiting_analytics(period="30d", username=None): 

597 """Get rate limiting analytics for the specified period.""" 

598 try: 

599 if not username: 

600 return { 

601 "rate_limiting": { 

602 "total_attempts": 0, 

603 "successful_attempts": 0, 

604 "failed_attempts": 0, 

605 "success_rate": 0, 

606 "rate_limit_events": 0, 

607 "avg_wait_time": 0, 

608 "avg_successful_wait": 0, 

609 "tracked_engines": 0, 

610 "engine_stats": [], 

611 "total_engines_tracked": 0, 

612 "healthy_engines": 0, 

613 "degraded_engines": 0, 

614 "poor_engines": 0, 

615 "error": "No user session", 

616 } 

617 } 

618 

619 # Calculate date range for timestamp filtering 

620 import time 

621 

622 if period == "7d": 

623 cutoff_time = time.time() - (7 * 24 * 3600) 

624 elif period == "30d": 

625 cutoff_time = time.time() - (30 * 24 * 3600) 

626 elif period == "3m": 626 ↛ 627line 626 didn't jump to line 627 because the condition on line 626 was never true

627 cutoff_time = time.time() - (90 * 24 * 3600) 

628 elif period == "1y": 

629 cutoff_time = time.time() - (365 * 24 * 3600) 

630 else: # all 

631 cutoff_time = 0 

632 

633 with get_user_db_session(username) as session: 

634 # Rate-limit analytics are derived from RateLimitEstimate, the 

635 # learned per-engine wait-time model that production code 

636 # actually persists. The raw per-attempt table 

637 # (RateLimitAttempt) is intentionally NOT written — attempt 

638 # persistence was disabled (commit fef359be9) to avoid DB 

639 # locking under parallel search — so the previous code, which 

640 # read RateLimitAttempt, always returned an empty panel. 

641 # 

642 # Limitations of deriving from estimates (documented so the 

643 # numbers aren't mistaken for raw-attempt counts): 

644 # - total_attempts is each engine's recent rolling window 

645 # (capped at rate_limiting.memory_window, default 100), not 

646 # a lifetime count. 

647 # - rate_limit_events (RateLimitError-specific failures) and a 

648 # true per-attempt average wait cannot be reconstructed and 

649 # are reported as 0 / the learned base wait respectively. 

650 estimates_query = session.query(RateLimitEstimate) 

651 

652 # Recency filter uses the estimate's last_updated (epoch 

653 # seconds); there is no per-attempt timestamp history. 

654 if cutoff_time > 0: 

655 estimates_query = estimates_query.filter( 

656 RateLimitEstimate.last_updated >= cutoff_time 

657 ) 

658 

659 estimates = estimates_query.all() 

660 

661 engine_stats = [] 

662 total_attempts = 0 

663 successful_attempts = 0 

664 base_wait_sum = 0.0 

665 

666 for estimate in estimates: 

667 # success_rate is stored as a 0..1 fraction. 

668 success_rate_pct = round(estimate.success_rate * 100, 1) 

669 engine_attempts = estimate.total_attempts or 0 

670 engine_success = round(engine_attempts * estimate.success_rate) 

671 

672 total_attempts += engine_attempts 

673 successful_attempts += engine_success 

674 base_wait_sum += estimate.base_wait_seconds 

675 

676 status = ( 

677 "healthy" 

678 if estimate.success_rate > 0.8 

679 else "degraded" 

680 if estimate.success_rate > 0.5 

681 else "poor" 

682 ) 

683 

684 engine_stats.append( 

685 { 

686 "engine": estimate.engine_type, 

687 "base_wait": estimate.base_wait_seconds, 

688 "base_wait_seconds": round( 

689 estimate.base_wait_seconds, 2 

690 ), 

691 "min_wait_seconds": round(estimate.min_wait_seconds, 2), 

692 "max_wait_seconds": round(estimate.max_wait_seconds, 2), 

693 "success_rate": success_rate_pct, 

694 "total_attempts": engine_attempts, 

695 "recent_attempts": engine_attempts, 

696 "recent_success_rate": success_rate_pct, 

697 "attempts": engine_attempts, 

698 "status": status, 

699 # ISO format already includes timezone 

700 "last_updated": datetime.fromtimestamp( 

701 estimate.last_updated, UTC 

702 ).isoformat(), 

703 } 

704 ) 

705 

706 tracked_engines = len(engine_stats) 

707 failed_attempts = total_attempts - successful_attempts 

708 # base_wait_seconds is the learned optimal (median of recent 

709 # successful waits), so it represents both the typical wait and 

710 # the typical successful wait; a true per-attempt average needs 

711 # the raw attempts table. 

712 avg_wait_time = ( 

713 base_wait_sum / tracked_engines if tracked_engines else 0 

714 ) 

715 avg_successful_wait = avg_wait_time 

716 # Not derivable from estimates (needs the raw attempts table). 

717 rate_limit_events = 0 

718 

719 logger.info( 

720 f"Rate limiting analytics from estimates: " 

721 f"tracked_engines={tracked_engines}, " 

722 f"total_attempts(recent)={total_attempts}" 

723 ) 

724 

725 result = { 

726 "rate_limiting": { 

727 "total_attempts": total_attempts, 

728 "successful_attempts": successful_attempts, 

729 "failed_attempts": failed_attempts, 

730 "success_rate": (successful_attempts / total_attempts * 100) 

731 if total_attempts > 0 

732 else 0, 

733 "rate_limit_events": rate_limit_events, 

734 "avg_wait_time": round(float(avg_wait_time), 2), 

735 "avg_successful_wait": round(float(avg_successful_wait), 2), 

736 "tracked_engines": tracked_engines, 

737 "engine_stats": engine_stats, 

738 "total_engines_tracked": tracked_engines, 

739 "healthy_engines": len( 

740 [s for s in engine_stats if s["status"] == "healthy"] 

741 ), 

742 "degraded_engines": len( 

743 [s for s in engine_stats if s["status"] == "degraded"] 

744 ), 

745 "poor_engines": len( 

746 [s for s in engine_stats if s["status"] == "poor"] 

747 ), 

748 } 

749 } 

750 

751 logger.info( 

752 f"DEBUG: Returning rate_limiting_analytics result: {result}" 

753 ) 

754 return result 

755 

756 except Exception: 

757 logger.exception("Error getting rate limiting analytics") 

758 return { 

759 "rate_limiting": { 

760 "total_attempts": 0, 

761 "successful_attempts": 0, 

762 "failed_attempts": 0, 

763 "success_rate": 0, 

764 "rate_limit_events": 0, 

765 "avg_wait_time": 0, 

766 "avg_successful_wait": 0, 

767 "tracked_engines": 0, 

768 "engine_stats": [], 

769 "total_engines_tracked": 0, 

770 "healthy_engines": 0, 

771 "degraded_engines": 0, 

772 "poor_engines": 0, 

773 "error": "An internal error occurred while processing the request.", 

774 } 

775 } 

776 

777 

778@router.get("/") 

779def metrics_dashboard( 

780 request: Request, username: Annotated[str, Depends(require_auth)] 

781): 

782 """Render the metrics dashboard page.""" 

783 return templates.TemplateResponse( 

784 request=request, name="pages/metrics.html", context={"request": request} 

785 ) 

786 

787 

788@router.get("/context-overflow") 

789def context_overflow_page( 

790 request: Request, username: Annotated[str, Depends(require_auth)] 

791): 

792 """Context overflow analytics page.""" 

793 return templates.TemplateResponse( 

794 request=request, 

795 name="pages/context_overflow.html", 

796 context={"request": request}, 

797 ) 

798 

799 

800@router.get("/api/metrics") 

801def api_metrics( 

802 request: Request, username: Annotated[str, Depends(require_auth)] 

803): 

804 """Get overall metrics data.""" 

805 logger.debug("api_metrics endpoint called") 

806 try: 

807 # Get username from session 

808 

809 # Get time period and research mode from query parameters 

810 period = request.query_params.get("period", "30d") 

811 research_mode = request.query_params.get("mode", "all") 

812 

813 token_counter = TokenCounter() 

814 search_tracker = get_search_tracker() 

815 

816 # Get both token and search metrics 

817 token_metrics = token_counter.get_overall_metrics( 

818 period=period, research_mode=research_mode, username=username 

819 ) 

820 search_metrics = search_tracker.get_search_metrics( 

821 period=period, 

822 research_mode=research_mode, 

823 username=username, 

824 ) 

825 

826 # Get user satisfaction rating data 

827 try: 

828 with get_user_db_session(username) as session: 

829 # Build base query with time filter 

830 ratings_query = session.query(ResearchRating) 

831 time_condition = get_time_filter_condition( 

832 period, ResearchRating.created_at 

833 ) 

834 if time_condition is not None: 

835 ratings_query = ratings_query.filter(time_condition) 

836 

837 # Get average rating 

838 avg_rating = ratings_query.with_entities( 

839 func.avg(ResearchRating.rating).label("avg_rating") 

840 ).scalar() 

841 

842 # Get total rating count 

843 total_ratings = ratings_query.count() 

844 

845 user_satisfaction = { 

846 "avg_rating": round(avg_rating, 1) if avg_rating else None, 

847 "total_ratings": total_ratings, 

848 } 

849 except Exception: 

850 logger.exception("Error getting user satisfaction data") 

851 user_satisfaction = {"avg_rating": None, "total_ratings": 0} 

852 

853 # Get strategy analytics 

854 strategy_data = get_strategy_analytics(period, username) 

855 logger.debug(f"strategy_data keys: {list(strategy_data.keys())}") 

856 

857 # Get rate limiting analytics 

858 rate_limiting_data = get_rate_limiting_analytics(period, username) 

859 logger.debug(f"rate_limiting_data: {rate_limiting_data}") 

860 logger.debug( 

861 f"rate_limiting_data keys: {list(rate_limiting_data.keys())}" 

862 ) 

863 

864 # Truncation summary surfaced on the main dashboard. Failure sentinel 

865 # is None (not 0): a real zero means "no truncation", so falling back 

866 # to 0 on error would silently flip a red signal green. 

867 context_overflow_data = { 

868 "truncation_rate": None, 

869 "avg_tokens_truncated": None, 

870 } 

871 try: 

872 with get_user_db_session(username) as session: 

873 # Honor the dashboard's research_mode filter the same way the 

874 # rest of api_metrics() does (token_metrics, search_metrics, 

875 # etc.). Without this the panel ignores mode toggles. 

876 summary = get_context_overflow_truncation_summary( 

877 session, period, research_mode=research_mode 

878 ) 

879 context_overflow_data = { 

880 "truncation_rate": round(summary["truncation_rate"], 1), 

881 "avg_tokens_truncated": int(summary["avg_tokens_truncated"]), 

882 } 

883 except Exception: 

884 logger.exception( 

885 "Error getting context overflow summary for /api/metrics" 

886 ) 

887 

888 # Combine metrics 

889 combined_metrics = { 

890 **token_metrics, 

891 **search_metrics, 

892 **strategy_data, 

893 **rate_limiting_data, 

894 **context_overflow_data, 

895 "user_satisfaction": user_satisfaction, 

896 } 

897 

898 logger.debug(f"combined_metrics keys: {list(combined_metrics.keys())}") 

899 logger.debug( 

900 f"combined_metrics['rate_limiting']: {combined_metrics.get('rate_limiting', 'NOT FOUND')}" 

901 ) 

902 

903 return { 

904 "status": "success", 

905 "metrics": combined_metrics, 

906 "period": period, 

907 "research_mode": research_mode, 

908 } 

909 

910 except Exception: 

911 logger.exception("Error getting metrics") 

912 return JSONResponse( 

913 { 

914 "status": "error", 

915 "message": "An internal error occurred. Please try again later.", 

916 }, 

917 status_code=500, 

918 ) 

919 

920 

921@router.get("/api/rate-limiting") 

922def get_rate_limiting_metrics( 

923 request: Request, username: Annotated[str, Depends(require_auth)] 

924): 

925 """Get detailed rate limiting metrics.""" 

926 logger.info("DEBUG: get_rate_limiting_metrics endpoint called") 

927 try: 

928 period = request.query_params.get("period", "30d") 

929 rate_limiting_data = get_rate_limiting_analytics(period, username) 

930 

931 return { 

932 "status": "success", 

933 "data": rate_limiting_data, 

934 "period": period, 

935 } 

936 

937 except Exception: 

938 logger.exception("Error getting rate limiting metrics") 

939 return JSONResponse( 

940 { 

941 "status": "error", 

942 "message": "Failed to retrieve rate limiting metrics", 

943 }, 

944 status_code=500, 

945 ) 

946 

947 

948@router.get("/api/rate-limiting/current") 

949def api_current_rate_limits( 

950 request: Request, username: Annotated[str, Depends(require_auth)] 

951): 

952 """Get current rate limit estimates for all engines.""" 

953 try: 

954 with get_user_db_session(username) as session: 

955 estimates = ( 

956 session.query(RateLimitEstimate) 

957 .order_by(RateLimitEstimate.engine_type) 

958 .all() 

959 ) 

960 

961 current_limits = [] 

962 for est in estimates: 

963 current_limits.append( 

964 { 

965 "engine_type": est.engine_type, 

966 "base_wait_seconds": round(est.base_wait_seconds, 2), 

967 "min_wait_seconds": round(est.min_wait_seconds, 2), 

968 "max_wait_seconds": round(est.max_wait_seconds, 2), 

969 "success_rate": round(est.success_rate * 100, 1), 

970 "total_attempts": est.total_attempts, 

971 "last_updated": datetime.fromtimestamp( 

972 est.last_updated, UTC 

973 ).isoformat(), 

974 "status": "healthy" 

975 if est.success_rate > 0.8 

976 else "degraded" 

977 if est.success_rate > 0.5 

978 else "poor", 

979 } 

980 ) 

981 

982 return { 

983 "status": "success", 

984 "current_limits": current_limits, 

985 "timestamp": datetime.now(UTC).isoformat(), 

986 } 

987 except Exception: 

988 logger.exception("Error getting current rate limits") 

989 return JSONResponse( 

990 { 

991 "status": "error", 

992 "message": "Failed to retrieve current rate limits", 

993 }, 

994 status_code=500, 

995 ) 

996 

997 

998@router.get("/api/metrics/research/{research_id}/links") 

999def api_research_link_metrics( 

1000 request: Request, 

1001 research_id, 

1002 username: Annotated[str, Depends(require_auth)], 

1003): 

1004 """Get link analytics for a specific research.""" 

1005 try: 

1006 with get_user_db_session(username) as session: 

1007 # Get all resources for this specific research 

1008 resources = ( 

1009 session.query(ResearchResource) 

1010 .filter(ResearchResource.research_id == research_id) 

1011 .all() 

1012 ) 

1013 

1014 if not resources: 

1015 return { 

1016 "status": "success", 

1017 "data": { 

1018 "total_links": 0, 

1019 "unique_domains": 0, 

1020 "domains": [], 

1021 "category_distribution": {}, 

1022 "domain_categories": {}, 

1023 "resources": [], 

1024 }, 

1025 } 

1026 

1027 # Extract domain information 

1028 domain_counts: dict[str, Any] = {} 

1029 

1030 # Generic category counting from LLM classifications 

1031 category_counts: dict[str, Any] = {} 

1032 

1033 # First pass: collect all domains 

1034 all_domains = set() 

1035 for resource in resources: 

1036 if resource.url: 

1037 domain = _extract_domain(resource.url) 

1038 if domain: 1038 ↛ 1035line 1038 didn't jump to line 1035 because the condition on line 1038 was always true

1039 all_domains.add(domain) 

1040 

1041 # Batch load all domain classifications in one query (fix N+1) 

1042 domain_classifications_map = {} 

1043 if all_domains: 1043 ↛ 1055line 1043 didn't jump to line 1055 because the condition on line 1043 was always true

1044 all_classifications = ( 

1045 session.query(DomainClassification) 

1046 .filter(DomainClassification.domain.in_(all_domains)) 

1047 .all() 

1048 ) 

1049 for classification in all_classifications: 1049 ↛ 1050line 1049 didn't jump to line 1050 because the loop on line 1049 never started

1050 domain_classifications_map[classification.domain] = ( 

1051 classification 

1052 ) 

1053 

1054 # Second pass: process resources with pre-loaded classifications 

1055 for resource in resources: 

1056 if resource.url: 

1057 try: 

1058 domain = _extract_domain(resource.url) 

1059 if not domain: 1059 ↛ 1060line 1059 didn't jump to line 1060 because the condition on line 1059 was never true

1060 continue 

1061 

1062 domain_counts[domain] = domain_counts.get(domain, 0) + 1 

1063 

1064 # Count categories from pre-loaded classifications (no N+1) 

1065 classification = domain_classifications_map.get(domain) 

1066 if classification: 1066 ↛ 1067line 1066 didn't jump to line 1067 because the condition on line 1066 was never true

1067 category = classification.category 

1068 category_counts[category] = ( 

1069 category_counts.get(category, 0) + 1 

1070 ) 

1071 else: 

1072 category_counts["Unclassified"] = ( 

1073 category_counts.get("Unclassified", 0) + 1 

1074 ) 

1075 except (AttributeError, KeyError) as e: 

1076 logger.debug(f"Error classifying domain {domain}: {e}") 

1077 

1078 # Sort domains by count 

1079 sorted_domains = sorted( 

1080 domain_counts.items(), key=lambda x: x[1], reverse=True 

1081 ) 

1082 

1083 return { 

1084 "status": "success", 

1085 "data": { 

1086 "total_links": len(resources), 

1087 "unique_domains": len(domain_counts), 

1088 "domains": [ 

1089 { 

1090 "domain": domain, 

1091 "count": count, 

1092 "percentage": round( 

1093 count / len(resources) * 100, 1 

1094 ), 

1095 } 

1096 for domain, count in sorted_domains[ 

1097 :20 

1098 ] # Top 20 domains 

1099 ], 

1100 "category_distribution": category_counts, 

1101 "domain_categories": category_counts, # Generic categories from LLM 

1102 "resources": [ 

1103 { 

1104 "title": r.title or "Untitled", 

1105 "url": r.url, 

1106 "preview": r.content_preview[:200] 

1107 if r.content_preview 

1108 else None, 

1109 } 

1110 for r in resources[:10] # First 10 resources 

1111 ], 

1112 }, 

1113 } 

1114 

1115 except Exception: 

1116 logger.exception("Error getting research link metrics") 

1117 return JSONResponse( 

1118 {"status": "error", "message": "Failed to retrieve link metrics"}, 

1119 status_code=500, 

1120 ) 

1121 

1122 

1123@router.get("/api/metrics/research/{research_id}") 

1124def api_research_metrics( 

1125 request: Request, 

1126 research_id, 

1127 username: Annotated[str, Depends(require_auth)], 

1128): 

1129 """Get metrics for a specific research.""" 

1130 try: 

1131 token_counter = TokenCounter() 

1132 metrics = token_counter.get_research_metrics( 

1133 research_id, username=username 

1134 ) 

1135 return {"status": "success", "metrics": metrics} 

1136 except Exception: 

1137 logger.exception("Error getting research metrics") 

1138 return JSONResponse( 

1139 { 

1140 "status": "error", 

1141 "message": "An internal error occurred. Please try again later.", 

1142 }, 

1143 status_code=500, 

1144 ) 

1145 

1146 

1147@router.get("/api/metrics/research/{research_id}/timeline") 

1148def api_research_timeline_metrics( 

1149 request: Request, 

1150 research_id, 

1151 username: Annotated[str, Depends(require_auth)], 

1152): 

1153 """Get timeline metrics for a specific research.""" 

1154 try: 

1155 token_counter = TokenCounter() 

1156 timeline_metrics = token_counter.get_research_timeline_metrics( 

1157 research_id, username=username 

1158 ) 

1159 return {"status": "success", "metrics": timeline_metrics} 

1160 except Exception: 

1161 logger.exception("Error getting research timeline metrics") 

1162 return JSONResponse( 

1163 { 

1164 "status": "error", 

1165 "message": "An internal error occurred. Please try again later.", 

1166 }, 

1167 status_code=500, 

1168 ) 

1169 

1170 

1171@router.get("/api/metrics/research/{research_id}/search") 

1172def api_research_search_metrics( 

1173 request: Request, 

1174 research_id, 

1175 username: Annotated[str, Depends(require_auth)], 

1176): 

1177 """Get search metrics for a specific research.""" 

1178 try: 

1179 search_tracker = get_search_tracker() 

1180 search_metrics = search_tracker.get_research_search_metrics( 

1181 research_id, username=username 

1182 ) 

1183 return {"status": "success", "metrics": search_metrics} 

1184 except Exception: 

1185 logger.exception("Error getting research search metrics") 

1186 return JSONResponse( 

1187 { 

1188 "status": "error", 

1189 "message": "An internal error occurred. Please try again later.", 

1190 }, 

1191 status_code=500, 

1192 ) 

1193 

1194 

1195@router.get("/api/metrics/enhanced") 

1196def api_enhanced_metrics( 

1197 request: Request, username: Annotated[str, Depends(require_auth)] 

1198): 

1199 """Get enhanced Phase 1 tracking metrics.""" 

1200 try: 

1201 # Get time period and research mode from query parameters 

1202 period = request.query_params.get("period", "30d") 

1203 research_mode = request.query_params.get("mode", "all") 

1204 

1205 token_counter = TokenCounter() 

1206 search_tracker = get_search_tracker() 

1207 

1208 enhanced_metrics = token_counter.get_enhanced_metrics( 

1209 period=period, research_mode=research_mode, username=username 

1210 ) 

1211 

1212 # Add search time series data for the chart 

1213 search_time_series = search_tracker.get_search_time_series( 

1214 period=period, 

1215 research_mode=research_mode, 

1216 username=username, 

1217 ) 

1218 enhanced_metrics["search_time_series"] = search_time_series 

1219 

1220 # Add rating analytics 

1221 rating_analytics = get_rating_analytics(period, research_mode, username) 

1222 enhanced_metrics.update(rating_analytics) 

1223 

1224 return { 

1225 "status": "success", 

1226 "metrics": enhanced_metrics, 

1227 "period": period, 

1228 "research_mode": research_mode, 

1229 } 

1230 

1231 except Exception: 

1232 logger.exception("Error getting enhanced metrics") 

1233 return JSONResponse( 

1234 { 

1235 "status": "error", 

1236 "message": "An internal error occurred. Please try again later.", 

1237 }, 

1238 status_code=500, 

1239 ) 

1240 

1241 

1242@router.get("/api/ratings/{research_id}") 

1243def api_get_research_rating( 

1244 request: Request, 

1245 research_id, 

1246 username: Annotated[str, Depends(require_auth)], 

1247): 

1248 """Get rating for a specific research session.""" 

1249 try: 

1250 with get_user_db_session(username) as session: 

1251 rating = ( 

1252 session.query(ResearchRating) 

1253 .filter_by(research_id=research_id) 

1254 .first() 

1255 ) 

1256 

1257 if rating: 

1258 return { 

1259 "status": "success", 

1260 "rating": rating.rating, 

1261 "created_at": rating.created_at.isoformat(), 

1262 "updated_at": rating.updated_at.isoformat(), 

1263 } 

1264 return {"status": "success", "rating": None} 

1265 

1266 except Exception: 

1267 logger.exception("Error getting research rating") 

1268 return JSONResponse( 

1269 { 

1270 "status": "error", 

1271 "message": "An internal error occurred. Please try again later.", 

1272 }, 

1273 status_code=500, 

1274 ) 

1275 

1276 

1277@router.post("/api/ratings/{research_id}") 

1278async def api_save_research_rating( 

1279 request: Request, 

1280 research_id, 

1281 username: Annotated[str, Depends(require_auth)], 

1282): 

1283 """Save or update rating for a specific research session.""" 

1284 # Parsed outside the try/except below: a malformed body must raise 

1285 # json.JSONDecodeError uncaught so it reaches the app's registered 

1286 # handler (-> 400), not the broad `except Exception` further down 

1287 # (which would otherwise turn it into a hardcoded 500). 

1288 data = await request.json() 

1289 if not isinstance(data, dict): 

1290 return json_body_error("status", "Request body must be valid JSON") 

1291 try: 

1292 rating_value = data.get("rating") 

1293 

1294 if ( 

1295 not isinstance(rating_value, int) 

1296 or isinstance(rating_value, bool) 

1297 or rating_value < 1 

1298 or rating_value > 5 

1299 ): 

1300 return JSONResponse( 

1301 { 

1302 "status": "error", 

1303 "message": "Rating must be an integer between 1 and 5", 

1304 }, 

1305 status_code=400, 

1306 ) 

1307 

1308 # Optional sub-dimension fields (1-5) 

1309 sub_dimensions = {} 

1310 for field in ("accuracy", "completeness", "relevance", "readability"): 

1311 val = data.get(field) 

1312 if val is not None: 

1313 if ( 

1314 not isinstance(val, int) 

1315 or isinstance(val, bool) 

1316 or val < 1 

1317 or val > 5 

1318 ): 

1319 return JSONResponse( 

1320 { 

1321 "status": "error", 

1322 "message": f"{field} must be an integer between 1 and 5", 

1323 }, 

1324 status_code=400, 

1325 ) 

1326 sub_dimensions[field] = val 

1327 

1328 feedback_text = data.get("feedback") 

1329 if feedback_text is not None: 

1330 if not isinstance(feedback_text, str): 

1331 return JSONResponse( 

1332 { 

1333 "status": "error", 

1334 "message": "feedback must be a string", 

1335 }, 

1336 status_code=400, 

1337 ) 

1338 if len(feedback_text) > 10000: 

1339 return JSONResponse( 

1340 { 

1341 "status": "error", 

1342 "message": "feedback must be 10000 characters or fewer", 

1343 }, 

1344 status_code=400, 

1345 ) 

1346 

1347 def _save_sync(): 

1348 with get_user_db_session(username) as session: 

1349 existing_rating = ( 

1350 session.query(ResearchRating) 

1351 .filter_by(research_id=research_id) 

1352 .first() 

1353 ) 

1354 if existing_rating: 

1355 existing_rating.rating = rating_value 

1356 existing_rating.updated_at = func.now() 

1357 for field, val in sub_dimensions.items(): 

1358 setattr(existing_rating, field, val) 

1359 if feedback_text is not None: 1359 ↛ 1360line 1359 didn't jump to line 1360 because the condition on line 1359 was never true

1360 existing_rating.feedback = feedback_text 

1361 else: 

1362 session.add( 

1363 ResearchRating( 

1364 research_id=research_id, 

1365 rating=rating_value, 

1366 feedback=feedback_text, 

1367 **sub_dimensions, 

1368 ) 

1369 ) 

1370 session.commit() 

1371 

1372 await run_db_sync(_save_sync) 

1373 return { 

1374 "status": "success", 

1375 "message": "Rating saved successfully", 

1376 "rating": rating_value, 

1377 } 

1378 

1379 except Exception: 

1380 logger.exception("Error saving research rating") 

1381 return JSONResponse( 

1382 { 

1383 "status": "error", 

1384 "message": "An internal error occurred. Please try again later.", 

1385 }, 

1386 status_code=500, 

1387 ) 

1388 

1389 

1390@router.get("/star-reviews") 

1391def star_reviews( 

1392 request: Request, username: Annotated[str, Depends(require_auth)] 

1393): 

1394 """Display star reviews metrics page.""" 

1395 return templates.TemplateResponse( 

1396 request=request, 

1397 name="pages/star_reviews.html", 

1398 context={"request": request}, 

1399 ) 

1400 

1401 

1402@router.get("/costs") 

1403def cost_analytics( 

1404 request: Request, username: Annotated[str, Depends(require_auth)] 

1405): 

1406 """Display cost analytics page.""" 

1407 return templates.TemplateResponse( 

1408 request=request, 

1409 name="pages/cost_analytics.html", 

1410 context={"request": request}, 

1411 ) 

1412 

1413 

1414@router.get("/api/star-reviews") 

1415def api_star_reviews( 

1416 request: Request, username: Annotated[str, Depends(require_auth)] 

1417): 

1418 """Get star reviews analytics data.""" 

1419 try: 

1420 period = request.query_params.get("period", "30d") 

1421 

1422 with get_user_db_session(username) as session: 

1423 # Build base query with time filter 

1424 base_query = session.query(ResearchRating) 

1425 time_condition = get_time_filter_condition( 

1426 period, ResearchRating.created_at 

1427 ) 

1428 if time_condition is not None: 

1429 base_query = base_query.filter(time_condition) 

1430 

1431 # Overall rating statistics 

1432 overall_stats = session.query( 

1433 func.avg(ResearchRating.rating).label("avg_rating"), 

1434 func.count(ResearchRating.rating).label("total_ratings"), 

1435 func.sum(case((ResearchRating.rating == 5, 1), else_=0)).label( 

1436 "five_star" 

1437 ), 

1438 func.sum(case((ResearchRating.rating == 4, 1), else_=0)).label( 

1439 "four_star" 

1440 ), 

1441 func.sum(case((ResearchRating.rating == 3, 1), else_=0)).label( 

1442 "three_star" 

1443 ), 

1444 func.sum(case((ResearchRating.rating == 2, 1), else_=0)).label( 

1445 "two_star" 

1446 ), 

1447 func.sum(case((ResearchRating.rating == 1, 1), else_=0)).label( 

1448 "one_star" 

1449 ), 

1450 ) 

1451 

1452 if time_condition is not None: 

1453 overall_stats = overall_stats.filter(time_condition) 

1454 

1455 overall_stats = overall_stats.first() 

1456 

1457 # Ratings by LLM model. A research has many token_usage rows (one per 

1458 # LLM call), so joining TokenUsage directly fans out and multiplies the 

1459 # counts/averages. Collapse to distinct (research_id, model) pairs first 

1460 # so each rating is counted once per model it actually used. 

1461 # Normalize the "missing model" sentinels — empty/whitespace and the 

1462 # lowercase "unknown" fallback the token counter writes — to a single 

1463 # "Unknown" *inside* the subquery, so DISTINCT dedups on the normalized 

1464 # value. (Normalizing only at group_by time lets "" and "unknown" 

1465 # survive DISTINCT as separate rows and double-count one rating into the 

1466 # merged bucket.) Real model names keep their original casing via else_. 

1467 normalized_model = case( 

1468 ( 

1469 func.lower(func.trim(TokenUsage.model_name)).in_( 

1470 ["", "unknown"] 

1471 ), 

1472 "Unknown", 

1473 ), 

1474 else_=TokenUsage.model_name, 

1475 ) 

1476 research_models = ( 

1477 session.query( 

1478 TokenUsage.research_id.label("research_id"), 

1479 normalized_model.label("model_name"), 

1480 ) 

1481 .distinct() 

1482 .subquery() 

1483 ) 

1484 # A rating with no token rows has no subquery match -> NULL via the 

1485 # outerjoin -> "Unknown". 

1486 model_label = func.coalesce(research_models.c.model_name, "Unknown") 

1487 

1488 llm_ratings_query = ( 

1489 session.query( 

1490 model_label.label("model"), 

1491 func.avg(ResearchRating.rating).label("avg_rating"), 

1492 func.count(ResearchRating.rating).label("rating_count"), 

1493 func.sum( 

1494 case((ResearchRating.rating >= 4, 1), else_=0) 

1495 ).label("positive_ratings"), 

1496 func.sum( 

1497 case((ResearchRating.rating == 3, 1), else_=0) 

1498 ).label("neutral_ratings"), 

1499 func.sum( 

1500 case((ResearchRating.rating <= 2, 1), else_=0) 

1501 ).label("negative_ratings"), 

1502 ) 

1503 .select_from(ResearchRating) 

1504 .outerjoin( 

1505 research_models, 

1506 ResearchRating.research_id == research_models.c.research_id, 

1507 ) 

1508 ) 

1509 

1510 if time_condition is not None: 

1511 llm_ratings_query = llm_ratings_query.filter(time_condition) 

1512 

1513 llm_ratings = ( 

1514 llm_ratings_query.group_by(model_label) 

1515 .order_by(func.avg(ResearchRating.rating).desc()) 

1516 .all() 

1517 ) 

1518 

1519 # Ratings by search engine. Same one-to-many fan-out concern as the LLM 

1520 # query — collapse to distinct (research_id, search_engine) pairs first. 

1521 # search_engine_selected is NULL on non-search LLM-call rows (and is 

1522 # occasionally an empty/whitespace string); exclude both so a research 

1523 # that used a real engine isn't ALSO attributed to the "Unknown" bucket. 

1524 # A research with no recorded engine still falls through the outerjoin to 

1525 # "Unknown" exactly once. 

1526 research_engines = ( 

1527 session.query( 

1528 TokenUsage.research_id.label("research_id"), 

1529 TokenUsage.search_engine_selected.label("search_engine"), 

1530 ) 

1531 .filter( 

1532 func.trim( 

1533 func.coalesce(TokenUsage.search_engine_selected, "") 

1534 ) 

1535 != "" 

1536 ) 

1537 .distinct() 

1538 .subquery() 

1539 ) 

1540 engine_label = func.coalesce( 

1541 research_engines.c.search_engine, "Unknown" 

1542 ) 

1543 

1544 search_engine_ratings_query = ( 

1545 session.query( 

1546 engine_label.label("search_engine"), 

1547 func.avg(ResearchRating.rating).label("avg_rating"), 

1548 func.count(ResearchRating.rating).label("rating_count"), 

1549 func.sum( 

1550 case((ResearchRating.rating >= 4, 1), else_=0) 

1551 ).label("positive_ratings"), 

1552 ) 

1553 .select_from(ResearchRating) 

1554 .outerjoin( 

1555 research_engines, 

1556 ResearchRating.research_id 

1557 == research_engines.c.research_id, 

1558 ) 

1559 ) 

1560 

1561 if time_condition is not None: 

1562 search_engine_ratings_query = ( 

1563 search_engine_ratings_query.filter(time_condition) 

1564 ) 

1565 

1566 search_engine_ratings = ( 

1567 search_engine_ratings_query.group_by(engine_label) 

1568 .having(func.count(ResearchRating.rating) > 0) 

1569 .order_by(func.avg(ResearchRating.rating).desc()) 

1570 .all() 

1571 ) 

1572 

1573 # Rating trends over time 

1574 rating_trends_query = session.query( 

1575 func.date(ResearchRating.created_at).label("date"), 

1576 func.avg(ResearchRating.rating).label("avg_rating"), 

1577 func.count(ResearchRating.rating).label("daily_count"), 

1578 ) 

1579 

1580 if time_condition is not None: 

1581 rating_trends_query = rating_trends_query.filter(time_condition) 

1582 

1583 rating_trends = ( 

1584 rating_trends_query.group_by( 

1585 func.date(ResearchRating.created_at) 

1586 ) 

1587 .order_by("date") 

1588 .all() 

1589 ) 

1590 

1591 # Ratings by research mode 

1592 mode_ratings_query = session.query( 

1593 func.coalesce(ResearchHistory.mode, "Unknown").label("mode"), 

1594 func.avg(ResearchRating.rating).label("avg_rating"), 

1595 func.count(ResearchRating.rating).label("rating_count"), 

1596 func.sum(case((ResearchRating.rating >= 4, 1), else_=0)).label( 

1597 "positive_ratings" 

1598 ), 

1599 ).outerjoin( 

1600 ResearchHistory, 

1601 ResearchRating.research_id == ResearchHistory.id, 

1602 ) 

1603 

1604 if time_condition is not None: 

1605 mode_ratings_query = mode_ratings_query.filter(time_condition) 

1606 

1607 mode_ratings = ( 

1608 mode_ratings_query.group_by(ResearchHistory.mode) 

1609 .having(func.count(ResearchRating.rating) > 0) 

1610 .order_by(func.avg(ResearchRating.rating).desc()) 

1611 .all() 

1612 ) 

1613 

1614 # Quality dimension averages. Each sub-dimension is independently 

1615 # nullable, so count them separately; dimension_count reflects rows 

1616 # with ANY dimension filled, so the radar isn't hidden when only some 

1617 # dimensions have data. 

1618 dimension_stats = session.query( 

1619 func.avg(ResearchRating.accuracy).label("avg_accuracy"), 

1620 func.avg(ResearchRating.completeness).label("avg_completeness"), 

1621 func.avg(ResearchRating.relevance).label("avg_relevance"), 

1622 func.avg(ResearchRating.readability).label("avg_readability"), 

1623 func.count(ResearchRating.accuracy).label("count_accuracy"), 

1624 func.count(ResearchRating.completeness).label( 

1625 "count_completeness" 

1626 ), 

1627 func.count(ResearchRating.relevance).label("count_relevance"), 

1628 func.count(ResearchRating.readability).label( 

1629 "count_readability" 

1630 ), 

1631 func.count( 

1632 func.coalesce( 

1633 ResearchRating.accuracy, 

1634 ResearchRating.completeness, 

1635 ResearchRating.relevance, 

1636 ResearchRating.readability, 

1637 ) 

1638 ).label("dimension_count"), 

1639 ) 

1640 

1641 if time_condition is not None: 

1642 dimension_stats = dimension_stats.filter(time_condition) 

1643 

1644 dimension_stats = dimension_stats.first() 

1645 

1646 # Recent ratings with research details. Join a one-row-per-research 

1647 # model subquery instead of TokenUsage directly: a research has many 

1648 # token_usage rows, which would fan out and make limit(20) return far 

1649 # fewer than 20 unique ratings (the same rating duplicated per row). 

1650 recent_model_subq = ( 

1651 session.query( 

1652 TokenUsage.research_id.label("research_id"), 

1653 func.max(TokenUsage.model_name).label("model_name"), 

1654 ) 

1655 .group_by(TokenUsage.research_id) 

1656 .subquery() 

1657 ) 

1658 

1659 recent_ratings_query = ( 

1660 session.query( 

1661 ResearchRating.rating, 

1662 ResearchRating.created_at, 

1663 ResearchRating.research_id, 

1664 ResearchHistory.query, 

1665 ResearchHistory.mode, 

1666 recent_model_subq.c.model_name, 

1667 ResearchRating.feedback, 

1668 ) 

1669 .outerjoin( 

1670 ResearchHistory, 

1671 ResearchRating.research_id == ResearchHistory.id, 

1672 ) 

1673 .outerjoin( 

1674 recent_model_subq, 

1675 ResearchRating.research_id 

1676 == recent_model_subq.c.research_id, 

1677 ) 

1678 ) 

1679 

1680 if time_condition is not None: 

1681 recent_ratings_query = recent_ratings_query.filter( 

1682 time_condition 

1683 ) 

1684 

1685 recent_ratings = ( 

1686 recent_ratings_query.order_by(ResearchRating.created_at.desc()) 

1687 .limit(20) 

1688 .all() 

1689 ) 

1690 

1691 # Recent feedback entries (non-empty feedback text) 

1692 recent_feedback_query = session.query( 

1693 ResearchRating.feedback, 

1694 ResearchRating.rating, 

1695 ResearchRating.created_at, 

1696 ResearchRating.research_id, 

1697 ).filter( 

1698 ResearchRating.feedback.isnot(None), 

1699 ResearchRating.feedback != "", 

1700 ) 

1701 

1702 if time_condition is not None: 

1703 recent_feedback_query = recent_feedback_query.filter( 

1704 time_condition 

1705 ) 

1706 

1707 recent_feedback = ( 

1708 recent_feedback_query.order_by(ResearchRating.created_at.desc()) 

1709 .limit(20) 

1710 .all() 

1711 ) 

1712 

1713 return { 

1714 "overall_stats": { 

1715 "avg_rating": round(overall_stats.avg_rating or 0, 2), 

1716 "total_ratings": overall_stats.total_ratings or 0, 

1717 "rating_distribution": { 

1718 "5": overall_stats.five_star or 0, 

1719 "4": overall_stats.four_star or 0, 

1720 "3": overall_stats.three_star or 0, 

1721 "2": overall_stats.two_star or 0, 

1722 "1": overall_stats.one_star or 0, 

1723 }, 

1724 }, 

1725 "llm_ratings": [ 

1726 { 

1727 "model": rating.model, 

1728 "avg_rating": round(rating.avg_rating or 0, 2), 

1729 "rating_count": rating.rating_count or 0, 

1730 "positive_ratings": rating.positive_ratings or 0, 

1731 "neutral_ratings": rating.neutral_ratings or 0, 

1732 "negative_ratings": rating.negative_ratings or 0, 

1733 "satisfaction_rate": round( 

1734 (rating.positive_ratings or 0) 

1735 / max(rating.rating_count or 1, 1) 

1736 * 100, 

1737 1, 

1738 ), 

1739 } 

1740 for rating in llm_ratings 

1741 ], 

1742 "search_engine_ratings": [ 

1743 { 

1744 "search_engine": rating.search_engine, 

1745 "avg_rating": round(rating.avg_rating or 0, 2), 

1746 "rating_count": rating.rating_count or 0, 

1747 "positive_ratings": rating.positive_ratings or 0, 

1748 "satisfaction_rate": round( 

1749 (rating.positive_ratings or 0) 

1750 / max(rating.rating_count or 1, 1) 

1751 * 100, 

1752 1, 

1753 ), 

1754 } 

1755 for rating in search_engine_ratings 

1756 ], 

1757 "rating_trends": [ 

1758 { 

1759 "date": str(trend.date), 

1760 "avg_rating": round(trend.avg_rating or 0, 2), 

1761 "count": trend.daily_count or 0, 

1762 } 

1763 for trend in rating_trends 

1764 ], 

1765 "recent_ratings": [ 

1766 { 

1767 "rating": rating.rating, 

1768 "created_at": rating.created_at.isoformat() 

1769 if rating.created_at 

1770 else None, 

1771 "research_id": rating.research_id, 

1772 "query": ( 

1773 rating.query 

1774 if rating.query 

1775 else f"Research Session #{rating.research_id}" 

1776 ), 

1777 "mode": rating.mode 

1778 if rating.mode 

1779 else "Standard Research", 

1780 "llm_model": ( 

1781 rating.model_name 

1782 if rating.model_name 

1783 else "LLM Model" 

1784 ), 

1785 "feedback": rating.feedback, 

1786 } 

1787 for rating in recent_ratings 

1788 ], 

1789 "mode_ratings": [ 

1790 { 

1791 "mode": rating.mode, 

1792 "avg_rating": round(rating.avg_rating or 0, 2), 

1793 "rating_count": rating.rating_count or 0, 

1794 "satisfaction_rate": round( 

1795 (rating.positive_ratings or 0) 

1796 / max(rating.rating_count or 1, 1) 

1797 * 100, 

1798 1, 

1799 ), 

1800 } 

1801 for rating in mode_ratings 

1802 ], 

1803 "quality_dimensions": { 

1804 "avg_accuracy": round(dimension_stats.avg_accuracy, 2) 

1805 if dimension_stats.avg_accuracy is not None 

1806 else None, 

1807 "avg_completeness": round( 

1808 dimension_stats.avg_completeness, 2 

1809 ) 

1810 if dimension_stats.avg_completeness is not None 

1811 else None, 

1812 "avg_relevance": round(dimension_stats.avg_relevance, 2) 

1813 if dimension_stats.avg_relevance is not None 

1814 else None, 

1815 "avg_readability": round(dimension_stats.avg_readability, 2) 

1816 if dimension_stats.avg_readability is not None 

1817 else None, 

1818 "dimension_count": dimension_stats.dimension_count or 0, 

1819 "dimension_counts": { 

1820 "accuracy": dimension_stats.count_accuracy or 0, 

1821 "completeness": dimension_stats.count_completeness or 0, 

1822 "relevance": dimension_stats.count_relevance or 0, 

1823 "readability": dimension_stats.count_readability or 0, 

1824 }, 

1825 }, 

1826 "recent_feedback": [ 

1827 { 

1828 "feedback": rating.feedback, 

1829 "rating": rating.rating, 

1830 "created_at": rating.created_at.isoformat() 

1831 if rating.created_at 

1832 else None, 

1833 "research_id": rating.research_id, 

1834 } 

1835 for rating in recent_feedback 

1836 ], 

1837 } 

1838 

1839 except Exception: 

1840 logger.exception("Error getting star reviews data") 

1841 return JSONResponse( 

1842 {"error": "An internal error occurred. Please try again later."}, 

1843 status_code=500, 

1844 ) 

1845 

1846 

1847@router.get("/api/pricing") 

1848def api_pricing( 

1849 request: Request, username: Annotated[str, Depends(require_auth)] 

1850): 

1851 """Get current LLM pricing data.""" 

1852 try: 

1853 from ...metrics.pricing.pricing_fetcher import PricingFetcher 

1854 

1855 # Use static pricing data instead of async 

1856 fetcher = PricingFetcher() 

1857 pricing_data = fetcher.static_pricing 

1858 

1859 return { 

1860 "status": "success", 

1861 "pricing": pricing_data, 

1862 "last_updated": datetime.now(UTC).isoformat(), 

1863 "note": "Pricing data is from static configuration. Real-time APIs not available for most providers.", 

1864 } 

1865 

1866 except Exception: 

1867 logger.exception("Error fetching pricing data") 

1868 return JSONResponse({"error": "Internal Server Error"}, status_code=500) 

1869 

1870 

1871@router.get("/api/pricing/{model_name}") 

1872def api_model_pricing( 

1873 request: Request, 

1874 model_name, 

1875 username: Annotated[str, Depends(require_auth)], 

1876): 

1877 """Get pricing for a specific model.""" 

1878 try: 

1879 # Optional provider parameter 

1880 provider = request.query_params.get("provider") 

1881 

1882 from ...metrics.pricing.cost_calculator import CostCalculator 

1883 

1884 # Use synchronous approach with cached/static pricing 

1885 calculator = CostCalculator() 

1886 pricing = calculator.cache.get_model_pricing( 

1887 model_name 

1888 ) or calculator.calculate_cost_sync(model_name, 1000, 1000).get( 

1889 "pricing_used", {} 

1890 ) 

1891 

1892 return { 

1893 "status": "success", 

1894 "model": model_name, 

1895 "provider": provider, 

1896 "pricing": pricing, 

1897 "last_updated": datetime.now(UTC).isoformat(), 

1898 } 

1899 

1900 except Exception: 

1901 logger.exception(f"Error getting pricing for model: {model_name}") 

1902 return JSONResponse( 

1903 {"error": "An internal error occurred"}, status_code=500 

1904 ) 

1905 

1906 

1907@router.post("/api/cost-calculation") 

1908async def api_cost_calculation( 

1909 request: Request, username: Annotated[str, Depends(require_auth)] 

1910): 

1911 """Calculate cost for token usage.""" 

1912 # Parsed outside the try/except below: a malformed body must raise 

1913 # json.JSONDecodeError uncaught so it reaches the app's registered 

1914 # handler (-> 400), not the broad `except Exception` further down 

1915 # (which would otherwise turn it into a hardcoded 500). 

1916 data = await request.json() 

1917 if not isinstance(data, dict): 

1918 return json_body_error("simple", "No data provided") 

1919 try: 

1920 model_name = data.get("model_name") 

1921 provider = data.get("provider") # Optional provider parameter 

1922 prompt_tokens = data.get("prompt_tokens", 0) 

1923 completion_tokens = data.get("completion_tokens", 0) 

1924 

1925 if not model_name: 

1926 return JSONResponse( 

1927 {"error": "model_name is required"}, status_code=400 

1928 ) 

1929 

1930 from ...metrics.pricing.cost_calculator import CostCalculator 

1931 import asyncio as _asyncio 

1932 

1933 # Offload the sync pricing lookup so the event loop isn't blocked 

1934 # waiting on its I/O (pricing table fetch / file read). 

1935 calculator = CostCalculator() 

1936 cost_data = await _asyncio.to_thread( 

1937 calculator.calculate_cost_sync, 

1938 model_name, 

1939 prompt_tokens, 

1940 completion_tokens, 

1941 ) 

1942 

1943 return { 

1944 "status": "success", 

1945 "model_name": model_name, 

1946 "provider": provider, 

1947 "prompt_tokens": prompt_tokens, 

1948 "completion_tokens": completion_tokens, 

1949 "total_tokens": prompt_tokens + completion_tokens, 

1950 **cost_data, 

1951 } 

1952 

1953 except Exception: 

1954 logger.exception("Error calculating cost") 

1955 return JSONResponse( 

1956 {"error": "An internal error occurred"}, status_code=500 

1957 ) 

1958 

1959 

1960@router.get("/api/research-costs/{research_id}") 

1961def api_research_costs( 

1962 request: Request, 

1963 research_id, 

1964 username: Annotated[str, Depends(require_auth)], 

1965): 

1966 """Get cost analysis for a specific research session.""" 

1967 try: 

1968 with get_user_db_session(username) as session: 

1969 # Get token usage records for this research 

1970 usage_records = ( 

1971 session.query(TokenUsage) 

1972 .filter(TokenUsage.research_id == research_id) 

1973 .all() 

1974 ) 

1975 

1976 if not usage_records: 

1977 return { 

1978 "status": "success", 

1979 "research_id": research_id, 

1980 "total_cost": 0.0, 

1981 "message": "No token usage data found for this research session", 

1982 } 

1983 

1984 # Convert to dict format for cost calculation 

1985 usage_data = [] 

1986 for record in usage_records: 

1987 usage_data.append( 

1988 { 

1989 "model_name": record.model_name, 

1990 "provider": getattr( 

1991 record, "provider", None 

1992 ), # Handle both old and new records 

1993 "prompt_tokens": record.prompt_tokens, 

1994 "completion_tokens": record.completion_tokens, 

1995 "timestamp": record.timestamp, 

1996 } 

1997 ) 

1998 

1999 from ...metrics.pricing.cost_calculator import CostCalculator 

2000 

2001 # Use synchronous calculation for research costs 

2002 calculator = CostCalculator() 

2003 costs = [] 

2004 for record in usage_data: 

2005 cost_data = calculator.calculate_cost_sync( 

2006 record["model_name"], 

2007 record["prompt_tokens"], 

2008 record["completion_tokens"], 

2009 ) 

2010 costs.append({**record, **cost_data}) 

2011 

2012 total_cost = sum(c["total_cost"] for c in costs) 

2013 total_prompt_tokens = sum(r["prompt_tokens"] for r in usage_data) 

2014 total_completion_tokens = sum( 

2015 r["completion_tokens"] for r in usage_data 

2016 ) 

2017 

2018 cost_summary = { 

2019 "total_cost": round(total_cost, 6), 

2020 "total_tokens": total_prompt_tokens + total_completion_tokens, 

2021 "prompt_tokens": total_prompt_tokens, 

2022 "completion_tokens": total_completion_tokens, 

2023 } 

2024 

2025 return { 

2026 "status": "success", 

2027 "research_id": research_id, 

2028 **cost_summary, 

2029 } 

2030 

2031 except Exception: 

2032 logger.exception( 

2033 f"Error getting research costs for research: {research_id}" 

2034 ) 

2035 return JSONResponse( 

2036 {"error": "An internal error occurred"}, status_code=500 

2037 ) 

2038 

2039 

2040@router.get("/api/cost-analytics") 

2041def api_cost_analytics( 

2042 request: Request, username: Annotated[str, Depends(require_auth)] 

2043): 

2044 """Get cost analytics across all research sessions.""" 

2045 try: 

2046 period = request.query_params.get("period", "30d") 

2047 

2048 with get_user_db_session(username) as session: 

2049 # Get token usage for the period 

2050 query = session.query(TokenUsage) 

2051 time_condition = get_time_filter_condition( 

2052 period, TokenUsage.timestamp 

2053 ) 

2054 if time_condition is not None: 

2055 query = query.filter(time_condition) 

2056 

2057 # First check if we have any records to avoid expensive queries 

2058 record_count = query.count() 

2059 

2060 if record_count == 0: 

2061 return { 

2062 "status": "success", 

2063 "period": period, 

2064 "overview": { 

2065 "total_cost": 0.0, 

2066 "total_tokens": 0, 

2067 "prompt_tokens": 0, 

2068 "completion_tokens": 0, 

2069 }, 

2070 "top_expensive_research": [], 

2071 "research_count": 0, 

2072 "message": "No token usage data found for this period", 

2073 } 

2074 

2075 # If we have too many records, limit to recent ones to avoid timeout 

2076 if record_count > 1000: 2076 ↛ 2086line 2076 didn't jump to line 2086 because the condition on line 2076 was always true

2077 logger.warning( 

2078 f"Large dataset detected ({record_count} records), limiting to recent 1000 for performance" 

2079 ) 

2080 usage_records = ( 

2081 query.order_by(TokenUsage.timestamp.desc()) 

2082 .limit(1000) 

2083 .all() 

2084 ) 

2085 else: 

2086 usage_records = query.all() 

2087 

2088 # Convert to dict format 

2089 usage_data = [] 

2090 for record in usage_records: 2090 ↛ 2091line 2090 didn't jump to line 2091 because the loop on line 2090 never started

2091 usage_data.append( 

2092 { 

2093 "model_name": record.model_name, 

2094 "provider": getattr( 

2095 record, "provider", None 

2096 ), # Handle both old and new records 

2097 "prompt_tokens": record.prompt_tokens, 

2098 "completion_tokens": record.completion_tokens, 

2099 "research_id": record.research_id, 

2100 "timestamp": record.timestamp, 

2101 } 

2102 ) 

2103 

2104 from ...metrics.pricing.cost_calculator import CostCalculator 

2105 

2106 # Use synchronous calculation 

2107 calculator = CostCalculator() 

2108 

2109 # Calculate overall costs 

2110 costs = [] 

2111 for record in usage_data: 2111 ↛ 2112line 2111 didn't jump to line 2112 because the loop on line 2111 never started

2112 cost_data = calculator.calculate_cost_sync( 

2113 record["model_name"], 

2114 record["prompt_tokens"], 

2115 record["completion_tokens"], 

2116 ) 

2117 costs.append({**record, **cost_data}) 

2118 

2119 total_cost = sum(c["total_cost"] for c in costs) 

2120 total_prompt_tokens = sum(r["prompt_tokens"] for r in usage_data) 

2121 total_completion_tokens = sum( 

2122 r["completion_tokens"] for r in usage_data 

2123 ) 

2124 

2125 cost_summary = { 

2126 "total_cost": round(total_cost, 6), 

2127 "total_tokens": total_prompt_tokens + total_completion_tokens, 

2128 "prompt_tokens": total_prompt_tokens, 

2129 "completion_tokens": total_completion_tokens, 

2130 } 

2131 

2132 # Group by research_id for per-research costs 

2133 research_costs: dict[str, Any] = {} 

2134 for record in usage_data: 2134 ↛ 2135line 2134 didn't jump to line 2135 because the loop on line 2134 never started

2135 rid = record["research_id"] 

2136 if rid not in research_costs: 

2137 research_costs[rid] = [] 

2138 research_costs[rid].append(record) 

2139 

2140 # Calculate cost per research 

2141 research_summaries = {} 

2142 for rid, records in research_costs.items(): 2142 ↛ 2143line 2142 didn't jump to line 2143 because the loop on line 2142 never started

2143 research_total: float = 0 

2144 for record in records: 

2145 cost_data = calculator.calculate_cost_sync( 

2146 record["model_name"], 

2147 record["prompt_tokens"], 

2148 record["completion_tokens"], 

2149 ) 

2150 research_total += cost_data["total_cost"] 

2151 research_summaries[rid] = { 

2152 "total_cost": round(research_total, 6) 

2153 } 

2154 

2155 # Top expensive research sessions 

2156 top_expensive = sorted( 

2157 [ 

2158 (rid, data["total_cost"]) 

2159 for rid, data in research_summaries.items() 

2160 ], 

2161 key=lambda x: x[1], 

2162 reverse=True, 

2163 )[:10] 

2164 

2165 return { 

2166 "status": "success", 

2167 "period": period, 

2168 "overview": cost_summary, 

2169 "top_expensive_research": [ 

2170 {"research_id": rid, "total_cost": cost} 

2171 for rid, cost in top_expensive 

2172 ], 

2173 "research_count": len(research_summaries), 

2174 } 

2175 

2176 except Exception: 

2177 logger.exception("Error getting cost analytics") 

2178 # Return a real error so the UI can distinguish "no data" from 

2179 # "this query failed". Returning 200 with status=success masked 

2180 # genuine failures (DB unavailable, schema mismatch). 

2181 return JSONResponse( 

2182 { 

2183 "status": "error", 

2184 "period": period, 

2185 "error": "Cost analytics temporarily unavailable", 

2186 }, 

2187 status_code=500, 

2188 ) 

2189 

2190 

2191@router.get("/links") 

2192def link_analytics( 

2193 request: Request, username: Annotated[str, Depends(require_auth)] 

2194): 

2195 """Display link analytics page.""" 

2196 return templates.TemplateResponse( 

2197 request=request, 

2198 name="pages/link_analytics.html", 

2199 context={"request": request}, 

2200 ) 

2201 

2202 

2203@router.get("/api/link-analytics") 

2204def api_link_analytics( 

2205 request: Request, username: Annotated[str, Depends(require_auth)] 

2206): 

2207 """Get link analytics data.""" 

2208 try: 

2209 period = request.query_params.get("period", "30d") 

2210 

2211 # Get link analytics data 

2212 link_data = get_link_analytics(period, username) 

2213 

2214 return { 

2215 "status": "success", 

2216 "data": link_data["link_analytics"], 

2217 "period": period, 

2218 } 

2219 

2220 except Exception: 

2221 logger.exception("Error getting link analytics") 

2222 return JSONResponse( 

2223 { 

2224 "status": "error", 

2225 "message": "An internal error occurred. Please try again later.", 

2226 }, 

2227 status_code=500, 

2228 ) 

2229 

2230 

2231@router.get("/api/domain-classifications") 

2232def api_get_domain_classifications( 

2233 request: Request, username: Annotated[str, Depends(require_auth)] 

2234): 

2235 """Get all domain classifications.""" 

2236 classifier = None 

2237 try: 

2238 classifier = DomainClassifier(username) 

2239 classifications = classifier.get_all_classifications() 

2240 

2241 return { 

2242 "status": "success", 

2243 "classifications": [c.to_dict() for c in classifications], 

2244 "total": len(classifications), 

2245 } 

2246 

2247 except Exception: 

2248 logger.exception("Error getting domain classifications") 

2249 return JSONResponse( 

2250 { 

2251 "status": "error", 

2252 "message": "Failed to retrieve classifications", 

2253 }, 

2254 status_code=500, 

2255 ) 

2256 finally: 

2257 if classifier is not None: 

2258 from ...utilities.resource_utils import safe_close 

2259 

2260 safe_close(classifier, "domain classifier") 

2261 

2262 

2263@router.get("/api/domain-classifications/summary") 

2264def api_get_classifications_summary( 

2265 request: Request, username: Annotated[str, Depends(require_auth)] 

2266): 

2267 """Get summary of domain classifications by category.""" 

2268 classifier = None 

2269 try: 

2270 classifier = DomainClassifier(username) 

2271 summary = classifier.get_categories_summary() 

2272 

2273 return {"status": "success", "summary": summary} 

2274 

2275 except Exception: 

2276 logger.exception("Error getting classifications summary") 

2277 return JSONResponse( 

2278 {"status": "error", "message": "Failed to retrieve summary"}, 

2279 status_code=500, 

2280 ) 

2281 finally: 

2282 if classifier is not None: 

2283 from ...utilities.resource_utils import safe_close 

2284 

2285 safe_close(classifier, "domain classifier") 

2286 

2287 

2288@router.post("/api/domain-classifications/classify") 

2289async def api_classify_domains( 

2290 request: Request, username: Annotated[str, Depends(require_auth)] 

2291): 

2292 """Trigger classification of a specific domain or batch classification.""" 

2293 data = await request.json() 

2294 # `or {}` alone only guards FALSY bodies (None, [], ""); a truthy 

2295 # non-dict (bare string, int, non-empty list/array) would still reach 

2296 # `.get(...)` below and raise AttributeError -> 500. 

2297 if not isinstance(data, dict): 

2298 return json_body_error("status", "Request body must be valid JSON") 

2299 domain = data.get("domain") 

2300 force_update = data.get("force_update", False) 

2301 batch_mode = data.get("batch", False) 

2302 if domain is not None and not isinstance(domain, str): 

2303 return json_body_error("status", "domain must be a string") 

2304 if not isinstance(force_update, bool): 

2305 return json_body_error("status", "force_update must be a boolean") 

2306 if not isinstance(batch_mode, bool): 

2307 return json_body_error("status", "batch must be a boolean") 

2308 

2309 def _classify_sync(): 

2310 """All the sync LLM + DB work — run in a threadpool so we don't 

2311 block the asyncio event loop for seconds per request.""" 

2312 from ...settings.manager import SettingsManager 

2313 from ...database.session_context import get_user_db_session 

2314 from ...utilities.resource_utils import safe_close 

2315 

2316 classifier = None 

2317 try: 

2318 with get_user_db_session(username) as db_session: 

2319 settings_manager = SettingsManager(db_session=db_session) 

2320 settings_snapshot = settings_manager.get_all_settings() 

2321 

2322 classifier = DomainClassifier( 

2323 username, settings_snapshot=settings_snapshot 

2324 ) 

2325 

2326 if domain and not batch_mode: 

2327 logger.info(f"Classifying single domain: {domain}") 

2328 classification = classifier.classify_domain( 

2329 domain, force_update 

2330 ) 

2331 if classification: 

2332 return { 

2333 "status": "success", 

2334 "classification": classification.to_dict(), 

2335 } 

2336 return JSONResponse( 

2337 { 

2338 "status": "error", 

2339 "message": f"Failed to classify domain: {domain}", 

2340 }, 

2341 status_code=400, 

2342 ) 

2343 if batch_mode: 

2344 logger.info("Starting batch classification of all domains") 

2345 results = classifier.classify_all_domains(force_update) 

2346 return {"status": "success", "results": results} 

2347 return JSONResponse( 

2348 { 

2349 "status": "error", 

2350 "message": "Must provide either 'domain' or set 'batch': true", 

2351 }, 

2352 status_code=400, 

2353 ) 

2354 except Exception: 

2355 logger.exception("Error classifying domains") 

2356 return JSONResponse( 

2357 {"status": "error", "message": "Failed to classify domains"}, 

2358 status_code=500, 

2359 ) 

2360 finally: 

2361 if classifier is not None: 

2362 safe_close(classifier, "domain classifier") 

2363 

2364 return await run_db_sync(_classify_sync) 

2365 

2366 

2367@router.get("/api/domain-classifications/progress") 

2368def api_classification_progress( 

2369 request: Request, username: Annotated[str, Depends(require_auth)] 

2370): 

2371 """Get progress of domain classification task.""" 

2372 try: 

2373 # Get counts of classified vs unclassified domains 

2374 with get_user_db_session(username) as session: 

2375 # Count total unique domains 

2376 resources = session.query(ResearchResource.url).distinct().all() 

2377 domains = set() 

2378 

2379 for (url,) in resources: 

2380 if url: 

2381 domain = _extract_domain(url) 

2382 if domain: 2382 ↛ 2379line 2382 didn't jump to line 2379 because the condition on line 2382 was always true

2383 domains.add(domain) 

2384 

2385 all_domains = sorted(domains) 

2386 total_domains = len(domains) 

2387 

2388 # Count classified domains 

2389 classified_count = session.query(DomainClassification).count() 

2390 

2391 return { 

2392 "status": "success", 

2393 "progress": { 

2394 "total_domains": total_domains, 

2395 "classified": classified_count, 

2396 "unclassified": total_domains - classified_count, 

2397 "percentage": round( 

2398 (classified_count / total_domains * 100) 

2399 if total_domains > 0 

2400 else 0, 

2401 1, 

2402 ), 

2403 "all_domains": all_domains, # Return all domains for classification 

2404 }, 

2405 } 

2406 

2407 except Exception: 

2408 logger.exception("Error getting classification progress") 

2409 return JSONResponse( 

2410 {"status": "error", "message": "Failed to retrieve progress"}, 

2411 status_code=500, 

2412 ) 

2413 

2414 

2415# --------------------------------------------------------------------------- 

2416# Journal Quality Dashboard 

2417# --------------------------------------------------------------------------- 

2418 

2419 

2420# Rate-limit constants for the journal-quality endpoints. The old Flask 

2421# decorators (`journal_data_limit`, `journals_read_limit`) lived in 

2422# `security/rate_limiter.py`, which the FastAPI migration removed. We 

2423# re-use slowapi's `limiter.limit()` with the same intent: 

2424# - journal-data download is expensive (~several hundred MB fetch + 

2425# reference DB rebuild), so cap tightly 

2426# - journal read endpoints are cheap but still worth a per-user rate 

2427# to blunt enumeration 

2428_JOURNAL_DATA_LIMIT = "2 per hour" 

2429_JOURNALS_READ_LIMIT = "60 per minute" 

2430 

2431# SHARED buckets, matching main's journal_data_limit / journals_read_limit 

2432# (security/rate_limiter.py). Using per-endpoint `limiter.limit()` gave each 

2433# of the three read endpoints its OWN 60/min bucket (180/min combined), which 

2434# defeats the anti-enumeration throttle on the ~217K-row reference DB. A 

2435# single scope per group + per-user keying restores the intended combined cap. 

2436_journal_data_limit = limiter.shared_limit( 

2437 _JOURNAL_DATA_LIMIT, scope="journal_data", key_func=_user_key 

2438) 

2439_journals_read_limit = limiter.shared_limit( 

2440 _JOURNALS_READ_LIMIT, scope="journals_read", key_func=_user_key 

2441) 

2442 

2443 

2444@router.get("/journals") 

2445def journal_quality( 

2446 request: Request, username: Annotated[str, Depends(require_auth)] 

2447): 

2448 """Display journal quality dashboard.""" 

2449 return templates.TemplateResponse( 

2450 request=request, 

2451 name="pages/journal_quality.html", 

2452 context={"request": request}, 

2453 ) 

2454 

2455 

2456@router.get("/api/journal-data/status") 

2457def api_journal_data_status( 

2458 request: Request, username: Annotated[str, Depends(require_auth)] 

2459): 

2460 """Get status of downloadable journal data files.""" 

2461 try: 

2462 from ...journal_quality.downloader import ( 

2463 get_journal_data_status, 

2464 ) 

2465 

2466 return get_journal_data_status() 

2467 except Exception: 

2468 logger.exception("Error checking journal data status") 

2469 return JSONResponse( 

2470 {"error": "Failed to check status"}, status_code=500 

2471 ) 

2472 

2473 

2474@router.post("/api/journal-data/download") 

2475@_journal_data_limit 

2476async def api_journal_data_download( 

2477 request: Request, username: Annotated[str, Depends(require_auth)] 

2478): 

2479 """Trigger download/update of journal data files. 

2480 

2481 Rate-limited to 2 per hour per authenticated user: the download streams 

2482 several hundred MB and rebuilds the on-disk reference DB, so unbounded 

2483 invocation is a DoS vector. The actual download + rebuild runs in a 

2484 worker thread so the uvicorn loop isn't blocked for the minutes this 

2485 may take. 

2486 """ 

2487 try: 

2488 from ...journal_quality.downloader import ( 

2489 download_journal_data, 

2490 get_download_state, 

2491 ) 

2492 from ...journal_quality.data_sources import ALL_SOURCES 

2493 

2494 # Egress policy: this endpoint streams several hundred MB over public 

2495 # HTTP. Under an offline-for-public scope (PRIVATE_ONLY / STRICT) the 

2496 # user has opted out of public egress, so refuse rather than reaching 

2497 # out. A corrupt/unknown scope also fails closed. The background 

2498 # auto-download path is already gated by 

2499 # JournalReputationFilter._should_skip_journal_fetch_for_scope; this 

2500 # closes the manual button as the matching entry point. 

2501 from ...security.egress.policy import ( 

2502 DEFAULT_EGRESS_SCOPE, 

2503 EgressScope, 

2504 PolicyDeniedError, 

2505 parse_user_egress_scope, 

2506 ) 

2507 from ...utilities.db_utils import get_settings_manager 

2508 

2509 # Settings read opens the user's SQLCipher session (sync) — keep it 

2510 # off the event loop. 

2511 scope_raw = await run_db_sync( 

2512 lambda: get_settings_manager(username=username).get_setting( 

2513 "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

2514 ) 

2515 ) 

2516 # Retired "both" reads as the adaptive default, mirroring 

2517 # context_from_snapshot's read-time backstop for un-migrated rows. 

2518 if str(scope_raw).strip().lower() == EgressScope.BOTH.value: 

2519 scope_raw = EgressScope.ADAPTIVE.value 

2520 try: 

2521 # Stale "unprotected" rows fall back to adaptive when the 

2522 # operator gate is off, matching every other runtime reader. 

2523 scope = parse_user_egress_scope( 

2524 scope_raw, disabled_unprotected="adaptive" 

2525 ) 

2526 except PolicyDeniedError: 

2527 scope = None # corrupt scope -> fail closed below 

2528 if scope is None or scope in ( 

2529 EgressScope.PRIVATE_ONLY, 

2530 EgressScope.STRICT, 

2531 ): 

2532 logger.bind(policy_audit=True).warning( 

2533 "journal data download refused by egress policy", 

2534 scope=str(scope_raw), 

2535 ) 

2536 return JSONResponse( 

2537 { 

2538 "success": False, 

2539 "message": ( 

2540 "Journal data download needs public network " 

2541 "access, which the current egress policy blocks " 

2542 "(private/offline scope). Change the egress " 

2543 "scope to download." 

2544 ), 

2545 }, 

2546 status_code=403, 

2547 ) 

2548 

2549 # Parse the force flag from the request body if present. 

2550 force = False 

2551 ctype = request.headers.get("content-type", "") 

2552 if "application/json" in ctype: 2552 ↛ 2587line 2552 didn't jump to line 2587 because the condition on line 2552 was always true

2553 try: 

2554 body = await request.json() 

2555 except Exception: 

2556 return JSONResponse( 

2557 { 

2558 "success": False, 

2559 "message": "Request body must be valid JSON", 

2560 }, 

2561 status_code=400, 

2562 ) 

2563 if not isinstance(body, dict): 

2564 return JSONResponse( 

2565 { 

2566 "success": False, 

2567 "message": "Request body must be valid JSON", 

2568 }, 

2569 status_code=400, 

2570 ) 

2571 force = body.get("force", False) 

2572 if not isinstance(force, bool): 

2573 return JSONResponse( 

2574 { 

2575 "success": False, 

2576 "message": "force must be a boolean", 

2577 }, 

2578 status_code=400, 

2579 ) 

2580 

2581 # `download_journal_data` already calls `build_reference_db()` 

2582 # + `reset_db()` internally on its success path (downloader.py 

2583 # → db.py); we used to redo it here, but a second rebuild ran 

2584 # the full ~30s pipeline twice and wrote to the legacy 

2585 # `journal_reference.db` filename that the downloader had just 

2586 # cleaned up. See main's #3574 for the simplification. 

2587 def _run_download() -> tuple[bool, str | None]: 

2588 return download_journal_data(force=force) 

2589 

2590 success, internal_message = await asyncio.to_thread(_run_download) 

2591 if not success: 

2592 logger.warning(f"Journal data download failed: {internal_message}") 

2593 return {"success": False, "message": "Download failed"} 

2594 

2595 # Build the user-facing message locally from structured state 

2596 # (ints + developer-authored source labels). We deliberately do 

2597 # NOT echo `internal_message` from download_journal_data: keeping 

2598 # the response safe-by-construction means a future refactor that 

2599 # lets arbitrary strings (exception info, user input, PII) slip 

2600 # into the downloader's message cannot reach the client. 

2601 counts = get_download_state().get("counts") 

2602 if counts is not None: 

2603 parts = [ 

2604 f"{int(counts.get(src.key) or 0)} {src.count_label}" 

2605 for src in ALL_SOURCES 

2606 ] 

2607 user_message = ( 

2608 f"Fetched {' + '.join(parts)}. Database rebuilt successfully." 

2609 ) 

2610 else: 

2611 # `counts` is None when download_journal_data took its 

2612 # early-return "already up to date" branch (no fetch ran). 

2613 user_message = "Journal data is already up to date." 

2614 return {"success": True, "message": user_message} 

2615 except Exception: 

2616 logger.exception("Error downloading journal data") 

2617 return JSONResponse( 

2618 {"success": False, "message": "Download failed"}, 

2619 status_code=500, 

2620 ) 

2621 

2622 

2623#: Allowlist of ``score_source`` values accepted by ``/api/journals``. 

2624#: Matches the writer side: ``openalex`` / ``doaj`` for reference-DB 

2625#: hits, ``llm`` for Tier 4 cache rows. Empty string means "no filter" 

2626#: and is handled by the caller before validation. 

2627_ALLOWED_SCORE_SOURCES = frozenset({"openalex", "doaj", "llm"}) 

2628 

2629#: Upper bound on the echoed ``page`` parameter. Prevents a crafted 

2630#: ``?page=10**9`` from issuing an OFFSET scan before the post-query 

2631#: clamp can take effect — reject at input validation instead. 

2632_MAX_PAGE = 10_000 

2633 

2634 

2635@router.get("/api/journals") 

2636@_journals_read_limit 

2637def api_journal_quality( 

2638 request: Request, username: Annotated[str, Depends(require_auth)] 

2639): 

2640 """Get journal quality data with server-side pagination and filtering. 

2641 

2642 Reads from the bundled read-only reference database (~217K journals) 

2643 rather than the per-user DB, so the dashboard is always populated. 

2644 

2645 Query params: 

2646 page (int): 1-indexed page number (default 1, max 10000) 

2647 per_page (int): rows per page, max 200 (default 50) 

2648 search (str): name substring filter 

2649 tier (str): elite/strong/moderate/low/predatory 

2650 score_source (str): openalex/doaj/llm (allowlisted) 

2651 sort (str): column to sort by (default quality) 

2652 order (str): asc or desc (default desc) 

2653 """ 

2654 try: 

2655 from ...journal_quality.db import get_journal_reference_db 

2656 

2657 ref = get_journal_reference_db() 

2658 if not ref.available: 

2659 return JSONResponse( 

2660 { 

2661 "status": "error", 

2662 "message": "Journal reference database not available.", 

2663 }, 

2664 status_code=503, 

2665 ) 

2666 

2667 try: 

2668 page = max(1, int(request.query_params.get("page", 1))) 

2669 per_page = min( 

2670 max(1, int(request.query_params.get("per_page", 50))), 200 

2671 ) 

2672 except (TypeError, ValueError): 

2673 return JSONResponse( 

2674 { 

2675 "status": "error", 

2676 "message": "Invalid pagination parameters", 

2677 }, 

2678 status_code=400, 

2679 ) 

2680 if page > _MAX_PAGE: 

2681 return JSONResponse( 

2682 { 

2683 "status": "error", 

2684 "message": ( 

2685 f"page exceeds maximum ({_MAX_PAGE}); narrow the " 

2686 "filter or increase per_page" 

2687 ), 

2688 }, 

2689 status_code=400, 

2690 ) 

2691 search = request.query_params.get("search", "") 

2692 tier = request.query_params.get("tier", "") 

2693 score_source = request.query_params.get("score_source", "") 

2694 if score_source and score_source not in _ALLOWED_SCORE_SOURCES: 

2695 return JSONResponse( 

2696 { 

2697 "status": "error", 

2698 "message": ( 

2699 f"Invalid score_source; must be one of " 

2700 f"{sorted(_ALLOWED_SCORE_SOURCES)}" 

2701 ), 

2702 }, 

2703 status_code=400, 

2704 ) 

2705 sort = request.query_params.get("sort", "quality") 

2706 order = request.query_params.get("order", "desc") 

2707 

2708 journals, total = ref.get_journals_page( 

2709 page=page, 

2710 per_page=per_page, 

2711 search=search, 

2712 tier=tier, 

2713 score_source=score_source, 

2714 sort=sort, 

2715 order=order, 

2716 ) 

2717 

2718 # Clamp the echoed page so the UI never displays out-of-range 

2719 # numbers on crafted input (e.g. ?page=10**9). SQLite's OFFSET on 

2720 # an indexed ORDER BY caps work at ~total rows regardless of the 

2721 # requested offset, so no DB-level clamp is needed. 

2722 total_pages = -(-total // per_page) if per_page > 0 and total > 0 else 1 

2723 page = min(page, total_pages) 

2724 

2725 result = { 

2726 "status": "success", 

2727 "journals": journals, 

2728 "pagination": { 

2729 "page": page, 

2730 "per_page": per_page, 

2731 "total_count": total, 

2732 "total_pages": total_pages, 

2733 }, 

2734 } 

2735 

2736 # Include summary only when requested (avoids 3 extra SQL queries 

2737 # on every pagination/sort/filter request) 

2738 if request.query_params.get("include_summary", "false") == "true": 2738 ↛ 2739line 2738 didn't jump to line 2739 because the condition on line 2738 was never true

2739 summary = ref.get_summary() 

2740 summary["quality_distribution"] = ref.get_quality_distribution() 

2741 summary["source_distribution"] = ref.get_source_distribution() 

2742 result["summary"] = summary 

2743 

2744 return result 

2745 

2746 except Exception: 

2747 logger.exception("Error getting journal quality data") 

2748 return JSONResponse( 

2749 { 

2750 "status": "error", 

2751 "message": "An internal error occurred. Please try again later.", 

2752 }, 

2753 status_code=500, 

2754 ) 

2755 

2756 

2757def _ref_db_lookup(ref_db, name: str) -> dict: 

2758 """Look up a journal's display bibliometrics in the reference DB. 

2759 

2760 Returns a dict with keys the dashboard template already renders 

2761 (h_index, impact_factor, sjr_quartile, publisher, is_predatory, 

2762 predatory_source, is_in_doaj). Missing fields default 

2763 to None / False so the frontend never sees KeyError. On any ref-DB 

2764 error the function returns an empty dict — the dashboard still shows 

2765 the name + user-DB quality, just without the extras. 

2766 """ 

2767 if ref_db is None or not name: 

2768 return {} 

2769 try: 

2770 entry = ref_db.lookup_source(name=name) or {} 

2771 except Exception: # allow: silent-exception 

2772 # Reference DB lookups are best-effort enrichment. Any failure 

2773 # degrades to "no bibliometric extras" without crashing the 

2774 # dashboard; detailed errors already surface via the DB layer's 

2775 # own logger.exception calls when they matter. 

2776 return {} 

2777 # lookup_source returns a compact dict; the sjr_quartile lives under 

2778 # "quartile" and predatory/DOAJ fields may be absent entirely. 

2779 return { 

2780 "h_index": entry.get("h_index"), 

2781 "impact_factor": entry.get("impact_factor"), 

2782 "sjr_quartile": entry.get("quartile"), 

2783 "is_predatory": bool(entry.get("is_predatory")), 

2784 "predatory_source": entry.get("predatory_source"), 

2785 "is_in_doaj": bool(entry.get("is_in_doaj")), 

2786 "publisher": entry.get("publisher"), 

2787 } 

2788 

2789 

2790def _get_ref_db_or_none(): 

2791 """Return the JournalQualityDB singleton, or None if unavailable. 

2792 

2793 The reference DB is optional — if the user hasn't downloaded the 

2794 snapshot, the dashboard still renders with user-DB data only. 

2795 """ 

2796 try: 

2797 from ...journal_quality.db import get_journal_reference_db 

2798 

2799 return get_journal_reference_db() 

2800 except Exception: # allow: silent-exception 

2801 # Reference DB is optional; if import or initialization fails 

2802 # (unusual: usually it's lazily built on first access), the 

2803 # dashboard falls back to user-DB-only rendering. 

2804 return None 

2805 

2806 

2807def _resolve_paper_quality( 

2808 llm_quality: int | None, enrichment: dict 

2809) -> tuple[int | None, str | None]: 

2810 """Pick a quality score for a dashboard row. 

2811 

2812 Precedence: current LLM verdict from the user's ``journals`` table 

2813 (Tier 4 cache, keyed by NFKC-normalized container_title) → live 

2814 derivation from the bundled reference DB row (Tier 1-3). Always 

2815 live — no frozen per-Paper copy exists, so a re-scored journal 

2816 propagates automatically. Returns (score, source_label) or 

2817 (None, None) if neither path had data. 

2818 """ 

2819 if llm_quality is not None: 2819 ↛ 2820line 2819 didn't jump to line 2820 because the condition on line 2819 was never true

2820 return llm_quality, "llm" 

2821 if not enrichment: 2821 ↛ 2822line 2821 didn't jump to line 2822 because the condition on line 2821 was never true

2822 return None, None 

2823 # enrichment comes from _source_to_dashboard_dict — row.quality is 

2824 # the ref-DB's pre-computed score (same formula as the filter uses), 

2825 # so we trust it directly rather than re-running derive_quality_score. 

2826 q = enrichment.get("quality") 

2827 if q is not None: 2827 ↛ 2829line 2827 didn't jump to line 2829 because the condition on line 2827 was always true

2828 return int(q), enrichment.get("score_source") or "openalex" 

2829 return None, None 

2830 

2831 

2832def _lookup_journal_llm_quality( 

2833 db, container_titles: list[str] 

2834) -> dict[str, int]: 

2835 """Batch-look up current Tier 4 LLM verdicts from the user's 

2836 ``journals`` table. 

2837 

2838 Returns a dict mapping ``normalize_name(container_title)`` → 

2839 ``Journal.quality``. Missing journals (never Tier-4-scored) simply 

2840 don't appear in the result — callers fall through to the bundled 

2841 reference DB. One indexed ``name_lower IN (...)`` query. 

2842 """ 

2843 from ...journal_quality.scoring import normalize_name 

2844 

2845 if not container_titles: 2845 ↛ 2846line 2845 didn't jump to line 2846 because the condition on line 2845 was never true

2846 return {} 

2847 normalized = list({normalize_name(ct) for ct in container_titles if ct}) 

2848 if not normalized: 2848 ↛ 2849line 2848 didn't jump to line 2849 because the condition on line 2848 was never true

2849 return {} 

2850 rows = ( 

2851 db.query(Journal.name_lower, Journal.quality) 

2852 .filter(Journal.name_lower.in_(normalized)) 

2853 .filter(Journal.quality.isnot(None)) 

2854 .all() 

2855 ) 

2856 return {name_lower: int(q) for name_lower, q in rows} 

2857 

2858 

2859@router.get("/api/journals/user-research") 

2860@_journals_read_limit 

2861def api_user_research_journals( 

2862 request: Request, username: Annotated[str, Depends(require_auth)] 

2863): 

2864 """Get journals from the user's own research sessions. 

2865 

2866 Paper-rooted query: groups by ``Paper.container_title`` (the 

2867 cleaned name the filter used to score the journal), counts paper 

2868 appearances. Quality is resolved live — Tier 4 via a batch lookup 

2869 against the user's ``journals`` table (keyed by NFKC-normalized 

2870 container_title), Tier 1-3 via the bundled read-only reference DB. 

2871 A re-scored journal propagates to existing research rows 

2872 automatically because no per-Paper score is stored. 

2873 """ 

2874 _empty_response = { 

2875 "status": "success", 

2876 "summary": { 

2877 "total_journals": 0, 

2878 "avg_quality": None, 

2879 "total_papers": 0, 

2880 "predatory_blocked": 0, 

2881 }, 

2882 "quality_distribution": {}, 

2883 "journals": [], 

2884 } 

2885 

2886 try: 

2887 from sqlalchemy import inspect as sa_inspect 

2888 

2889 with get_user_db_session(username) as db: 

2890 inspector = sa_inspect(db.bind) 

2891 if not inspector.has_table("papers"): 2891 ↛ 2892line 2891 didn't jump to line 2892 because the condition on line 2891 was never true

2892 return _empty_response 

2893 

2894 # Top-200 most-cited journals in this user's research. 

2895 # Orphan Papers (whose ``PaperAppearance`` rows were 

2896 # cascade-deleted when their research session was deleted) 

2897 # are excluded so the dashboard reflects what the user 

2898 # currently has, not residual rows from deleted sessions. 

2899 # See issue #3544. 

2900 rows = ( 

2901 db.query( 

2902 Paper.container_title, 

2903 func.count(Paper.id).label("paper_count"), 

2904 func.min(Paper.year).label("year_min"), 

2905 func.max(Paper.year).label("year_max"), 

2906 ) 

2907 .filter(Paper.container_title.isnot(None)) 

2908 .filter(Paper.appearances.any()) 

2909 .group_by(Paper.container_title) 

2910 .order_by(func.count(Paper.id).desc()) 

2911 .limit(200) 

2912 .all() 

2913 ) 

2914 

2915 if not rows: 2915 ↛ 2921line 2915 didn't jump to line 2921 because the condition on line 2915 was always true

2916 return _empty_response 

2917 

2918 # One batched ref-DB lookup for the whole top-200 slice — 

2919 # hits `sources.name_lower IN (…)` rather than 200 point 

2920 # queries. 

2921 ref_db = _get_ref_db_or_none() 

2922 enrich_map = {} 

2923 if ref_db is not None: 

2924 enrich_map = ref_db.lookup_sources_batch( 

2925 [r.container_title for r in rows] 

2926 ) 

2927 

2928 from ...journal_quality.scoring import normalize_name 

2929 

2930 # Batch-look up current LLM verdicts (Tier 4) from the 

2931 # user's journals table, keyed by NFKC-normalized name. 

2932 # Always live — no frozen Paper copy — so a re-scored 

2933 # journal propagates here without any backfill. 

2934 llm_by_name = _lookup_journal_llm_quality( 

2935 db, [r.container_title for r in rows] 

2936 ) 

2937 

2938 journals: list[dict] = [] 

2939 qualities: list[int] = [] 

2940 for r in rows: 

2941 normalized = normalize_name(r.container_title) 

2942 enrichment = enrich_map.get(normalized, {}) 

2943 quality, source_label = _resolve_paper_quality( 

2944 llm_by_name.get(normalized), enrichment 

2945 ) 

2946 if quality is not None: 

2947 qualities.append(quality) 

2948 journals.append( 

2949 { 

2950 "name": r.container_title, 

2951 "quality": quality, 

2952 "score_source": source_label, 

2953 "paper_count": r.paper_count, 

2954 "year_min": r.year_min, 

2955 "year_max": r.year_max, 

2956 **{ 

2957 k: v 

2958 for k, v in enrichment.items() 

2959 if k not in ("quality", "score_source", "name") 

2960 }, 

2961 } 

2962 ) 

2963 

2964 # Aggregate stats computed across the top-200 slice for the 

2965 # dashboard summary — matches how the table renders. 

2966 total_journals = len(journals) 

2967 total_papers = sum(r.paper_count for r in rows) 

2968 avg_quality = ( 

2969 round(sum(qualities) / len(qualities), 1) if qualities else None 

2970 ) 

2971 quality_distribution: dict[str, int] = {} 

2972 for q in qualities: 

2973 quality_distribution[str(q)] = ( 

2974 quality_distribution.get(str(q), 0) + 1 

2975 ) 

2976 

2977 # Predatory count uses the full set of distinct 

2978 # container_titles across the user's research, not just the 

2979 # top-200 display slice. One batched query. 

2980 # 

2981 # KNOWN-DEFERRED: unbounded SELECT DISTINCT. Acceptable today 

2982 # because typical users have <5K distinct titles even after 

2983 # years of use, and count_predatory_by_names documents 

2984 # support up to ~100K params. Adding .limit(N) was considered 

2985 # and rejected — it would SILENTLY UNDERCOUNT predatory 

2986 # journals, which violates the no-fallbacks rule. Proper fix 

2987 # (cross-DB correlated subquery or TTL cache) is tracked as 

2988 # a post-merge follow-up. Threshold for visible impact: 

2989 # ~50K papers. 

2990 predatory_blocked = 0 

2991 if ref_db is not None: 

2992 # Same orphan-exclusion as the top-200 query above — 

2993 # otherwise predatory_blocked stays inflated by titles 

2994 # whose only Papers belong to deleted research sessions. 

2995 all_names = [ 

2996 name 

2997 for (name,) in db.query(Paper.container_title) 

2998 .filter(Paper.container_title.isnot(None)) 

2999 .filter(Paper.appearances.any()) 

3000 .distinct() 

3001 .all() 

3002 ] 

3003 predatory_blocked = ref_db.count_predatory_by_names(all_names) 

3004 

3005 return { 

3006 "status": "success", 

3007 "summary": { 

3008 "total_journals": total_journals, 

3009 "avg_quality": avg_quality, 

3010 "total_papers": total_papers, 

3011 "predatory_blocked": predatory_blocked, 

3012 }, 

3013 "quality_distribution": quality_distribution, 

3014 "journals": journals, 

3015 } 

3016 except Exception: 

3017 logger.exception("Error getting user research journals") 

3018 return JSONResponse( 

3019 { 

3020 "status": "error", 

3021 "message": "Failed to load your research data.", 

3022 }, 

3023 status_code=500, 

3024 ) 

3025 

3026 

3027@router.get("/api/journals/research/{research_id}") 

3028@_journals_read_limit 

3029def api_research_journals( 

3030 request: Request, 

3031 research_id: str, 

3032 username: Annotated[str, Depends(require_auth)], 

3033): 

3034 """Get journals encountered in a single research session. 

3035 

3036 Filters the per-user papers table by joining through 

3037 Paper → PaperAppearance → ResearchResource and matching ``research_id``. 

3038 Quality is resolved live (journals.quality + bundled reference DB) 

3039 so results always reflect the current verdict, not a stale snapshot. 

3040 Mirrors the response shape of /api/journals/user-research so the 

3041 dashboard can reuse its rendering code. 

3042 """ 

3043 _empty_response = { 

3044 "status": "success", 

3045 "summary": { 

3046 "total_journals": 0, 

3047 "avg_quality": None, 

3048 "total_papers": 0, 

3049 "predatory_blocked": 0, 

3050 }, 

3051 "quality_distribution": {}, 

3052 "journals": [], 

3053 } 

3054 

3055 try: 

3056 from sqlalchemy import inspect as sa_inspect 

3057 

3058 with get_user_db_session(username) as db: 

3059 inspector = sa_inspect(db.bind) 

3060 if not inspector.has_table("papers") or not inspector.has_table( 3060 ↛ 3063line 3060 didn't jump to line 3063 because the condition on line 3060 was never true

3061 "paper_appearances" 

3062 ): 

3063 return _empty_response 

3064 

3065 # Verify the research_id belongs to this user before exposing 

3066 # any data — research_history is in the same per-user DB so 

3067 # the existence check doubles as an ownership check. 

3068 from ...database.models.research import ResearchHistory 

3069 

3070 research = ( 

3071 db.query(ResearchHistory.id) 

3072 .filter(ResearchHistory.id == research_id) 

3073 .first() 

3074 ) 

3075 if research is None: 

3076 return JSONResponse( 

3077 {"status": "error", "message": "Research not found"}, 

3078 status_code=404, 

3079 ) 

3080 

3081 # Aggregate container_title → paper_count for this research. 

3082 # Join chain: Paper → PaperAppearance → ResearchResource. 

3083 rows = ( 

3084 db.query( 

3085 Paper.container_title, 

3086 func.count(Paper.id).label("paper_count"), 

3087 func.min(Paper.year).label("year_min"), 

3088 func.max(Paper.year).label("year_max"), 

3089 ) 

3090 .join( 

3091 PaperAppearance, 

3092 PaperAppearance.paper_id == Paper.id, 

3093 ) 

3094 .join( 

3095 ResearchResource, 

3096 ResearchResource.id == PaperAppearance.resource_id, 

3097 ) 

3098 .filter( 

3099 ResearchResource.research_id == research_id, 

3100 Paper.container_title.isnot(None), 

3101 ) 

3102 .group_by(Paper.container_title) 

3103 .order_by(func.count(Paper.id).desc()) 

3104 .all() 

3105 ) 

3106 

3107 if not rows: 3107 ↛ 3108line 3107 didn't jump to line 3108 because the condition on line 3107 was never true

3108 return _empty_response 

3109 

3110 ref_db = _get_ref_db_or_none() 

3111 enrich_map = {} 

3112 if ref_db is not None: 3112 ↛ 3117line 3112 didn't jump to line 3117 because the condition on line 3112 was always true

3113 enrich_map = ref_db.lookup_sources_batch( 

3114 [r.container_title for r in rows] 

3115 ) 

3116 

3117 from ...journal_quality.scoring import normalize_name 

3118 

3119 # Batch-look up current LLM verdicts (Tier 4) — see 

3120 # _lookup_journal_llm_quality for rationale. Same live 

3121 # resolution as the cross-research rollup above. 

3122 llm_by_name = _lookup_journal_llm_quality( 

3123 db, [r.container_title for r in rows] 

3124 ) 

3125 

3126 journals: list[dict] = [] 

3127 qualities: list[int] = [] 

3128 predatory_blocked = 0 

3129 for r in rows: 

3130 normalized = normalize_name(r.container_title) 

3131 enrichment = enrich_map.get(normalized, {}) 

3132 if enrichment.get("is_predatory"): 

3133 predatory_blocked += 1 

3134 quality, source_label = _resolve_paper_quality( 

3135 llm_by_name.get(normalized), enrichment 

3136 ) 

3137 if quality is not None: 3137 ↛ 3139line 3137 didn't jump to line 3139 because the condition on line 3137 was always true

3138 qualities.append(quality) 

3139 journals.append( 

3140 { 

3141 "name": r.container_title, 

3142 "quality": quality, 

3143 "score_source": source_label, 

3144 "paper_count": r.paper_count, 

3145 "year_min": r.year_min, 

3146 "year_max": r.year_max, 

3147 **{ 

3148 k: v 

3149 for k, v in enrichment.items() 

3150 if k not in ("quality", "score_source", "name") 

3151 }, 

3152 } 

3153 ) 

3154 

3155 total_papers = sum(r.paper_count for r in rows) 

3156 avg_quality = ( 

3157 round(sum(qualities) / len(qualities), 1) if qualities else None 

3158 ) 

3159 quality_distribution: dict[str, int] = {} 

3160 for q in qualities: 

3161 quality_distribution[str(q)] = ( 

3162 quality_distribution.get(str(q), 0) + 1 

3163 ) 

3164 

3165 return { 

3166 "status": "success", 

3167 "summary": { 

3168 "total_journals": len(journals), 

3169 "avg_quality": avg_quality, 

3170 "total_papers": total_papers, 

3171 "predatory_blocked": predatory_blocked, 

3172 }, 

3173 "quality_distribution": quality_distribution, 

3174 "journals": journals, 

3175 } 

3176 except Exception: 

3177 logger.exception("Error getting per-research journals") 

3178 return JSONResponse( 

3179 { 

3180 "status": "error", 

3181 "message": "Failed to load research journals.", 

3182 }, 

3183 status_code=500, 

3184 )