Coverage for src/local_deep_research/web/routes/metrics_routes.py: 84%

927 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

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

2 

3from datetime import datetime, timedelta, UTC 

4from typing import Any 

5from urllib.parse import urlparse 

6 

7from flask import Blueprint, jsonify, request, session as flask_session 

8from loguru import logger 

9from sqlalchemy import case, func 

10 

11from ...database.models import ( 

12 Journal, 

13 Paper, 

14 PaperAppearance, 

15 RateLimitEstimate, 

16 ResearchHistory, 

17 ResearchRating, 

18 ResearchResource, 

19 ResearchStrategy, 

20 TokenUsage, 

21) 

22from ...constants import get_available_strategies 

23from ...domain_classifier import DomainClassifier, DomainClassification 

24from ...database.session_context import get_user_db_session 

25from ...metrics import TokenCounter 

26from ...metrics.query_utils import ( 

27 get_context_overflow_truncation_summary, 

28 get_period_days, 

29 get_time_filter_condition, 

30) 

31from ...metrics.search_tracker import get_search_tracker 

32from ...security.decorators import require_json_body 

33from ...security.rate_limiter import journal_data_limit, journals_read_limit 

34from ..auth.decorators import login_required 

35from ..utils.templates import render_template_with_defaults 

36 

37# Create a Blueprint for metrics 

38metrics_bp = Blueprint("metrics", __name__, url_prefix="/metrics") 

39 

40# NOTE: Routes use flask_session["username"] (not .get()) intentionally. 

41# @login_required guarantees the key exists; direct access fails fast 

42# if the decorator is ever removed. 

43 

44 

45def _extract_domain(url): 

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

47 try: 

48 parsed = urlparse(url) 

49 domain = parsed.netloc.lower() 

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

51 domain = domain[4:] 

52 return domain if domain else None 

53 except (ValueError, AttributeError, TypeError): 

54 return None 

55 

56 

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

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

59 try: 

60 if not username: 

61 username = flask_session.get("username") 

62 

63 if not username: 

64 return { 

65 "rating_analytics": { 

66 "avg_rating": None, 

67 "total_ratings": 0, 

68 "rating_distribution": {}, 

69 "satisfaction_stats": { 

70 "very_satisfied": 0, 

71 "satisfied": 0, 

72 "neutral": 0, 

73 "dissatisfied": 0, 

74 "very_dissatisfied": 0, 

75 }, 

76 "error": "No user session", 

77 } 

78 } 

79 

80 # Calculate date range 

81 days = get_period_days(period) 

82 

83 with get_user_db_session(username) as session: 

84 query = session.query(ResearchRating) 

85 

86 # Apply time filter 

87 if days: 

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

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

90 

91 # Get all ratings 

92 ratings = query.all() 

93 

94 if not ratings: 

95 return { 

96 "rating_analytics": { 

97 "avg_rating": None, 

98 "total_ratings": 0, 

99 "rating_distribution": {}, 

100 "satisfaction_stats": { 

101 "very_satisfied": 0, 

102 "satisfied": 0, 

103 "neutral": 0, 

104 "dissatisfied": 0, 

105 "very_dissatisfied": 0, 

106 }, 

107 } 

108 } 

109 

110 # Calculate statistics 

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

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

113 

114 # Rating distribution 

115 rating_counts = {} 

116 for i in range(1, 6): 

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

118 

119 # Satisfaction categories 

120 satisfaction_stats = { 

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

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

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

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

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

126 } 

127 

128 return { 

129 "rating_analytics": { 

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

131 "total_ratings": len(ratings), 

132 "rating_distribution": rating_counts, 

133 "satisfaction_stats": satisfaction_stats, 

134 } 

135 } 

136 

137 except Exception: 

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

139 return { 

140 "rating_analytics": { 

141 "avg_rating": None, 

142 "total_ratings": 0, 

143 "rating_distribution": {}, 

144 "satisfaction_stats": { 

145 "very_satisfied": 0, 

146 "satisfied": 0, 

147 "neutral": 0, 

148 "dissatisfied": 0, 

149 "very_dissatisfied": 0, 

150 }, 

151 } 

152 } 

153 

154 

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

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

157 try: 

158 if not username: 

159 username = flask_session.get("username") 

160 

161 if not username: 

162 return { 

163 "link_analytics": { 

164 "top_domains": [], 

165 "total_unique_domains": 0, 

166 "avg_links_per_research": 0, 

167 "domain_distribution": {}, 

168 "source_type_analysis": {}, 

169 "academic_vs_general": {}, 

170 "total_links": 0, 

171 "error": "No user session", 

172 } 

173 } 

174 

175 # Calculate date range 

176 days = get_period_days(period) 

177 

178 with get_user_db_session(username) as session: 

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

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

181 # boolean for whether content_preview is present. Loading full 

182 # ``ResearchResource`` entities would materialize the 

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

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

185 has_preview = ( 

186 ResearchResource.content_preview.isnot(None) 

187 & (ResearchResource.content_preview != "") 

188 ).label("has_preview") 

189 query = session.query( 

190 ResearchResource.url, 

191 ResearchResource.research_id, 

192 ResearchResource.created_at, 

193 ResearchResource.source_type, 

194 ResearchResource.title, 

195 has_preview, 

196 ) 

197 

198 # Apply time filter 

199 if days: 

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

201 query = query.filter( 

202 ResearchResource.created_at >= cutoff_date.isoformat() 

203 ) 

204 

205 # Get all resources 

206 resources = query.all() 

207 

208 if not resources: 

209 return { 

210 "link_analytics": { 

211 "top_domains": [], 

212 "total_unique_domains": 0, 

213 "avg_links_per_research": 0, 

214 "domain_distribution": {}, 

215 "source_type_analysis": {}, 

216 "academic_vs_general": {}, 

217 "total_links": 0, 

218 } 

219 } 

220 

221 # Extract domains from URLs 

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

