File size: 19,371 Bytes
7c6ffa6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
"""Developer-only Beta Launch Doctor.

GET /dev/beta-health

Runs a structured pre-launch checklist and returns safe diagnostic data.
No secret values are ever included in the response.

Access rules:
  - In development (ENVIRONMENT=development): unauthenticated access allowed.
  - In production/staging: requires authenticated admin user (role="admin").
"""
from __future__ import annotations

import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import inspect as sa_inspect, text
from sqlalchemy.orm import Session

from app.core.auth import get_current_user_optional
from app.core.config import get_settings
from app.core.database import get_db
from app.models.user import User

router = APIRouter()

# ── Data model ────────────────────────────────────────────────────────────────

STATUS_OK = "ok"
STATUS_WARN = "warning"
STATUS_FAIL = "failed"


@dataclass
class Check:
    name: str
    category: str
    status: str
    message: str
    detail: str | None = None


@dataclass
class BetaHealthReport:
    status: str
    checks: list[Check] = field(default_factory=list)

    def add(self, check: Check) -> None:
        self.checks.append(check)

    def finalize(self) -> None:
        """Set overall status: failed > warning > ok."""
        statuses = {c.status for c in self.checks}
        if STATUS_FAIL in statuses:
            self.status = STATUS_FAIL
        elif STATUS_WARN in statuses:
            self.status = STATUS_WARN
        else:
            self.status = STATUS_OK

    def to_dict(self) -> dict[str, Any]:
        ok = sum(1 for c in self.checks if c.status == STATUS_OK)
        warn = sum(1 for c in self.checks if c.status == STATUS_WARN)
        fail = sum(1 for c in self.checks if c.status == STATUS_FAIL)
        return {
            "status": self.status,
            "summary": {"ok": ok, "warning": warn, "failed": fail, "total": len(self.checks)},
            "checks": [
                {
                    "name": c.name,
                    "category": c.category,
                    "status": c.status,
                    "message": c.message,
                    **({"detail": c.detail} if c.detail else {}),
                }
                for c in self.checks
            ],
        }


# ── Individual check helpers ──────────────────────────────────────────────────

def _check_db_connection(db: Session) -> Check:
    try:
        db.execute(text("SELECT 1"))
        return Check("db_connection", "database", STATUS_OK, "Database connection healthy.")
    except Exception as exc:  # noqa: BLE001
        return Check("db_connection", "database", STATUS_FAIL,
                     "Cannot connect to database.", str(exc))


def _check_required_columns(db: Session) -> list[Check]:
    """Verify schema columns required for Phase 3 features exist."""
    required: list[tuple[str, str, bool]] = [
        # (table, column, is_critical)
        ("documents", "material_type", True),
        ("previous_papers", "verification_status", True),
        ("previous_papers", "official_source", True),
        ("video_render_jobs", "evidence_label", True),
        ("video_render_jobs", "source_document_id", True),
    ]
    optional_tables = ["provider_usage_logs", "generation_cache"]

    checks: list[Check] = []
    try:
        insp = sa_inspect(db.bind)  # type: ignore[arg-type]
        existing_tables = set(insp.get_table_names())

        for table, column, critical in required:
            if table not in existing_tables:
                sev = STATUS_FAIL if critical else STATUS_WARN
                checks.append(Check(
                    f"column_{table}_{column}", "database", sev,
                    f"Table '{table}' does not exist.",
                ))
                continue
            cols = {c["name"] for c in insp.get_columns(table)}
            if column in cols:
                checks.append(Check(
                    f"column_{table}_{column}", "database", STATUS_OK,
                    f"{table}.{column} β€” present.",
                ))
            else:
                sev = STATUS_FAIL if critical else STATUS_WARN
                checks.append(Check(
                    f"column_{table}_{column}", "database", sev,
                    f"{table}.{column} β€” MISSING. Run migrations.",
                ))

        for table in optional_tables:
            if table in existing_tables:
                checks.append(Check(
                    f"table_{table}", "database", STATUS_OK,
                    f"Table '{table}' exists.",
                ))
            else:
                checks.append(Check(
                    f"table_{table}", "database", STATUS_WARN,
                    f"Optional table '{table}' not found β€” feature may be disabled.",
                ))

    except Exception as exc:  # noqa: BLE001
        checks.append(Check(
            "schema_inspect", "database", STATUS_FAIL,
            "Schema inspection failed.", str(exc),
        ))

    return checks


