277 lines
11 KiB
Python
277 lines
11 KiB
Python
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,
|
|
"recalls": recalls,
|
|
"complaints": complaints,
|
|
"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)
|