223 domain_researches: dict[ 

224 str, Any 

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

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

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

228 domain_connections: dict[ 

229 str, Any 

230 ] = {} # Track domain co-occurrences 

231 

232 # Generic category counting from LLM classifications 

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

234 

235 quality_metrics = { 

236 "with_title": 0, 

237 "with_preview": 0, 

238 "with_both": 0, 

239 "total": 0, 

240 } 

241 

242 # First pass: collect all domains from resources 

243 all_domains = set() 

244 for resource in resources: 

245 if resource.url: 

246 domain = _extract_domain(resource.url) 

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

248 all_domains.add(domain) 

249 

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

251 domain_classifications_map = {} 

252 if all_domains: 

253 all_classifications = ( 

254 session.query(DomainClassification) 

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

256 .all() 

257 ) 

258 for classification in all_classifications: 

259 domain_classifications_map[classification.domain] = ( 

260 classification 

261 ) 

262 

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

264 for resource in resources: 

265 if resource.url: 

266 try: 

267 domain = _extract_domain(resource.url) 

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

269 continue 

270 

271 # Count domains 

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

273 

274 # Track research IDs for each domain 

275 if domain not in domain_researches: 

276 domain_researches[domain] = set() 

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

278 

279 # Track temporal data (daily counts) 

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

281 date_str = resource.created_at[ 

282 :10 

283 ] # Extract YYYY-MM-DD 

284 temporal_data[date_str] = ( 

285 temporal_data.get(date_str, 0) + 1 

286 ) 

287 

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

289 classification = domain_classifications_map.get(domain) 

290 if classification: 

291 category = classification.category 

292 category_counts[category] = ( 

293 category_counts.get(category, 0) + 1 

294 ) 

295 else: 

296 category_counts["Unclassified"] = ( 

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

298 ) 

299 

300 # Track source type from metadata if available 

301 if resource.source_type: 

302 source_types[resource.source_type] = ( 

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

304 ) 

305 

306 # Track quality metrics 

307 quality_metrics["total"] += 1 

308 if resource.title: 

309 quality_metrics["with_title"] += 1 

310 if resource.has_preview: 

311 quality_metrics["with_preview"] += 1 

312 if resource.title and resource.has_preview: 

313 quality_metrics["with_both"] += 1 

314 

315 # Track domain co-occurrences for network visualization 

316 research_id = resource.research_id 

317 if research_id not in domain_connections: 

318 domain_connections[research_id] = [] 

319 domain_connections[research_id].append(domain) 

320 

321 except Exception: 

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

323 

324 # Sort domains by count and get top 10 

325 sorted_domains = sorted( 

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

327 ) 

328 top_10_domains = sorted_domains[:10] 

329 

330 # Calculate domain distribution (top domains vs others) 

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

332 others_count = len(resources) - top_10_count 

333 

334 # Get unique research IDs to calculate average 

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

336 avg_links = ( 

337 len(resources) / len(unique_research_ids) 

338 if unique_research_ids 

339 else 0 

340 ) 

341 

342 # Prepare temporal trend data (sorted by date) 

343 temporal_trend = sorted( 

344 [ 

345 {"date": date, "count": count} 

346 for date, count in temporal_data.items() 

347 ], 

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

349 ) 

350 

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

352 domain_recent_research = {} 

353 # Build domain_classifications dict from pre-loaded data 

354 domain_classifications = { 

355 domain: { 

356 "category": classification.category, 

357 "subcategory": classification.subcategory, 

358 "confidence": classification.confidence, 

359 } 

360 for domain, classification in domain_classifications_map.items() 

361 } 

362 

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

364 all_research_ids = [] 

365 domain_research_id_lists = {} 

366 for domain, _ in top_10_domains: 

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

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

369 domain_research_id_lists[domain] = ids 

370 all_research_ids.extend(ids) 

371 

372 research_by_id = {} 

373 if all_research_ids: 

374 researches = ( 

375 session.query(ResearchHistory) 

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

377 .all() 

378 ) 

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

380 

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

382 domain_recent_research[domain] = [ 

383 { 

384 "id": r_id, 

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

386 if research_by_id.get(r_id) 

387 and research_by_id[r_id].query 

388 else "Research", 

389 } 

390 for r_id in ids 

391 if r_id in research_by_id 

392 ] 

393 

394 return { 

395 "link_analytics": { 

396 "top_domains": [ 

397 { 

398 "domain": domain, 

399 "count": count, 

400 "percentage": round( 

401 count / len(resources) * 100, 1 

402 ), 

403 "research_count": len( 

404 domain_researches.get(domain, set()) 

405 ), 

406 "recent_researches": domain_recent_research.get( 

407 domain, [] 

408 ), 

409 "classification": domain_classifications.get( 

410 domain, None 

411 ), 

412 } 

413 for domain, count in top_10_domains 

414 ], 

415 "total_unique_domains": len(domain_counts), 

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

417 "domain_distribution": { 

418 "top_10": top_10_count, 

419 "others": others_count, 

420 }, 

421 "source_type_analysis": source_types, 

422 "category_distribution": category_counts, 

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

424 "domain_categories": category_counts, 

425 "total_links": len(resources), 

426 "total_researches": len(unique_research_ids), 

427 "temporal_trend": temporal_trend, 

428 "domain_metrics": { 

429 domain: { 

430 "usage_count": count, 

431 "usage_percentage": round( 

432 count / len(resources) * 100, 1 

433 ), 

434 "research_diversity": len( 

435 domain_researches.get(domain, set()) 

436 ), 

437 "frequency_rank": rank + 1, 

438 } 

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

440 }, 

441 } 

442 } 

443 

444 except Exception: 

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

446 return { 

447 "link_analytics": { 

448 "top_domains": [], 

449 "total_unique_domains": 0, 

450 "avg_links_per_research": 0, 

451 "domain_distribution": {}, 

452 "source_type_analysis": {}, 

453 "academic_vs_general": {}, 

454 "total_links": 0, 

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

456 } 

457 } 

458 

459 

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

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

462 try: 

463 if not username: 

464 username = flask_session.get("username") 

465 

466 if not username: 

467 return { 

468 "strategy_analytics": { 

469 "total_research_with_strategy": 0, 

470 "total_research": 0, 

471 "most_popular_strategy": None, 

472 "strategy_usage": [], 

473 "strategy_distribution": {}, 

474 "available_strategies": get_available_strategies(), 

475 "error": "No user session", 

476 } 

477 } 

478 

479 # Calculate date range 

480 days = get_period_days(period) 

481 

482 with get_user_db_session(username) as session: 

483 # Check if we have any ResearchStrategy records 

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

485 

486 if strategy_count == 0: 

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

488 return { 

489 "strategy_analytics": { 

490 "total_research_with_strategy": 0, 

491 "total_research": 0, 

492 "most_popular_strategy": None, 

493 "strategy_usage": [], 

494 "strategy_distribution": {}, 

495 "available_strategies": get_available_strategies(), 

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

497 } 

498 } 

499 

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

501 query = session.query( 

502 ResearchStrategy.strategy_name, 

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

504 ) 

505 

506 # Apply time filter if specified 

507 if days: 

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

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

510 

511 # Group by strategy and order by usage 

512 strategy_results = ( 

513 query.group_by(ResearchStrategy.strategy_name) 

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

515 .all() 

516 ) 

517 

518 # Get total strategy count for percentage calculation 

519 total_query = session.query(ResearchStrategy) 

520 if days: 

521 total_query = total_query.filter( 

522 ResearchStrategy.created_at >= cutoff_date 

523 ) 

524 total_research = total_query.count() 

525 

526 # Format strategy data 

527 strategy_usage = [] 

528 strategy_distribution = {} 

529 

530 for strategy_name, usage_count in strategy_results: 

531 percentage = ( 

532 (usage_count / total_research * 100) 

533 if total_research > 0 

534 else 0 

535 ) 

536 strategy_usage.append( 

537 { 

538 "strategy": strategy_name, 

539 "count": usage_count, 

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

541 } 

542 ) 

543 strategy_distribution[strategy_name] = usage_count 

544 

545 # Find most popular strategy 

546 most_popular = ( 

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

548 ) 

549 

550 return { 

551 "strategy_analytics": { 

552 "total_research_with_strategy": sum( 

553 item["count"] for item in strategy_usage 

554 ), 

555 "total_research": total_research, 

556 "most_popular_strategy": most_popular, 

557 "strategy_usage": strategy_usage, 

558 "strategy_distribution": strategy_distribution, 

559 "available_strategies": get_available_strategies(), 

560 } 

561 } 

562 

563 except Exception: 

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

565 return { 

566 "strategy_analytics": { 

567 "total_research_with_strategy": 0, 

568 "total_research": 0, 

569 "most_popular_strategy": None, 

570 "strategy_usage": [], 

571 "strategy_distribution": {}, 

572 "available_strategies": get_available_strategies(), 

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

574 } 

575 } 

576 

577 

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

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

580 try: 

581 if not username: 

582 username = flask_session.get("username") 

583 

584 if not username: 

585 return { 

586 "rate_limiting": { 

587 "total_attempts": 0, 

588 "successful_attempts": 0, 

589 "failed_attempts": 0, 

590 "success_rate": 0, 

591 "rate_limit_events": 0, 

592 "avg_wait_time": 0, 

593 "avg_successful_wait": 0, 

594 "tracked_engines": 0, 

595 "engine_stats": [], 

596 "total_engines_tracked": 0, 

597 "healthy_engines": 0, 

598 "degraded_engines": 0, 

599 "poor_engines": 0, 

600 "error": "No user session", 

601 } 

602 } 

603 

604 # Calculate date range for timestamp filtering 

605 import time 

606 

607 if period == "7d": 

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

609 elif period == "30d": 

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

611 elif period == "3m": 

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

613 elif period == "1y": 

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

615 else: # all 

616 cutoff_time = 0 

617 

618 with get_user_db_session(username) as session: 

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

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

621 # actually persists. The raw per-attempt table 

622 # (RateLimitAttempt) is intentionally NOT written — attempt 

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

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

625 # read RateLimitAttempt, always returned an empty panel. 

626 # 

627 # Limitations of deriving from estimates (documented so the 

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

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

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

631 # a lifetime count. 

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

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

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

635 estimates_query = session.query(RateLimitEstimate) 

636 

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

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

639 if cutoff_time > 0: 

640 estimates_query = estimates_query.filter( 

641 RateLimitEstimate.last_updated >= cutoff_time 

642 ) 

643 

644 estimates = estimates_query.all() 

645 

646 engine_stats = [] 

647 total_attempts = 0 

648 successful_attempts = 0 

649 base_wait_sum = 0.0 

650 

651 for estimate in estimates: 

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

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

654 engine_attempts = estimate.total_attempts or 0 

655 engine_success = round(engine_attempts * estimate.success_rate) 

656 

657 total_attempts += engine_attempts 

658 successful_attempts += engine_success 

659 base_wait_sum += estimate.base_wait_seconds 

660 

661 status = ( 

662 "healthy" 

663 if estimate.success_rate > 0.8 

664 else "degraded" 

665 if estimate.success_rate > 0.5 

666 else "poor" 

667 ) 

668 

669 engine_stats.append( 

670 { 

671 "engine": estimate.engine_type, 

672 "base_wait": estimate.base_wait_seconds, 

673 "base_wait_seconds": round( 

674 estimate.base_wait_seconds, 2 

675 ), 

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

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

678 "success_rate": success_rate_pct, 

679 "total_attempts": engine_attempts, 

680 "recent_attempts": engine_attempts, 

681 "recent_success_rate": success_rate_pct, 

682 "attempts": engine_attempts, 

683 "status": status, 

684 # ISO format already includes timezone 

685 "last_updated": datetime.fromtimestamp( 

686 estimate.last_updated, UTC 

687 ).isoformat(), 

688 } 

689 ) 

690 

691 tracked_engines = len(engine_stats) 

692 failed_attempts = total_attempts - successful_attempts 

693 # base_wait_seconds is the learned optimal (median of recent 

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

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

696 # the raw attempts table. 

697 avg_wait_time = ( 

698 base_wait_sum / tracked_engines if tracked_engines else 0 

699 ) 

700 avg_successful_wait = avg_wait_time 

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

702 rate_limit_events = 0 

703 

704 logger.info( 

705 f"Rate limiting analytics from estimates: " 

706 f"tracked_engines={tracked_engines}, " 

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

708 ) 

709 

710 result = { 

711 "rate_limiting": { 

712 "total_attempts": total_attempts, 

713 "successful_attempts": successful_attempts, 

714 "failed_attempts": failed_attempts, 

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

716 if total_attempts > 0 

717 else 0, 

718 "rate_limit_events": rate_limit_events, 

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

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

721 "tracked_engines": tracked_engines, 

722 "engine_stats": engine_stats, 

723 "total_engines_tracked": tracked_engines, 

724 "healthy_engines": len( 

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

726 ), 

727 "degraded_engines": len( 

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

729 ), 

730 "poor_engines": len( 

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

732 ), 

733 } 

734 } 

735 

736 logger.info( 

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

738 ) 

739 return result 

740 

741 except Exception: 

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

743 return { 

744 "rate_limiting": { 

745 "total_attempts": 0, 

746 "successful_attempts": 0, 

747 "failed_attempts": 0, 

748 "success_rate": 0, 

749 "rate_limit_events": 0, 

750 "avg_wait_time": 0, 

751 "avg_successful_wait": 0, 

752 "tracked_engines": 0, 

753 "engine_stats": [], 

754 "total_engines_tracked": 0, 

755 "healthy_engines": 0, 

756 "degraded_engines": 0, 

757 "poor_engines": 0, 

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

759 } 

760 } 

761 

762 

763@metrics_bp.route("/") 

764@login_required 

765def metrics_dashboard(): 

766 """Render the metrics dashboard page.""" 

767 return render_template_with_defaults("pages/metrics.html") 

768 

769 

770@metrics_bp.route("/context-overflow") 

771@login_required 

772def context_overflow_page(): 

773 """Context overflow analytics page.""" 

774 return render_template_with_defaults("pages/context_overflow.html") 

775 

776 

777@metrics_bp.route("/api/metrics") 

778@login_required 

779def api_metrics(): 

780 """Get overall metrics data.""" 

781 logger.debug("api_metrics endpoint called") 

782 try: 

783 # Get username from session 

784 username = flask_session["username"] 

785 

786 # Get time period and research mode from query parameters 

787 period = request.args.get("period", "30d") 

788 research_mode = request.args.get("mode", "all") 

789 

790 token_counter = TokenCounter() 

791 search_tracker = get_search_tracker() 

792 

793 # Get both token and search metrics 

794 token_metrics = token_counter.get_overall_metrics( 

795 period=period, research_mode=research_mode 

796 ) 

797 search_metrics = search_tracker.get_search_metrics( 

798 period=period, 

799 research_mode=research_mode, 

800 username=username, 

801 ) 

802 

803 # Get user satisfaction rating data 

804 try: 

805 with get_user_db_session(username) as session: 

806 # Build base query with time filter 

807 ratings_query = session.query(ResearchRating) 

808 time_condition = get_time_filter_condition( 

809 period, ResearchRating.created_at 

810 ) 

811 if time_condition is not None: 

812 ratings_query = ratings_query.filter(time_condition) 

813 

814 # Get average rating 

815 avg_rating = ratings_query.with_entities( 

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

817 ).scalar() 

818 

819 # Get total rating count 

820 total_ratings = ratings_query.count() 

821 

822 user_satisfaction = { 

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

824 "total_ratings": total_ratings, 

825 } 

826 except Exception: 

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

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

829 

830 # Get strategy analytics 

831 strategy_data = get_strategy_analytics(period, username) 

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

833 

834 # Get rate limiting analytics 

835 rate_limiting_data = get_rate_limiting_analytics(period, username) 

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

837 logger.debug( 

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

839 ) 

840 

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

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

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

844 context_overflow_data = { 

845 "truncation_rate": None, 

846 "avg_tokens_truncated": None, 

847 } 

848 try: 

849 with get_user_db_session(username) as session: 

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

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

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

853 summary = get_context_overflow_truncation_summary( 

854 session, period, research_mode=research_mode 

855 ) 

856 context_overflow_data = { 

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

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

859 } 

860 except Exception: 

861 logger.exception( 

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

863 ) 

864 

865 # Combine metrics 

866 combined_metrics = { 

867 **token_metrics, 

868 **search_metrics, 

869 **strategy_data, 

870 **rate_limiting_data, 

871 **context_overflow_data, 

872 "user_satisfaction": user_satisfaction, 

873 } 

874 

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

876 logger.debug( 

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

878 ) 

879 

880 return jsonify( 

881 { 

882 "status": "success", 

883 "metrics": combined_metrics, 

884 "period": period, 

885 "research_mode": research_mode, 

886 } 

887 ) 

888 except Exception: 

889 logger.exception("Error getting metrics") 

890 return ( 

891 jsonify( 

892 { 

893 "status": "error", 

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

895 } 

896 ), 

897 500, 

898 ) 

899 

900 

901@metrics_bp.route("/api/rate-limiting") 

902@login_required 

903def api_rate_limiting_metrics(): 

904 """Get detailed rate limiting metrics.""" 

905 # KNOWN-DEFERRED: debug log left in during development. Not harmful 

906 # (no PII, just marks endpoint entry) but noisy — post-merge cleanup. 

907 logger.info("DEBUG: api_rate_limiting_metrics endpoint called") 

908 try: 

909 username = flask_session["username"] 

910 period = request.args.get("period", "30d") 

911 rate_limiting_data = get_rate_limiting_analytics(period, username) 

912 

913 return jsonify( 

914 {"status": "success", "data": rate_limiting_data, "period": period} 

915 ) 

916 except Exception: 

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

918 return jsonify( 

919 { 

920 "status": "error", 

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

922 } 

923 ), 500 

924 

925 

926@metrics_bp.route("/api/rate-limiting/current") 

927@login_required 

928def api_current_rate_limits(): 

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

930 try: 

931 username = flask_session["username"] 

932 

933 with get_user_db_session(username) as session: 

934 estimates = ( 

935 session.query(RateLimitEstimate) 

936 .order_by(RateLimitEstimate.engine_type) 

937 .all() 

938 ) 

939 

940 current_limits = [] 

941 for est in estimates: 

942 current_limits.append( 

943 { 

944 "engine_type": est.engine_type, 

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

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

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

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

949 "total_attempts": est.total_attempts, 

950 "last_updated": datetime.fromtimestamp( 

951 est.last_updated, UTC 

952 ).isoformat(), 

953 "status": "healthy" 

954 if est.success_rate > 0.8 

955 else "degraded" 

956 if est.success_rate > 0.5 

957 else "poor", 

958 } 

959 ) 

960 

961 return jsonify( 

962 { 

963 "status": "success", 

964 "current_limits": current_limits, 

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

966 } 

967 ) 

968 except Exception: 

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

970 return jsonify( 

971 { 

972 "status": "error", 

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

974 } 

975 ), 500 

976 

977 

978@metrics_bp.route("/api/metrics/research/<string:research_id>/links") 

979@login_required 

980def api_research_link_metrics(research_id): 

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

982 try: 

983 username = flask_session["username"] 

984 

985 with get_user_db_session(username) as session: 

986 # Get all resources for this specific research 

987 resources = ( 

988 session.query(ResearchResource) 

989 .filter(ResearchResource.research_id == research_id) 

990 .all() 

991 ) 

992 

993 if not resources: 

994 return jsonify( 

995 { 

996 "status": "success", 

997 "data": { 

998 "total_links": 0, 

999 "unique_domains": 0, 

1000 "domains": [], 

1001 "category_distribution": {}, 

1002 "domain_categories": {}, 

1003 "resources": [], 

1004 }, 

1005 } 

1006 ) 

1007 

1008 # Extract domain information 

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

1010 

1011 # Generic category counting from LLM classifications 

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

1013 

1014 # First pass: collect all domains 

1015 all_domains = set() 

1016 for resource in resources: 

1017 if resource.url: 1017 ↛ 1016line 1017 didn't jump to line 1016 because the condition on line 1017 was always true

1018 domain = _extract_domain(resource.url) 

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

1020 all_domains.add(domain) 

1021 

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

1023 domain_classifications_map = {} 

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

1025 all_classifications = ( 

1026 session.query(DomainClassification) 

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

1028 .all() 

1029 ) 

1030 for classification in all_classifications: 

1031 domain_classifications_map[classification.domain] = ( 

1032 classification 

1033 ) 

1034 

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

1036 for resource in resources: 

1037 if resource.url: 1037 ↛ 1036line 1037 didn't jump to line 1036 because the condition on line 1037 was always true

1038 try: 

1039 domain = _extract_domain(resource.url) 

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

1041 continue 

1042 

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

1044 

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

1046 classification = domain_classifications_map.get(domain) 

1047 if classification: 

1048 category = classification.category 

1049 category_counts[category] = ( 

1050 category_counts.get(category, 0) + 1 

1051 ) 

1052 else: 

1053 category_counts["Unclassified"] = ( 

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

1055 ) 

1056 except (AttributeError, KeyError) as e: 

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

1058 

1059 # Sort domains by count 

1060 sorted_domains = sorted( 

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

1062 ) 

1063 

1064 return jsonify( 

1065 { 

1066 "status": "success", 

1067 "data": { 

1068 "total_links": len(resources), 

1069 "unique_domains": len(domain_counts), 

1070 "domains": [ 

1071 { 

1072 "domain": domain, 

1073 "count": count, 

1074 "percentage": round( 

1075 count / len(resources) * 100, 1 

1076 ), 

1077 } 

1078 for domain, count in sorted_domains[ 

1079 :20 

1080 ] # Top 20 domains 

1081 ], 

1082 "category_distribution": category_counts, 

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

1084 "resources": [ 

1085 { 

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

1087 "url": r.url, 

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

1089 if r.content_preview 

1090 else None, 

1091 } 

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

1093 ], 

1094 }, 

1095 } 

1096 ) 

1097 

1098 except Exception: 

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

1100 return jsonify( 

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

1102 ), 500 

1103 

1104 

1105@metrics_bp.route("/api/metrics/research/<string:research_id>") 

1106@login_required 

1107def api_research_metrics(research_id): 

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

1109 try: 

1110 token_counter = TokenCounter() 

1111 metrics = token_counter.get_research_metrics(research_id) 

1112 return jsonify({"status": "success", "metrics": metrics}) 

1113 except Exception: 

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

1115 return ( 

1116 jsonify( 

1117 { 

1118 "status": "error", 

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

1120 } 

1121 ), 

1122 500, 

1123 ) 

1124 

1125 

1126@metrics_bp.route("/api/metrics/research/<string:research_id>/timeline") 

1127@login_required 

1128def api_research_timeline_metrics(research_id): 

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

1130 try: 

1131 token_counter = TokenCounter() 

1132 timeline_metrics = token_counter.get_research_timeline_metrics( 

1133 research_id 

1134 ) 

1135 return jsonify({"status": "success", "metrics": timeline_metrics}) 

1136 except Exception: 

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

1138 return ( 

1139 jsonify( 

1140 { 

1141 "status": "error", 

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

1143 } 

1144 ), 

1145 500, 

1146 ) 

1147 

1148 

1149@metrics_bp.route("/api/metrics/research/<string:research_id>/search") 

1150@login_required 

1151def api_research_search_metrics(research_id): 

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

1153 try: 

1154 username = flask_session["username"] 

1155 search_tracker = get_search_tracker() 

1156 search_metrics = search_tracker.get_research_search_metrics( 

1157 research_id, username=username 

1158 ) 

1159 return jsonify({"status": "success", "metrics": search_metrics}) 

1160 except Exception: 

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

1162 return ( 

1163 jsonify( 

1164 { 

1165 "status": "error", 

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

1167 } 

1168 ), 

1169 500, 

1170 ) 

1171 

1172 

1173@metrics_bp.route("/api/metrics/enhanced") 

1174@login_required 

1175def api_enhanced_metrics(): 

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

1177 try: 

1178 # Get time period and research mode from query parameters 

1179 period = request.args.get("period", "30d") 

1180 research_mode = request.args.get("mode", "all") 

1181 username = flask_session["username"] 

1182 

1183 token_counter = TokenCounter() 

1184 search_tracker = get_search_tracker() 

1185 

1186 enhanced_metrics = token_counter.get_enhanced_metrics( 

1187 period=period, research_mode=research_mode 

1188 ) 

1189 

1190 # Add search time series data for the chart 

1191 search_time_series = search_tracker.get_search_time_series( 

1192 period=period, 

1193 research_mode=research_mode, 

1194 username=username, 

1195 ) 

1196 enhanced_metrics["search_time_series"] = search_time_series 

1197 

1198 # Add rating analytics 

1199 rating_analytics = get_rating_analytics(period, research_mode, username) 

1200 enhanced_metrics.update(rating_analytics) 

1201 

1202 return jsonify( 

1203 { 

1204 "status": "success", 

1205 "metrics": enhanced_metrics, 

1206 "period": period, 

1207 "research_mode": research_mode, 

1208 } 

1209 ) 

1210 except Exception: 

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

1212 return ( 

1213 jsonify( 

1214 { 

1215 "status": "error", 

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

1217 } 

1218 ), 

1219 500, 

1220 ) 

1221 

1222 

1223@metrics_bp.route("/api/ratings/<string:research_id>", methods=["GET"]) 

1224@login_required 

1225def api_get_research_rating(research_id): 

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

1227 try: 

1228 username = flask_session["username"] 

1229 

1230 with get_user_db_session(username) as session: 

1231 rating = ( 

1232 session.query(ResearchRating) 

1233 .filter_by(research_id=research_id) 

1234 .first() 

1235 ) 

1236 

1237 if rating: 

1238 return jsonify( 

1239 { 

1240 "status": "success", 

1241 "rating": rating.rating, 

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

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

1244 } 

1245 ) 

1246 return jsonify({"status": "success", "rating": None}) 

1247 

1248 except Exception: 

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

1250 return ( 

1251 jsonify( 

1252 { 

1253 "status": "error", 

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

1255 } 

1256 ), 

1257 500, 

1258 ) 

1259 

1260 

1261@metrics_bp.route("/api/ratings/<string:research_id>", methods=["POST"]) 

1262@login_required 

1263@require_json_body(error_format="status") 

1264def api_save_research_rating(research_id): 

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

1266 try: 

1267 username = flask_session["username"] 

1268 

1269 data = request.get_json() 

1270 rating_value = data.get("rating") 

1271 

1272 if ( 

1273 not isinstance(rating_value, int) 

1274 or isinstance(rating_value, bool) 

1275 or rating_value < 1 

1276 or rating_value > 5 

1277 ): 

1278 return ( 

1279 jsonify( 

1280 { 

1281 "status": "error", 

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

1283 } 

1284 ), 

1285 400, 

1286 ) 

1287 

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

1289 sub_dimensions = {} 

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

1291 val = data.get(field) 

1292 if val is not None: 

1293 if ( 

1294 not isinstance(val, int) 

1295 or isinstance(val, bool) 

1296 or val < 1 

1297 or val > 5 

1298 ): 

1299 return ( 

1300 jsonify( 

1301 { 

1302 "status": "error", 

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

1304 } 

1305 ), 

1306 400, 

1307 ) 

1308 sub_dimensions[field] = val 

1309 

1310 feedback_text = data.get("feedback") 

1311 if feedback_text is not None: 

1312 if not isinstance(feedback_text, str): 

1313 return ( 

1314 jsonify( 

1315 { 

1316 "status": "error", 

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

1318 } 

1319 ), 

1320 400, 

1321 ) 

1322 if len(feedback_text) > 10000: 

1323 return ( 

1324 jsonify( 

1325 { 

1326 "status": "error", 

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

1328 } 

1329 ), 

1330 400, 

1331 ) 

1332 

1333 with get_user_db_session(username) as session: 

1334 # Check if rating already exists 

1335 existing_rating = ( 

1336 session.query(ResearchRating) 

1337 .filter_by(research_id=research_id) 

1338 .first() 

1339 ) 

1340 

1341 if existing_rating: 

1342 # Update existing rating 

1343 existing_rating.rating = rating_value 

1344 existing_rating.updated_at = func.now() 

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

1346 setattr(existing_rating, field, val) 

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

1348 existing_rating.feedback = feedback_text 

1349 else: 

1350 # Create new rating 

1351 new_rating = ResearchRating( 

1352 research_id=research_id, 

1353 rating=rating_value, 

1354 feedback=feedback_text, 

1355 **sub_dimensions, 

1356 ) 

1357 session.add(new_rating) 

1358 

1359 session.commit() 

1360 

1361 return jsonify( 

1362 { 

1363 "status": "success", 

1364 "message": "Rating saved successfully", 

1365 "rating": rating_value, 

1366 } 

1367 ) 

1368 

1369 except Exception: 

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

1371 return ( 

1372 jsonify( 

1373 { 

1374 "status": "error", 

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

1376 } 

1377 ), 

1378 500, 

1379 ) 

1380 

1381 

1382@metrics_bp.route("/star-reviews") 

1383@login_required 

1384def star_reviews(): 

1385 """Display star reviews metrics page.""" 

1386 return render_template_with_defaults("pages/star_reviews.html") 

1387 

1388 

1389@metrics_bp.route("/costs") 

1390@login_required 

1391def cost_analytics(): 

1392 """Display cost analytics page.""" 

1393 return render_template_with_defaults("pages/cost_analytics.html") 

1394 

1395 

1396@metrics_bp.route("/api/star-reviews") 

1397@login_required 

1398def api_star_reviews(): 

1399 """Get star reviews analytics data.""" 

1400 try: 

1401 username = flask_session["username"] 

1402 

1403 period = request.args.get("period", "30d") 

1404 

1405 with get_user_db_session(username) as session: 

1406 # Build base query with time filter 

1407 base_query = session.query(ResearchRating) 

1408 time_condition = get_time_filter_condition( 

1409 period, ResearchRating.created_at 

1410 ) 

1411 if time_condition is not None: 

1412 base_query = base_query.filter(time_condition) 

1413 

1414 # Overall rating statistics 

1415 overall_stats = session.query( 

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

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

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

1419 "five_star" 

1420 ), 

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

1422 "four_star" 

1423 ), 

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