def _check_storage(settings: Any) -> list[Check]:
    checks: list[Check] = []

    dirs_to_check: list[tuple[str, Any, bool]] = [
        ("upload_dir", settings.resolved_upload_dir, True),
        ("tts_output_dir", settings.resolved_tts_output_dir, False),
        ("video_output_dir", Path(settings.generated_video_output_dir), False),
        ("video_jobs_dir", Path(settings.generated_video_jobs_dir), False),
    ]

    for name, raw_path, critical in dirs_to_check:
        try:
            path = Path(raw_path) if raw_path else None
            if path is None:
                sev = STATUS_FAIL if critical else STATUS_WARN
                checks.append(Check(f"storage_{name}", "storage", sev,
                                    f"{name} not configured."))
                continue

            if not path.exists():
                try:
                    path.mkdir(parents=True, exist_ok=True)
                    checks.append(Check(f"storage_{name}", "storage", STATUS_WARN,
                                        f"{name}: directory created (was missing)."))
                    continue
                except OSError as exc:
                    sev = STATUS_FAIL if critical else STATUS_WARN
                    checks.append(Check(f"storage_{name}", "storage", sev,
                                        f"{name}: cannot create directory.", str(exc)))
                    continue

            # Write-test
            probe = path / ".beta_health_probe"
            try:
                probe.write_text("probe")
                probe.unlink()
                checks.append(Check(f"storage_{name}", "storage", STATUS_OK,
                                    f"{name}: exists and writable."))
            except OSError as exc:
                sev = STATUS_FAIL if critical else STATUS_WARN
                checks.append(Check(f"storage_{name}", "storage", sev,
                                    f"{name}: directory not writable.", str(exc)))

        except Exception as exc:  # noqa: BLE001
            checks.append(Check(f"storage_{name}", "storage", STATUS_WARN,
                                f"{name}: check error.", str(exc)))

    return checks


def _check_provider_config(settings: Any) -> list[Check]:
    checks: list[Check] = []

    # AI provider β€” never show key, only configured/missing
    provider = settings.ai_provider.strip().lower()
    if provider == "sarvam":
        has_key = bool(settings.sarvam_api_key)
    elif provider == "openrouter":
        has_key = bool(settings.openrouter_api_key)
    else:
        has_key = False

    key_status = STATUS_OK if has_key else STATUS_WARN
    checks.append(Check(
        "ai_provider_key", "provider",
        key_status if provider != "mock" else STATUS_WARN,
        f"AI provider: {provider} β€” key {'configured' if has_key else 'NOT configured'}."
        if provider != "mock" else "AI provider: mock β€” no real AI, fallback only.",
    ))

    # AI router
    checks.append(Check(
        "ai_router", "provider", STATUS_OK,
        f"AI_ROUTER_ENABLED={settings.ai_router_enabled}.",
    ))

    # TTS
    tts = getattr(settings, "tts_provider", None) or "ai4bharat"
    if tts in {"ai4bharat", "indic_parler", "indic_parler_tts"}:
        has_tts_key = bool(settings.huggingface_api_key)
    elif tts == "sarvam":
        has_tts_key = bool(settings.sarvam_api_key)
    elif tts == "indic_tts":
        has_tts_key = True  # local
    else:
        has_tts_key = True  # local/Edge providers need no paid API key

    tts_sev = STATUS_OK if has_tts_key else STATUS_WARN
    checks.append(Check(
        "tts_provider", "provider", tts_sev,
        f"TTS provider: {tts} β€” {'key configured' if has_tts_key else 'key NOT configured'}.",
    ))

    # PYQ discovery
    pyq_enabled = getattr(settings, "pyq_discovery_enabled", False)
    pyq_key = bool(getattr(settings, "pyq_search_api_key", None))
    if pyq_enabled and not pyq_key:
        checks.append(Check(
            "pyq_discovery", "provider", STATUS_WARN,
            "PYQ_DISCOVERY_ENABLED=true but PYQ_SEARCH_API_KEY not set.",
        ))
    elif pyq_enabled:
        checks.append(Check(
            "pyq_discovery", "provider", STATUS_OK,
            "PYQ discovery enabled and key configured.",
        ))
    else:
        checks.append(Check(
            "pyq_discovery", "provider", STATUS_OK,
            "PYQ discovery disabled (safe default).",
        ))

    return checks


