|
| 1 | +"""Debug endpoints for internal monitoring.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import time |
| 6 | +from datetime import datetime, timezone |
| 7 | + |
| 8 | +from fastapi import APIRouter, Depends, Request |
| 9 | +from pydantic import BaseModel |
| 10 | +from sqlalchemy.ext.asyncio import AsyncSession |
| 11 | + |
| 12 | +from api.dependencies import require_db |
| 13 | +from core.constants import SUPPORTED_LIBRARIES |
| 14 | +from core.database import SpecRepository |
| 15 | + |
| 16 | + |
| 17 | +router = APIRouter(prefix="/debug", tags=["debug"]) |
| 18 | + |
| 19 | + |
| 20 | +# ============================================================================ |
| 21 | +# Response Models |
| 22 | +# ============================================================================ |
| 23 | + |
| 24 | + |
| 25 | +class SpecStatusItem(BaseModel): |
| 26 | + """Status for a single specification with library scores.""" |
| 27 | + |
| 28 | + id: str |
| 29 | + title: str |
| 30 | + updated: str | None |
| 31 | + avg_score: float | None = None |
| 32 | + # Library scores - None means no implementation |
| 33 | + altair: float | None = None |
| 34 | + bokeh: float | None = None |
| 35 | + highcharts: float | None = None |
| 36 | + letsplot: float | None = None |
| 37 | + matplotlib: float | None = None |
| 38 | + plotly: float | None = None |
| 39 | + plotnine: float | None = None |
| 40 | + pygal: float | None = None |
| 41 | + seaborn: float | None = None |
| 42 | + |
| 43 | + |
| 44 | +class LibraryStats(BaseModel): |
| 45 | + """Statistics for a single library.""" |
| 46 | + |
| 47 | + id: str |
| 48 | + name: str |
| 49 | + impl_count: int |
| 50 | + avg_score: float | None |
| 51 | + min_score: float | None |
| 52 | + max_score: float | None |
| 53 | + |
| 54 | + |
| 55 | +class ProblemSpec(BaseModel): |
| 56 | + """A spec with issues.""" |
| 57 | + |
| 58 | + id: str |
| 59 | + title: str |
| 60 | + issue: str # Description of the problem |
| 61 | + value: str | None = None # Optional value (e.g., score, date) |
| 62 | + |
| 63 | + |
| 64 | +class SystemHealth(BaseModel): |
| 65 | + """System health information.""" |
| 66 | + |
| 67 | + database_connected: bool |
| 68 | + api_response_time_ms: float |
| 69 | + timestamp: str |
| 70 | + total_specs_in_db: int |
| 71 | + total_impls_in_db: int |
| 72 | + |
| 73 | + |
| 74 | +class DebugStatusResponse(BaseModel): |
| 75 | + """Debug dashboard data.""" |
| 76 | + |
| 77 | + # Summary |
| 78 | + total_specs: int |
| 79 | + total_implementations: int |
| 80 | + coverage_percent: float |
| 81 | + |
| 82 | + # Library statistics |
| 83 | + library_stats: list[LibraryStats] |
| 84 | + |
| 85 | + # Problem areas |
| 86 | + low_score_specs: list[ProblemSpec] # Specs with avg score < 85 |
| 87 | + oldest_specs: list[ProblemSpec] # 10 oldest specs |
| 88 | + missing_preview_specs: list[ProblemSpec] # Specs with missing GCS images |
| 89 | + missing_tags_specs: list[ProblemSpec] # Specs without tags |
| 90 | + |
| 91 | + # System health |
| 92 | + system: SystemHealth |
| 93 | + |
| 94 | + # All specs for table |
| 95 | + specs: list[SpecStatusItem] |
| 96 | + |
| 97 | + |
| 98 | +# ============================================================================ |
| 99 | +# Endpoint |
| 100 | +# ============================================================================ |
| 101 | + |
| 102 | + |
| 103 | +@router.get("/status", response_model=DebugStatusResponse) |
| 104 | +async def get_debug_status(request: Request, db: AsyncSession = Depends(require_db)) -> DebugStatusResponse: |
| 105 | + """ |
| 106 | + Get comprehensive debug dashboard data. |
| 107 | +
|
| 108 | + Includes: |
| 109 | + - All specs with quality scores per library |
| 110 | + - Library statistics (avg/min/max scores, coverage) |
| 111 | + - Problem specs (low scores, old, missing data) |
| 112 | + - System health info |
| 113 | + """ |
| 114 | + start_time = time.time() |
| 115 | + |
| 116 | + repo = SpecRepository(db) |
| 117 | + all_specs = await repo.get_all() |
| 118 | + |
| 119 | + # ======================================================================== |
| 120 | + # Build specs list and collect statistics |
| 121 | + # ======================================================================== |
| 122 | + |
| 123 | + specs_status: list[SpecStatusItem] = [] |
| 124 | + total_implementations = 0 |
| 125 | + |
| 126 | + # Library aggregates |
| 127 | + library_scores: dict[str, list[float]] = {lib: [] for lib in SUPPORTED_LIBRARIES} |
| 128 | + library_counts: dict[str, int] = dict.fromkeys(SUPPORTED_LIBRARIES, 0) # type: ignore[arg-type] |
| 129 | + |
| 130 | + # Problem tracking |
| 131 | + missing_preview: list[ProblemSpec] = [] |
| 132 | + missing_tags: list[ProblemSpec] = [] |
| 133 | + |
| 134 | + for spec in all_specs: |
| 135 | + # Build library score map for this spec |
| 136 | + spec_scores: dict[str, float | None] = dict.fromkeys(SUPPORTED_LIBRARIES, None) |
| 137 | + spec_score_values: list[float] = [] |
| 138 | + |
| 139 | + for impl in spec.impls: |
| 140 | + lib_id = impl.library_id |
| 141 | + score = impl.quality_score |
| 142 | + |
| 143 | + spec_scores[lib_id] = score |
| 144 | + total_implementations += 1 |
| 145 | + library_counts[lib_id] += 1 |
| 146 | + |
| 147 | + if score is not None: |
| 148 | + library_scores[lib_id].append(score) |
| 149 | + spec_score_values.append(score) |
| 150 | + |
| 151 | + # Check for missing preview |
| 152 | + if not impl.preview_url: |
| 153 | + missing_preview.append(ProblemSpec(id=spec.id, title=spec.title, issue=f"Missing preview for {lib_id}")) |
| 154 | + |
| 155 | + # Calculate average score for this spec |
| 156 | + avg_score = sum(spec_score_values) / len(spec_score_values) if spec_score_values else None |
| 157 | + |
| 158 | + # Find most recent update |
| 159 | + timestamps = [spec.updated] if spec.updated else [] |
| 160 | + timestamps.extend(impl.updated for impl in spec.impls if impl.updated) |
| 161 | + most_recent = max(timestamps) if timestamps else None |
| 162 | + |
| 163 | + # Check for missing tags |
| 164 | + if not spec.tags or not any(spec.tags.values()): |
| 165 | + missing_tags.append(ProblemSpec(id=spec.id, title=spec.title, issue="No tags defined")) |
| 166 | + |
| 167 | + specs_status.append( |
| 168 | + SpecStatusItem( |
| 169 | + id=spec.id, |
| 170 | + title=spec.title, |
| 171 | + updated=most_recent.isoformat() if most_recent else None, |
| 172 | + avg_score=round(avg_score, 1) if avg_score else None, |
| 173 | + altair=spec_scores.get("altair"), |
| 174 | + bokeh=spec_scores.get("bokeh"), |
| 175 | + highcharts=spec_scores.get("highcharts"), |
| 176 | + letsplot=spec_scores.get("letsplot"), |
| 177 | + matplotlib=spec_scores.get("matplotlib"), |
| 178 | + plotly=spec_scores.get("plotly"), |
| 179 | + plotnine=spec_scores.get("plotnine"), |
| 180 | + pygal=spec_scores.get("pygal"), |
| 181 | + seaborn=spec_scores.get("seaborn"), |
| 182 | + ) |
| 183 | + ) |
| 184 | + |
| 185 | + # Sort by updated (most recent first) |
| 186 | + specs_status.sort(key=lambda s: (s.updated or "", s.id), reverse=True) |
| 187 | + |
| 188 | + # ======================================================================== |
| 189 | + # Library Statistics |
| 190 | + # ======================================================================== |
| 191 | + |
| 192 | + library_names = { |
| 193 | + "altair": "Altair", |
| 194 | + "bokeh": "Bokeh", |
| 195 | + "highcharts": "Highcharts", |
| 196 | + "letsplot": "lets-plot", |
| 197 | + "matplotlib": "Matplotlib", |
| 198 | + "plotly": "Plotly", |
| 199 | + "plotnine": "plotnine", |
| 200 | + "pygal": "Pygal", |
| 201 | + "seaborn": "Seaborn", |
| 202 | + } |
| 203 | + |
| 204 | + lib_stats: list[LibraryStats] = [] |
| 205 | + for lib_id in sorted(SUPPORTED_LIBRARIES): |
| 206 | + scores = library_scores[lib_id] |
| 207 | + lib_stats.append( |
| 208 | + LibraryStats( |
| 209 | + id=lib_id, |
| 210 | + name=library_names.get(lib_id, lib_id), |
| 211 | + impl_count=library_counts[lib_id], |
| 212 | + avg_score=round(sum(scores) / len(scores), 1) if scores else None, |
| 213 | + min_score=round(min(scores), 1) if scores else None, |
| 214 | + max_score=round(max(scores), 1) if scores else None, |
| 215 | + ) |
| 216 | + ) |
| 217 | + |
| 218 | + # Sort by impl_count descending |
| 219 | + lib_stats.sort(key=lambda x: x.impl_count, reverse=True) |
| 220 | + |
| 221 | + # ======================================================================== |
| 222 | + # Problem Specs |
| 223 | + # ======================================================================== |
| 224 | + |
| 225 | + # Low score specs (avg < 85) |
| 226 | + low_score_specs: list[ProblemSpec] = [] |
| 227 | + for spec in specs_status: |
| 228 | + if spec.avg_score is not None and spec.avg_score < 85: |
| 229 | + low_score_specs.append( |
| 230 | + ProblemSpec(id=spec.id, title=spec.title, issue="Low average score", value=f"{spec.avg_score:.1f}") |
| 231 | + ) |
| 232 | + low_score_specs.sort(key=lambda x: float(x.value or 0)) # Lowest first |
| 233 | + |
| 234 | + # Oldest specs (by updated timestamp) |
| 235 | + specs_by_age = sorted(specs_status, key=lambda s: s.updated or "") |
| 236 | + oldest_specs: list[ProblemSpec] = [] |
| 237 | + for spec in specs_by_age[:10]: # 10 oldest |
| 238 | + if spec.updated: |
| 239 | + try: |
| 240 | + dt = datetime.fromisoformat(spec.updated.replace("Z", "+00:00")) |
| 241 | + # Ensure dt is timezone-aware |
| 242 | + if dt.tzinfo is None: |
| 243 | + dt = dt.replace(tzinfo=timezone.utc) |
| 244 | + age_days = (datetime.now(timezone.utc) - dt).days |
| 245 | + oldest_specs.append( |
| 246 | + ProblemSpec(id=spec.id, title=spec.title, issue="Old spec", value=f"{age_days} days ago") |
| 247 | + ) |
| 248 | + except ValueError: |
| 249 | + pass |
| 250 | + |
| 251 | + # ======================================================================== |
| 252 | + # System Health |
| 253 | + # ======================================================================== |
| 254 | + |
| 255 | + response_time_ms = (time.time() - start_time) * 1000 |
| 256 | + coverage = (total_implementations / (len(all_specs) * 9) * 100) if all_specs else 0 |
| 257 | + |
| 258 | + system_health = SystemHealth( |
| 259 | + database_connected=True, |
| 260 | + api_response_time_ms=round(response_time_ms, 2), |
| 261 | + timestamp=datetime.now(timezone.utc).isoformat(), |
| 262 | + total_specs_in_db=len(all_specs), |
| 263 | + total_impls_in_db=total_implementations, |
| 264 | + ) |
| 265 | + |
| 266 | + # ======================================================================== |
| 267 | + # Return Response |
| 268 | + # ======================================================================== |
| 269 | + |
| 270 | + return DebugStatusResponse( |
| 271 | + total_specs=len(all_specs), |
| 272 | + total_implementations=total_implementations, |
| 273 | + coverage_percent=round(coverage, 1), |
| 274 | + library_stats=lib_stats, |
| 275 | + low_score_specs=low_score_specs[:20], # Limit to 20 |
| 276 | + oldest_specs=oldest_specs, |
| 277 | + missing_preview_specs=missing_preview[:20], # Limit to 20 |
| 278 | + missing_tags_specs=missing_tags[:20], # Limit to 20 |
| 279 | + system=system_health, |
| 280 | + specs=specs_status, |
| 281 | + ) |
0 commit comments