1425 "three_star" 

1426 ), 

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

1428 "two_star" 

1429 ), 

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

1431 "one_star" 

1432 ), 

1433 ) 

1434 

1435 if time_condition is not None: 

1436 overall_stats = overall_stats.filter(time_condition) 

1437 

1438 overall_stats = overall_stats.first() 

1439 

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

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

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

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

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

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

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

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

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

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

1450 normalized_model = case( 

1451 ( 

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

1453 ["", "unknown"] 

1454 ), 

1455 "Unknown", 

1456 ), 

1457 else_=TokenUsage.model_name, 

1458 ) 

1459 research_models = ( 

1460 session.query( 

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

1462 normalized_model.label("model_name"), 

1463 ) 

1464 .distinct() 

1465 .subquery() 

1466 ) 

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

1468 # outerjoin -> "Unknown". 

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

1470 

1471 llm_ratings_query = ( 

1472 session.query( 

1473 model_label.label("model"), 

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

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

1476 func.sum( 

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

1478 ).label("positive_ratings"), 

1479 func.sum( 

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

1481 ).label("neutral_ratings"), 

1482 func.sum( 

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

1484 ).label("negative_ratings"), 

1485 ) 

1486 .select_from(ResearchRating) 

1487 .outerjoin( 

1488 research_models, 

1489 ResearchRating.research_id == research_models.c.research_id, 

1490 ) 

1491 ) 

