import asyncio import base64 import ipaddress import json import os import sys from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Optional from urllib.parse import urlparse import httpx import uvicorn from fastapi import FastAPI, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from src.ai.ollama_analyzer import analyze_vehicle from src.api.epa import fetch_epa_data from src.api.nhtsa import decode_vin, fetch_complaints, fetch_investigations, fetch_recalls from src.report.pdf import generate_pdf from src.utils.risk import calculate_risk, compute_extended_fields from src.utils.validator import validate_vin Path("output").mkdir(exist_ok=True) _PRIVATE_NETS = [ ipaddress.ip_network("10.0.0.0/8"), ipaddress.ip_network("172.16.0.0/12"), ipaddress.ip_network("192.168.0.0/16"), ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("169.254.0.0/16"), ipaddress.ip_network("::1/128"), ipaddress.ip_network("fc00::/7"), ] def _is_safe_photo_url(url: str) -> bool: try: parsed = urlparse(url) if parsed.scheme not in ("http", "https"): return False host = parsed.hostname or "" if not host: return False try: addr = ipaddress.ip_address(host) return not any(addr in net for net in _PRIVATE_NETS) except ValueError: blocked = ("localhost", "internal", "metadata", "169.254") return not any(b in host.lower() for b in blocked) except Exception: return False app = FastAPI(title="AR-VinReport", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3050", "http://127.0.0.1:3050"], allow_methods=["GET", "POST"], allow_headers=["Content-Type"], ) app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/output", StaticFiles(directory="output"), name="output") templates = Jinja2Templates(directory="templates") _executor = ThreadPoolExecutor(max_workers=4) _store: dict[str, dict] = {} # ── Pages ───────────────────────────────────────────────────────────────────── @app.get("/") async def index(request: Request): return templates.TemplateResponse("index.html", {"request": request}) # ── SSE fetch endpoint ──────────────────────────────────────────────────────── @app.get("/api/fetch") async def fetch( vin: str = Query(...), odometer: Optional[str] = Query(None), primary_damage: Optional[str] = Query(None), secondary_damage: Optional[str] = Query(None), title: Optional[str] = Query(None), bid: Optional[str] = Query(None), auction: Optional[str] = Query(None), photo_url: Optional[str] = Query(None), ): async def stream(): def evt(step: str, status: str = "progress", **extra) -> str: payload = {"step": step, "status": status, **extra} return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" try: # 1 — Validate VIN vin_clean = vin.strip().upper() ok, err = validate_vin(vin_clean) if not ok: yield evt(f"❌ VIN inválido: {err}", "error") return yield evt(f"✅ VIN válido: {vin_clean}", "success") await asyncio.sleep(0.05) # 2 — Decode VIN yield evt("⏳ Consultando NHTSA VPIC...", "progress") vpic = await decode_vin(vin_clean) if not vpic: yield evt("⚠️ VPIC sin datos — continuando de todas formas", "warning") make = vpic.get("Make", "") model = vpic.get("Model", "") year = vpic.get("ModelYear", "") yield evt(f"✅ Vehículo: {year} {make} {model}", "success") await asyncio.sleep(0.05) # 3 — Recalls + Complaints + Investigations (paralelo) yield evt("⏳ Consultando recalls, quejas e investigaciones...", "progress") recalls, complaints, invests = await asyncio.gather( fetch_recalls(make, model, year), fetch_complaints(make, model, year), fetch_investigations(make, model, year), ) yield evt( f"✅ {len(recalls)} recalls · {len(complaints)} quejas · {len(invests)} investigaciones", "success", ) await asyncio.sleep(0.05) # 4 — EPA yield evt("⏳ Consultando EPA FuelEconomy...", "progress") epa = await fetch_epa_data(year, make, model) yield evt(f"✅ Eficiencia: {epa.get('mpg_combined', 'N/A')}", "success") await asyncio.sleep(0.05) # 5 — Photo photo_bytes = None if photo_url and photo_url.strip(): yield evt("⏳ Descargando foto del vehículo...", "progress") if not _is_safe_photo_url(photo_url.strip()): yield evt("⚠️ URL de foto no permitida (dominio bloqueado)", "warning") else: try: async with httpx.AsyncClient(timeout=10) as client: r = await client.get(photo_url.strip()) if r.status_code == 200: photo_bytes = r.content yield evt("✅ Foto descargada", "success") else: yield evt("⚠️ No se pudo descargar la foto (HTTP error)", "warning") except Exception: yield evt("⚠️ Error al descargar la foto", "warning") # 6 — Risk score + extended fields risk = calculate_risk( title=title or "", recalls=recalls, secondary_damage=secondary_damage or "", complaints=complaints, odometer=odometer or "0", ) extended = compute_extended_fields( title=title or "", recalls=recalls, complaints=complaints, odometer=odometer or "0", secondary_damage=secondary_damage or "", ) score = risk["score"] level = risk["level"] emoji = risk["emoji"] yield evt(f"{emoji} Score: {score}/100 — {level}", "success") await asyncio.sleep(0.05) # 7 — Ollama AI analysis yield evt("⏳ Consultando IA local (DealAnalyzer)...", "progress") vehicle_data_for_ai = { "vin": vin_clean, "odometer": odometer or "—", "primary_damage": primary_damage or "—", "secondary_damage": secondary_damage or "—", "title": title or "—", "bid": bid or "—", "vpic": vpic, "risk": risk, "critical_recall_count": extended["critical_recall_count"], "engine_complaints": extended["engine_complaints"], "transmission_complaints": extended["transmission_complaints"], "alerts": extended["alerts"], } ai_analysis = await analyze_vehicle(vehicle_data_for_ai) if ai_analysis: yield evt("✅ Análisis IA generado", "success") else: yield evt("⚠️ Ollama no disponible — sección IA omitida", "warning") await asyncio.sleep(0.05) # Store for PDF generation _store[vin_clean] = { "vin": vin_clean, "odometer": odometer or "—", "primary_damage": primary_damage or "—", "secondary_damage": secondary_damage or "—", "title": title or "—", "bid": bid or "—", "auction": auction or "—", "photo_url": photo_url or "", "photo_bytes": base64.b64encode(photo_bytes).decode() if photo_bytes else None, "vpic": vpic, "recalls": recalls, "complaints": complaints, "investigations": invests, "epa": epa, "risk": risk, "ai_analysis": ai_analysis, **extended, } vehicle_label = f"{year} {make} {model}".strip() or vin_clean yield evt( "✅ Datos listos — puedes generar el reporte PDF", "done", vin=vin_clean, vehicle=vehicle_label, risk=risk, ) except Exception: yield evt("❌ Error inesperado procesando la solicitud", "error") return StreamingResponse( stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) # ── Generate PDF ─────────────────────────────────────────────────────────────── @app.post("/api/generate") async def generate(request: Request): body = await request.json() vin = (body.get("vin") or "").strip().upper() if vin not in _store: return {"error": "No hay datos para este VIN. Haz fetch primero."} loop = asyncio.get_event_loop() try: filename = await loop.run_in_executor(_executor, generate_pdf, _store[vin]) except Exception: return {"error": "Error generando el PDF"} return {"filename": filename, "url": f"/output/{filename}"} # ── Open file locally (Windows only) ────────────────────────────────────────── @app.post("/api/open") async def open_file(request: Request): body = await request.json() filename = (body.get("filename") or "").replace("..", "").replace("/", "").replace("\\", "") path = Path("output") / filename if not path.exists(): return {"error": "Archivo no encontrado"} if sys.platform != "win32": return {"error": "Auto-open solo disponible en Windows"} os.startfile(str(path.resolve())) return {"ok": True} # ── Entry point ──────────────────────────────────────────────────────────────── if __name__ == "__main__": uvicorn.run("main:app", host="0.0.0.0", port=3050, reload=True)