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
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1"""Routes for metrics dashboard."""
3from datetime import datetime, timedelta, UTC
4from typing import Any
5from urllib.parse import urlparse
7from flask import Blueprint, jsonify, request, session as flask_session
8from loguru import logger
9from sqlalchemy import case, func
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
37# Create a Blueprint for metrics
38metrics_bp = Blueprint("metrics", __name__, url_prefix="/metrics")
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.
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
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")
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 }
80 # Calculate date range
81 days = get_period_days(period)
83 with get_user_db_session(username) as session:
84 query = session.query(ResearchRating)
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)
91 # Get all ratings
92 ratings = query.all()
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 }
110 # Calculate statistics
111 rating_values = [r.rating for r in ratings]
112 avg_rating = sum(rating_values) / len(rating_values)
114 # Rating distribution
115 rating_counts = {}
116 for i in range(1, 6):
117 rating_counts[str(i)] = rating_values.count(i)
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 }
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 }
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 }
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")
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 }
175 # Calculate date range
176 days = get_period_days(period)
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 )
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 )
205 # Get all resources
206 resources = query.all()
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 }
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
232 # Generic category counting from LLM classifications
233 category_counts: dict[str, Any] = {}
235 quality_metrics = {
236 "with_title": 0,
237 "with_preview": 0,
238 "with_both": 0,
239 "total": 0,
240 }
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)
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 )
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
271 # Count domains
272 domain_counts[domain] = domain_counts.get(domain, 0) + 1
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)
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 )
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 )
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 )
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
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)
321 except Exception:
322 logger.exception(f"Error parsing URL {resource.url}")
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]
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
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 )
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 )
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 }
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)
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}
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 ]
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 }
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 }
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")
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 }
479 # Calculate date range
480 days = get_period_days(period)
482 with get_user_db_session(username) as session:
483 # Check if we have any ResearchStrategy records
484 strategy_count = session.query(ResearchStrategy).count()
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 }
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 )
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)
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 )
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()
526 # Format strategy data
527 strategy_usage = []
528 strategy_distribution = {}
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
545 # Find most popular strategy
546 most_popular = (
547 strategy_usage[0]["strategy"] if strategy_usage else None
548 )
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 }
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 }
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")
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 }
604 # Calculate date range for timestamp filtering
605 import time
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
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)
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 )
644 estimates = estimates_query.all()
646 engine_stats = []
647 total_attempts = 0
648 successful_attempts = 0
649 base_wait_sum = 0.0
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)
657 total_attempts += engine_attempts
658 successful_attempts += engine_success
659 base_wait_sum += estimate.base_wait_seconds
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 )
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 )
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
704 logger.info(
705 f"Rate limiting analytics from estimates: "
706 f"tracked_engines={tracked_engines}, "
707 f"total_attempts(recent)={total_attempts}"
708 )
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 }
736 logger.info(
737 f"DEBUG: Returning rate_limiting_analytics result: {result}"
738 )
739 return result
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 }
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")
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")
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"]
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")
790 token_counter = TokenCounter()
791 search_tracker = get_search_tracker()
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 )
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)
814 # Get average rating
815 avg_rating = ratings_query.with_entities(
816 func.avg(ResearchRating.rating).label("avg_rating")
817 ).scalar()
819 # Get total rating count
820 total_ratings = ratings_query.count()
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}
830 # Get strategy analytics
831 strategy_data = get_strategy_analytics(period, username)
832 logger.debug(f"strategy_data keys: {list(strategy_data.keys())}")
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 )
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 )
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 }
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 )
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 )
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)
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
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"]
933 with get_user_db_session(username) as session:
934 estimates = (
935 session.query(RateLimitEstimate)
936 .order_by(RateLimitEstimate.engine_type)
937 .all()
938 )
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 )
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
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"]
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 )
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 )
1008 # Extract domain information
1009 domain_counts: dict[str, Any] = {}
1011 # Generic category counting from LLM classifications
1012 category_counts: dict[str, Any] = {}
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)
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 )
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
1043 domain_counts[domain] = domain_counts.get(domain, 0) + 1
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}")
1059 # Sort domains by count
1060 sorted_domains = sorted(
1061 domain_counts.items(), key=lambda x: x[1], reverse=True
1062 )
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 )
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
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 )
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 )
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 )
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"]
1183 token_counter = TokenCounter()
1184 search_tracker = get_search_tracker()
1186 enhanced_metrics = token_counter.get_enhanced_metrics(
1187 period=period, research_mode=research_mode
1188 )
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
1198 # Add rating analytics
1199 rating_analytics = get_rating_analytics(period, research_mode, username)
1200 enhanced_metrics.update(rating_analytics)
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 )
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"]
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 )
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})
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 )
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"]
1269 data = request.get_json()
1270 rating_value = data.get("rating")
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 )
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
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 )
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 )
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)
1359 session.commit()
1361 return jsonify(
1362 {
1363 "status": "success",
1364 "message": "Rating saved successfully",
1365 "rating": rating_value,
1366 }
1367 )
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 )
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")
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")
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"]
1403 period = request.args.get("period", "30d")
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)
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 )
1435 if time_condition is not None:
1436 overall_stats = overall_stats.filter(time_condition)
1438 overall_stats = overall_stats.first()
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")
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 )
1493 if time_condition is not None:
1494 llm_ratings_query = llm_ratings_query.filter(time_condition)
1496 llm_ratings = (
1497 llm_ratings_query.group_by(model_label)
1498 .order_by(func.avg(ResearchRating.rating).desc())
1499 .all()
1500 )
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 )
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 )
1544 if time_condition is not None:
1545 search_engine_ratings_query = (
1546 search_engine_ratings_query.filter(time_condition)
1547 )
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 )
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 )
1563 if time_condition is not None:
1564 rating_trends_query = rating_trends_query.filter(time_condition)
1566 rating_trends = (
1567 rating_trends_query.group_by(
1568 func.date(ResearchRating.created_at)
1569 )
1570 .order_by("date")
1571 .all()
1572 )
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 )
1587 if time_condition is not None:
1588 mode_ratings_query = mode_ratings_query.filter(time_condition)
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 )
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 )
1624 if time_condition is not None:
1625 dimension_stats = dimension_stats.filter(time_condition)
1627 dimension_stats = dimension_stats.first()
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 )
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 )
1663 if time_condition is not None:
1664 recent_ratings_query = recent_ratings_query.filter(
1665 time_condition
1666 )
1668 recent_ratings = (
1669 recent_ratings_query.order_by(ResearchRating.created_at.desc())
1670 .limit(20)
1671 .all()
1672 )
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 )
1685 if time_condition is not None:
1686 recent_feedback_query = recent_feedback_query.filter(
1687 time_condition
1688 )
1690 recent_feedback = (
1691 recent_feedback_query.order_by(ResearchRating.created_at.desc())
1692 .limit(20)
1693 .all()
1694 )
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 )
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 )
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
1845 # Use static pricing data instead of async
1846 fetcher = PricingFetcher()
1847 pricing_data = fetcher.static_pricing
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 )
1858 except Exception:
1859 logger.exception("Error fetching pricing data")
1860 return jsonify({"error": "Internal Server Error"}), 500
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")
1871 from ...metrics.pricing.cost_calculator import CostCalculator
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 )
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 )
1891 except Exception:
1892 logger.exception(f"Error getting pricing for model: {model_name}")
1893 return jsonify({"error": "An internal error occurred"}), 500
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)
1908 if not model_name:
1909 return jsonify({"error": "model_name is required"}), 400
1911 from ...metrics.pricing.cost_calculator import CostCalculator
1913 # Use synchronous cost calculation
1914 calculator = CostCalculator()
1915 cost_data = calculator.calculate_cost_sync(
1916 model_name, prompt_tokens, completion_tokens
1917 )
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 )
1931 except Exception:
1932 logger.exception("Error calculating cost")
1933 return jsonify({"error": "An internal error occurred"}), 500
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"]
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 )
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 )
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 )
1976 from ...metrics.pricing.cost_calculator import CostCalculator
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})
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 )
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 }
2002 return jsonify(
2003 {
2004 "status": "success",
2005 "research_id": research_id,
2006 **cost_summary,
2007 }
2008 )
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
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"]
2024 period = request.args.get("period", "30d")
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)
2035 # First check if we have any records to avoid expensive queries
2036 record_count = query.count()
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 )
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()
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 )
2084 from ...metrics.pricing.cost_calculator import CostCalculator
2086 # Use synchronous calculation
2087 calculator = CostCalculator()
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})
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 )
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 }
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)
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 }
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]
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 )
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
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")
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"]
2195 period = request.args.get("period", "30d")
2197 # Get link analytics data
2198 link_data = get_link_analytics(period, username)
2200 return jsonify(
2201 {
2202 "status": "success",
2203 "data": link_data["link_analytics"],
2204 "period": period,
2205 }
2206 )
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 )
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"]
2229 classifier = DomainClassifier(username)
2230 classifications = classifier.get_all_classifications()
2232 return jsonify(
2233 {
2234 "status": "success",
2235 "classifications": [c.to_dict() for c in classifications],
2236 "total": len(classifications),
2237 }
2238 )
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
2249 safe_close(classifier, "domain classifier")
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"]
2260 classifier = DomainClassifier(username)
2261 summary = classifier.get_categories_summary()
2263 return jsonify({"status": "success", "summary": summary})
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
2274 safe_close(classifier, "domain classifier")
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"]
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)
2290 # Get settings snapshot for LLM configuration
2291 from ...settings.manager import SettingsManager
2292 from ...database.session_context import get_user_db_session
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()
2298 classifier = DomainClassifier(
2299 username, settings_snapshot=settings_snapshot
2300 )
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)
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
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
2342 safe_close(classifier, "domain classifier")
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"]
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()
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)
2364 all_domains = sorted(domains)
2365 total_domains = len(domains)
2367 # Count classified domains
2368 classified_count = session.query(DomainClassification).count()
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 )
2388 except Exception:
2389 logger.exception("Error getting classification progress")
2390 return jsonify(
2391 {"status": "error", "message": "Failed to retrieve progress"}
2392 ), 500
2395# ---------------------------------------------------------------------------
2396# Journal Quality Dashboard
2397# ---------------------------------------------------------------------------
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")
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 )
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
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.
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
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
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 )
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"})
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.
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
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"})
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
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.
2540 Reads from the bundled read-only reference database (~217K journals)
2541 rather than the per-user DB, so the dashboard is always populated.
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
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
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")
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 )
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)
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 }
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
2636 return jsonify(result)
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 )
2651def _ref_db_lookup(ref_db, name: str) -> dict:
2652 """Look up a journal's display bibliometrics in the reference DB.
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 }
2684def _get_ref_db_or_none():
2685 """Return the JournalQualityDB singleton, or None if unavailable.
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
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
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.
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
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.
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
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}
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.
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
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 }
2783 try:
2784 from sqlalchemy import inspect as sa_inspect
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)
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 )
2812 if not rows:
2813 return jsonify(_empty_response)
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 )
2825 from ...journal_quality.scoring import normalize_name
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 )
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 )
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 )
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)
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 )
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.
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
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 }
2957 try:
2958 from sqlalchemy import inspect as sa_inspect
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)
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
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 )
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 )
3011 if not rows:
3012 return jsonify(_empty_response)
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 )
3021 from ...journal_quality.scoring import normalize_name
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 )
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 )
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 )
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 )