1492 

1493 if time_condition is not None: 

1494 llm_ratings_query = llm_ratings_query.filter(time_condition) 

1495 

1496 llm_ratings = ( 

1497 llm_ratings_query.group_by(model_label) 

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

1499 .all() 

1500 ) 

1501 

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

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

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

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

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

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

1508 # "Unknown" exactly once. 

1509 research_engines = ( 

1510 session.query( 

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

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

1513 ) 

1514 .filter( 

1515 func.trim( 

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

1517 ) 

1518 != "" 

1519 ) 

1520 .distinct() 

1521 .subquery() 

1522 ) 

1523 engine_label = func.coalesce( 

1524 research_engines.c.search_engine, "Unknown" 

1525 ) 

1526 

1527 search_engine_ratings_query = ( 

1528 session.query( 

1529 engine_label.label("search_engine"), 

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

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

1532 func.sum( 

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

1534 ).label("positive_ratings"), 

1535 ) 

1536 .select_from(ResearchRating) 

1537 .outerjoin( 

1538 research_engines, 

1539 ResearchRating.research_id 

1540 == research_engines.c.research_id, 

1541 ) 

1542 ) 

1543 

1544 if time_condition is not None: 

1545 search_engine_ratings_query = ( 

1546 search_engine_ratings_query.filter(time_condition) 

1547 ) 

1548 

1549 search_engine_ratings = ( 

1550 search_engine_ratings_query.group_by(engine_label) 

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

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

1553 .all() 

1554 ) 

1555 

1556 # Rating trends over time 

1557 rating_trends_query = session.query( 

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

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

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

1561 ) 

1562 

1563 if time_condition is not None: 

1564 rating_trends_query = rating_trends_query.filter(time_condition) 

1565 

1566 rating_trends = ( 

1567 rating_trends_query.group_by( 

1568 func.date(ResearchRating.created_at) 

1569 ) 

1570 .order_by("date") 

1571 .all() 

1572 ) 

1573 

1574 # Ratings by research mode 

1575 mode_ratings_query = session.query( 

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

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

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

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

1580 "positive_ratings" 

1581 ), 

1582 ).outerjoin( 

1583 ResearchHistory, 

1584 ResearchRating.research_id == ResearchHistory.id, 

1585 ) 

1586 

