Coverage for src/local_deep_research/web/routers/metrics.py: 89%
902 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""Routes for metrics dashboard."""
3import asyncio
5from fastapi import APIRouter, Depends, Request
6from fastapi.responses import JSONResponse
7from ..dependencies.auth import require_auth
8from ..dependencies.threadpool import run_db_sync
9from ..dependencies.rate_limit import limiter, _user_key
10from ..template_config import templates
12from datetime import datetime, timedelta, UTC
13from typing import Any, Annotated
14from urllib.parse import urlparse
16from loguru import logger
17from sqlalchemy import case, func
19from ...database.models import (
20 Journal,
21 Paper,
22 PaperAppearance,
23 RateLimitEstimate,
24 ResearchHistory,
25 ResearchRating,
26 ResearchResource,
27 ResearchStrategy,
28 TokenUsage,
29)
30from ...constants import (
31 get_available_strategies as get_central_available_strategies,
32)
33from ...domain_classifier import DomainClassifier, DomainClassification
34from ...database.session_context import get_user_db_session
35from ...metrics import TokenCounter
36from ...metrics.query_utils import (
37 get_context_overflow_truncation_summary,
38 get_period_days,
39 get_time_filter_condition,
40)
41from ...metrics.search_tracker import get_search_tracker
42from ..dependencies.json_body import json_body_error
44# Create the router for metrics
45router = APIRouter(prefix="/metrics", tags=["metrics"])
47# NOTE: Routes use username (not .get()) intentionally.
48# Depends(require_auth) guarantees the key exists; direct access fails
49# fast if the dependency is ever removed.
52def _extract_domain(url):
53 """Extract normalized domain from URL, stripping www. prefix."""
54 try:
55 parsed = urlparse(url)
56 domain = parsed.netloc.lower()
57 if domain.startswith("www."):
58 domain = domain[4:]
59 return domain if domain else None
60 except (ValueError, AttributeError, TypeError):
61 return None
64def get_rating_analytics(period="30d", research_mode="all", username=None):
65 """Get rating analytics for the specified period and research mode."""
66 try:
67 if not username:
68 return {
69 "rating_analytics": {
70 "avg_rating": None,
71 "total_ratings": 0,
72 "rating_distribution": {},
73 "satisfaction_stats": {
74 "very_satisfied": 0,
75 "satisfied": 0,
76 "neutral": 0,
77 "dissatisfied": 0,
78 "very_dissatisfied": 0,
79 },
80 "error": "No user session",
81 }
82 }
84 # Calculate date range
85 days = get_period_days(period)
87 with get_user_db_session(username) as session:
88 query = session.query(ResearchRating)
90 # Apply time filter
91 if days:
92 cutoff_date = datetime.now(UTC) - timedelta(days=days)
93 query = query.filter(ResearchRating.created_at >= cutoff_date)
95 # Get all ratings
96 ratings = query.all()
98 if not ratings:
99 return {
100 "rating_analytics": {
101 "avg_rating": None,
102 "total_ratings": 0,
103 "rating_distribution": {},
104 "satisfaction_stats": {
105 "very_satisfied": 0,
106 "satisfied": 0,
107 "neutral": 0,
108 "dissatisfied": 0,
109 "very_dissatisfied": 0,
110 },
111 }
112 }
114 # Calculate statistics
115 rating_values = [r.rating for r in ratings]
116 avg_rating = sum(rating_values) / len(rating_values)
118 # Rating distribution
119 rating_counts = {}
120 for i in range(1, 6):
121 rating_counts[str(i)] = rating_values.count(i)
123 # Satisfaction categories
124 satisfaction_stats = {
125 "very_satisfied": rating_values.count(5),
126 "satisfied": rating_values.count(4),
127 "neutral": rating_values.count(3),
128 "dissatisfied": rating_values.count(2),
129 "very_dissatisfied": rating_values.count(1),
130 }
132 return {
133 "rating_analytics": {
134 "avg_rating": round(avg_rating, 1),
135 "total_ratings": len(ratings),
136 "rating_distribution": rating_counts,
137 "satisfaction_stats": satisfaction_stats,
138 }
139 }
141 except Exception:
142 logger.exception("Error getting rating analytics")
143 return {
144 "rating_analytics": {
145 "avg_rating": None,
146 "total_ratings": 0,
147 "rating_distribution": {},
148 "satisfaction_stats": {
149 "very_satisfied": 0,
150 "satisfied": 0,
151 "neutral": 0,
152 "dissatisfied": 0,
153 "very_dissatisfied": 0,
154 },
155 }
156 }
159def get_link_analytics(period="30d", username=None):
160 """Get link analytics from research resources."""
161 try:
162 if not username:
163 return {
164 "link_analytics": {
165 "top_domains": [],
166 "total_unique_domains": 0,
167 "avg_links_per_research": 0,
168 "domain_distribution": {},
169 "source_type_analysis": {},
170 "academic_vs_general": {},
171 "total_links": 0,
172 "error": "No user session",
173 }
174 }
176 # Calculate date range
177 days = get_period_days(period)
179 with get_user_db_session(username) as session:
180 # Project only the columns the analytics loop reads (url /
181 # research_id / created_at / source_type / title) plus a SQL-level
182 # boolean for whether content_preview is present. Loading full
183 # ``ResearchResource`` entities would materialize the
184 # ``content_preview`` Text column for every row on this whole-table
185 # scan — which runs unfiltered when ``period=all`` (#4560).
186 has_preview = (
187 ResearchResource.content_preview.isnot(None)
188 & (ResearchResource.content_preview != "")
189 ).label("has_preview")
190 query = session.query(
191 ResearchResource.url,
192 ResearchResource.research_id,
193 ResearchResource.created_at,
194 ResearchResource.source_type,
195 ResearchResource.title,
196 has_preview,
197 )
199 # Apply time filter
200 if days:
201 cutoff_date = datetime.now(UTC) - timedelta(days=days)
202 query = query.filter(
203 ResearchResource.created_at >= cutoff_date.isoformat()
204 )
206 # Get all resources
207 resources = query.all()
209 if not resources:
210 return {
211 "link_analytics": {
212 "top_domains": [],
213 "total_unique_domains": 0,
214 "avg_links_per_research": 0,
215 "domain_distribution": {},
216 "source_type_analysis": {},
217 "academic_vs_general": {},
218 "total_links": 0,
219 }
220 }
222 # Extract domains from URLs
223 domain_counts: dict[str, Any] = {}
224 domain_researches: dict[
225 str, Any
226 ] = {} # Track which researches used each domain
227 source_types: dict[str, Any] = {}
228 temporal_data: dict[str, Any] = {} # Track links over time
229 domain_connections: dict[
230 str, Any
231 ] = {} # Track domain co-occurrences
233 # Generic category counting from LLM classifications
234 category_counts: dict[str, Any] = {}
236 quality_metrics = {
237 "with_title": 0,
238 "with_preview": 0,
239 "with_both": 0,
240 "total": 0,
241 }
243 # First pass: collect all domains from resources
244 all_domains = set()
245 for resource in resources:
246 if resource.url:
247 domain = _extract_domain(resource.url)
248 if domain:
249 all_domains.add(domain)
251 # Batch load all domain classifications in one query (fix N+1)
252 domain_classifications_map = {}
253 if all_domains: 253 ↛ 265line 253 didn't jump to line 265 because the condition on line 253 was always true
254 all_classifications = (
255 session.query(DomainClassification)
256 .filter(DomainClassification.domain.in_(all_domains))
257 .all()
258 )
259 for classification in all_classifications:
260 domain_classifications_map[classification.domain] = (
261 classification
262 )
264 # Second pass: process resources with pre-loaded classifications
265 for resource in resources:
266 if resource.url:
267 try:
268 domain = _extract_domain(resource.url)
269 if not domain:
270 continue
272 # Count domains
273 domain_counts[domain] = domain_counts.get(domain, 0) + 1
275 # Track research IDs for each domain
276 if domain not in domain_researches:
277 domain_researches[domain] = set()
278 domain_researches[domain].add(resource.research_id)
280 # Track temporal data (daily counts)
281 if resource.created_at: 281 ↛ 290line 281 didn't jump to line 290 because the condition on line 281 was always true
282 date_str = resource.created_at[
283 :10
284 ] # Extract YYYY-MM-DD
285 temporal_data[date_str] = (
286 temporal_data.get(date_str, 0) + 1
287 )
289 # Count categories from pre-loaded classifications (no N+1)
290 classification = domain_classifications_map.get(domain)
291 if classification:
292 category = classification.category
293 category_counts[category] = (
294 category_counts.get(category, 0) + 1
295 )
296 else:
297 category_counts["Unclassified"] = (
298 category_counts.get("Unclassified", 0) + 1
299 )
301 # Track source type from metadata if available
302 if resource.source_type:
303 source_types[resource.source_type] = (
304 source_types.get(resource.source_type, 0) + 1
305 )
307 # Track quality metrics
308 quality_metrics["total"] += 1
309 if resource.title: 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true
310 quality_metrics["with_title"] += 1
311 if resource.has_preview: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true
312 quality_metrics["with_preview"] += 1
313 if resource.title and resource.has_preview: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true
314 quality_metrics["with_both"] += 1
316 # Track domain co-occurrences for network visualization
317 research_id = resource.research_id
318 if research_id not in domain_connections:
319 domain_connections[research_id] = []
320 domain_connections[research_id].append(domain)
322 except Exception:
323 logger.exception(f"Error parsing URL {resource.url}")
325 # Sort domains by count and get top 10
326 sorted_domains = sorted(
327 domain_counts.items(), key=lambda x: x[1], reverse=True
328 )
329 top_10_domains = sorted_domains[:10]
331 # Calculate domain distribution (top domains vs others)
332 top_10_count = sum(count for _, count in top_10_domains)
333 others_count = len(resources) - top_10_count
335 # Get unique research IDs to calculate average
336 unique_research_ids = {r.research_id for r in resources}
337 avg_links = (
338 len(resources) / len(unique_research_ids)
339 if unique_research_ids
340 else 0
341 )
343 # Prepare temporal trend data (sorted by date)
344 temporal_trend = sorted(
345 [
346 {"date": date, "count": count}
347 for date, count in temporal_data.items()
348 ],
349 key=lambda x: x["date"],
350 )
352 # Get most recent research for each top domain and classifications
353 domain_recent_research = {}
354 # Build domain_classifications dict from pre-loaded data
355 domain_classifications = {
356 domain: {
357 "category": classification.category,
358 "subcategory": classification.subcategory,
359 "confidence": classification.confidence,
360 }
361 for domain, classification in domain_classifications_map.items()
362 }
364 # Batch-load research details for top domains (fix N+1 query)
365 all_research_ids = []
366 domain_research_id_lists = {}
367 for domain, _ in top_10_domains:
368 if domain in domain_researches: 368 ↛ 367line 368 didn't jump to line 367 because the condition on line 368 was always true
369 ids = list(domain_researches[domain])[:3]
370 domain_research_id_lists[domain] = ids
371 all_research_ids.extend(ids)
373 research_by_id = {}
374 if all_research_ids: 374 ↛ 382line 374 didn't jump to line 382 because the condition on line 374 was always true
375 researches = (
376 session.query(ResearchHistory)
377 .filter(ResearchHistory.id.in_(all_research_ids))
378 .all()
379 )
380 research_by_id = {r.id: r for r in researches}
382 for domain, ids in domain_research_id_lists.items():
383 domain_recent_research[domain] = [
384 {
385 "id": r_id,
386 "query": research_by_id[r_id].query[:50]
387 if research_by_id.get(r_id)
388 and research_by_id[r_id].query
389 else "Research",
390 }
391 for r_id in ids
392 if r_id in research_by_id
393 ]
395 return {
396 "link_analytics": {
397 "top_domains": [
398 {
399 "domain": domain,
400 "count": count,
401 "percentage": round(
402 count / len(resources) * 100, 1
403 ),
404 "research_count": len(
405 domain_researches.get(domain, set())
406 ),
407 "recent_researches": domain_recent_research.get(
408 domain, []
409 ),
410 "classification": domain_classifications.get(
411 domain, None
412 ),
413 }
414 for domain, count in top_10_domains
415 ],
416 "total_unique_domains": len(domain_counts),
417 "avg_links_per_research": round(avg_links, 1),
418 "domain_distribution": {
419 "top_10": top_10_count,
420 "others": others_count,
421 },
422 "source_type_analysis": source_types,
423 "category_distribution": category_counts,
424 # Generic pie chart data - use whatever LLM classifier outputs
425 "domain_categories": category_counts,
426 "total_links": len(resources),
427 "total_researches": len(unique_research_ids),
428 "temporal_trend": temporal_trend,
429 "domain_metrics": {
430 domain: {
431 "usage_count": count,
432 "usage_percentage": round(
433 count / len(resources) * 100, 1
434 ),
435 "research_diversity": len(
436 domain_researches.get(domain, set())
437 ),
438 "frequency_rank": rank + 1,
439 }
440 for rank, (domain, count) in enumerate(top_10_domains)
441 },
442 }
443 }
445 except Exception:
446 logger.exception("Error getting link analytics")
447 return {
448 "link_analytics": {
449 "top_domains": [],
450 "total_unique_domains": 0,
451 "avg_links_per_research": 0,
452 "domain_distribution": {},
453 "source_type_analysis": {},
454 "academic_vs_general": {},
455 "total_links": 0,
456 "error": "Failed to retrieve link analytics",
457 }
458 }
461def get_available_strategies():
462 """Get list of all available search strategies, for the analytics UI.
464 Derived from ``constants.AVAILABLE_STRATEGIES`` -- the single
465 app-wide source of truth for UI-facing strategies (also consumed by
466 ``search_system_factory.create_strategy``, the settings UI, the news
467 subscription form, and the MCP server). This function only adapts the
468 shape: it keeps this endpoint's existing ``{"name", "description"}``
469 wire contract by dropping the ``"label"`` field that the central list
470 carries for dropdown widgets, so response consumers are unaffected.
472 Do not hand-maintain a separate strategy list here -- add new
473 strategies to ``AVAILABLE_STRATEGIES`` in constants.py instead.
474 """
475 return [
476 {"name": strategy["name"], "description": strategy["description"]}
477 for strategy in get_central_available_strategies()
478 ]
481def get_strategy_analytics(period="30d", username=None):
482 """Get strategy usage analytics for the specified period."""
483 try:
484 if not username:
485 return {
486 "strategy_analytics": {
487 "total_research_with_strategy": 0,
488 "total_research": 0,
489 "most_popular_strategy": None,
490 "strategy_usage": [],
491 "strategy_distribution": {},
492 "available_strategies": get_available_strategies(),
493 "error": "No user session",
494 }
495 }
497 # Calculate date range
498 days = get_period_days(period)
500 with get_user_db_session(username) as session:
501 # Check if we have any ResearchStrategy records
502 strategy_count = session.query(ResearchStrategy).count()
504 if strategy_count == 0:
505 logger.warning("No research strategies found in database")
506 return {
507 "strategy_analytics": {
508 "total_research_with_strategy": 0,
509 "total_research": 0,
510 "most_popular_strategy": None,
511 "strategy_usage": [],
512 "strategy_distribution": {},
513 "available_strategies": get_available_strategies(),
514 "message": "Strategy tracking not yet available - run a research to start tracking",
515 }
516 }
518 # Base query for strategy usage (no JOIN needed since we just want strategy counts)
519 query = session.query(
520 ResearchStrategy.strategy_name,
521 func.count(ResearchStrategy.id).label("usage_count"),
522 )
524 # Apply time filter if specified
525 if days:
526 cutoff_date = datetime.now(UTC) - timedelta(days=days)
527 query = query.filter(ResearchStrategy.created_at >= cutoff_date)
529 # Group by strategy and order by usage
530 strategy_results = (
531 query.group_by(ResearchStrategy.strategy_name)
532 .order_by(func.count(ResearchStrategy.id).desc())
533 .all()
534 )
536 # Get total strategy count for percentage calculation
537 total_query = session.query(ResearchStrategy)
538 if days:
539 total_query = total_query.filter(
540 ResearchStrategy.created_at >= cutoff_date
541 )
542 total_research = total_query.count()
544 # Format strategy data
545 strategy_usage = []
546 strategy_distribution = {}
548 for strategy_name, usage_count in strategy_results:
549 percentage = (
550 (usage_count / total_research * 100)
551 if total_research > 0
552 else 0
553 )
554 strategy_usage.append(
555 {
556 "strategy": strategy_name,
557 "count": usage_count,
558 "percentage": round(percentage, 1),
559 }
560 )
561 strategy_distribution[strategy_name] = usage_count
563 # Find most popular strategy
564 most_popular = (
565 strategy_usage[0]["strategy"] if strategy_usage else None
566 )
568 return {
569 "strategy_analytics": {
570 "total_research_with_strategy": sum(
571 item["count"] for item in strategy_usage
572 ),
573 "total_research": total_research,
574 "most_popular_strategy": most_popular,
575 "strategy_usage": strategy_usage,
576 "strategy_distribution": strategy_distribution,
577 "available_strategies": get_available_strategies(),
578 }
579 }
581 except Exception:
582 logger.exception("Error getting strategy analytics")
583 return {
584 "strategy_analytics": {
585 "total_research_with_strategy": 0,
586 "total_research": 0,
587 "most_popular_strategy": None,
588 "strategy_usage": [],
589 "strategy_distribution": {},
590 "available_strategies": get_available_strategies(),
591 "error": "Failed to retrieve strategy data",
592 }
593 }
596def get_rate_limiting_analytics(period="30d", username=None):
597 """Get rate limiting analytics for the specified period."""
598 try:
599 if not username:
600 return {
601 "rate_limiting": {
602 "total_attempts": 0,
603 "successful_attempts": 0,
604 "failed_attempts": 0,
605 "success_rate": 0,
606 "rate_limit_events": 0,
607 "avg_wait_time": 0,
608 "avg_successful_wait": 0,
609 "tracked_engines": 0,
610 "engine_stats": [],
611 "total_engines_tracked": 0,
612 "healthy_engines": 0,
613 "degraded_engines": 0,
614 "poor_engines": 0,
615 "error": "No user session",
616 }
617 }
619 # Calculate date range for timestamp filtering
620 import time
622 if period == "7d":
623 cutoff_time = time.time() - (7 * 24 * 3600)
624 elif period == "30d":
625 cutoff_time = time.time() - (30 * 24 * 3600)
626 elif period == "3m": 626 ↛ 627line 626 didn't jump to line 627 because the condition on line 626 was never true
627 cutoff_time = time.time() - (90 * 24 * 3600)
628 elif period == "1y":
629 cutoff_time = time.time() - (365 * 24 * 3600)
630 else: # all
631 cutoff_time = 0
633 with get_user_db_session(username) as session:
634 # Rate-limit analytics are derived from RateLimitEstimate, the
635 # learned per-engine wait-time model that production code
636 # actually persists. The raw per-attempt table
637 # (RateLimitAttempt) is intentionally NOT written — attempt
638 # persistence was disabled (commit fef359be9) to avoid DB
639 # locking under parallel search — so the previous code, which
640 # read RateLimitAttempt, always returned an empty panel.
641 #
642 # Limitations of deriving from estimates (documented so the
643 # numbers aren't mistaken for raw-attempt counts):
644 # - total_attempts is each engine's recent rolling window
645 # (capped at rate_limiting.memory_window, default 100), not
646 # a lifetime count.
647 # - rate_limit_events (RateLimitError-specific failures) and a
648 # true per-attempt average wait cannot be reconstructed and
649 # are reported as 0 / the learned base wait respectively.
650 estimates_query = session.query(RateLimitEstimate)
652 # Recency filter uses the estimate's last_updated (epoch
653 # seconds); there is no per-attempt timestamp history.
654 if cutoff_time > 0:
655 estimates_query = estimates_query.filter(
656 RateLimitEstimate.last_updated >= cutoff_time
657 )
659 estimates = estimates_query.all()
661 engine_stats = []
662 total_attempts = 0
663 successful_attempts = 0
664 base_wait_sum = 0.0
666 for estimate in estimates:
667 # success_rate is stored as a 0..1 fraction.
668 success_rate_pct = round(estimate.success_rate * 100, 1)
669 engine_attempts = estimate.total_attempts or 0
670 engine_success = round(engine_attempts * estimate.success_rate)
672 total_attempts += engine_attempts
673 successful_attempts += engine_success
674 base_wait_sum += estimate.base_wait_seconds
676 status = (
677 "healthy"
678 if estimate.success_rate > 0.8
679 else "degraded"
680 if estimate.success_rate > 0.5
681 else "poor"
682 )
684 engine_stats.append(
685 {
686 "engine": estimate.engine_type,
687 "base_wait": estimate.base_wait_seconds,
688 "base_wait_seconds": round(
689 estimate.base_wait_seconds, 2
690 ),
691 "min_wait_seconds": round(estimate.min_wait_seconds, 2),
692 "max_wait_seconds": round(estimate.max_wait_seconds, 2),
693 "success_rate": success_rate_pct,
694 "total_attempts": engine_attempts,
695 "recent_attempts": engine_attempts,
696 "recent_success_rate": success_rate_pct,
697 "attempts": engine_attempts,
698 "status": status,
699 # ISO format already includes timezone
700 "last_updated": datetime.fromtimestamp(
701 estimate.last_updated, UTC
702 ).isoformat(),
703 }
704 )
706 tracked_engines = len(engine_stats)
707 failed_attempts = total_attempts - successful_attempts
708 # base_wait_seconds is the learned optimal (median of recent
709 # successful waits), so it represents both the typical wait and
710 # the typical successful wait; a true per-attempt average needs
711 # the raw attempts table.
712 avg_wait_time = (
713 base_wait_sum / tracked_engines if tracked_engines else 0
714 )
715 avg_successful_wait = avg_wait_time
716 # Not derivable from estimates (needs the raw attempts table).
717 rate_limit_events = 0
719 logger.info(
720 f"Rate limiting analytics from estimates: "
721 f"tracked_engines={tracked_engines}, "
722 f"total_attempts(recent)={total_attempts}"
723 )
725 result = {
726 "rate_limiting": {
727 "total_attempts": total_attempts,
728 "successful_attempts": successful_attempts,
729 "failed_attempts": failed_attempts,
730 "success_rate": (successful_attempts / total_attempts * 100)
731 if total_attempts > 0
732 else 0,
733 "rate_limit_events": rate_limit_events,
734 "avg_wait_time": round(float(avg_wait_time), 2),
735 "avg_successful_wait": round(float(avg_successful_wait), 2),
736 "tracked_engines": tracked_engines,
737 "engine_stats": engine_stats,
738 "total_engines_tracked": tracked_engines,
739 "healthy_engines": len(
740 [s for s in engine_stats if s["status"] == "healthy"]
741 ),
742 "degraded_engines": len(
743 [s for s in engine_stats if s["status"] == "degraded"]
744 ),
745 "poor_engines": len(
746 [s for s in engine_stats if s["status"] == "poor"]
747 ),
748 }
749 }
751 logger.info(
752 f"DEBUG: Returning rate_limiting_analytics result: {result}"
753 )
754 return result
756 except Exception:
757 logger.exception("Error getting rate limiting analytics")
758 return {
759 "rate_limiting": {
760 "total_attempts": 0,
761 "successful_attempts": 0,
762 "failed_attempts": 0,
763 "success_rate": 0,
764 "rate_limit_events": 0,
765 "avg_wait_time": 0,
766 "avg_successful_wait": 0,
767 "tracked_engines": 0,
768 "engine_stats": [],
769 "total_engines_tracked": 0,
770 "healthy_engines": 0,
771 "degraded_engines": 0,
772 "poor_engines": 0,
773 "error": "An internal error occurred while processing the request.",
774 }
775 }
778@router.get("/")
779def metrics_dashboard(
780 request: Request, username: Annotated[str, Depends(require_auth)]
781):
782 """Render the metrics dashboard page."""
783 return templates.TemplateResponse(
784 request=request, name="pages/metrics.html", context={"request": request}
785 )
788@router.get("/context-overflow")
789def context_overflow_page(
790 request: Request, username: Annotated[str, Depends(require_auth)]
791):
792 """Context overflow analytics page."""
793 return templates.TemplateResponse(
794 request=request,
795 name="pages/context_overflow.html",
796 context={"request": request},
797 )
800@router.get("/api/metrics")
801def api_metrics(
802 request: Request, username: Annotated[str, Depends(require_auth)]
803):
804 """Get overall metrics data."""
805 logger.debug("api_metrics endpoint called")
806 try:
807 # Get username from session
809 # Get time period and research mode from query parameters
810 period = request.query_params.get("period", "30d")
811 research_mode = request.query_params.get("mode", "all")
813 token_counter = TokenCounter()
814 search_tracker = get_search_tracker()
816 # Get both token and search metrics
817 token_metrics = token_counter.get_overall_metrics(
818 period=period, research_mode=research_mode, username=username
819 )
820 search_metrics = search_tracker.get_search_metrics(
821 period=period,
822 research_mode=research_mode,
823 username=username,
824 )
826 # Get user satisfaction rating data
827 try:
828 with get_user_db_session(username) as session:
829 # Build base query with time filter
830 ratings_query = session.query(ResearchRating)
831 time_condition = get_time_filter_condition(
832 period, ResearchRating.created_at
833 )
834 if time_condition is not None:
835 ratings_query = ratings_query.filter(time_condition)
837 # Get average rating
838 avg_rating = ratings_query.with_entities(
839 func.avg(ResearchRating.rating).label("avg_rating")
840 ).scalar()
842 # Get total rating count
843 total_ratings = ratings_query.count()
845 user_satisfaction = {
846 "avg_rating": round(avg_rating, 1) if avg_rating else None,
847 "total_ratings": total_ratings,
848 }
849 except Exception:
850 logger.exception("Error getting user satisfaction data")
851 user_satisfaction = {"avg_rating": None, "total_ratings": 0}
853 # Get strategy analytics
854 strategy_data = get_strategy_analytics(period, username)
855 logger.debug(f"strategy_data keys: {list(strategy_data.keys())}")
857 # Get rate limiting analytics
858 rate_limiting_data = get_rate_limiting_analytics(period, username)
859 logger.debug(f"rate_limiting_data: {rate_limiting_data}")
860 logger.debug(
861 f"rate_limiting_data keys: {list(rate_limiting_data.keys())}"
862 )
864 # Truncation summary surfaced on the main dashboard. Failure sentinel
865 # is None (not 0): a real zero means "no truncation", so falling back
866 # to 0 on error would silently flip a red signal green.
867 context_overflow_data = {
868 "truncation_rate": None,
869 "avg_tokens_truncated": None,
870 }
871 try:
872 with get_user_db_session(username) as session:
873 # Honor the dashboard's research_mode filter the same way the
874 # rest of api_metrics() does (token_metrics, search_metrics,
875 # etc.). Without this the panel ignores mode toggles.
876 summary = get_context_overflow_truncation_summary(
877 session, period, research_mode=research_mode
878 )
879 context_overflow_data = {
880 "truncation_rate": round(summary["truncation_rate"], 1),
881 "avg_tokens_truncated": int(summary["avg_tokens_truncated"]),
882 }
883 except Exception:
884 logger.exception(
885 "Error getting context overflow summary for /api/metrics"
886 )
888 # Combine metrics
889 combined_metrics = {
890 **token_metrics,
891 **search_metrics,
892 **strategy_data,
893 **rate_limiting_data,
894 **context_overflow_data,
895 "user_satisfaction": user_satisfaction,
896 }
898 logger.debug(f"combined_metrics keys: {list(combined_metrics.keys())}")
899 logger.debug(
900 f"combined_metrics['rate_limiting']: {combined_metrics.get('rate_limiting', 'NOT FOUND')}"
901 )
903 return {
904 "status": "success",
905 "metrics": combined_metrics,
906 "period": period,
907 "research_mode": research_mode,
908 }
910 except Exception:
911 logger.exception("Error getting metrics")
912 return JSONResponse(
913 {
914 "status": "error",
915 "message": "An internal error occurred. Please try again later.",
916 },
917 status_code=500,
918 )
921@router.get("/api/rate-limiting")
922def get_rate_limiting_metrics(
923 request: Request, username: Annotated[str, Depends(require_auth)]
924):
925 """Get detailed rate limiting metrics."""
926 logger.info("DEBUG: get_rate_limiting_metrics endpoint called")
927 try:
928 period = request.query_params.get("period", "30d")
929 rate_limiting_data = get_rate_limiting_analytics(period, username)
931 return {
932 "status": "success",
933 "data": rate_limiting_data,
934 "period": period,
935 }
937 except Exception:
938 logger.exception("Error getting rate limiting metrics")
939 return JSONResponse(
940 {
941 "status": "error",
942 "message": "Failed to retrieve rate limiting metrics",
943 },
944 status_code=500,
945 )
948@router.get("/api/rate-limiting/current")
949def api_current_rate_limits(
950 request: Request, username: Annotated[str, Depends(require_auth)]
951):
952 """Get current rate limit estimates for all engines."""
953 try:
954 with get_user_db_session(username) as session:
955 estimates = (
956 session.query(RateLimitEstimate)
957 .order_by(RateLimitEstimate.engine_type)
958 .all()
959 )
961 current_limits = []
962 for est in estimates:
963 current_limits.append(
964 {
965 "engine_type": est.engine_type,
966 "base_wait_seconds": round(est.base_wait_seconds, 2),
967 "min_wait_seconds": round(est.min_wait_seconds, 2),
968 "max_wait_seconds": round(est.max_wait_seconds, 2),
969 "success_rate": round(est.success_rate * 100, 1),
970 "total_attempts": est.total_attempts,
971 "last_updated": datetime.fromtimestamp(
972 est.last_updated, UTC
973 ).isoformat(),
974 "status": "healthy"
975 if est.success_rate > 0.8
976 else "degraded"
977 if est.success_rate > 0.5
978 else "poor",
979 }
980 )
982 return {
983 "status": "success",
984 "current_limits": current_limits,
985 "timestamp": datetime.now(UTC).isoformat(),
986 }
987 except Exception:
988 logger.exception("Error getting current rate limits")
989 return JSONResponse(
990 {
991 "status": "error",
992 "message": "Failed to retrieve current rate limits",
993 },
994 status_code=500,
995 )
998@router.get("/api/metrics/research/{research_id}/links")
999def api_research_link_metrics(
1000 request: Request,
1001 research_id,
1002 username: Annotated[str, Depends(require_auth)],
1003):
1004 """Get link analytics for a specific research."""
1005 try:
1006 with get_user_db_session(username) as session:
1007 # Get all resources for this specific research
1008 resources = (
1009 session.query(ResearchResource)
1010 .filter(ResearchResource.research_id == research_id)
1011 .all()
1012 )
1014 if not resources:
1015 return {
1016 "status": "success",
1017 "data": {
1018 "total_links": 0,
1019 "unique_domains": 0,
1020 "domains": [],
1021 "category_distribution": {},
1022 "domain_categories": {},
1023 "resources": [],
1024 },
1025 }
1027 # Extract domain information
1028 domain_counts: dict[str, Any] = {}
1030 # Generic category counting from LLM classifications
1031 category_counts: dict[str, Any] = {}
1033 # First pass: collect all domains
1034 all_domains = set()
1035 for resource in resources:
1036 if resource.url:
1037 domain = _extract_domain(resource.url)
1038 if domain: 1038 ↛ 1035line 1038 didn't jump to line 1035 because the condition on line 1038 was always true
1039 all_domains.add(domain)
1041 # Batch load all domain classifications in one query (fix N+1)
1042 domain_classifications_map = {}
1043 if all_domains: 1043 ↛ 1055line 1043 didn't jump to line 1055 because the condition on line 1043 was always true
1044 all_classifications = (
1045 session.query(DomainClassification)
1046 .filter(DomainClassification.domain.in_(all_domains))
1047 .all()
1048 )
1049 for classification in all_classifications: 1049 ↛ 1050line 1049 didn't jump to line 1050 because the loop on line 1049 never started
1050 domain_classifications_map[classification.domain] = (
1051 classification
1052 )
1054 # Second pass: process resources with pre-loaded classifications
1055 for resource in resources:
1056 if resource.url:
1057 try:
1058 domain = _extract_domain(resource.url)
1059 if not domain: 1059 ↛ 1060line 1059 didn't jump to line 1060 because the condition on line 1059 was never true
1060 continue
1062 domain_counts[domain] = domain_counts.get(domain, 0) + 1
1064 # Count categories from pre-loaded classifications (no N+1)
1065 classification = domain_classifications_map.get(domain)
1066 if classification: 1066 ↛ 1067line 1066 didn't jump to line 1067 because the condition on line 1066 was never true
1067 category = classification.category
1068 category_counts[category] = (
1069 category_counts.get(category, 0) + 1
1070 )
1071 else:
1072 category_counts["Unclassified"] = (
1073 category_counts.get("Unclassified", 0) + 1
1074 )
1075 except (AttributeError, KeyError) as e:
1076 logger.debug(f"Error classifying domain {domain}: {e}")
1078 # Sort domains by count
1079 sorted_domains = sorted(
1080 domain_counts.items(), key=lambda x: x[1], reverse=True
1081 )
1083 return {
1084 "status": "success",
1085 "data": {
1086 "total_links": len(resources),
1087 "unique_domains": len(domain_counts),
1088 "domains": [
1089 {
1090 "domain": domain,
1091 "count": count,
1092 "percentage": round(
1093 count / len(resources) * 100, 1
1094 ),
1095 }
1096 for domain, count in sorted_domains[
1097 :20
1098 ] # Top 20 domains
1099 ],
1100 "category_distribution": category_counts,
1101 "domain_categories": category_counts, # Generic categories from LLM
1102 "resources": [
1103 {
1104 "title": r.title or "Untitled",
1105 "url": r.url,
1106 "preview": r.content_preview[:200]
1107 if r.content_preview
1108 else None,
1109 }
1110 for r in resources[:10] # First 10 resources
1111 ],
1112 },
1113 }
1115 except Exception:
1116 logger.exception("Error getting research link metrics")
1117 return JSONResponse(
1118 {"status": "error", "message": "Failed to retrieve link metrics"},
1119 status_code=500,
1120 )
1123@router.get("/api/metrics/research/{research_id}")
1124def api_research_metrics(
1125 request: Request,
1126 research_id,
1127 username: Annotated[str, Depends(require_auth)],
1128):
1129 """Get metrics for a specific research."""
1130 try:
1131 token_counter = TokenCounter()
1132 metrics = token_counter.get_research_metrics(
1133 research_id, username=username
1134 )
1135 return {"status": "success", "metrics": metrics}
1136 except Exception:
1137 logger.exception("Error getting research metrics")
1138 return JSONResponse(
1139 {
1140 "status": "error",
1141 "message": "An internal error occurred. Please try again later.",
1142 },
1143 status_code=500,
1144 )
1147@router.get("/api/metrics/research/{research_id}/timeline")
1148def api_research_timeline_metrics(
1149 request: Request,
1150 research_id,
1151 username: Annotated[str, Depends(require_auth)],
1152):
1153 """Get timeline metrics for a specific research."""
1154 try:
1155 token_counter = TokenCounter()
1156 timeline_metrics = token_counter.get_research_timeline_metrics(
1157 research_id, username=username
1158 )
1159 return {"status": "success", "metrics": timeline_metrics}
1160 except Exception:
1161 logger.exception("Error getting research timeline metrics")
1162 return JSONResponse(
1163 {
1164 "status": "error",
1165 "message": "An internal error occurred. Please try again later.",
1166 },
1167 status_code=500,
1168 )
1171@router.get("/api/metrics/research/{research_id}/search")
1172def api_research_search_metrics(
1173 request: Request,
1174 research_id,
1175 username: Annotated[str, Depends(require_auth)],
1176):
1177 """Get search metrics for a specific research."""
1178 try:
1179 search_tracker = get_search_tracker()
1180 search_metrics = search_tracker.get_research_search_metrics(
1181 research_id, username=username
1182 )
1183 return {"status": "success", "metrics": search_metrics}
1184 except Exception:
1185 logger.exception("Error getting research search metrics")
1186 return JSONResponse(
1187 {
1188 "status": "error",
1189 "message": "An internal error occurred. Please try again later.",
1190 },
1191 status_code=500,
1192 )
1195@router.get("/api/metrics/enhanced")
1196def api_enhanced_metrics(
1197 request: Request, username: Annotated[str, Depends(require_auth)]
1198):
1199 """Get enhanced Phase 1 tracking metrics."""
1200 try:
1201 # Get time period and research mode from query parameters
1202 period = request.query_params.get("period", "30d")
1203 research_mode = request.query_params.get("mode", "all")
1205 token_counter = TokenCounter()
1206 search_tracker = get_search_tracker()
1208 enhanced_metrics = token_counter.get_enhanced_metrics(
1209 period=period, research_mode=research_mode, username=username
1210 )
1212 # Add search time series data for the chart
1213 search_time_series = search_tracker.get_search_time_series(
1214 period=period,
1215 research_mode=research_mode,
1216 username=username,
1217 )
1218 enhanced_metrics["search_time_series"] = search_time_series
1220 # Add rating analytics
1221 rating_analytics = get_rating_analytics(period, research_mode, username)
1222 enhanced_metrics.update(rating_analytics)
1224 return {
1225 "status": "success",
1226 "metrics": enhanced_metrics,
1227 "period": period,
1228 "research_mode": research_mode,
1229 }
1231 except Exception:
1232 logger.exception("Error getting enhanced metrics")
1233 return JSONResponse(
1234 {
1235 "status": "error",
1236 "message": "An internal error occurred. Please try again later.",
1237 },
1238 status_code=500,
1239 )
1242@router.get("/api/ratings/{research_id}")
1243def api_get_research_rating(
1244 request: Request,
1245 research_id,
1246 username: Annotated[str, Depends(require_auth)],
1247):
1248 """Get rating for a specific research session."""
1249 try:
1250 with get_user_db_session(username) as session:
1251 rating = (
1252 session.query(ResearchRating)
1253 .filter_by(research_id=research_id)
1254 .first()
1255 )
1257 if rating:
1258 return {
1259 "status": "success",
1260 "rating": rating.rating,
1261 "created_at": rating.created_at.isoformat(),
1262 "updated_at": rating.updated_at.isoformat(),
1263 }
1264 return {"status": "success", "rating": None}
1266 except Exception:
1267 logger.exception("Error getting research rating")
1268 return JSONResponse(
1269 {
1270 "status": "error",
1271 "message": "An internal error occurred. Please try again later.",
1272 },
1273 status_code=500,
1274 )
1277@router.post("/api/ratings/{research_id}")
1278async def api_save_research_rating(
1279 request: Request,
1280 research_id,
1281 username: Annotated[str, Depends(require_auth)],
1282):
1283 """Save or update rating for a specific research session."""
1284 # Parsed outside the try/except below: a malformed body must raise
1285 # json.JSONDecodeError uncaught so it reaches the app's registered
1286 # handler (-> 400), not the broad `except Exception` further down
1287 # (which would otherwise turn it into a hardcoded 500).
1288 data = await request.json()
1289 if not isinstance(data, dict):
1290 return json_body_error("status", "Request body must be valid JSON")
1291 try:
1292 rating_value = data.get("rating")
1294 if (
1295 not isinstance(rating_value, int)
1296 or isinstance(rating_value, bool)
1297 or rating_value < 1
1298 or rating_value > 5
1299 ):
1300 return JSONResponse(
1301 {
1302 "status": "error",
1303 "message": "Rating must be an integer between 1 and 5",
1304 },
1305 status_code=400,
1306 )
1308 # Optional sub-dimension fields (1-5)
1309 sub_dimensions = {}
1310 for field in ("accuracy", "completeness", "relevance", "readability"):
1311 val = data.get(field)
1312 if val is not None:
1313 if (
1314 not isinstance(val, int)
1315 or isinstance(val, bool)
1316 or val < 1
1317 or val > 5
1318 ):
1319 return JSONResponse(
1320 {
1321 "status": "error",
1322 "message": f"{field} must be an integer between 1 and 5",
1323 },
1324 status_code=400,
1325 )
1326 sub_dimensions[field] = val
1328 feedback_text = data.get("feedback")
1329 if feedback_text is not None:
1330 if not isinstance(feedback_text, str):
1331 return JSONResponse(
1332 {
1333 "status": "error",
1334 "message": "feedback must be a string",
1335 },
1336 status_code=400,
1337 )
1338 if len(feedback_text) > 10000:
1339 return JSONResponse(
1340 {
1341 "status": "error",
1342 "message": "feedback must be 10000 characters or fewer",
1343 },
1344 status_code=400,
1345 )
1347 def _save_sync():
1348 with get_user_db_session(username) as session:
1349 existing_rating = (
1350 session.query(ResearchRating)
1351 .filter_by(research_id=research_id)
1352 .first()
1353 )
1354 if existing_rating:
1355 existing_rating.rating = rating_value
1356 existing_rating.updated_at = func.now()
1357 for field, val in sub_dimensions.items():
1358 setattr(existing_rating, field, val)
1359 if feedback_text is not None: 1359 ↛ 1360line 1359 didn't jump to line 1360 because the condition on line 1359 was never true
1360 existing_rating.feedback = feedback_text
1361 else:
1362 session.add(
1363 ResearchRating(
1364 research_id=research_id,
1365 rating=rating_value,
1366 feedback=feedback_text,
1367 **sub_dimensions,
1368 )
1369 )
1370 session.commit()
1372 await run_db_sync(_save_sync)
1373 return {
1374 "status": "success",
1375 "message": "Rating saved successfully",
1376 "rating": rating_value,
1377 }
1379 except Exception:
1380 logger.exception("Error saving research rating")
1381 return JSONResponse(
1382 {
1383 "status": "error",
1384 "message": "An internal error occurred. Please try again later.",
1385 },
1386 status_code=500,
1387 )
1390@router.get("/star-reviews")
1391def star_reviews(
1392 request: Request, username: Annotated[str, Depends(require_auth)]
1393):
1394 """Display star reviews metrics page."""
1395 return templates.TemplateResponse(
1396 request=request,
1397 name="pages/star_reviews.html",
1398 context={"request": request},
1399 )
1402@router.get("/costs")
1403def cost_analytics(
1404 request: Request, username: Annotated[str, Depends(require_auth)]
1405):
1406 """Display cost analytics page."""
1407 return templates.TemplateResponse(
1408 request=request,
1409 name="pages/cost_analytics.html",
1410 context={"request": request},
1411 )
1414@router.get("/api/star-reviews")
1415def api_star_reviews(
1416 request: Request, username: Annotated[str, Depends(require_auth)]
1417):
1418 """Get star reviews analytics data."""
1419 try:
1420 period = request.query_params.get("period", "30d")
1422 with get_user_db_session(username) as session:
1423 # Build base query with time filter
1424 base_query = session.query(ResearchRating)
1425 time_condition = get_time_filter_condition(
1426 period, ResearchRating.created_at
1427 )
1428 if time_condition is not None:
1429 base_query = base_query.filter(time_condition)
1431 # Overall rating statistics
1432 overall_stats = session.query(
1433 func.avg(ResearchRating.rating).label("avg_rating"),
1434 func.count(ResearchRating.rating).label("total_ratings"),
1435 func.sum(case((ResearchRating.rating == 5, 1), else_=0)).label(
1436 "five_star"
1437 ),
1438 func.sum(case((ResearchRating.rating == 4, 1), else_=0)).label(
1439 "four_star"
1440 ),
1441 func.sum(case((ResearchRating.rating == 3, 1), else_=0)).label(
1442 "three_star"
1443 ),
1444 func.sum(case((ResearchRating.rating == 2, 1), else_=0)).label(
1445 "two_star"
1446 ),
1447 func.sum(case((ResearchRating.rating == 1, 1), else_=0)).label(
1448 "one_star"
1449 ),
1450 )
1452 if time_condition is not None:
1453 overall_stats = overall_stats.filter(time_condition)
1455 overall_stats = overall_stats.first()
1457 # Ratings by LLM model. A research has many token_usage rows (one per
1458 # LLM call), so joining TokenUsage directly fans out and multiplies the
1459 # counts/averages. Collapse to distinct (research_id, model) pairs first
1460 # so each rating is counted once per model it actually used.
1461 # Normalize the "missing model" sentinels — empty/whitespace and the
1462 # lowercase "unknown" fallback the token counter writes — to a single
1463 # "Unknown" *inside* the subquery, so DISTINCT dedups on the normalized
1464 # value. (Normalizing only at group_by time lets "" and "unknown"
1465 # survive DISTINCT as separate rows and double-count one rating into the
1466 # merged bucket.) Real model names keep their original casing via else_.
1467 normalized_model = case(
1468 (
1469 func.lower(func.trim(TokenUsage.model_name)).in_(
1470 ["", "unknown"]
1471 ),
1472 "Unknown",
1473 ),
1474 else_=TokenUsage.model_name,
1475 )
1476 research_models = (
1477 session.query(
1478 TokenUsage.research_id.label("research_id"),
1479 normalized_model.label("model_name"),
1480 )
1481 .distinct()
1482 .subquery()
1483 )
1484 # A rating with no token rows has no subquery match -> NULL via the
1485 # outerjoin -> "Unknown".
1486 model_label = func.coalesce(research_models.c.model_name, "Unknown")
1488 llm_ratings_query = (
1489 session.query(
1490 model_label.label("model"),
1491 func.avg(ResearchRating.rating).label("avg_rating"),
1492 func.count(ResearchRating.rating).label("rating_count"),
1493 func.sum(
1494 case((ResearchRating.rating >= 4, 1), else_=0)
1495 ).label("positive_ratings"),
1496 func.sum(
1497 case((ResearchRating.rating == 3, 1), else_=0)
1498 ).label("neutral_ratings"),
1499 func.sum(
1500 case((ResearchRating.rating <= 2, 1), else_=0)
1501 ).label("negative_ratings"),
1502 )
1503 .select_from(ResearchRating)
1504 .outerjoin(
1505 research_models,
1506 ResearchRating.research_id == research_models.c.research_id,
1507 )
1508 )
1510 if time_condition is not None:
1511 llm_ratings_query = llm_ratings_query.filter(time_condition)
1513 llm_ratings = (
1514 llm_ratings_query.group_by(model_label)
1515 .order_by(func.avg(ResearchRating.rating).desc())
1516 .all()
1517 )
1519 # Ratings by search engine. Same one-to-many fan-out concern as the LLM
1520 # query — collapse to distinct (research_id, search_engine) pairs first.
1521 # search_engine_selected is NULL on non-search LLM-call rows (and is
1522 # occasionally an empty/whitespace string); exclude both so a research
1523 # that used a real engine isn't ALSO attributed to the "Unknown" bucket.
1524 # A research with no recorded engine still falls through the outerjoin to
1525 # "Unknown" exactly once.
1526 research_engines = (
1527 session.query(
1528 TokenUsage.research_id.label("research_id"),
1529 TokenUsage.search_engine_selected.label("search_engine"),
1530 )
1531 .filter(
1532 func.trim(
1533 func.coalesce(TokenUsage.search_engine_selected, "")
1534 )
1535 != ""
1536 )
1537 .distinct()
1538 .subquery()
1539 )
1540 engine_label = func.coalesce(
1541 research_engines.c.search_engine, "Unknown"
1542 )
1544 search_engine_ratings_query = (
1545 session.query(
1546 engine_label.label("search_engine"),
1547 func.avg(ResearchRating.rating).label("avg_rating"),
1548 func.count(ResearchRating.rating).label("rating_count"),
1549 func.sum(
1550 case((ResearchRating.rating >= 4, 1), else_=0)
1551 ).label("positive_ratings"),
1552 )
1553 .select_from(ResearchRating)
1554 .outerjoin(
1555 research_engines,
1556 ResearchRating.research_id
1557 == research_engines.c.research_id,
1558 )
1559 )
1561 if time_condition is not None:
1562 search_engine_ratings_query = (
1563 search_engine_ratings_query.filter(time_condition)
1564 )
1566 search_engine_ratings = (
1567 search_engine_ratings_query.group_by(engine_label)
1568 .having(func.count(ResearchRating.rating) > 0)
1569 .order_by(func.avg(ResearchRating.rating).desc())
1570 .all()
1571 )
1573 # Rating trends over time
1574 rating_trends_query = session.query(
1575 func.date(ResearchRating.created_at).label("date"),
1576 func.avg(ResearchRating.rating).label("avg_rating"),
1577 func.count(ResearchRating.rating).label("daily_count"),
1578 )
1580 if time_condition is not None:
1581 rating_trends_query = rating_trends_query.filter(time_condition)
1583 rating_trends = (
1584 rating_trends_query.group_by(
1585 func.date(ResearchRating.created_at)
1586 )
1587 .order_by("date")
1588 .all()
1589 )
1591 # Ratings by research mode
1592 mode_ratings_query = session.query(
1593 func.coalesce(ResearchHistory.mode, "Unknown").label("mode"),
1594 func.avg(ResearchRating.rating).label("avg_rating"),
1595 func.count(ResearchRating.rating).label("rating_count"),
1596 func.sum(case((ResearchRating.rating >= 4, 1), else_=0)).label(
1597 "positive_ratings"
1598 ),
1599 ).outerjoin(
1600 ResearchHistory,
1601 ResearchRating.research_id == ResearchHistory.id,
1602 )
1604 if time_condition is not None:
1605 mode_ratings_query = mode_ratings_query.filter(time_condition)
1607 mode_ratings = (
1608 mode_ratings_query.group_by(ResearchHistory.mode)
1609 .having(func.count(ResearchRating.rating) > 0)
1610 .order_by(func.avg(ResearchRating.rating).desc())
1611 .all()
1612 )
1614 # Quality dimension averages. Each sub-dimension is independently
1615 # nullable, so count them separately; dimension_count reflects rows
1616 # with ANY dimension filled, so the radar isn't hidden when only some
1617 # dimensions have data.
1618 dimension_stats = session.query(
1619 func.avg(ResearchRating.accuracy).label("avg_accuracy"),
1620 func.avg(ResearchRating.completeness).label("avg_completeness"),
1621 func.avg(ResearchRating.relevance).label("avg_relevance"),
1622 func.avg(ResearchRating.readability).label("avg_readability"),
1623 func.count(ResearchRating.accuracy).label("count_accuracy"),
1624 func.count(ResearchRating.completeness).label(
1625 "count_completeness"
1626 ),
1627 func.count(ResearchRating.relevance).label("count_relevance"),
1628 func.count(ResearchRating.readability).label(
1629 "count_readability"
1630 ),
1631 func.count(
1632 func.coalesce(
1633 ResearchRating.accuracy,
1634 ResearchRating.completeness,
1635 ResearchRating.relevance,
1636 ResearchRating.readability,
1637 )
1638 ).label("dimension_count"),
1639 )
1641 if time_condition is not None:
1642 dimension_stats = dimension_stats.filter(time_condition)
1644 dimension_stats = dimension_stats.first()
1646 # Recent ratings with research details. Join a one-row-per-research
1647 # model subquery instead of TokenUsage directly: a research has many
1648 # token_usage rows, which would fan out and make limit(20) return far
1649 # fewer than 20 unique ratings (the same rating duplicated per row).
1650 recent_model_subq = (
1651 session.query(
1652 TokenUsage.research_id.label("research_id"),
1653 func.max(TokenUsage.model_name).label("model_name"),
1654 )
1655 .group_by(TokenUsage.research_id)
1656 .subquery()
1657 )
1659 recent_ratings_query = (
1660 session.query(
1661 ResearchRating.rating,
1662 ResearchRating.created_at,
1663 ResearchRating.research_id,
1664 ResearchHistory.query,
1665 ResearchHistory.mode,
1666 recent_model_subq.c.model_name,
1667 ResearchRating.feedback,
1668 )
1669 .outerjoin(
1670 ResearchHistory,
1671 ResearchRating.research_id == ResearchHistory.id,
1672 )
1673 .outerjoin(
1674 recent_model_subq,
1675 ResearchRating.research_id
1676 == recent_model_subq.c.research_id,
1677 )
1678 )
1680 if time_condition is not None:
1681 recent_ratings_query = recent_ratings_query.filter(
1682 time_condition
1683 )
1685 recent_ratings = (
1686 recent_ratings_query.order_by(ResearchRating.created_at.desc())
1687 .limit(20)
1688 .all()
1689 )
1691 # Recent feedback entries (non-empty feedback text)
1692 recent_feedback_query = session.query(
1693 ResearchRating.feedback,
1694 ResearchRating.rating,
1695 ResearchRating.created_at,
1696 ResearchRating.research_id,
1697 ).filter(
1698 ResearchRating.feedback.isnot(None),
1699 ResearchRating.feedback != "",
1700 )
1702 if time_condition is not None:
1703 recent_feedback_query = recent_feedback_query.filter(
1704 time_condition
1705 )
1707 recent_feedback = (
1708 recent_feedback_query.order_by(ResearchRating.created_at.desc())
1709 .limit(20)
1710 .all()
1711 )
1713 return {
1714 "overall_stats": {
1715 "avg_rating": round(overall_stats.avg_rating or 0, 2),
1716 "total_ratings": overall_stats.total_ratings or 0,
1717 "rating_distribution": {
1718 "5": overall_stats.five_star or 0,
1719 "4": overall_stats.four_star or 0,
1720 "3": overall_stats.three_star or 0,
1721 "2": overall_stats.two_star or 0,
1722 "1": overall_stats.one_star or 0,
1723 },
1724 },
1725 "llm_ratings": [
1726 {
1727 "model": rating.model,
1728 "avg_rating": round(rating.avg_rating or 0, 2),
1729 "rating_count": rating.rating_count or 0,
1730 "positive_ratings": rating.positive_ratings or 0,
1731 "neutral_ratings": rating.neutral_ratings or 0,
1732 "negative_ratings": rating.negative_ratings or 0,
1733 "satisfaction_rate": round(
1734 (rating.positive_ratings or 0)
1735 / max(rating.rating_count or 1, 1)
1736 * 100,
1737 1,
1738 ),
1739 }
1740 for rating in llm_ratings
1741 ],
1742 "search_engine_ratings": [
1743 {
1744 "search_engine": rating.search_engine,
1745 "avg_rating": round(rating.avg_rating or 0, 2),
1746 "rating_count": rating.rating_count or 0,
1747 "positive_ratings": rating.positive_ratings or 0,
1748 "satisfaction_rate": round(
1749 (rating.positive_ratings or 0)
1750 / max(rating.rating_count or 1, 1)
1751 * 100,
1752 1,
1753 ),
1754 }
1755 for rating in search_engine_ratings
1756 ],
1757 "rating_trends": [
1758 {
1759 "date": str(trend.date),
1760 "avg_rating": round(trend.avg_rating or 0, 2),
1761 "count": trend.daily_count or 0,
1762 }
1763 for trend in rating_trends
1764 ],
1765 "recent_ratings": [
1766 {
1767 "rating": rating.rating,
1768 "created_at": rating.created_at.isoformat()
1769 if rating.created_at
1770 else None,
1771 "research_id": rating.research_id,
1772 "query": (
1773 rating.query
1774 if rating.query
1775 else f"Research Session #{rating.research_id}"
1776 ),
1777 "mode": rating.mode
1778 if rating.mode
1779 else "Standard Research",
1780 "llm_model": (
1781 rating.model_name
1782 if rating.model_name
1783 else "LLM Model"
1784 ),
1785 "feedback": rating.feedback,
1786 }
1787 for rating in recent_ratings
1788 ],
1789 "mode_ratings": [
1790 {
1791 "mode": rating.mode,
1792 "avg_rating": round(rating.avg_rating or 0, 2),
1793 "rating_count": rating.rating_count or 0,
1794 "satisfaction_rate": round(
1795 (rating.positive_ratings or 0)
1796 / max(rating.rating_count or 1, 1)
1797 * 100,
1798 1,
1799 ),
1800 }
1801 for rating in mode_ratings
1802 ],
1803 "quality_dimensions": {
1804 "avg_accuracy": round(dimension_stats.avg_accuracy, 2)
1805 if dimension_stats.avg_accuracy is not None
1806 else None,
1807 "avg_completeness": round(
1808 dimension_stats.avg_completeness, 2
1809 )
1810 if dimension_stats.avg_completeness is not None
1811 else None,
1812 "avg_relevance": round(dimension_stats.avg_relevance, 2)
1813 if dimension_stats.avg_relevance is not None
1814 else None,
1815 "avg_readability": round(dimension_stats.avg_readability, 2)
1816 if dimension_stats.avg_readability is not None
1817 else None,
1818 "dimension_count": dimension_stats.dimension_count or 0,
1819 "dimension_counts": {
1820 "accuracy": dimension_stats.count_accuracy or 0,
1821 "completeness": dimension_stats.count_completeness or 0,
1822 "relevance": dimension_stats.count_relevance or 0,
1823 "readability": dimension_stats.count_readability or 0,
1824 },
1825 },
1826 "recent_feedback": [
1827 {
1828 "feedback": rating.feedback,
1829 "rating": rating.rating,
1830 "created_at": rating.created_at.isoformat()
1831 if rating.created_at
1832 else None,
1833 "research_id": rating.research_id,
1834 }
1835 for rating in recent_feedback
1836 ],
1837 }
1839 except Exception:
1840 logger.exception("Error getting star reviews data")
1841 return JSONResponse(
1842 {"error": "An internal error occurred. Please try again later."},
1843 status_code=500,
1844 )
1847@router.get("/api/pricing")
1848def api_pricing(
1849 request: Request, username: Annotated[str, Depends(require_auth)]
1850):
1851 """Get current LLM pricing data."""
1852 try:
1853 from ...metrics.pricing.pricing_fetcher import PricingFetcher
1855 # Use static pricing data instead of async
1856 fetcher = PricingFetcher()
1857 pricing_data = fetcher.static_pricing
1859 return {
1860 "status": "success",
1861 "pricing": pricing_data,
1862 "last_updated": datetime.now(UTC).isoformat(),
1863 "note": "Pricing data is from static configuration. Real-time APIs not available for most providers.",
1864 }
1866 except Exception:
1867 logger.exception("Error fetching pricing data")
1868 return JSONResponse({"error": "Internal Server Error"}, status_code=500)
1871@router.get("/api/pricing/{model_name}")
1872def api_model_pricing(
1873 request: Request,
1874 model_name,
1875 username: Annotated[str, Depends(require_auth)],
1876):
1877 """Get pricing for a specific model."""
1878 try:
1879 # Optional provider parameter
1880 provider = request.query_params.get("provider")
1882 from ...metrics.pricing.cost_calculator import CostCalculator
1884 # Use synchronous approach with cached/static pricing
1885 calculator = CostCalculator()
1886 pricing = calculator.cache.get_model_pricing(
1887 model_name
1888 ) or calculator.calculate_cost_sync(model_name, 1000, 1000).get(
1889 "pricing_used", {}
1890 )
1892 return {
1893 "status": "success",
1894 "model": model_name,
1895 "provider": provider,
1896 "pricing": pricing,
1897 "last_updated": datetime.now(UTC).isoformat(),
1898 }
1900 except Exception:
1901 logger.exception(f"Error getting pricing for model: {model_name}")
1902 return JSONResponse(
1903 {"error": "An internal error occurred"}, status_code=500
1904 )
1907@router.post("/api/cost-calculation")
1908async def api_cost_calculation(
1909 request: Request, username: Annotated[str, Depends(require_auth)]
1910):
1911 """Calculate cost for token usage."""
1912 # Parsed outside the try/except below: a malformed body must raise
1913 # json.JSONDecodeError uncaught so it reaches the app's registered
1914 # handler (-> 400), not the broad `except Exception` further down
1915 # (which would otherwise turn it into a hardcoded 500).
1916 data = await request.json()
1917 if not isinstance(data, dict):
1918 return json_body_error("simple", "No data provided")
1919 try:
1920 model_name = data.get("model_name")
1921 provider = data.get("provider") # Optional provider parameter
1922 prompt_tokens = data.get("prompt_tokens", 0)
1923 completion_tokens = data.get("completion_tokens", 0)
1925 if not model_name:
1926 return JSONResponse(
1927 {"error": "model_name is required"}, status_code=400
1928 )
1930 from ...metrics.pricing.cost_calculator import CostCalculator
1931 import asyncio as _asyncio
1933 # Offload the sync pricing lookup so the event loop isn't blocked
1934 # waiting on its I/O (pricing table fetch / file read).
1935 calculator = CostCalculator()
1936 cost_data = await _asyncio.to_thread(
1937 calculator.calculate_cost_sync,
1938 model_name,
1939 prompt_tokens,
1940 completion_tokens,
1941 )
1943 return {
1944 "status": "success",
1945 "model_name": model_name,
1946 "provider": provider,
1947 "prompt_tokens": prompt_tokens,
1948 "completion_tokens": completion_tokens,
1949 "total_tokens": prompt_tokens + completion_tokens,
1950 **cost_data,
1951 }
1953 except Exception:
1954 logger.exception("Error calculating cost")
1955 return JSONResponse(
1956 {"error": "An internal error occurred"}, status_code=500
1957 )
1960@router.get("/api/research-costs/{research_id}")
1961def api_research_costs(
1962 request: Request,
1963 research_id,
1964 username: Annotated[str, Depends(require_auth)],
1965):
1966 """Get cost analysis for a specific research session."""
1967 try:
1968 with get_user_db_session(username) as session:
1969 # Get token usage records for this research
1970 usage_records = (
1971 session.query(TokenUsage)
1972 .filter(TokenUsage.research_id == research_id)
1973 .all()
1974 )
1976 if not usage_records:
1977 return {
1978 "status": "success",
1979 "research_id": research_id,
1980 "total_cost": 0.0,
1981 "message": "No token usage data found for this research session",
1982 }
1984 # Convert to dict format for cost calculation
1985 usage_data = []
1986 for record in usage_records:
1987 usage_data.append(
1988 {
1989 "model_name": record.model_name,
1990 "provider": getattr(
1991 record, "provider", None
1992 ), # Handle both old and new records
1993 "prompt_tokens": record.prompt_tokens,
1994 "completion_tokens": record.completion_tokens,
1995 "timestamp": record.timestamp,
1996 }
1997 )
1999 from ...metrics.pricing.cost_calculator import CostCalculator
2001 # Use synchronous calculation for research costs
2002 calculator = CostCalculator()
2003 costs = []
2004 for record in usage_data:
2005 cost_data = calculator.calculate_cost_sync(
2006 record["model_name"],
2007 record["prompt_tokens"],
2008 record["completion_tokens"],
2009 )
2010 costs.append({**record, **cost_data})
2012 total_cost = sum(c["total_cost"] for c in costs)
2013 total_prompt_tokens = sum(r["prompt_tokens"] for r in usage_data)
2014 total_completion_tokens = sum(
2015 r["completion_tokens"] for r in usage_data
2016 )
2018 cost_summary = {
2019 "total_cost": round(total_cost, 6),
2020 "total_tokens": total_prompt_tokens + total_completion_tokens,
2021 "prompt_tokens": total_prompt_tokens,
2022 "completion_tokens": total_completion_tokens,
2023 }
2025 return {
2026 "status": "success",
2027 "research_id": research_id,
2028 **cost_summary,
2029 }
2031 except Exception:
2032 logger.exception(
2033 f"Error getting research costs for research: {research_id}"
2034 )
2035 return JSONResponse(
2036 {"error": "An internal error occurred"}, status_code=500
2037 )
2040@router.get("/api/cost-analytics")
2041def api_cost_analytics(
2042 request: Request, username: Annotated[str, Depends(require_auth)]
2043):
2044 """Get cost analytics across all research sessions."""
2045 try:
2046 period = request.query_params.get("period", "30d")
2048 with get_user_db_session(username) as session:
2049 # Get token usage for the period
2050 query = session.query(TokenUsage)
2051 time_condition = get_time_filter_condition(
2052 period, TokenUsage.timestamp
2053 )
2054 if time_condition is not None:
2055 query = query.filter(time_condition)
2057 # First check if we have any records to avoid expensive queries
2058 record_count = query.count()
2060 if record_count == 0:
2061 return {
2062 "status": "success",
2063 "period": period,
2064 "overview": {
2065 "total_cost": 0.0,
2066 "total_tokens": 0,
2067 "prompt_tokens": 0,
2068 "completion_tokens": 0,
2069 },
2070 "top_expensive_research": [],
2071 "research_count": 0,
2072 "message": "No token usage data found for this period",
2073 }
2075 # If we have too many records, limit to recent ones to avoid timeout
2076 if record_count > 1000: 2076 ↛ 2086line 2076 didn't jump to line 2086 because the condition on line 2076 was always true
2077 logger.warning(
2078 f"Large dataset detected ({record_count} records), limiting to recent 1000 for performance"
2079 )
2080 usage_records = (
2081 query.order_by(TokenUsage.timestamp.desc())
2082 .limit(1000)
2083 .all()
2084 )
2085 else:
2086 usage_records = query.all()
2088 # Convert to dict format
2089 usage_data = []
2090 for record in usage_records: 2090 ↛ 2091line 2090 didn't jump to line 2091 because the loop on line 2090 never started
2091 usage_data.append(
2092 {
2093 "model_name": record.model_name,
2094 "provider": getattr(
2095 record, "provider", None
2096 ), # Handle both old and new records
2097 "prompt_tokens": record.prompt_tokens,
2098 "completion_tokens": record.completion_tokens,
2099 "research_id": record.research_id,
2100 "timestamp": record.timestamp,
2101 }
2102 )
2104 from ...metrics.pricing.cost_calculator import CostCalculator
2106 # Use synchronous calculation
2107 calculator = CostCalculator()
2109 # Calculate overall costs
2110 costs = []
2111 for record in usage_data: 2111 ↛ 2112line 2111 didn't jump to line 2112 because the loop on line 2111 never started
2112 cost_data = calculator.calculate_cost_sync(
2113 record["model_name"],
2114 record["prompt_tokens"],
2115 record["completion_tokens"],
2116 )
2117 costs.append({**record, **cost_data})
2119 total_cost = sum(c["total_cost"] for c in costs)
2120 total_prompt_tokens = sum(r["prompt_tokens"] for r in usage_data)
2121 total_completion_tokens = sum(
2122 r["completion_tokens"] for r in usage_data
2123 )
2125 cost_summary = {
2126 "total_cost": round(total_cost, 6),
2127 "total_tokens": total_prompt_tokens + total_completion_tokens,
2128 "prompt_tokens": total_prompt_tokens,
2129 "completion_tokens": total_completion_tokens,
2130 }
2132 # Group by research_id for per-research costs
2133 research_costs: dict[str, Any] = {}
2134 for record in usage_data: 2134 ↛ 2135line 2134 didn't jump to line 2135 because the loop on line 2134 never started
2135 rid = record["research_id"]
2136 if rid not in research_costs:
2137 research_costs[rid] = []
2138 research_costs[rid].append(record)
2140 # Calculate cost per research
2141 research_summaries = {}
2142 for rid, records in research_costs.items(): 2142 ↛ 2143line 2142 didn't jump to line 2143 because the loop on line 2142 never started
2143 research_total: float = 0
2144 for record in records:
2145 cost_data = calculator.calculate_cost_sync(
2146 record["model_name"],
2147 record["prompt_tokens"],
2148 record["completion_tokens"],
2149 )
2150 research_total += cost_data["total_cost"]
2151 research_summaries[rid] = {
2152 "total_cost": round(research_total, 6)
2153 }
2155 # Top expensive research sessions
2156 top_expensive = sorted(
2157 [
2158 (rid, data["total_cost"])
2159 for rid, data in research_summaries.items()
2160 ],
2161 key=lambda x: x[1],
2162 reverse=True,
2163 )[:10]
2165 return {
2166 "status": "success",
2167 "period": period,
2168 "overview": cost_summary,
2169 "top_expensive_research": [
2170 {"research_id": rid, "total_cost": cost}
2171 for rid, cost in top_expensive
2172 ],
2173 "research_count": len(research_summaries),
2174 }
2176 except Exception:
2177 logger.exception("Error getting cost analytics")
2178 # Return a real error so the UI can distinguish "no data" from
2179 # "this query failed". Returning 200 with status=success masked
2180 # genuine failures (DB unavailable, schema mismatch).
2181 return JSONResponse(
2182 {
2183 "status": "error",
2184 "period": period,
2185 "error": "Cost analytics temporarily unavailable",
2186 },
2187 status_code=500,
2188 )
2191@router.get("/links")
2192def link_analytics(
2193 request: Request, username: Annotated[str, Depends(require_auth)]
2194):
2195 """Display link analytics page."""
2196 return templates.TemplateResponse(
2197 request=request,
2198 name="pages/link_analytics.html",
2199 context={"request": request},
2200 )
2203@router.get("/api/link-analytics")
2204def api_link_analytics(
2205 request: Request, username: Annotated[str, Depends(require_auth)]
2206):
2207 """Get link analytics data."""
2208 try:
2209 period = request.query_params.get("period", "30d")
2211 # Get link analytics data
2212 link_data = get_link_analytics(period, username)
2214 return {
2215 "status": "success",
2216 "data": link_data["link_analytics"],
2217 "period": period,
2218 }
2220 except Exception:
2221 logger.exception("Error getting link analytics")
2222 return JSONResponse(
2223 {
2224 "status": "error",
2225 "message": "An internal error occurred. Please try again later.",
2226 },
2227 status_code=500,
2228 )
2231@router.get("/api/domain-classifications")
2232def api_get_domain_classifications(
2233 request: Request, username: Annotated[str, Depends(require_auth)]
2234):
2235 """Get all domain classifications."""
2236 classifier = None
2237 try:
2238 classifier = DomainClassifier(username)
2239 classifications = classifier.get_all_classifications()
2241 return {
2242 "status": "success",
2243 "classifications": [c.to_dict() for c in classifications],
2244 "total": len(classifications),
2245 }
2247 except Exception:
2248 logger.exception("Error getting domain classifications")
2249 return JSONResponse(
2250 {
2251 "status": "error",
2252 "message": "Failed to retrieve classifications",
2253 },
2254 status_code=500,
2255 )
2256 finally:
2257 if classifier is not None:
2258 from ...utilities.resource_utils import safe_close
2260 safe_close(classifier, "domain classifier")
2263@router.get("/api/domain-classifications/summary")
2264def api_get_classifications_summary(
2265 request: Request, username: Annotated[str, Depends(require_auth)]
2266):
2267 """Get summary of domain classifications by category."""
2268 classifier = None
2269 try:
2270 classifier = DomainClassifier(username)
2271 summary = classifier.get_categories_summary()
2273 return {"status": "success", "summary": summary}
2275 except Exception:
2276 logger.exception("Error getting classifications summary")
2277 return JSONResponse(
2278 {"status": "error", "message": "Failed to retrieve summary"},
2279 status_code=500,
2280 )
2281 finally:
2282 if classifier is not None:
2283 from ...utilities.resource_utils import safe_close
2285 safe_close(classifier, "domain classifier")
2288@router.post("/api/domain-classifications/classify")
2289async def api_classify_domains(
2290 request: Request, username: Annotated[str, Depends(require_auth)]
2291):
2292 """Trigger classification of a specific domain or batch classification."""
2293 data = await request.json()
2294 # `or {}` alone only guards FALSY bodies (None, [], ""); a truthy
2295 # non-dict (bare string, int, non-empty list/array) would still reach
2296 # `.get(...)` below and raise AttributeError -> 500.
2297 if not isinstance(data, dict):
2298 return json_body_error("status", "Request body must be valid JSON")
2299 domain = data.get("domain")
2300 force_update = data.get("force_update", False)
2301 batch_mode = data.get("batch", False)
2302 if domain is not None and not isinstance(domain, str):
2303 return json_body_error("status", "domain must be a string")
2304 if not isinstance(force_update, bool):
2305 return json_body_error("status", "force_update must be a boolean")
2306 if not isinstance(batch_mode, bool):
2307 return json_body_error("status", "batch must be a boolean")
2309 def _classify_sync():
2310 """All the sync LLM + DB work — run in a threadpool so we don't
2311 block the asyncio event loop for seconds per request."""
2312 from ...settings.manager import SettingsManager
2313 from ...database.session_context import get_user_db_session
2314 from ...utilities.resource_utils import safe_close
2316 classifier = None
2317 try:
2318 with get_user_db_session(username) as db_session:
2319 settings_manager = SettingsManager(db_session=db_session)
2320 settings_snapshot = settings_manager.get_all_settings()
2322 classifier = DomainClassifier(
2323 username, settings_snapshot=settings_snapshot
2324 )
2326 if domain and not batch_mode:
2327 logger.info(f"Classifying single domain: {domain}")
2328 classification = classifier.classify_domain(
2329 domain, force_update
2330 )
2331 if classification:
2332 return {
2333 "status": "success",
2334 "classification": classification.to_dict(),
2335 }
2336 return JSONResponse(
2337 {
2338 "status": "error",
2339 "message": f"Failed to classify domain: {domain}",
2340 },
2341 status_code=400,
2342 )
2343 if batch_mode:
2344 logger.info("Starting batch classification of all domains")
2345 results = classifier.classify_all_domains(force_update)
2346 return {"status": "success", "results": results}
2347 return JSONResponse(
2348 {
2349 "status": "error",
2350 "message": "Must provide either 'domain' or set 'batch': true",
2351 },
2352 status_code=400,
2353 )
2354 except Exception:
2355 logger.exception("Error classifying domains")
2356 return JSONResponse(
2357 {"status": "error", "message": "Failed to classify domains"},
2358 status_code=500,
2359 )
2360 finally:
2361 if classifier is not None:
2362 safe_close(classifier, "domain classifier")
2364 return await run_db_sync(_classify_sync)
2367@router.get("/api/domain-classifications/progress")
2368def api_classification_progress(
2369 request: Request, username: Annotated[str, Depends(require_auth)]
2370):
2371 """Get progress of domain classification task."""
2372 try:
2373 # Get counts of classified vs unclassified domains
2374 with get_user_db_session(username) as session:
2375 # Count total unique domains
2376 resources = session.query(ResearchResource.url).distinct().all()
2377 domains = set()
2379 for (url,) in resources:
2380 if url:
2381 domain = _extract_domain(url)
2382 if domain: 2382 ↛ 2379line 2382 didn't jump to line 2379 because the condition on line 2382 was always true
2383 domains.add(domain)
2385 all_domains = sorted(domains)
2386 total_domains = len(domains)
2388 # Count classified domains
2389 classified_count = session.query(DomainClassification).count()
2391 return {
2392 "status": "success",
2393 "progress": {
2394 "total_domains": total_domains,
2395 "classified": classified_count,
2396 "unclassified": total_domains - classified_count,
2397 "percentage": round(
2398 (classified_count / total_domains * 100)
2399 if total_domains > 0
2400 else 0,
2401 1,
2402 ),
2403 "all_domains": all_domains, # Return all domains for classification
2404 },
2405 }
2407 except Exception:
2408 logger.exception("Error getting classification progress")
2409 return JSONResponse(
2410 {"status": "error", "message": "Failed to retrieve progress"},
2411 status_code=500,
2412 )
2415# ---------------------------------------------------------------------------
2416# Journal Quality Dashboard
2417# ---------------------------------------------------------------------------
2420# Rate-limit constants for the journal-quality endpoints. The old Flask
2421# decorators (`journal_data_limit`, `journals_read_limit`) lived in
2422# `security/rate_limiter.py`, which the FastAPI migration removed. We
2423# re-use slowapi's `limiter.limit()` with the same intent:
2424# - journal-data download is expensive (~several hundred MB fetch +
2425# reference DB rebuild), so cap tightly
2426# - journal read endpoints are cheap but still worth a per-user rate
2427# to blunt enumeration
2428_JOURNAL_DATA_LIMIT = "2 per hour"
2429_JOURNALS_READ_LIMIT = "60 per minute"
2431# SHARED buckets, matching main's journal_data_limit / journals_read_limit
2432# (security/rate_limiter.py). Using per-endpoint `limiter.limit()` gave each
2433# of the three read endpoints its OWN 60/min bucket (180/min combined), which
2434# defeats the anti-enumeration throttle on the ~217K-row reference DB. A
2435# single scope per group + per-user keying restores the intended combined cap.
2436_journal_data_limit = limiter.shared_limit(
2437 _JOURNAL_DATA_LIMIT, scope="journal_data", key_func=_user_key
2438)
2439_journals_read_limit = limiter.shared_limit(
2440 _JOURNALS_READ_LIMIT, scope="journals_read", key_func=_user_key
2441)
2444@router.get("/journals")
2445def journal_quality(
2446 request: Request, username: Annotated[str, Depends(require_auth)]
2447):
2448 """Display journal quality dashboard."""
2449 return templates.TemplateResponse(
2450 request=request,
2451 name="pages/journal_quality.html",
2452 context={"request": request},
2453 )
2456@router.get("/api/journal-data/status")
2457def api_journal_data_status(
2458 request: Request, username: Annotated[str, Depends(require_auth)]
2459):
2460 """Get status of downloadable journal data files."""
2461 try:
2462 from ...journal_quality.downloader import (
2463 get_journal_data_status,
2464 )
2466 return get_journal_data_status()
2467 except Exception:
2468 logger.exception("Error checking journal data status")
2469 return JSONResponse(
2470 {"error": "Failed to check status"}, status_code=500
2471 )
2474@router.post("/api/journal-data/download")
2475@_journal_data_limit
2476async def api_journal_data_download(
2477 request: Request, username: Annotated[str, Depends(require_auth)]
2478):
2479 """Trigger download/update of journal data files.
2481 Rate-limited to 2 per hour per authenticated user: the download streams
2482 several hundred MB and rebuilds the on-disk reference DB, so unbounded
2483 invocation is a DoS vector. The actual download + rebuild runs in a
2484 worker thread so the uvicorn loop isn't blocked for the minutes this
2485 may take.
2486 """
2487 try:
2488 from ...journal_quality.downloader import (
2489 download_journal_data,
2490 get_download_state,
2491 )
2492 from ...journal_quality.data_sources import ALL_SOURCES
2494 # Egress policy: this endpoint streams several hundred MB over public
2495 # HTTP. Under an offline-for-public scope (PRIVATE_ONLY / STRICT) the
2496 # user has opted out of public egress, so refuse rather than reaching
2497 # out. A corrupt/unknown scope also fails closed. The background
2498 # auto-download path is already gated by
2499 # JournalReputationFilter._should_skip_journal_fetch_for_scope; this
2500 # closes the manual button as the matching entry point.
2501 from ...security.egress.policy import (
2502 DEFAULT_EGRESS_SCOPE,
2503 EgressScope,
2504 PolicyDeniedError,
2505 parse_user_egress_scope,
2506 )
2507 from ...utilities.db_utils import get_settings_manager
2509 # Settings read opens the user's SQLCipher session (sync) — keep it
2510 # off the event loop.
2511 scope_raw = await run_db_sync(
2512 lambda: get_settings_manager(username=username).get_setting(
2513 "policy.egress_scope", DEFAULT_EGRESS_SCOPE
2514 )
2515 )
2516 # Retired "both" reads as the adaptive default, mirroring
2517 # context_from_snapshot's read-time backstop for un-migrated rows.
2518 if str(scope_raw).strip().lower() == EgressScope.BOTH.value:
2519 scope_raw = EgressScope.ADAPTIVE.value
2520 try:
2521 # Stale "unprotected" rows fall back to adaptive when the
2522 # operator gate is off, matching every other runtime reader.
2523 scope = parse_user_egress_scope(
2524 scope_raw, disabled_unprotected="adaptive"
2525 )
2526 except PolicyDeniedError:
2527 scope = None # corrupt scope -> fail closed below
2528 if scope is None or scope in (
2529 EgressScope.PRIVATE_ONLY,
2530 EgressScope.STRICT,
2531 ):
2532 logger.bind(policy_audit=True).warning(
2533 "journal data download refused by egress policy",
2534 scope=str(scope_raw),
2535 )
2536 return JSONResponse(
2537 {
2538 "success": False,
2539 "message": (
2540 "Journal data download needs public network "
2541 "access, which the current egress policy blocks "
2542 "(private/offline scope). Change the egress "
2543 "scope to download."
2544 ),
2545 },
2546 status_code=403,
2547 )
2549 # Parse the force flag from the request body if present.
2550 force = False
2551 ctype = request.headers.get("content-type", "")
2552 if "application/json" in ctype: 2552 ↛ 2587line 2552 didn't jump to line 2587 because the condition on line 2552 was always true
2553 try:
2554 body = await request.json()
2555 except Exception:
2556 return JSONResponse(
2557 {
2558 "success": False,
2559 "message": "Request body must be valid JSON",
2560 },
2561 status_code=400,
2562 )
2563 if not isinstance(body, dict):
2564 return JSONResponse(
2565 {
2566 "success": False,
2567 "message": "Request body must be valid JSON",
2568 },
2569 status_code=400,
2570 )
2571 force = body.get("force", False)
2572 if not isinstance(force, bool):
2573 return JSONResponse(
2574 {
2575 "success": False,
2576 "message": "force must be a boolean",
2577 },
2578 status_code=400,
2579 )
2581 # `download_journal_data` already calls `build_reference_db()`
2582 # + `reset_db()` internally on its success path (downloader.py
2583 # → db.py); we used to redo it here, but a second rebuild ran
2584 # the full ~30s pipeline twice and wrote to the legacy
2585 # `journal_reference.db` filename that the downloader had just
2586 # cleaned up. See main's #3574 for the simplification.
2587 def _run_download() -> tuple[bool, str | None]:
2588 return download_journal_data(force=force)
2590 success, internal_message = await asyncio.to_thread(_run_download)
2591 if not success:
2592 logger.warning(f"Journal data download failed: {internal_message}")
2593 return {"success": False, "message": "Download failed"}
2595 # Build the user-facing message locally from structured state
2596 # (ints + developer-authored source labels). We deliberately do
2597 # NOT echo `internal_message` from download_journal_data: keeping
2598 # the response safe-by-construction means a future refactor that
2599 # lets arbitrary strings (exception info, user input, PII) slip
2600 # into the downloader's message cannot reach the client.
2601 counts = get_download_state().get("counts")
2602 if counts is not None:
2603 parts = [
2604 f"{int(counts.get(src.key) or 0)} {src.count_label}"
2605 for src in ALL_SOURCES
2606 ]
2607 user_message = (
2608 f"Fetched {' + '.join(parts)}. Database rebuilt successfully."
2609 )
2610 else:
2611 # `counts` is None when download_journal_data took its
2612 # early-return "already up to date" branch (no fetch ran).
2613 user_message = "Journal data is already up to date."
2614 return {"success": True, "message": user_message}
2615 except Exception:
2616 logger.exception("Error downloading journal data")
2617 return JSONResponse(
2618 {"success": False, "message": "Download failed"},
2619 status_code=500,
2620 )
2623#: Allowlist of ``score_source`` values accepted by ``/api/journals``.
2624#: Matches the writer side: ``openalex`` / ``doaj`` for reference-DB
2625#: hits, ``llm`` for Tier 4 cache rows. Empty string means "no filter"
2626#: and is handled by the caller before validation.
2627_ALLOWED_SCORE_SOURCES = frozenset({"openalex", "doaj", "llm"})
2629#: Upper bound on the echoed ``page`` parameter. Prevents a crafted
2630#: ``?page=10**9`` from issuing an OFFSET scan before the post-query
2631#: clamp can take effect — reject at input validation instead.
2632_MAX_PAGE = 10_000
2635@router.get("/api/journals")
2636@_journals_read_limit
2637def api_journal_quality(
2638 request: Request, username: Annotated[str, Depends(require_auth)]
2639):
2640 """Get journal quality data with server-side pagination and filtering.
2642 Reads from the bundled read-only reference database (~217K journals)
2643 rather than the per-user DB, so the dashboard is always populated.
2645 Query params:
2646 page (int): 1-indexed page number (default 1, max 10000)
2647 per_page (int): rows per page, max 200 (default 50)
2648 search (str): name substring filter
2649 tier (str): elite/strong/moderate/low/predatory
2650 score_source (str): openalex/doaj/llm (allowlisted)
2651 sort (str): column to sort by (default quality)
2652 order (str): asc or desc (default desc)
2653 """
2654 try:
2655 from ...journal_quality.db import get_journal_reference_db
2657 ref = get_journal_reference_db()
2658 if not ref.available:
2659 return JSONResponse(
2660 {
2661 "status": "error",
2662 "message": "Journal reference database not available.",
2663 },
2664 status_code=503,
2665 )
2667 try:
2668 page = max(1, int(request.query_params.get("page", 1)))
2669 per_page = min(
2670 max(1, int(request.query_params.get("per_page", 50))), 200
2671 )
2672 except (TypeError, ValueError):
2673 return JSONResponse(
2674 {
2675 "status": "error",
2676 "message": "Invalid pagination parameters",
2677 },
2678 status_code=400,
2679 )
2680 if page > _MAX_PAGE:
2681 return JSONResponse(
2682 {
2683 "status": "error",
2684 "message": (
2685 f"page exceeds maximum ({_MAX_PAGE}); narrow the "
2686 "filter or increase per_page"
2687 ),
2688 },
2689 status_code=400,
2690 )
2691 search = request.query_params.get("search", "")
2692 tier = request.query_params.get("tier", "")
2693 score_source = request.query_params.get("score_source", "")
2694 if score_source and score_source not in _ALLOWED_SCORE_SOURCES:
2695 return JSONResponse(
2696 {
2697 "status": "error",
2698 "message": (
2699 f"Invalid score_source; must be one of "
2700 f"{sorted(_ALLOWED_SCORE_SOURCES)}"
2701 ),
2702 },
2703 status_code=400,
2704 )
2705 sort = request.query_params.get("sort", "quality")
2706 order = request.query_params.get("order", "desc")
2708 journals, total = ref.get_journals_page(
2709 page=page,
2710 per_page=per_page,
2711 search=search,
2712 tier=tier,
2713 score_source=score_source,
2714 sort=sort,
2715 order=order,
2716 )
2718 # Clamp the echoed page so the UI never displays out-of-range
2719 # numbers on crafted input (e.g. ?page=10**9). SQLite's OFFSET on
2720 # an indexed ORDER BY caps work at ~total rows regardless of the
2721 # requested offset, so no DB-level clamp is needed.
2722 total_pages = -(-total // per_page) if per_page > 0 and total > 0 else 1
2723 page = min(page, total_pages)
2725 result = {
2726 "status": "success",
2727 "journals": journals,
2728 "pagination": {
2729 "page": page,
2730 "per_page": per_page,
2731 "total_count": total,
2732 "total_pages": total_pages,
2733 },
2734 }
2736 # Include summary only when requested (avoids 3 extra SQL queries
2737 # on every pagination/sort/filter request)
2738 if request.query_params.get("include_summary", "false") == "true": 2738 ↛ 2739line 2738 didn't jump to line 2739 because the condition on line 2738 was never true
2739 summary = ref.get_summary()
2740 summary["quality_distribution"] = ref.get_quality_distribution()
2741 summary["source_distribution"] = ref.get_source_distribution()
2742 result["summary"] = summary
2744 return result
2746 except Exception:
2747 logger.exception("Error getting journal quality data")
2748 return JSONResponse(
2749 {
2750 "status": "error",
2751 "message": "An internal error occurred. Please try again later.",
2752 },
2753 status_code=500,
2754 )
2757def _ref_db_lookup(ref_db, name: str) -> dict:
2758 """Look up a journal's display bibliometrics in the reference DB.
2760 Returns a dict with keys the dashboard template already renders
2761 (h_index, impact_factor, sjr_quartile, publisher, is_predatory,
2762 predatory_source, is_in_doaj). Missing fields default
2763 to None / False so the frontend never sees KeyError. On any ref-DB
2764 error the function returns an empty dict — the dashboard still shows
2765 the name + user-DB quality, just without the extras.
2766 """
2767 if ref_db is None or not name:
2768 return {}
2769 try:
2770 entry = ref_db.lookup_source(name=name) or {}
2771 except Exception: # allow: silent-exception
2772 # Reference DB lookups are best-effort enrichment. Any failure
2773 # degrades to "no bibliometric extras" without crashing the
2774 # dashboard; detailed errors already surface via the DB layer's
2775 # own logger.exception calls when they matter.
2776 return {}
2777 # lookup_source returns a compact dict; the sjr_quartile lives under
2778 # "quartile" and predatory/DOAJ fields may be absent entirely.
2779 return {
2780 "h_index": entry.get("h_index"),
2781 "impact_factor": entry.get("impact_factor"),
2782 "sjr_quartile": entry.get("quartile"),
2783 "is_predatory": bool(entry.get("is_predatory")),
2784 "predatory_source": entry.get("predatory_source"),
2785 "is_in_doaj": bool(entry.get("is_in_doaj")),
2786 "publisher": entry.get("publisher"),
2787 }
2790def _get_ref_db_or_none():
2791 """Return the JournalQualityDB singleton, or None if unavailable.
2793 The reference DB is optional — if the user hasn't downloaded the
2794 snapshot, the dashboard still renders with user-DB data only.
2795 """
2796 try:
2797 from ...journal_quality.db import get_journal_reference_db
2799 return get_journal_reference_db()
2800 except Exception: # allow: silent-exception
2801 # Reference DB is optional; if import or initialization fails
2802 # (unusual: usually it's lazily built on first access), the
2803 # dashboard falls back to user-DB-only rendering.
2804 return None
2807def _resolve_paper_quality(
2808 llm_quality: int | None, enrichment: dict
2809) -> tuple[int | None, str | None]:
2810 """Pick a quality score for a dashboard row.
2812 Precedence: current LLM verdict from the user's ``journals`` table
2813 (Tier 4 cache, keyed by NFKC-normalized container_title) → live
2814 derivation from the bundled reference DB row (Tier 1-3). Always
2815 live — no frozen per-Paper copy exists, so a re-scored journal
2816 propagates automatically. Returns (score, source_label) or
2817 (None, None) if neither path had data.
2818 """
2819 if llm_quality is not None: 2819 ↛ 2820line 2819 didn't jump to line 2820 because the condition on line 2819 was never true
2820 return llm_quality, "llm"
2821 if not enrichment: 2821 ↛ 2822line 2821 didn't jump to line 2822 because the condition on line 2821 was never true
2822 return None, None
2823 # enrichment comes from _source_to_dashboard_dict — row.quality is
2824 # the ref-DB's pre-computed score (same formula as the filter uses),
2825 # so we trust it directly rather than re-running derive_quality_score.
2826 q = enrichment.get("quality")
2827 if q is not None: 2827 ↛ 2829line 2827 didn't jump to line 2829 because the condition on line 2827 was always true
2828 return int(q), enrichment.get("score_source") or "openalex"
2829 return None, None
2832def _lookup_journal_llm_quality(
2833 db, container_titles: list[str]
2834) -> dict[str, int]:
2835 """Batch-look up current Tier 4 LLM verdicts from the user's
2836 ``journals`` table.
2838 Returns a dict mapping ``normalize_name(container_title)`` →
2839 ``Journal.quality``. Missing journals (never Tier-4-scored) simply
2840 don't appear in the result — callers fall through to the bundled
2841 reference DB. One indexed ``name_lower IN (...)`` query.
2842 """
2843 from ...journal_quality.scoring import normalize_name
2845 if not container_titles: 2845 ↛ 2846line 2845 didn't jump to line 2846 because the condition on line 2845 was never true
2846 return {}
2847 normalized = list({normalize_name(ct) for ct in container_titles if ct})
2848 if not normalized: 2848 ↛ 2849line 2848 didn't jump to line 2849 because the condition on line 2848 was never true
2849 return {}
2850 rows = (
2851 db.query(Journal.name_lower, Journal.quality)
2852 .filter(Journal.name_lower.in_(normalized))
2853 .filter(Journal.quality.isnot(None))
2854 .all()
2855 )
2856 return {name_lower: int(q) for name_lower, q in rows}
2859@router.get("/api/journals/user-research")
2860@_journals_read_limit
2861def api_user_research_journals(
2862 request: Request, username: Annotated[str, Depends(require_auth)]
2863):
2864 """Get journals from the user's own research sessions.
2866 Paper-rooted query: groups by ``Paper.container_title`` (the
2867 cleaned name the filter used to score the journal), counts paper
2868 appearances. Quality is resolved live — Tier 4 via a batch lookup
2869 against the user's ``journals`` table (keyed by NFKC-normalized
2870 container_title), Tier 1-3 via the bundled read-only reference DB.
2871 A re-scored journal propagates to existing research rows
2872 automatically because no per-Paper score is stored.
2873 """
2874 _empty_response = {
2875 "status": "success",
2876 "summary": {
2877 "total_journals": 0,
2878 "avg_quality": None,
2879 "total_papers": 0,
2880 "predatory_blocked": 0,
2881 },
2882 "quality_distribution": {},
2883 "journals": [],
2884 }
2886 try:
2887 from sqlalchemy import inspect as sa_inspect
2889 with get_user_db_session(username) as db:
2890 inspector = sa_inspect(db.bind)
2891 if not inspector.has_table("papers"): 2891 ↛ 2892line 2891 didn't jump to line 2892 because the condition on line 2891 was never true
2892 return _empty_response
2894 # Top-200 most-cited journals in this user's research.
2895 # Orphan Papers (whose ``PaperAppearance`` rows were
2896 # cascade-deleted when their research session was deleted)
2897 # are excluded so the dashboard reflects what the user
2898 # currently has, not residual rows from deleted sessions.
2899 # See issue #3544.
2900 rows = (
2901 db.query(
2902 Paper.container_title,
2903 func.count(Paper.id).label("paper_count"),
2904 func.min(Paper.year).label("year_min"),
2905 func.max(Paper.year).label("year_max"),
2906 )
2907 .filter(Paper.container_title.isnot(None))
2908 .filter(Paper.appearances.any())
2909 .group_by(Paper.container_title)
2910 .order_by(func.count(Paper.id).desc())
2911 .limit(200)
2912 .all()
2913 )
2915 if not rows: 2915 ↛ 2921line 2915 didn't jump to line 2921 because the condition on line 2915 was always true
2916 return _empty_response
2918 # One batched ref-DB lookup for the whole top-200 slice —
2919 # hits `sources.name_lower IN (…)` rather than 200 point
2920 # queries.
2921 ref_db = _get_ref_db_or_none()
2922 enrich_map = {}
2923 if ref_db is not None:
2924 enrich_map = ref_db.lookup_sources_batch(
2925 [r.container_title for r in rows]
2926 )
2928 from ...journal_quality.scoring import normalize_name
2930 # Batch-look up current LLM verdicts (Tier 4) from the
2931 # user's journals table, keyed by NFKC-normalized name.
2932 # Always live — no frozen Paper copy — so a re-scored
2933 # journal propagates here without any backfill.
2934 llm_by_name = _lookup_journal_llm_quality(
2935 db, [r.container_title for r in rows]
2936 )
2938 journals: list[dict] = []
2939 qualities: list[int] = []
2940 for r in rows:
2941 normalized = normalize_name(r.container_title)
2942 enrichment = enrich_map.get(normalized, {})
2943 quality, source_label = _resolve_paper_quality(
2944 llm_by_name.get(normalized), enrichment
2945 )
2946 if quality is not None:
2947 qualities.append(quality)
2948 journals.append(
2949 {
2950 "name": r.container_title,
2951 "quality": quality,
2952 "score_source": source_label,
2953 "paper_count": r.paper_count,
2954 "year_min": r.year_min,
2955 "year_max": r.year_max,
2956 **{
2957 k: v
2958 for k, v in enrichment.items()
2959 if k not in ("quality", "score_source", "name")
2960 },
2961 }
2962 )
2964 # Aggregate stats computed across the top-200 slice for the
2965 # dashboard summary — matches how the table renders.
2966 total_journals = len(journals)
2967 total_papers = sum(r.paper_count for r in rows)
2968 avg_quality = (
2969 round(sum(qualities) / len(qualities), 1) if qualities else None
2970 )
2971 quality_distribution: dict[str, int] = {}
2972 for q in qualities:
2973 quality_distribution[str(q)] = (
2974 quality_distribution.get(str(q), 0) + 1
2975 )
2977 # Predatory count uses the full set of distinct
2978 # container_titles across the user's research, not just the
2979 # top-200 display slice. One batched query.
2980 #
2981 # KNOWN-DEFERRED: unbounded SELECT DISTINCT. Acceptable today
2982 # because typical users have <5K distinct titles even after
2983 # years of use, and count_predatory_by_names documents
2984 # support up to ~100K params. Adding .limit(N) was considered
2985 # and rejected — it would SILENTLY UNDERCOUNT predatory
2986 # journals, which violates the no-fallbacks rule. Proper fix
2987 # (cross-DB correlated subquery or TTL cache) is tracked as
2988 # a post-merge follow-up. Threshold for visible impact:
2989 # ~50K papers.
2990 predatory_blocked = 0
2991 if ref_db is not None:
2992 # Same orphan-exclusion as the top-200 query above —
2993 # otherwise predatory_blocked stays inflated by titles
2994 # whose only Papers belong to deleted research sessions.
2995 all_names = [
2996 name
2997 for (name,) in db.query(Paper.container_title)
2998 .filter(Paper.container_title.isnot(None))
2999 .filter(Paper.appearances.any())
3000 .distinct()
3001 .all()
3002 ]
3003 predatory_blocked = ref_db.count_predatory_by_names(all_names)
3005 return {
3006 "status": "success",
3007 "summary": {
3008 "total_journals": total_journals,
3009 "avg_quality": avg_quality,
3010 "total_papers": total_papers,
3011 "predatory_blocked": predatory_blocked,
3012 },
3013 "quality_distribution": quality_distribution,
3014 "journals": journals,
3015 }
3016 except Exception:
3017 logger.exception("Error getting user research journals")
3018 return JSONResponse(
3019 {
3020 "status": "error",
3021 "message": "Failed to load your research data.",
3022 },
3023 status_code=500,
3024 )
3027@router.get("/api/journals/research/{research_id}")
3028@_journals_read_limit
3029def api_research_journals(
3030 request: Request,
3031 research_id: str,
3032 username: Annotated[str, Depends(require_auth)],
3033):
3034 """Get journals encountered in a single research session.
3036 Filters the per-user papers table by joining through
3037 Paper → PaperAppearance → ResearchResource and matching ``research_id``.
3038 Quality is resolved live (journals.quality + bundled reference DB)
3039 so results always reflect the current verdict, not a stale snapshot.
3040 Mirrors the response shape of /api/journals/user-research so the
3041 dashboard can reuse its rendering code.
3042 """
3043 _empty_response = {
3044 "status": "success",
3045 "summary": {
3046 "total_journals": 0,
3047 "avg_quality": None,
3048 "total_papers": 0,
3049 "predatory_blocked": 0,
3050 },
3051 "quality_distribution": {},
3052 "journals": [],
3053 }
3055 try:
3056 from sqlalchemy import inspect as sa_inspect
3058 with get_user_db_session(username) as db:
3059 inspector = sa_inspect(db.bind)
3060 if not inspector.has_table("papers") or not inspector.has_table( 3060 ↛ 3063line 3060 didn't jump to line 3063 because the condition on line 3060 was never true
3061 "paper_appearances"
3062 ):
3063 return _empty_response
3065 # Verify the research_id belongs to this user before exposing
3066 # any data — research_history is in the same per-user DB so
3067 # the existence check doubles as an ownership check.
3068 from ...database.models.research import ResearchHistory
3070 research = (
3071 db.query(ResearchHistory.id)
3072 .filter(ResearchHistory.id == research_id)
3073 .first()
3074 )
3075 if research is None:
3076 return JSONResponse(
3077 {"status": "error", "message": "Research not found"},
3078 status_code=404,
3079 )
3081 # Aggregate container_title → paper_count for this research.
3082 # Join chain: Paper → PaperAppearance → ResearchResource.
3083 rows = (
3084 db.query(
3085 Paper.container_title,
3086 func.count(Paper.id).label("paper_count"),
3087 func.min(Paper.year).label("year_min"),
3088 func.max(Paper.year).label("year_max"),
3089 )
3090 .join(
3091 PaperAppearance,
3092 PaperAppearance.paper_id == Paper.id,
3093 )
3094 .join(
3095 ResearchResource,
3096 ResearchResource.id == PaperAppearance.resource_id,
3097 )
3098 .filter(
3099 ResearchResource.research_id == research_id,
3100 Paper.container_title.isnot(None),
3101 )
3102 .group_by(Paper.container_title)
3103 .order_by(func.count(Paper.id).desc())
3104 .all()
3105 )
3107 if not rows: 3107 ↛ 3108line 3107 didn't jump to line 3108 because the condition on line 3107 was never true
3108 return _empty_response
3110 ref_db = _get_ref_db_or_none()
3111 enrich_map = {}
3112 if ref_db is not None: 3112 ↛ 3117line 3112 didn't jump to line 3117 because the condition on line 3112 was always true
3113 enrich_map = ref_db.lookup_sources_batch(
3114 [r.container_title for r in rows]
3115 )
3117 from ...journal_quality.scoring import normalize_name
3119 # Batch-look up current LLM verdicts (Tier 4) — see
3120 # _lookup_journal_llm_quality for rationale. Same live
3121 # resolution as the cross-research rollup above.
3122 llm_by_name = _lookup_journal_llm_quality(
3123 db, [r.container_title for r in rows]
3124 )
3126 journals: list[dict] = []
3127 qualities: list[int] = []
3128 predatory_blocked = 0
3129 for r in rows:
3130 normalized = normalize_name(r.container_title)
3131 enrichment = enrich_map.get(normalized, {})
3132 if enrichment.get("is_predatory"):
3133 predatory_blocked += 1
3134 quality, source_label = _resolve_paper_quality(
3135 llm_by_name.get(normalized), enrichment
3136 )
3137 if quality is not None: 3137 ↛ 3139line 3137 didn't jump to line 3139 because the condition on line 3137 was always true
3138 qualities.append(quality)
3139 journals.append(
3140 {
3141 "name": r.container_title,
3142 "quality": quality,
3143 "score_source": source_label,
3144 "paper_count": r.paper_count,
3145 "year_min": r.year_min,
3146 "year_max": r.year_max,
3147 **{
3148 k: v
3149 for k, v in enrichment.items()
3150 if k not in ("quality", "score_source", "name")
3151 },
3152 }
3153 )
3155 total_papers = sum(r.paper_count for r in rows)
3156 avg_quality = (
3157 round(sum(qualities) / len(qualities), 1) if qualities else None
3158 )
3159 quality_distribution: dict[str, int] = {}
3160 for q in qualities:
3161 quality_distribution[str(q)] = (
3162 quality_distribution.get(str(q), 0) + 1
3163 )
3165 return {
3166 "status": "success",
3167 "summary": {
3168 "total_journals": len(journals),
3169 "avg_quality": avg_quality,
3170 "total_papers": total_papers,
3171 "predatory_blocked": predatory_blocked,
3172 },
3173 "quality_distribution": quality_distribution,
3174 "journals": journals,
3175 }
3176 except Exception:
3177 logger.exception("Error getting per-research journals")
3178 return JSONResponse(
3179 {
3180 "status": "error",
3181 "message": "Failed to load research journals.",
3182 },
3183 status_code=500,
3184 )