def _check_service_imports() -> list[Check]:
    """Verify critical service modules import without error."""
    services = [
        ("source_guard", "app.services.source_guard", "Source Reality Guard"),
        ("evidence_contract", "app.services.evidence_contract", "Evidence Contract"),
        ("pyq_discovery", "app.services.pyq_discovery", "PYQ Discovery service"),
        ("video_study_planner", "app.services.video_study_planner", "Video Study Planner"),
        ("video_study_preview_renderer", "app.services.video_study_preview_renderer",
         "Video Preview Renderer"),
    ]
    checks: list[Check] = []
    for key, module, label in services:
        try:
            __import__(module)
            checks.append(Check(f"import_{key}", "service", STATUS_OK,
                                f"{label} β€” import OK."))
        except ImportError as exc:
            checks.append(Check(f"import_{key}", "service", STATUS_FAIL,
                                f"{label} β€” import FAILED.", str(exc)))
        except Exception as exc:  # noqa: BLE001
            checks.append(Check(f"import_{key}", "service", STATUS_WARN,
                                f"{label} β€” import raised unexpected error.", str(exc)))
    return checks


def _check_video_pipeline(settings: Any) -> list[Check]:
    checks: list[Check] = []
    project_root = Path(__file__).resolve().parents[3]

    # Remotion render script
    render_script = project_root / "scripts" / "render-video-from-json.mjs"
    if render_script.exists():
        checks.append(Check("render_script", "video", STATUS_OK,
                            "Render script found at scripts/render-video-from-json.mjs."))
    else:
        checks.append(Check("render_script", "video", STATUS_WARN,
                            "Render script missing β€” video rendering will fail.",
                            str(render_script)))

    # Node.js
    node = shutil.which("node")
    if node:
        try:
            result = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=5)
            checks.append(Check("node_js", "video", STATUS_OK,
                                f"Node.js available: {result.stdout.strip()}."))
        except Exception:  # noqa: BLE001
            checks.append(Check("node_js", "video", STATUS_WARN, "Node.js found but version check failed."))
    else:
        checks.append(Check("node_js", "video", STATUS_WARN,
                            "Node.js not found β€” Remotion rendering unavailable."))

    # ffmpeg / ffprobe
    for binary in ("ffmpeg", "ffprobe"):
        path = shutil.which(binary)
        if path:
            checks.append(Check(binary, "video", STATUS_OK, f"{binary} available."))
        else:
            checks.append(Check(binary, "video", STATUS_WARN,
                                f"{binary} not found β€” audio/video processing limited."))

    # Generated video output dir writable
    video_out = Path(settings.generated_video_output_dir)
    if not video_out.is_absolute():
        video_out = project_root / video_out
    probe = video_out / ".beta_health_probe"
    try:
        video_out.mkdir(parents=True, exist_ok=True)
        probe.write_text("probe")
        probe.unlink()
        checks.append(Check("video_output_dir", "video", STATUS_OK,
                            "Video output directory writable."))
    except OSError as exc:
        checks.append(Check("video_output_dir", "video", STATUS_WARN,
                            "Video output directory not writable.", str(exc)))

    return checks