1587 if time_condition is not None: 

1588 mode_ratings_query = mode_ratings_query.filter(time_condition) 

1589 

1590 mode_ratings = ( 

1591 mode_ratings_query.group_by(ResearchHistory.mode) 

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

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

1594 .all() 

1595 ) 

1596 

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

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

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

1600 # dimensions have data. 

1601 dimension_stats = session.query( 

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

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

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

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

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

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

1608 "count_completeness" 

1609 ), 

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

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

1612 "count_readability" 

1613 ), 

1614 func.count( 

1615 func.coalesce( 

1616 ResearchRating.accuracy, 

1617 ResearchRating.completeness, 

1618 ResearchRating.relevance, 

1619 ResearchRating.readability, 

1620 ) 

1621 ).label("dimension_count"), 

1622 ) 

1623 

1624 if time_condition is not None: 

1625 dimension_stats = dimension_stats.filter(time_condition) 

1626 

1627 dimension_stats = dimension_stats.first() 

1628 

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

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

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

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

1633 recent_model_subq = ( 

1634 session.query( 

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

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

1637 ) 

1638 .group_by(TokenUsage.research_id) 

1639 .subquery() 

1640 ) 

1641 

1642 recent_ratings_query = ( 

1643 session.query( 

1644 ResearchRating.rating, 

1645 ResearchRating.created_at, 

1646 ResearchRating.research_id, 

1647 ResearchHistory.query, 

1648 ResearchHistory.mode, 

1649 recent_model_subq.c.model_name, 

1650 ResearchRating.feedback, 

1651 ) 

1652 .outerjoin( 

1653 ResearchHistory, 

1654 ResearchRating.research_id == ResearchHistory.id, 

1655 ) 

1656 .outerjoin( 

1657 recent_model_subq, 

1658 ResearchRating.research_id 

1659 == recent_model_subq.c.research_id, 

1660 ) 

1661 ) 

1662 

1663 if time_condition is not None: 

1664 recent_ratings_query = recent_ratings_query.filter( 

1665 time_condition 

1666 ) 

1667 

1668 recent_ratings = ( 

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

1670 .limit(20) 

1671 .all() 

1672 ) 

1673 

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

1675 recent_feedback_query = session.query( 

1676 ResearchRating.feedback, 

1677 ResearchRating.rating, 

1678 ResearchRating.created_at, 

1679 ResearchRating.research_id, 

1680 ).filter( 

1681 ResearchRating.feedback.isnot(None), 

1682 ResearchRating.feedback != "", 

1683 ) 

1684 

1685 if time_condition is not None: 

1686 recent_feedback_query = recent_feedback_query.filter( 

1687 time_condition 

1688 ) 

1689 

1690 recent_feedback = ( 

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

1692 .limit(20) 

1693 .all() 

1694 ) 

1695 

1696 return jsonify( 

1697 { 

1698 "overall_stats": { 

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

1700 "total_ratings": overall_stats.total_ratings or 0, 

1701 "rating_distribution": { 

1702 "5": overall_stats.five_star or 0, 

1703 "4": overall_stats.four_star or 0, 

1704 "3": overall_stats.three_star or 0, 

1705 "2": overall_stats.two_star or 0, 

1706 "1": overall_stats.one_star or 0, 

1707 }, 

1708 }, 

1709 "llm_ratings": [ 

1710 { 

1711 "model": rating.model, 

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

1713 "rating_count": rating.rating_count or 0, 

1714 "positive_ratings": rating.positive_ratings or 0, 

1715 "neutral_ratings": rating.neutral_ratings or 0, 

1716 "negative_ratings": rating.negative_ratings or 0, 

1717 "satisfaction_rate": round( 

1718 (rating.positive_ratings or 0) 

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

1720 * 100, 

1721 1, 

1722 ), 

1723 } 

1724 for rating in llm_ratings 

1725 ], 

1726 "search_engine_ratings": [ 

1727 { 

1728 "search_engine": rating.search_engine, 

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

1730 "rating_count": rating.rating_count or 0, 

1731 "positive_ratings": rating.positive_ratings or 0, 

1732 "satisfaction_rate": round( 

1733 (rating.positive_ratings or 0) 

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

1735 * 100, 

1736 1, 

1737 ), 

1738 } 

1739 for rating in search_engine_ratings 

1740 ], 

1741 "rating_trends": [ 

1742 { 

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

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

1745 "count": trend.daily_count or 0, 

1746 } 

1747 for trend in rating_trends 

1748 ], 

1749 "recent_ratings": [ 

1750 { 

1751 "rating": rating.rating, 

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

1753 if rating.created_at 

1754 else None, 

1755 "research_id": rating.research_id, 

1756 "query": ( 

1757 rating.query 

1758 if rating.query 

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

1760 ), 

1761 "mode": rating.mode 

1762 if rating.mode 

1763 else "Standard Research", 

1764 "llm_model": ( 

1765 rating.model_name 

1766 if rating.model_name 

1767 else "LLM Model" 

1768 ), 

1769 "feedback": rating.feedback, 

1770 } 

1771 for rating in recent_ratings 

1772 ], 

1773 "mode_ratings": [ 

1774 { 

1775 "mode": rating.mode, 

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

1777 "rating_count": rating.rating_count or 0, 

1778 "satisfaction_rate": round( 

1779 (rating.positive_ratings or 0) 

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

1781 * 100, 

1782 1, 

1783 ), 

1784 } 

1785 for rating in mode_ratings 

1786 ], 

1787 "quality_dimensions": { 

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

1789 if dimension_stats.avg_accuracy is not None 

1790 else None, 

1791 "avg_completeness": round( 

1792 dimension_stats.avg_completeness, 2 

1793 ) 

1794 if dimension_stats.avg_completeness is not None 

1795 else None, 

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

1797 if dimension_stats.avg_relevance is not None 

1798 else None, 

1799 "avg_readability": round( 

1800 dimension_stats.avg_readability, 2 

1801 ) 

1802 if dimension_stats.avg_readability is not None 

1803 else None, 

1804 "dimension_count": dimension_stats.dimension_count or 0, 

1805 "dimension_counts": { 

1806 "accuracy": dimension_stats.count_accuracy or 0, 

1807 "completeness": dimension_stats.count_completeness 

1808 or 0, 

1809 "relevance": dimension_stats.count_relevance or 0, 

1810 "readability": dimension_stats.count_readability 

1811 or 0, 

1812 }, 

1813 }, 

1814 "recent_feedback": [ 

1815 { 

1816 "feedback": rating.feedback, 

1817 "rating": rating.rating, 

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

1819 if rating.created_at 

1820 else None, 

1821 "research_id": rating.research_id, 

1822 } 

1823 for rating in recent_feedback 

1824 ], 

1825 } 

1826 ) 

1827 

1828 except Exception: 

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

1830 return ( 

1831 jsonify( 

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

1833 ), 

1834 500, 

1835 ) 

1836 

1837 

1838@metrics_bp.route("/api/pricing") 

1839@login_required 

1840def api_pricing(): 

1841 """Get current LLM pricing data.""" 

1842 try: 

1843 from ...metrics.pricing.pricing_fetcher import PricingFetcher 

1844 

1845 # Use static pricing data instead of async 

1846 fetcher = PricingFetcher() 

1847 pricing_data = fetcher.static_pricing 

1848 

1849 return jsonify( 

1850 { 

1851 "status": "success", 

1852 "pricing": pricing_data, 

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

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

1855 } 

1856 ) 

1857 

1858 except Exception: 

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

1860 return jsonify({"error": "Internal Server Error"}), 500 

1861 

1862 

1863@metrics_bp.route("/api/pricing/<model_name>") 

1864@login_required 

1865def api_model_pricing(model_name): 

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

1867 try: 

1868 # Optional provider parameter 

1869 provider = request.args.get("provider") 

1870 

1871 from ...metrics.pricing.cost_calculator import CostCalculator 

1872 

1873 # Use synchronous approach with cached/static pricing 

1874 calculator = CostCalculator() 

1875 pricing = calculator.cache.get_model_pricing( 

1876 model_name 

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

1878 "pricing_used", {} 

1879 ) 

1880 

1881 return jsonify( 

1882 { 

1883 "status": "success", 

1884 "model": model_name, 

1885 "provider": provider, 

1886 "pricing": pricing, 

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

1888 } 

1889 ) 

1890 

1891 except Exception: 

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

1893 return jsonify({"error": "An internal error occurred"}), 500 

1894 

1895 

1896@metrics_bp.route("/api/cost-calculation", methods=["POST"]) 

1897@login_required 

1898@require_json_body(error_message="No data provided") 

1899def api_cost_calculation(): 

1900 """Calculate cost for token usage.""" 

1901 try: 

1902 data = request.get_json() 

1903 model_name = data.get("model_name") 

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

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

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

1907 

1908 if not model_name: 

1909 return jsonify({"error": "model_name is required"}), 400 

1910 

1911 from ...metrics.pricing.cost_calculator import CostCalculator 

1912 

1913 # Use synchronous cost calculation 

1914 calculator = CostCalculator() 

1915 cost_data = calculator.calculate_cost_sync( 

1916 model_name, prompt_tokens, completion_tokens 

1917 ) 

1918 

1919 return jsonify( 

1920 { 

1921 "status": "success", 

1922 "model_name": model_name, 

1923 "provider": provider, 

1924 "prompt_tokens": prompt_tokens, 

1925 "completion_tokens": completion_tokens, 

1926 "total_tokens": prompt_tokens + completion_tokens, 

1927 **cost_data, 

1928 } 

1929 ) 

1930 

1931 except Exception: 

1932 logger.exception("Error calculating cost") 

1933 return jsonify({"error": "An internal error occurred"}), 500 

1934 

1935 

1936@metrics_bp.route("/api/research-costs/<string:research_id>") 

1937@login_required 

1938def api_research_costs(research_id): 

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

1940 try: 

1941 username = flask_session["username"] 

1942 

1943 with get_user_db_session(username) as session: 

1944 # Get token usage records for this research 

