import os import secrets from typing import Any import httpx from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from httpx import HTTPError from starlette.datastructures import UploadFile from app.config import settings from app.models import ArtifactContext, FactCheckReport, FactCheckRequest, FactCheckResponse from app.services.ai import _ai_available, extract_image_key_info, judge_report, summarize_context from app.services.evidence import gather_evidence from app.services.extractor import extract_artifact_context from app.services.scoring import score_report # Optional Firebase Firestore _firestore_db: Any | None = None def _get_firestore_db() -> Any | None: global _firestore_db if _firestore_db is not None: return _firestore_db try: import json import firebase_admin from firebase_admin import credentials, firestore if not firebase_admin._apps: cred_json = settings.firebase_service_account_json cred_path = settings.firebase_credentials_path if cred_json: firebase_admin.initialize_app(credentials.Certificate(json.loads(cred_json))) elif cred_path: firebase_admin.initialize_app(credentials.Certificate(cred_path)) else: firebase_admin.initialize_app() _firestore_db = firestore.client() return _firestore_db except Exception as exc: import traceback print("[firebase] initialization failed:", exc) traceback.print_exc() return None app = FastAPI(title=settings.app_name) app.add_middleware( CORSMiddleware, allow_origins=[settings.frontend_origin], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # In-memory fallback store for shareable reports reports_db: dict[str, FactCheckReport] = {} # Internal Next.js server address (standalone, started in start.sh) NEXT_JS_URL = "http://localhost:3000" def _save_report(report_id: str, report: FactCheckReport) -> None: db = _get_firestore_db() if db: db.collection("reports").document(report_id).set(report.model_dump()) else: reports_db[report_id] = report def _load_report(report_id: str) -> FactCheckReport | None: db = _get_firestore_db() if db: doc = db.collection("reports").document(report_id).get() if doc.exists: return FactCheckReport(**doc.to_dict()) return None return reports_db.get(report_id) def _mask(value: str | None, length: int = 6) -> str: if not value: return "not set" if len(value) <= length * 2: return "set" return f"{value[:length]}...{value[-length:]}" @app.on_event("startup") async def startup() -> None: print("[startup] Tavily key:", _mask(settings.tavily_api_key)) print("[startup] AI mode:", "local" if settings.use_local_llm else "api") if settings.use_local_llm: print("[startup] Ollama base URL:", settings.ollama_base_url) print("[startup] Ollama model:", settings.ollama_model) else: print("[startup] OpenAI key:", _mask(settings.openai_api_key)) print("[startup] OpenAI base URL:", settings.openai_base_url or "not set") print("[startup] OpenAI model:", settings.openai_model) db = _get_firestore_db() print("[startup] Firebase configured:", "yes" if db else "no") @app.get("/health") async def health() -> dict[str, str]: return { "status": "ok", "tavily_configured": "yes" if settings.tavily_api_key else "no", "ai_configured": "yes" if _ai_available() else "no", "ai_mode": "local" if settings.use_local_llm else "api", "firebase_configured": "yes" if _get_firestore_db() else "no", } async def _parse_fact_check_submission(request: Request) -> tuple[str | None, bytes | None, str | None, str | None]: content_type = request.headers.get("content-type", "") if content_type.startswith("multipart/form-data"): form = await request.form() url_value = form.get("url") image_file = form.get("image") image_bytes = None image_name = None image_content_type = None if isinstance(image_file, UploadFile): image_name = image_file.filename image_content_type = image_file.content_type image_bytes = await image_file.read() return (str(url_value) if url_value else None, image_bytes, image_name, image_content_type) payload = await request.json() if not isinstance(payload, dict): raise HTTPException(status_code=422, detail="Invalid request payload") request_model = FactCheckRequest(**payload) return (str(request_model.url) if request_model.url else None, None, None, None) @app.post("/api/fact-check", response_model=FactCheckResponse) async def fact_check(request: Request) -> FactCheckResponse: url, image_bytes, image_name, image_content_type = await _parse_fact_check_submission(request) if not url and not image_bytes: raise HTTPException(status_code=422, detail="Provide either a URL or an image") try: image_key_info = await extract_image_key_info(image_bytes, image_content_type, image_name) if url: context = await extract_artifact_context(url) context = context.model_copy(update={"image_key_info": image_key_info}) else: context = ArtifactContext( source_url=image_name or "Uploaded image", title=image_name, description="Image-only investigation", image_key_info=image_key_info, ) summary, claims = await summarize_context(context) evidence = await gather_evidence(summary, claims) scores, verdict = score_report(evidence) reason = await judge_report(summary, claims, evidence, verdict) except HTTPError as exc: raise HTTPException(status_code=422, detail=f"Unable to fetch or inspect the URL: {exc}") from exc except Exception as exc: raise HTTPException(status_code=500, detail=f"Fact-check pipeline failed: {exc}") from exc report = FactCheckReport( url=url, context_summary=summary, image_key_info=context.image_key_info, key_claims=claims, evidence=evidence, scores=scores, verdict=verdict, verdict_reason=reason, ) report_id = secrets.token_urlsafe(8) _save_report(report_id, report) return FactCheckResponse(id=report_id, **report.model_dump()) @app.get("/api/reports/{report_id}", response_model=FactCheckReport) async def get_report(report_id: str) -> FactCheckReport: report = _load_report(report_id) if report is None: raise HTTPException(status_code=404, detail="Report not found") return report # ── Reverse proxy: forward all non-API requests to Next.js on port 3000 ────── @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]) async def proxy_to_nextjs(request: Request, path: str) -> StreamingResponse: if path.startswith("api/") or path == "health": raise HTTPException(status_code=404) url = f"{NEXT_JS_URL}/{path}" if request.url.query: url += f"?{request.url.query}" try: async with httpx.AsyncClient(timeout=30.0) as client: response = await client.request( method=request.method, url=url, headers={k: v for k, v in request.headers.items() if k.lower() != "host"}, content=await request.body(), follow_redirects=True, ) except httpx.ConnectError: raise HTTPException(status_code=503, detail="Frontend server unavailable") # Strip headers that conflict with proxied streaming responses excluded_headers = { "content-encoding", # httpx already decoded — browser must not decode again "content-length", # length is now wrong after decoding "transfer-encoding", # may conflict with StreamingResponse chunking } headers = { k: v for k, v in response.headers.items() if k.lower() not in excluded_headers } return StreamingResponse( content=response.aiter_bytes(), status_code=response.status_code, headers=headers, )