df09627ccb
New modules: nicb.py, auction_history.py, dmv_florida.py, theft_stats.py, odometer_validator.py Expanded risk.py with 8 new factors (NICB alert, safety rating, auction flags, odo validation, theft level, odo fraud, DMV lien, open VIN recalls) PDF now has 11 sections including NICB check, theft stats, odometer fraud, odometer validation, auction history, Florida HSMV AI prompt enriched with all new data fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
328 lines
14 KiB
Python
328 lines
14 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,
|
|
fetch_vin_recalls, fetch_safety_ratings, check_odometer_complaints,
|
|
)
|
|
from src.api.nicb import check_nicb
|
|
from src.api.auction_history import search_auction_history
|
|
from src.api.dmv_florida import check_florida_dmv
|
|
from src.api.theft_stats import get_theft_risk
|
|
from src.report.pdf import generate_pdf
|
|
from src.utils.risk import calculate_risk, compute_extended_fields
|
|
from src.utils.odometer_validator import validate_odometer
|
|
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 — NHTSA + EPA + NICB + Subastas + DMV (todo en paralelo)
|
|
yield evt("⏳ Consultando NHTSA, EPA, NICB, historial web, Florida DMV...", "progress")
|
|
(
|
|
recalls, complaints, invests,
|
|
vin_recalls, safety_ratings, epa,
|
|
nicb, auction_hist, dmv_fl,
|
|
) = await asyncio.gather(
|
|
fetch_recalls(make, model, year),
|
|
fetch_complaints(make, model, year),
|
|
fetch_investigations(make, model, year),
|
|
fetch_vin_recalls(vin_clean),
|
|
fetch_safety_ratings(year, make, model),
|
|
fetch_epa_data(year, make, model),
|
|
check_nicb(vin_clean),
|
|
search_auction_history(vin_clean),
|
|
check_florida_dmv(vin_clean),
|
|
)
|
|
|
|
# Local enrichment (no I/O)
|
|
theft_info = get_theft_risk(make, model)
|
|
odo_check = validate_odometer(year, odometer or "0")
|
|
odo_fraud = check_odometer_complaints(complaints)
|
|
|
|
open_vin_recalls = len([
|
|
r for r in vin_recalls
|
|
if not (r.get("RemedyAvailability") or "").lower().startswith("remedy")
|
|
])
|
|
stars = safety_ratings.get("overall", "")
|
|
nicb_status = nicb.get("status", "NO_DISPONIBLE")
|
|
yield evt(
|
|
f"✅ {len(recalls)} recalls · {len(complaints)} quejas · "
|
|
f"NICB: {nicb_status} · "
|
|
f"{stars+'★' if stars and stars != 'NR' else 'seg. N/A'}",
|
|
"success",
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
|
|
# 4 — 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",
|
|
nicb_status=nicb_status,
|
|
safety_overall=stars,
|
|
auction_flags=auction_hist.get("flags", []),
|
|
auction_pts=auction_hist.get("extra_points", 0),
|
|
odo_extra_pts=odo_check.get("extra_points", 0),
|
|
theft_level=theft_info.get("level", ""),
|
|
odo_fraud_count=odo_fraud.get("count", 0),
|
|
dmv_has_lien=dmv_fl.get("has_lien", False),
|
|
open_vin_recalls=open_vin_recalls,
|
|
)
|
|
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,
|
|
"vin_recalls": vin_recalls,
|
|
"safety_ratings": safety_ratings,
|
|
"nicb": nicb,
|
|
"auction_hist": auction_hist,
|
|
"dmv_fl": dmv_fl,
|
|
"theft_info": theft_info,
|
|
"odo_check": odo_check,
|
|
"odo_fraud": odo_fraud,
|
|
"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,
|
|
"vin_recalls": vin_recalls,
|
|
"safety_ratings": safety_ratings,
|
|
"nicb": nicb,
|
|
"auction_hist": auction_hist,
|
|
"dmv_fl": dmv_fl,
|
|
"theft_info": theft_info,
|
|
"odo_check": odo_check,
|
|
"odo_fraud": odo_fraud,
|
|
"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)
|