1945 usage_records = ( 

1946 session.query(TokenUsage) 

1947 .filter(TokenUsage.research_id == research_id) 

1948 .all() 

1949 ) 

1950 

1951 if not usage_records: 

1952 return jsonify( 

1953 { 

1954 "status": "success", 

1955 "research_id": research_id, 

1956 "total_cost": 0.0, 

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

1958 } 

1959 ) 

1960 

1961 # Convert to dict format for cost calculation 

1962 usage_data = [] 

1963 for record in usage_records: 

1964 usage_data.append( 

1965 { 

1966 "model_name": record.model_name, 

1967 "provider": getattr( 

1968 record, "provider", None 

1969 ), # Handle both old and new records 

1970 "prompt_tokens": record.prompt_tokens, 

1971 "completion_tokens": record.completion_tokens, 

1972 "timestamp": record.timestamp, 

1973 } 

1974 ) 

1975 

1976 from ...metrics.pricing.cost_calculator import CostCalculator 

1977 

1978 # Use synchronous calculation for research costs 

1979 calculator = CostCalculator() 

1980 costs = [] 

1981 for record in usage_data: 

1982 cost_data = calculator.calculate_cost_sync( 

1983 record["model_name"], 

1984 record["prompt_tokens"], 

1985 record["completion_tokens"], 

1986 ) 

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

1988 

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

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

1991 total_completion_tokens = sum( 

1992 r["completion_tokens"] for r in usage_data 

1993 ) 

1994 

1995 cost_summary = { 

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

1997 "total_tokens": total_prompt_tokens + total_completion_tokens, 

1998 "prompt_tokens": total_prompt_tokens, 

1999 "completion_tokens": total_completion_tokens, 

2000 } 

2001 

2002 return jsonify( 

2003 { 

2004 "status": "success", 

2005 "research_id": research_id, 

2006 **cost_summary, 

2007 } 

2008 ) 

2009 

2010 except Exception: 

2011 logger.exception( 

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

2013 ) 

2014 return jsonify({"error": "An internal error occurred"}), 500 

2015 

2016 

2017@metrics_bp.route("/api/cost-analytics") 

2018@login_required 

2019def api_cost_analytics(): 

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

2021 try: 

2022 username = flask_session["username"] 

2023 

2024 period = request.args.get("period", "30d") 

2025 

2026 with get_user_db_session(username) as session: 

2027 # Get token usage for the period 

2028 query = session.query(TokenUsage) 

2029 time_condition = get_time_filter_condition( 

2030 period, TokenUsage.timestamp 

2031 ) 

2032 if time_condition is not None: 

2033 query = query.filter(time_condition) 

2034 

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

2036 record_count = query.count() 

2037 

2038 if record_count == 0: 

2039 return jsonify( 

2040 { 

2041 "status": "success", 

2042 "period": period, 

2043 "overview": { 

2044 "total_cost": 0.0, 

2045 "total_tokens": 0, 

2046 "prompt_tokens": 0, 

2047 "completion_tokens": 0, 

2048 }, 

2049 "top_expensive_research": [], 

2050 "research_count": 0, 

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

2052 } 

2053 ) 

2054 

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

2056 if record_count > 1000: 

2057 logger.warning( 

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

2059 ) 

2060 usage_records = ( 

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

2062 .limit(1000) 

2063 .all() 

2064 ) 

2065 else: 

2066 usage_records = query.all() 

2067 

2068 # Convert to dict format 

2069 usage_data = [] 

2070 for record in usage_records: 

2071 usage_data.append( 

2072 { 

2073 "model_name": record.model_name, 

2074 "provider": getattr( 

2075 record, "provider", None 

2076 ), # Handle both old and new records 

2077 "prompt_tokens": record.prompt_tokens, 

2078 "completion_tokens": record.completion_tokens, 

2079 "research_id": record.research_id, 

2080 "timestamp": record.timestamp, 

2081 } 

2082 ) 

2083 

2084 from ...metrics.pricing.cost_calculator import CostCalculator 

2085 

2086 # Use synchronous calculation 

2087 calculator = CostCalculator() 

2088 

2089 # Calculate overall costs 

2090 costs = [] 

2091 for record in usage_data: 

2092 cost_data = calculator.calculate_cost_sync( 

2093 record["model_name"], 

2094 record["prompt_tokens"], 

2095 record["completion_tokens"], 

2096 ) 

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

2098 

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

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

2101 total_completion_tokens = sum( 

2102 r["completion_tokens"] for r in usage_data 

2103 ) 

2104 

2105 cost_summary = { 

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

2107 "total_tokens": total_prompt_tokens + total_completion_tokens, 

2108 "prompt_tokens": total_prompt_tokens, 

2109 "completion_tokens": total_completion_tokens, 

2110 } 

2111 

2112 # Group by research_id for per-research costs 

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

2114 for record in usage_data: 

2115 rid = record["research_id"] 

2116 if rid not in research_costs: 2116 ↛ 2118line 2116 didn't jump to line 2118 because the condition on line 2116 was always true

2117 research_costs[rid] = [] 

2118 research_costs[rid].append(record) 

2119 

2120 # Calculate cost per research 

2121 research_summaries = {} 

2122 for rid, records in research_costs.items(): 

2123 research_total: float = 0 

2124 for record in records: 

2125 cost_data = calculator.calculate_cost_sync( 

2126 record["model_name"], 

2127 record["prompt_tokens"], 

2128 record["completion_tokens"], 

2129 ) 

2130 research_total += cost_data["total_cost"] 

2131 research_summaries[rid] = { 

2132 "total_cost": round(research_total, 6) 

2133 } 

2134 

2135 # Top expensive research sessions 

2136 top_expensive = sorted( 

2137 [ 

2138 (rid, data["total_cost"]) 

2139 for rid, data in research_summaries.items() 

2140 ], 

2141 key=lambda x: x[1], 

2142 reverse=True, 

2143 )[:10] 

2144 

2145 return jsonify( 

2146 { 

2147 "status": "success", 

2148 "period": period, 

2149 "overview": cost_summary, 

2150 "top_expensive_research": [ 

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

2152 for rid, cost in top_expensive 

2153 ], 

2154 "research_count": len(research_summaries), 

2155 } 

2156 ) 

2157 

2158 except Exception: 

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

2160 # Return a more graceful error response 

2161 return ( 

2162 jsonify( 

2163 { 

2164 "status": "success", 

2165 "period": period, 

2166 "overview": { 

2167 "total_cost": 0.0, 

2168 "total_tokens": 0, 

2169 "prompt_tokens": 0, 

2170 "completion_tokens": 0, 

2171 }, 

2172 "top_expensive_research": [], 

2173 "research_count": 0, 

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

2175 } 

2176 ), 

2177 200, 

2178 ) # Return 200 to avoid breaking the UI 

2179 

2180 

2181@metrics_bp.route("/links") 

2182@login_required 

2183def link_analytics(): 

2184 """Display link analytics page.""" 

2185 return render_template_with_defaults("pages/link_analytics.html") 

2186 

2187 

2188@metrics_bp.route("/api/link-analytics") 

2189@login_required 

2190def api_link_analytics(): 

2191 """Get link analytics data.""" 

2192 try: 

2193 username = flask_session["username"] 

2194 

2195 period = request.args.get("period", "30d") 

2196 

2197 # Get link analytics data 

2198 link_data = get_link_analytics(period, username) 

2199 

2200 return jsonify( 

2201 { 

2202 "status": "success", 

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

2204 "period": period, 

2205 } 

2206 ) 

2207 

2208 except Exception: 

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

2210 return ( 

2211 jsonify( 

2212 { 

2213 "status": "error", 

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

2215 } 

2216 ), 

2217 500, 

2218 ) 

2219 

2220 

2221@metrics_bp.route("/api/domain-classifications", methods=["GET"]) 

2222@login_required 

2223def api_get_domain_classifications(): 

2224 """Get all domain classifications.""" 

2225 classifier = None 

2226 try: 

2227 username = flask_session["username"] 

2228 

2229 classifier = DomainClassifier(username) 

2230 classifications = classifier.get_all_classifications() 

2231 

2232 return jsonify( 

2233 { 

2234 "status": "success", 

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

2236 "total": len(classifications), 

2237 } 

2238 ) 

2239 

2240 except Exception: 

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

2242 return jsonify( 

2243 {"status": "error", "message": "Failed to retrieve classifications"} 

2244 ), 500 

2245 finally: 

2246 if classifier is not None: 

2247 from ...utilities.resource_utils import safe_close 

2248 

2249 safe_close(classifier, "domain classifier") 

2250 

2251 

2252@metrics_bp.route("/api/domain-classifications/summary", methods=["GET"]) 

2253@login_required 

2254def api_get_classifications_summary(): 

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

2256 classifier = None 

2257 try: 

2258 username = flask_session["username"] 

2259 

2260 classifier = DomainClassifier(username) 

2261 summary = classifier.get_categories_summary() 

2262 

2263 return jsonify({"status": "success", "summary": summary}) 

2264 

2265 except Exception: 

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

2267 return jsonify( 

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

2269 ), 500 

2270 finally: 

2271 if classifier is not None: 

2272 from ...utilities.resource_utils import safe_close 

2273 

2274 safe_close(classifier, "domain classifier") 

2275 

2276 

2277@metrics_bp.route("/api/domain-classifications/classify", methods=["POST"]) 

2278@login_required 

2279def api_classify_domains(): 

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

2281 classifier = None 

2282 try: 

2283 username = flask_session["username"] 

2284 

2285 data = request.get_json() or {} 

2286 domain = data.get("domain") 

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

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

2289 

2290 # Get settings snapshot for LLM configuration 

2291 from ...settings.manager import SettingsManager 

2292 from ...database.session_context import get_user_db_session 

2293 

2294 with get_user_db_session(username) as db_session: 

2295 settings_manager = SettingsManager(db_session=db_session) 

2296 settings_snapshot = settings_manager.get_all_settings() 

2297 

2298 classifier = DomainClassifier( 

2299 username, settings_snapshot=settings_snapshot 

2300 ) 