def _check_security(settings: Any) -> list[Check]:
    checks: list[Check] = []
    project_root = Path(__file__).resolve().parents[3]

    # .env not tracked in git
    for env_rel in (".env", "backend/.env"):
        env_path = project_root / env_rel
        try:
            result = subprocess.run(
                ["git", "ls-files", "--error-unmatch", str(env_path)],
                capture_output=True, cwd=str(project_root), timeout=10,
            )
            if result.returncode == 0:
                checks.append(Check(
                    f"env_not_tracked_{env_rel.replace('/', '_').replace('.', '_')}",
                    "security", STATUS_FAIL,
                    f"{env_rel} IS tracked in git β€” secrets may be exposed.",
                ))
            else:
                checks.append(Check(
                    f"env_not_tracked_{env_rel.replace('/', '_').replace('.', '_')}",
                    "security", STATUS_OK,
                    f"{env_rel} not tracked in git.",
                ))
        except (FileNotFoundError, subprocess.TimeoutExpired):
            checks.append(Check(
                f"env_not_tracked_{env_rel.replace('/', '_').replace('.', '_')}",
                "security", STATUS_WARN,
                f"Could not verify git tracking for {env_rel} (git not available).",
            ))

    # Default JWT secret in use
    default_secret = "change-this-local-dev-secret"
    if settings.jwt_secret_key == default_secret and settings.environment != "development":
        checks.append(Check(
            "jwt_secret_not_default", "security", STATUS_FAIL,
            "JWT_SECRET_KEY is set to the default value β€” insecure in non-development.",
        ))
    elif settings.jwt_secret_key == default_secret:
        checks.append(Check(
            "jwt_secret_not_default", "security", STATUS_WARN,
            "JWT_SECRET_KEY is the default dev value β€” set a strong key for production.",
        ))
    else:
        checks.append(Check(
            "jwt_secret_not_default", "security", STATUS_OK,
            "JWT secret key is non-default.",
        ))

    # PYQ discovery safe-default
    pyq_enabled = getattr(settings, "pyq_discovery_enabled", False)
    if not pyq_enabled:
        checks.append(Check(
            "pyq_discovery_disabled_default", "security", STATUS_OK,
            "PYQ discovery disabled (safe default).",
        ))
    else:
        checks.append(Check(
            "pyq_discovery_disabled_default", "security", STATUS_WARN,
            "PYQ discovery is ENABLED β€” ensure this is intentional before launch.",
        ))

    # Quick scan for obvious secret patterns in tracked Python/env files
    try:
        result = subprocess.run(
            [
                "git", "grep", "--count", "-E",
                r"(sk-[A-Za-z0-9]{20,}|AIza[A-Za-z0-9_-]{35}|ghp_[A-Za-z0-9]{36})",
                "--", "*.py", "*.env",
            ],
            capture_output=True, text=True, cwd=str(project_root), timeout=15,
        )
        if result.stdout.strip():
            checks.append(Check(
                "no_secrets_in_tracked_files", "security", STATUS_WARN,
                "Possible API key patterns found in tracked files β€” review before deploying.",
                result.stdout.strip()[:200],
            ))
        else:
            checks.append(Check(
                "no_secrets_in_tracked_files", "security", STATUS_OK,
                "No obvious API key patterns found in tracked files.",
            ))
    except (FileNotFoundError, subprocess.TimeoutExpired):
        checks.append(Check(
            "no_secrets_in_tracked_files", "security", STATUS_WARN,
            "Could not scan tracked files for secrets (git not available).",
        ))

    return checks


# ── Endpoint ──────────────────────────────────────────────────────────────────

@router.get(
    "/beta-health",
    summary="Beta Launch Doctor β€” pre-launch diagnostic checklist",
    description=(
        "Developer-only endpoint. Returns a structured checklist of system health checks "
        "required before closed-beta launch. Never exposes secret values. "
        "Accessible without auth in development; requires admin role in production."
    ),
    tags=["Dev"],
)
def beta_health(
    db: Session = Depends(get_db),
    current_user: User | None = Depends(get_current_user_optional),
) -> dict[str, Any]:
    settings = get_settings()

    # Access control: allow in dev, require admin in production
    is_dev = settings.environment == "development"
    if not is_dev:
        if current_user is None:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Authentication required for beta-health outside development.",
            )
        if current_user.role != "admin":
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Admin role required for beta-health endpoint.",
            )

    report = BetaHealthReport(status=STATUS_OK)

    # 1. Database connection
    report.add(_check_db_connection(db))

    # 2. Required schema columns
    for check in _check_required_columns(db):
        report.add(check)

    # 3. Storage directories
    for check in _check_storage(settings):
        report.add(check)

    # 4. Provider config (keys masked)
    for check in _check_provider_config(settings):
        report.add(check)

    # 5. Service imports (Source Reality Guard, Evidence Contract, etc.)
    for check in _check_service_imports():
        report.add(check)

    # 6. Video pipeline
    for check in _check_video_pipeline(settings):
        report.add(check)

    # 7. Security
    for check in _check_security(settings):
        report.add(check)

    report.finalize()
    return report.to_dict()