2301 

2302 if domain and not batch_mode: 

2303 # Classify single domain 

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

2305 classification = classifier.classify_domain(domain, force_update) 

2306 if classification: 

2307 return jsonify( 

2308 { 

2309 "status": "success", 

2310 "classification": classification.to_dict(), 

2311 } 

2312 ) 

2313 return jsonify( 

2314 { 

2315 "status": "error", 

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

2317 } 

2318 ), 400 

2319 if batch_mode: 

2320 # Batch classification - this should really be a background task 

2321 # For now, we'll just return immediately and let the frontend poll 

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

2323 results = classifier.classify_all_domains(force_update) 

2324 

2325 return jsonify({"status": "success", "results": results}) 

2326 return jsonify( 

2327 { 

2328 "status": "error", 

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

2330 } 

2331 ), 400 

2332 

2333 except Exception: 

2334 logger.exception("Error classifying domains") 

2335 return jsonify( 

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

2337 ), 500 

2338 finally: 

2339 if classifier is not None: 

2340 from ...utilities.resource_utils import safe_close 

2341 

2342 safe_close(classifier, "domain classifier") 

2343 

2344 

2345@metrics_bp.route("/api/domain-classifications/progress", methods=["GET"]) 

2346@login_required 

2347def api_classification_progress(): 

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

2349 try: 

2350 username = flask_session["username"] 

2351 

2352 # Get counts of classified vs unclassified domains 

2353 with get_user_db_session(username) as session: 

2354 # Count total unique domains 

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

2356 domains = set() 

2357 

2358 for (url,) in resources: 

2359 if url: 

2360 domain = _extract_domain(url) 

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

2362 domains.add(domain) 

2363 

2364 all_domains = sorted(domains) 

2365 total_domains = len(domains) 

2366 

2367 # Count classified domains 

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

2369 

2370 return jsonify( 

2371 { 

2372 "status": "success", 

2373 "progress": { 

2374 "total_domains": total_domains, 

2375 "classified": classified_count, 

2376 "unclassified": total_domains - classified_count, 

2377 "percentage": round( 

2378 (classified_count / total_domains * 100) 

2379 if total_domains > 0 

2380 else 0, 

2381 1, 

2382 ), 

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

2384 }, 

2385 } 

2386 ) 

2387 

2388 except Exception: 

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

2390 return jsonify( 

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

2392 ), 500 

2393 

2394 

2395# --------------------------------------------------------------------------- 

2396# Journal Quality Dashboard 

2397# --------------------------------------------------------------------------- 

2398 

2399 

2400@metrics_bp.route("/journals") 

2401@login_required 

2402def journal_quality(): 

2403 """Display journal quality dashboard.""" 

2404 return render_template_with_defaults("pages/journal_quality.html") 

2405 

2406 

2407@metrics_bp.route("/api/journal-data/status") 

2408@login_required 

2409def api_journal_data_status(): 

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

2411 try: 

2412 from ...journal_quality.downloader import ( 

2413 get_journal_data_status, 

2414 ) 

2415 

2416 return jsonify(get_journal_data_status()) 

2417 except Exception: 

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

2419 return jsonify({"error": "Failed to check status"}), 500 

2420 

2421 

2422@metrics_bp.route("/api/journal-data/download", methods=["POST"]) 

2423@login_required 

2424@journal_data_limit 

2425def api_journal_data_download(): 

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

2427 

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

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

2430 invocation is a DoS vector. 

2431 """ 

2432 try: 

2433 from ...journal_quality.downloader import ( 

2434 download_journal_data, 

2435 get_download_state, 

2436 ) 

2437 from ...journal_quality.data_sources import ALL_SOURCES 

2438 

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

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

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

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

2443 # auto-download path is already gated by 

2444 # JournalReputationFilter._should_skip_journal_fetch_for_scope; this 

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

2446 from ...security.egress.policy import ( 

2447 DEFAULT_EGRESS_SCOPE, 

2448 EgressScope, 

2449 ) 

2450 from ...utilities.db_utils import get_settings_manager 

2451 

2452 username = flask_session.get("username") 

2453 scope_raw = get_settings_manager(username=username).get_setting( 

2454 "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

2455 ) 

2456 try: 

2457 scope = EgressScope(str(scope_raw).lower()) 

2458 except ValueError: 

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

2460 if scope is None or scope in ( 

2461 EgressScope.PRIVATE_ONLY, 

2462 EgressScope.STRICT, 

2463 ): 

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

2465 "journal data download refused by egress policy", 

2466 scope=str(scope_raw), 

2467 ) 

2468 return ( 

2469 jsonify( 

2470 { 

2471 "success": False, 

2472 "message": ( 

2473 "Journal data download needs public network " 

2474 "access, which the current egress policy blocks " 

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

2476 "scope to download." 

2477 ), 

2478 } 

2479 ), 

2480 403, 

2481 ) 

2482 

2483 force = request.json.get("force", False) if request.is_json else False 

2484 success, internal_message = download_journal_data(force=force) 

2485 if not success: 

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

2487 return jsonify({"success": False, "message": "Download failed"}) 

2488 

2489 # download_journal_data() already calls build_db() + reset_db() 

2490 # internally on its success path (downloader.py:563 → db.py:1209), 

2491 # so the DB is live on disk and the cached engine has been 

2492 # invalidated by the time we get here. Do not add a second build 

2493 # here — it would run the full ~30 s rebuild a second time and 

2494 # write to the legacy `journal_reference.db` filename that the 

2495 # downloader just cleaned up. 

2496 

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

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

2499 # NOT echo `internal_message` from download_journal_data: keeping 

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

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

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

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

2504 if counts is not None: 

2505 parts = [ 

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

2507 for src in ALL_SOURCES 

2508 ] 

2509 user_message = ( 

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

2511 ) 

2512 else: 

2513 # `counts` is None when download_journal_data took its 

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

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

2516 return jsonify({"success": True, "message": user_message}) 

2517 except Exception: 

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

2519 return jsonify({"success": False, "message": "Download failed"}), 500 

2520 

2521 

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

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

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

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

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

2527 

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

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

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

2531_MAX_PAGE = 10_000 

2532 

2533 

2534@metrics_bp.route("/api/journals") 

2535@login_required 

2536@journals_read_limit 

2537def api_journal_quality(): 

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

2539 

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

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

2542 

2543 Query params: 

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

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

2546 search (str): name substring filter 

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

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

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

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

2551 """ 

2552 try: 

2553 from ...journal_quality.db import get_journal_reference_db 

2554 

2555 ref = get_journal_reference_db() 

2556 if not ref.available: 2556 ↛ 2557line 2556 didn't jump to line 2557 because the condition on line 2556 was never true

2557 return jsonify( 

2558 { 

2559 "status": "error", 

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

2561 } 

2562 ), 503 

2563 

2564 try: 

2565 page = max(1, int(request.args.get("page", 1))) 

2566 per_page = min(max(1, int(request.args.get("per_page", 50))), 200) 

2567 except (TypeError, ValueError): 

2568 return jsonify( 

2569 { 

2570 "status": "error", 

2571 "message": "Invalid pagination parameters", 

2572 } 

2573 ), 400 

2574 if page > _MAX_PAGE: 

2575 return jsonify( 

2576 { 

2577 "status": "error", 

2578 "message": ( 

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

2580 "filter or increase per_page" 

2581 ), 

2582 } 

2583 ), 400 

2584 search = request.args.get("search", "") 

2585 tier = request.args.get("tier", "") 

2586 score_source = request.args.get("score_source", "") 

2587 if score_source and score_source not in _ALLOWED_SCORE_SOURCES: 

2588 return jsonify( 

2589 { 

2590 "status": "error", 

2591 "message": ( 

2592 f"Invalid score_source; must be one of " 

2593 f"{sorted(_ALLOWED_SCORE_SOURCES)}" 

2594 ), 

2595 } 

2596 ), 400 

2597 sort = request.args.get("sort", "quality") 

2598 order = request.args.get("order", "desc") 

2599 

2600 journals, total = ref.get_journals_page( 

2601 page=page, 

2602 per_page=per_page, 

2603 search=search, 

2604 tier=tier, 

2605 score_source=score_source, 

2606 sort=sort, 

2607 order=order, 

2608 ) 

2609 

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

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

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

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

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

2615 page = min(page, total_pages) 

2616 

2617 result = { 

2618 "status": "success", 

2619 "journals": journals, 

2620 "pagination": { 

2621 "page": page, 

2622 "per_page": per_page, 

2623 "total_count": total, 

2624 "total_pages": total_pages, 

2625 }, 

2626 } 

2627 

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

2629 # on every pagination/sort/filter request) 

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

2631 summary = ref.get_summary() 

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

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

2634 result["summary"] = summary 

2635 

2636 return jsonify(result) 

2637 

2638 except Exception: 

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

2640 return ( 

2641 jsonify( 

2642 { 

2643 "status": "error", 

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

2645 } 

2646 ), 

2647 500, 

2648 ) 

2649 

2650 

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

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

2653 

2654 Returns a dict with keys the dashboard template already renders 

2655 (h_index, impact_factor, sjr_quartile, publisher, is_predatory, 

2656 predatory_source, is_in_doaj). Missing fields default 

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

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

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

2660 """ 

2661 if ref_db is None or not name: 

2662 return {} 

2663 try: 

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

2665 except Exception: # noqa: silent-exception 

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

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

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

2669 # own logger.exception calls when they matter. 

2670 return {} 

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

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

2673 return { 

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

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

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

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

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

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

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

2681 } 

2682 

2683 

2684def _get_ref_db_or_none(): 

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

2686 

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

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

2689 """ 

2690 try: 

2691 from ...journal_quality.db import get_journal_reference_db 

2692 

2693 return get_journal_reference_db() 

2694 except Exception: # noqa: silent-exception 

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

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

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

2698 return None 

2699 

2700 

2701def _resolve_paper_quality( 

2702 llm_quality: int | None, enrichment: dict 

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

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

2705 

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

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

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

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

2710 propagates automatically. Returns (score, source_label) or 

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

2712 """ 

2713 if llm_quality is not None: 

2714 return llm_quality, "llm" 

2715 if not enrichment: 

2716 return None, None 

2717 # enrichment comes from _source_to_dashboard_dict — row.quality is 

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

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

2720 q = enrichment.get("quality") 

2721 if q is not None: 

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

2723 return None, None 

2724 

2725 

2726def _lookup_journal_llm_quality( 

2727 db, container_titles: list[str] 

2728) -> dict[str, int]: 

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

2730 ``journals`` table. 

2731 

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

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

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

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

2736 """ 

2737 from ...journal_quality.scoring import normalize_name 

2738 

2739 if not container_titles: 

2740 return {} 

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

2742 if not normalized: 

2743 return {} 

2744 rows = ( 

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

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

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

2748 .all() 

2749 ) 

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

2751 

2752 

2753@metrics_bp.route("/api/journals/user-research") 

2754@login_required 

2755@journals_read_limit 

2756def api_user_research_journals(): 

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

2758 

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

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

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

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

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

2764 A re-scored journal propagates to existing research rows 

2765 automatically because no per-Paper score is stored. 

2766 """ 

2767 username = flask_session.get("username") 

2768 if not username: 

2769 return jsonify({"status": "error", "message": "Not authenticated"}), 401 

2770 

2771 _empty_response = { 

2772 "status": "success", 

2773 "summary": { 

2774 "total_journals": 0, 

2775 "avg_quality": None, 

2776 "total_papers": 0, 

2777 "predatory_blocked": 0, 

2778 }, 

2779 "quality_distribution": {}, 

2780 "journals": [], 

2781 } 

2782 

2783 try: 

2784 from sqlalchemy import inspect as sa_inspect 

2785 

2786 with get_user_db_session(username) as db: 

2787 inspector = sa_inspect(db.bind) 

2788 if not inspector.has_table("papers"): 

2789 return jsonify(_empty_response) 

2790 

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

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

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

2794 # are excluded so the dashboard reflects what the user 

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

2796 # See issue #3544. 

2797 rows = ( 

2798 db.query( 

2799 Paper.container_title, 

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

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

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

2803 ) 

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

2805 .filter(Paper.appearances.any()) 

2806 .group_by(Paper.container_title) 

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

2808 .limit(200) 

2809 .all() 

2810 ) 

2811 

2812 if not rows: 

2813 return jsonify(_empty_response) 

2814 

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

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

2817 # queries. 

2818 ref_db = _get_ref_db_or_none() 

2819 enrich_map = {} 

2820 if ref_db is not None: 

2821 enrich_map = ref_db.lookup_sources_batch( 

2822 [r.container_title for r in rows] 

2823 ) 

2824 

2825 from ...journal_quality.scoring import normalize_name 

2826 

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

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

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

2830 # journal propagates here without any backfill. 

2831 llm_by_name = _lookup_journal_llm_quality( 

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

2833 ) 

2834 

2835 journals: list[dict] = [] 

2836 qualities: list[int] = [] 

2837 for r in rows: 

2838 normalized = normalize_name(r.container_title) 

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

2840 quality, source_label = _resolve_paper_quality( 

2841 llm_by_name.get(normalized), enrichment 

2842 ) 

2843 if quality is not None: 

2844 qualities.append(quality) 

2845 journals.append( 

2846 { 

2847 "name": r.container_title, 

2848 "quality": quality, 

2849 "score_source": source_label, 

2850 "paper_count": r.paper_count, 

2851 "year_min": r.year_min, 

2852 "year_max": r.year_max, 

2853 **{ 

2854 k: v 

2855 for k, v in enrichment.items() 

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

2857 }, 

2858 } 

2859 ) 

2860 

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

2862 # dashboard summary — matches how the table renders. 

2863 total_journals = len(journals) 

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

2865 avg_quality = ( 

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

2867 ) 

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

2869 for q in qualities: 

2870 quality_distribution[str(q)] = ( 

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

2872 ) 

2873 

2874 # Predatory count uses the full set of distinct 

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

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

2877 # 

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

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

2880 # years of use, and count_predatory_by_names documents 

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

2882 # and rejected — it would SILENTLY UNDERCOUNT predatory 

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

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

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

2886 # ~50K papers. 

2887 predatory_blocked = 0 

2888 if ref_db is not None: 

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

2890 # otherwise predatory_blocked stays inflated by titles 

2891 # whose only Papers belong to deleted research sessions. 

2892 all_names = [ 

2893 name 

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

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

2896 .filter(Paper.appearances.any()) 

2897 .distinct() 

2898 .all() 

2899 ] 

2900 predatory_blocked = ref_db.count_predatory_by_names(all_names) 

2901 

2902 return jsonify( 

2903 { 

2904 "status": "success", 

2905 "summary": { 

2906 "total_journals": total_journals, 

2907 "avg_quality": avg_quality, 

2908 "total_papers": total_papers, 

2909 "predatory_blocked": predatory_blocked, 

2910 }, 

2911 "quality_distribution": quality_distribution, 

2912 "journals": journals, 

2913 } 

2914 ) 

2915 except Exception: 

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

2917 return ( 

2918 jsonify( 

2919 { 

2920 "status": "error", 

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

2922 } 

2923 ), 

2924 500, 

2925 ) 

2926 

2927 

2928@metrics_bp.route("/api/journals/research/<research_id>") 

2929@login_required 

2930@journals_read_limit 

2931def api_research_journals(research_id): 

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

2933 

2934 Filters the per-user papers table by joining through 

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

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

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

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

2939 dashboard can reuse its rendering code. 

2940 """ 

2941 username = flask_session.get("username") 

2942 if not username: 2942 ↛ 2943line 2942 didn't jump to line 2943 because the condition on line 2942 was never true

2943 return jsonify({"status": "error", "message": "Not authenticated"}), 401 

2944 

2945 _empty_response = { 

2946 "status": "success", 

2947 "summary": { 

2948 "total_journals": 0, 

2949 "avg_quality": None, 

2950 "total_papers": 0, 

2951 "predatory_blocked": 0, 

2952 }, 

2953 "quality_distribution": {}, 

2954 "journals": [], 

2955 } 

2956 

2957 try: 

2958 from sqlalchemy import inspect as sa_inspect 

2959 

2960 with get_user_db_session(username) as db: 

2961 inspector = sa_inspect(db.bind) 

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

2963 "paper_appearances" 

2964 ): 

2965 return jsonify(_empty_response) 

2966 

2967 # Verify the research_id belongs to this user before exposing 

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

2969 # the existence check doubles as an ownership check. 

2970 from ...database.models.research import ResearchHistory 

2971 

2972 research = ( 

2973 db.query(ResearchHistory.id) 

2974 .filter(ResearchHistory.id == research_id) 

2975 .first() 

2976 ) 

2977 if research is None: 2977 ↛ 2987line 2977 didn't jump to line 2987 because the condition on line 2977 was always true

2978 return ( 

2979 jsonify( 

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

2981 ), 

2982 404, 

2983 ) 

2984 

2985 # Aggregate container_title → paper_count for this research. 

2986 # Join chain: Paper → PaperAppearance → ResearchResource. 

2987 rows = ( 

2988 db.query( 

2989 Paper.container_title, 

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

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

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

2993 ) 

2994 .join( 

2995 PaperAppearance, 

2996 PaperAppearance.paper_id == Paper.id, 

2997 ) 

2998 .join( 

2999 ResearchResource, 

3000 ResearchResource.id == PaperAppearance.resource_id, 

3001 ) 

3002 .filter( 

3003 ResearchResource.research_id == research_id, 

3004 Paper.container_title.isnot(None), 

3005 ) 

3006 .group_by(Paper.container_title) 

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

3008 .all() 

3009 ) 

3010 

3011 if not rows: 

3012 return jsonify(_empty_response) 

3013 

3014 ref_db = _get_ref_db_or_none() 

3015 enrich_map = {} 

3016 if ref_db is not None: 

3017 enrich_map = ref_db.lookup_sources_batch( 

3018 [r.container_title for r in rows] 

3019 ) 

3020 

3021 from ...journal_quality.scoring import normalize_name 

3022 

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

3024 # _lookup_journal_llm_quality for rationale. Same live 

3025 # resolution as the cross-research rollup above. 

3026 llm_by_name = _lookup_journal_llm_quality( 

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

3028 ) 

3029 

3030 journals: list[dict] = [] 

3031 qualities: list[int] = [] 

3032 predatory_blocked = 0 

3033 for r in rows: 

3034 normalized = normalize_name(r.container_title) 

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

3036 if enrichment.get("is_predatory"): 

3037 predatory_blocked += 1 

3038 quality, source_label = _resolve_paper_quality( 

3039 llm_by_name.get(normalized), enrichment 

3040 ) 

3041 if quality is not None: 

3042 qualities.append(quality) 

3043 journals.append( 

3044 { 

3045 "name": r.container_title, 

3046 "quality": quality, 

3047 "score_source": source_label, 

3048 "paper_count": r.paper_count, 

3049 "year_min": r.year_min, 

3050 "year_max": r.year_max, 

3051 **{ 

3052 k: v 

3053 for k, v in enrichment.items() 

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

3055 }, 

3056 } 

3057 ) 

3058 

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

3060 avg_quality = ( 

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

3062 ) 

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

3064 for q in qualities: 

3065 quality_distribution[str(q)] = ( 

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

3067 ) 

3068 

3069 return jsonify( 

3070 { 

3071 "status": "success", 

3072 "summary": { 

3073 "total_journals": len(journals), 

3074 "avg_quality": avg_quality, 

3075 "total_papers": total_papers, 

3076 "predatory_blocked": predatory_blocked, 

3077 }, 

3078 "quality_distribution": quality_distribution, 

3079 "journals": journals, 

3080 } 

3081 ) 

3082 except Exception: 

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

3084 return ( 

3085 jsonify( 

3086 { 

3087 "status": "error", 

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

3089 } 

3090 ), 

3091 500, 

3092 )