From dc3ec90109e5a583c07a577b1a91839648e85885 Mon Sep 17 00:00:00 2001 From: Alvaro Romero Date: Fri, 3 Jul 2026 11:40:35 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20AR-VINchecker=20v1.0=20=E2=80=94=20VIN?= =?UTF-8?q?=20report=20generator=20with=20AI=20analysis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastAPI server querying NHTSA/EPA APIs, generating PDF reports with risk scoring, and local Ollama AI analysis via DealAnalyzer. Security hardened: XSS fix, SSRF protection, CORS restricted. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 15 ++ AR-VinReport_ClaudeCode_Prompt.md | 256 +++++++++++++++++++ main.py | 274 ++++++++++++++++++++ requirements.txt | 9 + src/__init__.py | 0 src/ai/__init__.py | 0 src/ai/ollama_analyzer.py | 71 ++++++ src/api/__init__.py | 0 src/api/epa.py | 56 +++++ src/api/nhtsa.py | 83 ++++++ src/report/__init__.py | 0 src/report/pdf.py | 406 ++++++++++++++++++++++++++++++ src/utils/__init__.py | 0 src/utils/risk.py | 132 ++++++++++ src/utils/validator.py | 34 +++ static/css/styles.css | 284 +++++++++++++++++++++ static/js/app.js | 259 +++++++++++++++++++ templates/index.html | 91 +++++++ 18 files changed, 1970 insertions(+) create mode 100644 .gitignore create mode 100644 AR-VinReport_ClaudeCode_Prompt.md create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/ai/__init__.py create mode 100644 src/ai/ollama_analyzer.py create mode 100644 src/api/__init__.py create mode 100644 src/api/epa.py create mode 100644 src/api/nhtsa.py create mode 100644 src/report/__init__.py create mode 100644 src/report/pdf.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/risk.py create mode 100644 src/utils/validator.py create mode 100644 static/css/styles.css create mode 100644 static/js/app.js create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39f0cae --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +__pycache__/ +*.py[cod] +*.pyo +.env +.env.* +output/ +*.pdf +venv/ +.venv/ +env/ +*.egg-info/ +dist/ +build/ +.DS_Store +Thumbs.db diff --git a/AR-VinReport_ClaudeCode_Prompt.md b/AR-VinReport_ClaudeCode_Prompt.md new file mode 100644 index 0000000..438ce8b --- /dev/null +++ b/AR-VinReport_ClaudeCode_Prompt.md @@ -0,0 +1,256 @@ +# Claude Code Prompt — AR-VINChecker +## App de Reporte de Historial Vehicular por VIN (100% gratis, sin APIs de pago) + +--- + +## CONTEXTO DEL PROYECTO + +Desarrollar una aplicación de escritorio Python/PySide6 llamada **AR-VinReport** que genere reportes PDF profesionales de historial vehicular usando exclusivamente APIs públicas y gratuitas del gobierno de EE.UU. La app es para uso personal/profesional en evaluación de vehículos en subastas (Copart, IAAI) antes de pujar. + +**Ruta del proyecto:** `D:\Proyectos Software\AR-VinChecker\` +**Usuario:** Jairo — ingeniero naval en South Florida, evalúa vehículos en subastas para inversión. + +--- + +## LO QUE HACE LA APP + +### Input (pantalla principal) +El usuario ingresa manualmente los datos que ve en el listing de la subasta: +- VIN (17 caracteres) +- Odómetro (millas) +- Daño primario (ej: "Normal Wear") +- Daño secundario (ej: "Damage History") +- Título (ej: "NY - Certificate of Title") +- Precio de bid actual ($) +- Nombre/URL de la subasta (opcional) +- URL de foto del vehículo (opcional, para incluir en el PDF) + +### Procesamiento automático (al hacer click en "Generate Report") +La app consulta en paralelo: + +1. **NHTSA VPIC API** — Decode del VIN: + - Make, Model, Year, Trim, Body Style + - Engine type, displacement, cylinders + - Drive type, transmission + - Plant country of manufacture + - URL: `https://vpic.nhtsa.dot.gov/api/vehicles/decodevin/{VIN}?format=json` + +2. **NHTSA Recalls API** — Recalls activos: + - Lista de recalls por make/model/year + - Descripción del componente afectado + - Si está abierto o cerrado + - URL: `https://api.nhtsa.gov/recalls/recallsByVehicle?make={make}&model={model}&modelYear={year}` + +3. **NHTSA Complaints API** — Quejas de propietarios: + - Total de quejas registradas + - Top 5 componentes con más quejas + - URL: `https://api.nhtsa.gov/complaints/complaintsByVehicle?make={make}&model={model}&modelYear={year}` + +4. **NHTSA Investigations API** — Investigaciones de seguridad abiertas: + - URL: `https://api.nhtsa.gov/investigations/investigationsByVehicle?make={make}&model={model}&modelYear={year}` + +5. **EPA FuelEconomy API** — Eficiencia y specs: + - MPG ciudad/highway, o kWh/100mi si es EléctricoF + - URL: `https://www.fueleconomy.gov/ws/rest/vehicle/menu/options?year={year}&make={make}&model={model}` + +6. **Foto del vehículo** — Si el usuario pegó una URL, descargarla e incluirla en el PDF. + +### Output — PDF Profesional +Generar un PDF de 1-2 páginas con: + +**Header:** +- Logo "AR-VinReport" (texto estilizado si no hay imagen) +- Fecha y hora del reporte +- VIN consultado + +**Sección 1 — Identificación del Vehículo** +- Foto del vehículo (si se proporcionó URL) +- Tabla: Year / Make / Model / Trim / Body +- Tabla: Engine / Transmission / Drive / Plant + +**Sección 2 — Datos del Listing de Subasta** +- Tabla: Odómetro / Daño Primario / Daño Secundario / Título / Bid + +**Sección 3 — Recalls NHTSA** +- Total de recalls encontrados +- Lista con: fecha, componente, descripción breve +- Badge verde "0 RECALLS" o rojo "X RECALLS ACTIVOS" + +**Sección 4 — Quejas de Propietarios** +- Total de quejas +- Top 5 componentes problemáticos con conteo +- Gráfico de barras horizontal simple (matplotlib) + +**Sección 5 — Investigaciones de Seguridad** +- Lista de investigaciones abiertas si las hay + +**Sección 6 — Eficiencia EPA** +- MPG o kWh/100mi +- Estimado de costo anual de combustible/electricidad + +**Sección 7 — Análisis de Riesgo (generado por la app)** +- Score de riesgo calculado: 0-100 + - +20 puntos si título es Salvage + - +15 puntos por cada recall activo (max 30) + - +10 si daño secundario no es "None" + - +10 si quejas > 50 + - -10 si odómetro < 60,000 millas + - -15 si título es Certificate of Title (limpio) +- Badge: BAJO RIESGO (verde) / RIESGO MEDIO (amarillo) / ALTO RIESGO (rojo) + +**Footer:** +- "Datos de NHTSA.gov y EPA.gov — Información pública oficial del gobierno de EE.UU." +- "Este reporte no reemplaza un Carfax o inspección física" + +--- + +## STACK TÉCNICO + +``` +Python 3.11+ +PySide6 — GUI de escritorio +requests — llamadas HTTP a las APIs +reportlab — generación de PDF profesional +matplotlib — gráfico de barras de quejas +Pillow (PIL) — manejo de imagen del vehículo +``` + +--- + +## ESTRUCTURA DEL PROYECTO + +``` +D:\Proyectos Software\AR-VinReport\ +├── main.py ← Entry point, lanza la GUI +├── requirements.txt +├── src/ +│ ├── __init__.py +│ ├── gui/ +│ │ ├── __init__.py +│ │ ├── main_window.py ← Ventana principal PySide6 +│ │ └── styles.py ← QSS stylesheet oscuro profesional +│ ├── api/ +│ │ ├── __init__.py +│ │ ├── nhtsa.py ← Todas las llamadas a NHTSA +│ │ └── epa.py ← Llamadas a EPA FuelEconomy +│ ├── report/ +│ │ ├── __init__.py +│ │ └── pdf_generator.py ← Generación del PDF con reportlab +│ └── utils/ +│ ├── __init__.py +│ ├── vin_validator.py ← Validación de VIN (17 chars, checksum) +│ └── risk_score.py ← Cálculo del score de riesgo +├── assets/ +│ └── logo.png ← Logo AR-VinReport (crear simple si no existe) +├── output/ ← PDFs generados se guardan aquí +└── docs/ + ├── LOGBOOK.md + ├── LIBRARIES.md + └── modules/ + ├── gui_log.md + ├── api_log.md + └── report_log.md +``` + +--- + +## GUI — DISEÑO DE LA VENTANA PRINCIPAL + +Estilo oscuro profesional, consistente con las otras apps AR Suite (AR-ShipDesign, AR-Autopilot, etc.). + +**Layout:** +``` +┌─────────────────────────────────────────────────────┐ +│ AR-VinReport v1.0 [dark theme] │ +├──────────────────┬──────────────────────────────────┤ +│ INPUT PANEL │ STATUS / PREVIEW │ +│ │ │ +│ VIN: [_______] │ ┌──────────────────────────┐ │ +│ Odometer: [___] │ │ Waiting for VIN... │ │ +│ Primary dmg:[_] │ │ │ │ +│ Secondary: [__] │ │ [Progress steps show │ │ +│ Title: [______] │ │ here during fetch] │ │ +│ Bid ($): [____] │ │ │ │ +│ Auction: [____] │ └──────────────────────────┘ │ +│ Photo URL:[___] │ │ +│ │ [GENERATE PDF REPORT] ← botón │ +│ [FETCH DATA] │ [OPEN OUTPUT FOLDER] │ +└──────────────────┴──────────────────────────────────┘ +``` + +Al hacer click en FETCH DATA: +- Mostrar progress en el panel derecho paso a paso: + - ✅ VIN válido: 1FTVW1EL1NWG14881 + - ⏳ Consultando NHTSA VPIC... + - ✅ Vehículo: 2022 Ford F-150 Lightning Pro + - ⏳ Consultando recalls... + - ✅ 8 recalls encontrados + - ⏳ Consultando quejas... + - ✅ 95 quejas registradas + - ⏳ Consultando EPA... + - ✅ Eficiencia: 2.0 kWh/mi + - ✅ Foto descargada + - 🟡 Score de riesgo: 35/100 — RIESGO MEDIO + - ✅ Reporte generado: output/VIN_20260703_143022.pdf + +--- + +## INSTRUCCIONES PARA CLAUDE CODE + +1. **Leer este prompt completo antes de escribir código.** + +2. **Crear toda la estructura de carpetas** del proyecto primero. + +3. **Empezar por el módulo API** (`src/api/nhtsa.py`) y probarlo con el VIN de prueba `1FTVW1EL1NWG14881` (2022 Ford F-150 Lightning Pro). + +4. **Verificar que las APIs respondan** antes de construir la GUI. Las APIs de NHTSA son HTTP GET simples sin autenticación. + +5. **Construir la GUI** una vez confirmado que los datos llegan correctamente. + +6. **Construir el PDF** con reportlab, no con weasyprint ni html2pdf — reportlab da más control sobre el layout. + +7. **Al final de la sesión**, crear y poblar los archivos de documentación: + - `docs/LOGBOOK.md` — siguiendo el formato del app-organizer skill + - `docs/LIBRARIES.md` — documentando cada librería usada + - `docs/modules/api_log.md`, `gui_log.md`, `report_log.md` + +8. **El PDF generado** debe guardarse en `output/` con nombre: `{VIN}_{YYYYMMDD}_{HHMMSS}.pdf` + +9. **Manejo de errores**: si una API no responde, mostrar "No data available" en esa sección del PDF — nunca crashear. + +10. **El validador de VIN** debe verificar: + - Exactamente 17 caracteres + - No contiene I, O, Q (caracteres prohibidos en VINs) + - Checksum digit válido (posición 9) + +--- + +## VIN DE PRUEBA + +``` +VIN: 1FTVW1EL1NWG14881 +Odometer: 57,909 mi +Primary damage: Normal Wear +Secondary: Damage History +Title: NY - Certificate of Title +Bid: $4,100 +Auction: Copart +``` + +Usar este VIN para todas las pruebas durante el desarrollo. + +--- + +## CRITERIO DE ÉXITO + +La sesión es exitosa cuando: +- [ ] `python main.py` lanza la ventana sin errores +- [ ] Al ingresar el VIN de prueba y hacer FETCH DATA, se obtienen datos reales de NHTSA +- [ ] El botón GENERATE PDF crea un archivo PDF en `output/` +- [ ] El PDF tiene: foto (si se dio URL), specs del vehículo, recalls, quejas, score de riesgo +- [ ] El PDF se abre correctamente en cualquier visor +- [ ] Documentación de logbook creada y poblada + +--- + +*Prompt generado por Claude — AR Suite / Prisa Yachts LLC — Julio 2026* diff --git a/main.py b/main.py new file mode 100644 index 0000000..371117e --- /dev/null +++ b/main.py @@ -0,0 +1,274 @@ +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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c040cb9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +httpx==0.25.2 +reportlab==4.0.7 +matplotlib==3.8.2 +Pillow==10.1.0 +jinja2==3.1.2 +python-multipart==0.0.6 +aiofiles==23.2.1 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ai/__init__.py b/src/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ai/ollama_analyzer.py b/src/ai/ollama_analyzer.py new file mode 100644 index 0000000..5cbc11c --- /dev/null +++ b/src/ai/ollama_analyzer.py @@ -0,0 +1,71 @@ +import httpx + +OLLAMA_URL = "http://localhost:11434/api/generate" +PRIMARY_MODEL = "DealAnalyzer" +FALLBACK_MODEL = "qwen2.5:14b" + +_OLLAMA_OPTIONS = {"temperature": 0.3, "num_predict": 250} + + +def _build_prompt(data: dict) -> str: + # Sanitize string values to prevent prompt injection via control sequences + def s(val) -> str: + if val is None: + return "N/A" + cleaned = str(val).replace("\n", " ").replace("\r", " ").strip() + return cleaned[:200] # cap length per field + + vpic = data.get("vpic", {}) + risk = data.get("risk", {}) + alerts = data.get("alerts", []) + + return ( + "Eres un experto en evaluación de vehículos usados en subastas americanas (Copart, IAAI).\n" + "Analiza estos datos y genera un resumen ejecutivo en español de máximo 150 palabras.\n" + "Sé directo y específico. Termina con una recomendación clara: COMPRAR, INVESTIGAR MÁS, o EVITAR.\n\n" + "DATOS DEL VEHÍCULO:\n" + f"- VIN: {s(data.get('vin'))}\n" + f"- Vehículo: {s(vpic.get('ModelYear'))} {s(vpic.get('Make'))} {s(vpic.get('Model'))} {s(vpic.get('Trim'))}\n" + f"- Odómetro: {s(data.get('odometer'))} millas\n" + f"- Daño primario: {s(data.get('primary_damage'))}\n" + f"- Daño secundario: {s(data.get('secondary_damage'))}\n" + f"- Tipo de título: {s(data.get('title'))}\n" + f"- Bid actual: ${s(data.get('bid'))}\n" + f"- Recalls activos NHTSA: {s(risk.get('recall_count'))}\n" + f"- Recalls críticos: {s(data.get('critical_recall_count'))}\n" + f"- Total quejas propietarios: {s(risk.get('complaint_count'))}\n" + f"- Quejas de motor: {s(data.get('engine_complaints'))}\n" + f"- Quejas de transmisión: {s(data.get('transmission_complaints'))}\n" + f"- Score de riesgo calculado: {s(risk.get('score'))}/100\n" + f"- Alertas detectadas: {s('; '.join(alerts) if alerts else 'Ninguna')}\n\n" + "RESUMEN EJECUTIVO:\n" + ) + + +async def analyze_vehicle(data: dict) -> str | None: + prompt = _build_prompt(data) + + # Try PRIMARY with short timeout first, then FALLBACK with full timeout + schedule = [ + (PRIMARY_MODEL, 10), + (FALLBACK_MODEL, 30), + ] + + for model, timeout in schedule: + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post( + OLLAMA_URL, + json={ + "model": model, + "prompt": prompt, + "stream": False, + "options": _OLLAMA_OPTIONS, + }, + ) + if resp.status_code == 200: + return resp.json().get("response", "").strip() + except Exception: + continue + + return None diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/epa.py b/src/api/epa.py new file mode 100644 index 0000000..d352575 --- /dev/null +++ b/src/api/epa.py @@ -0,0 +1,56 @@ +import httpx + +_EPA_BASE = "https://www.fueleconomy.gov/ws/rest" +_HEADERS = {"Accept": "application/json"} + + +async def fetch_epa_data(year: str, make: str, model: str) -> dict: + empty = {"mpg_combined": "N/A", "mpg_city": "N/A", "mpg_highway": "N/A", + "fuel_type": "N/A", "annual_cost": "N/A"} + try: + url = f"{_EPA_BASE}/vehicle/menu/options" + params = {"year": year, "make": make.title(), "model": model} + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url, params=params, headers=_HEADERS) + if resp.status_code != 200: + return empty + data = resp.json() + + items = data.get("menuItem", []) + if not items: + return empty + if isinstance(items, dict): + items = [items] + + vehicle_id = items[0].get("value") + if not vehicle_id: + return empty + + async with httpx.AsyncClient(timeout=15) as client: + resp2 = await client.get(f"{_EPA_BASE}/vehicle/{vehicle_id}", headers=_HEADERS) + if resp2.status_code != 200: + return empty + v = resp2.json() + + fuel = v.get("fuelType1", v.get("fuelType", "Gasoline")) + is_electric = "Electricity" in (fuel or "") + + if is_electric: + return { + "mpg_combined": f"{v.get('combA', v.get('comb08', 'N/A'))} MPGe", + "mpg_city": f"{v.get('cityA', v.get('city08', 'N/A'))} MPGe city", + "mpg_highway": f"{v.get('highwayA', v.get('highway08', 'N/A'))} MPGe hwy", + "range_miles": str(v.get("range", v.get("rangeA", "N/A"))), + "fuel_type": fuel, + "annual_cost": f"${v.get('annually1', v.get('you1', 'N/A'))}", + } + + return { + "mpg_combined": f"{v.get('comb08', 'N/A')} MPG", + "mpg_city": f"{v.get('city08', 'N/A')} MPG city", + "mpg_highway": f"{v.get('highway08', 'N/A')} MPG hwy", + "fuel_type": fuel, + "annual_cost": f"${v.get('annually', v.get('you1', 'N/A'))}", + } + except Exception: + return empty diff --git a/src/api/nhtsa.py b/src/api/nhtsa.py new file mode 100644 index 0000000..3d26e6d --- /dev/null +++ b/src/api/nhtsa.py @@ -0,0 +1,83 @@ +import httpx + +_VPIC_BASE = "https://vpic.nhtsa.dot.gov/api/vehicles" +_NHTSA_BASE = "https://api.nhtsa.gov" + +_VPIC_FIELDS = { + "Make", "Model", "Model Year", "Trim", "Body Class", + "Displacement (L)", "Engine Number of Cylinders", "Fuel Type - Primary", + "Drive Type", "Transmission Style", "Plant Country", + "Electrification Level", "Engine Model", +} + +_FIELD_MAP = { + "Make": "Make", + "Model": "Model", + "Model Year": "ModelYear", + "Trim": "Trim", + "Body Class": "BodyClass", + "Displacement (L)": "DisplacementL", + "Engine Number of Cylinders": "Cylinders", + "Fuel Type - Primary": "FuelType", + "Drive Type": "DriveType", + "Transmission Style": "Transmission", + "Plant Country": "PlantCountry", + "Electrification Level": "EVLevel", + "Engine Model": "EngineModel", +} + + +async def decode_vin(vin: str) -> dict: + url = f"{_VPIC_BASE}/decodevin/{vin}?format=json" + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url) + resp.raise_for_status() + data = resp.json() + except Exception: + return {} + + result: dict = {} + for item in data.get("Results", []): + var = item.get("Variable", "") + val = (item.get("Value") or "").strip() + if var in _FIELD_MAP and val and val not in ("Not Applicable", "null", "None"): + result[_FIELD_MAP[var]] = val + + return result + + +async def fetch_recalls(make: str, model: str, year: str) -> list: + url = f"{_NHTSA_BASE}/recalls/recallsByVehicle" + params = {"make": make, "model": model, "modelYear": year} + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url, params=params) + resp.raise_for_status() + return resp.json().get("results", []) + except Exception: + return [] + + +async def fetch_complaints(make: str, model: str, year: str) -> list: + url = f"{_NHTSA_BASE}/complaints/complaintsByVehicle" + params = {"make": make, "model": model, "modelYear": year} + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url, params=params) + resp.raise_for_status() + return resp.json().get("results", []) + except Exception: + return [] + + +async def fetch_investigations(make: str, model: str, year: str) -> list: + url = f"{_NHTSA_BASE}/investigations/investigationsByVehicle" + params = {"make": make, "model": model, "modelYear": year} + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url, params=params) + resp.raise_for_status() + return resp.json().get("results", []) + except Exception: + return [] diff --git a/src/report/__init__.py b/src/report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/report/pdf.py b/src/report/pdf.py new file mode 100644 index 0000000..a02724b --- /dev/null +++ b/src/report/pdf.py @@ -0,0 +1,406 @@ +import io +import base64 +from collections import Counter +from datetime import datetime +from pathlib import Path + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +from PIL import Image as PILImage + +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.colors import HexColor, white, black +from reportlab.lib.units import inch +from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT +from reportlab.platypus import ( + SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, + Image, HRFlowable, KeepTogether, +) + +# ── Palette ────────────────────────────────────────────────────────────────── +NAVY = HexColor("#1a3a5c") +NAVY2 = HexColor("#2c3e50") +SLATE = HexColor("#34495e") +LGRAY = HexColor("#f4f6f8") +MGRAY = HexColor("#dee2e6") +DGRAY = HexColor("#6c757d") +C_GREEN = HexColor("#27ae60") +C_YELL = HexColor("#f39c12") +C_RED = HexColor("#e74c3c") +C_WHITE = white +C_BLACK = black + +W = letter[0] - inch # usable width + +# ── Styles ─────────────────────────────────────────────────────────────────── +_base = getSampleStyleSheet() + +STYLE_LOGO = ParagraphStyle("logo", fontSize=20, fontName="Helvetica-Bold", + textColor=C_WHITE, alignment=TA_LEFT) +STYLE_SMALL = ParagraphStyle("small", fontSize=7, fontName="Helvetica", + textColor=C_WHITE, alignment=TA_RIGHT) +STYLE_VIN = ParagraphStyle("vin", fontSize=8, fontName="Helvetica-Bold", + textColor=HexColor("#aed6f1"), alignment=TA_RIGHT) +STYLE_SEC = ParagraphStyle("sec", fontSize=10, fontName="Helvetica-Bold", + textColor=C_WHITE, spaceBefore=8, spaceAfter=4) +STYLE_BODY = ParagraphStyle("body", fontSize=8, fontName="Helvetica", + textColor=NAVY2, spaceAfter=2) +STYLE_FOOT = ParagraphStyle("foot", fontSize=6.5, fontName="Helvetica", + textColor=DGRAY, alignment=TA_CENTER) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── +def _section(title: str) -> list: + header = Table( + [[Paragraph(title, STYLE_SEC)]], + colWidths=[W], + ) + header.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), NAVY), + ("TOPPADDING", (0, 0), (-1, -1), 5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 5), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ])) + return [Spacer(1, 6), header, Spacer(1, 4)] + + +def _table(rows: list, col_widths: list, has_header: bool = True) -> Table: + t = Table(rows, colWidths=col_widths, repeatRows=1 if has_header else 0) + style = [ + ("FONTNAME", (0, 0), (-1, -1), "Helvetica"), + ("FONTSIZE", (0, 0), (-1, -1), 8), + ("GRID", (0, 0), (-1, -1), 0.4, MGRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("ROWBACKGROUNDS", (0, 1 if has_header else 0), (-1, -1), [LGRAY, C_WHITE]), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ] + if has_header: + style += [ + ("BACKGROUND", (0, 0), (-1, 0), NAVY2), + ("TEXTCOLOR", (0, 0), (-1, 0), C_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ] + t.setStyle(TableStyle(style)) + return t + + +def _badge(text: str, color: str) -> Table: + bg = C_GREEN if color == "green" else (C_YELL if color == "yellow" else C_RED) + p = ParagraphStyle("badge", fontSize=11, fontName="Helvetica-Bold", + textColor=C_WHITE, alignment=TA_CENTER) + t = Table([[Paragraph(text, p)]], colWidths=[W]) + t.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), bg), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ("ROUNDEDCORNERS", [4]), + ])) + return t + + +def _complaints_chart(complaints: list) -> io.BytesIO | None: + if not complaints: + return None + + components: list[str] = [] + for c in complaints: + raw = c.get("components", c.get("component", "")) or "" + for part in raw.split(","): + part = part.strip() + if part: + components.append(part[:40]) + + if not components: + return None + + top5 = Counter(components).most_common(5) + labels = [item[0] for item in top5] + values = [item[1] for item in top5] + + fig, ax = plt.subplots(figsize=(6.5, 2.2)) + fig.patch.set_facecolor("white") + ax.set_facecolor("#f8f9fa") + + bars = ax.barh(labels, values, color="#2980b9", edgecolor="none", height=0.55) + ax.set_xlabel("Quejas", fontsize=7, color="#555") + ax.tick_params(axis="both", labelsize=7, colors="#333") + for sp in ("top", "right"): + ax.spines[sp].set_visible(False) + for bar, val in zip(bars, values): + ax.text(bar.get_width() + 0.2, bar.get_y() + bar.get_height() / 2, + str(val), va="center", fontsize=7, color="#333") + + plt.tight_layout(pad=0.4) + buf = io.BytesIO() + plt.savefig(buf, format="png", dpi=130, bbox_inches="tight") + plt.close(fig) + buf.seek(0) + return buf + + +# ── Main generator ──────────────────────────────────────────────────────────── +def generate_pdf(data: dict) -> str: + vin = data["vin"] + vpic = data.get("vpic", {}) + recalls = data.get("recalls", []) + complaints = data.get("complaints", []) + invests = data.get("investigations", []) + epa = data.get("epa", {}) + risk = data.get("risk", {}) + + make = vpic.get("Make", "Unknown") + model = vpic.get("Model", "Unknown") + year = vpic.get("ModelYear", "Unknown") + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{vin}_{timestamp}.pdf" + out_path = Path("output") / filename + + doc = SimpleDocTemplate( + str(out_path), + pagesize=letter, + rightMargin=0.5 * inch, + leftMargin=0.5 * inch, + topMargin=0.5 * inch, + bottomMargin=0.5 * inch, + ) + + story: list = [] + + # ── Header ──────────────────────────────────────────────────────────────── + now_str = datetime.now().strftime("%B %d, %Y — %H:%M") + header = Table( + [[ + Paragraph("🚗 AR-VinReport", STYLE_LOGO), + Table( + [[Paragraph(now_str, STYLE_SMALL)], + [Paragraph(f"VIN: {vin}", STYLE_VIN)]], + colWidths=[3 * inch], + ), + ]], + colWidths=[W - 3 * inch, 3 * inch], + ) + header.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), NAVY), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ])) + story.append(header) + story.append(Spacer(1, 8)) + + # ── Vehicle photo + ID table ─────────────────────────────────────────────── + story += _section("1 — IDENTIFICACIÓN DEL VEHÍCULO") + + photo_elem = None + photo_b64 = data.get("photo_bytes") + if photo_b64: + try: + raw = base64.b64decode(photo_b64) + img = PILImage.open(io.BytesIO(raw)) + img.thumbnail((220, 160), PILImage.LANCZOS) + buf = io.BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + photo_elem = Image(buf, width=220, height=160) + except Exception: + photo_elem = None + + id_rows = [ + ["Campo", "Valor"], + ["Año / Make / Model", f"{year} {make} {model}"], + ["Trim", vpic.get("Trim", "—")], + ["Carrocería", vpic.get("BodyClass", "—")], + ["Motor", f"{vpic.get('Cylinders','—')} cil. — {vpic.get('DisplacementL','—')} L"], + ["Combustible", vpic.get("FuelType", "—")], + ["Tracción", vpic.get("DriveType", "—")], + ["Transmisión", vpic.get("Transmission", "—")], + ["Planta", vpic.get("PlantCountry", "—")], + ["EV Level", vpic.get("EVLevel", "—")], + ] + + if photo_elem: + id_tbl = _table(id_rows, [1.4 * inch, 3.4 * inch]) + combo = Table( + [[photo_elem, id_tbl]], + colWidths=[2.5 * inch, W - 2.5 * inch], + ) + combo.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (1, 0), (1, 0), 8)])) + story.append(combo) + else: + story.append(_table(id_rows, [2 * inch, W - 2 * inch])) + + # ── Auction listing ──────────────────────────────────────────────────────── + story += _section("2 — DATOS DEL LISTING DE SUBASTA") + + auction_rows = [ + ["Campo", "Valor"], + ["Odómetro", data.get("odometer", "—") + " mi"], + ["Daño Primario", data.get("primary_damage", "—")], + ["Daño Secundario", data.get("secondary_damage", "—")], + ["Título", data.get("title", "—")], + ["Bid actual", f"${data.get('bid', '—')}"], + ["Subasta", data.get("auction", "—")], + ] + story.append(_table(auction_rows, [2 * inch, W - 2 * inch])) + + # ── Recalls ─────────────────────────────────────────────────────────────── + story += _section("3 — RECALLS NHTSA") + + recall_badge_text = f"✅ 0 RECALLS" if not recalls else f"⚠️ {len(recalls)} RECALL{'S' if len(recalls)>1 else ''} ACTIVO{'S' if len(recalls)>1 else ''}" + badge_color = "green" if not recalls else "red" + story.append(_badge(recall_badge_text, badge_color)) + story.append(Spacer(1, 6)) + + if recalls: + r_rows = [["#", "Campaña", "Componente", "Descripción breve"]] + for i, r in enumerate(recalls[:20], 1): + comp = (r.get("Component") or r.get("component") or "—")[:35] + summary = (r.get("Summary") or r.get("summary") or "—")[:80] + campaign = r.get("NHTSACampaignNumber") or r.get("campaignNumber") or "—" + r_rows.append([str(i), campaign, comp, summary]) + story.append(_table(r_rows, [0.3*inch, 1.1*inch, 1.8*inch, W-3.2*inch])) + + # ── Complaints ──────────────────────────────────────────────────────────── + story += _section("4 — QUEJAS DE PROPIETARIOS") + + story.append(Paragraph( + f"Total de quejas registradas: {len(complaints)}", + STYLE_BODY, + )) + story.append(Spacer(1, 4)) + + chart_buf = _complaints_chart(complaints) + if chart_buf: + story.append(Image(chart_buf, width=W, height=1.8 * inch)) + elif complaints: + story.append(Paragraph("(Sin datos de componentes disponibles)", STYLE_BODY)) + else: + story.append(Paragraph("Sin quejas registradas.", STYLE_BODY)) + + # ── Investigations ──────────────────────────────────────────────────────── + story += _section("5 — INVESTIGACIONES DE SEGURIDAD") + + if invests: + inv_rows = [["Número", "Asunto", "Estado"]] + for inv in invests[:10]: + num = inv.get("InvestigationNumber") or inv.get("investigationNumber") or "—" + subj = (inv.get("Subject") or inv.get("subject") or "—")[:60] + closed = inv.get("ClosingDate") or inv.get("closingDate") + status = "Cerrada" if closed else "Abierta" + inv_rows.append([num, subj, status]) + story.append(_table(inv_rows, [1.2*inch, W-2.4*inch, 1.2*inch])) + else: + story.append(Paragraph("Sin investigaciones activas.", STYLE_BODY)) + + # ── EPA ─────────────────────────────────────────────────────────────────── + story += _section("6 — EFICIENCIA EPA") + + epa_rows = [ + ["Métrica", "Valor"], + ["Combustible", epa.get("fuel_type", "N/A")], + ["Eficiencia", epa.get("mpg_combined", "N/A")], + ["Ciudad", epa.get("mpg_city", "N/A")], + ["Autopista", epa.get("mpg_highway", "N/A")], + ["Costo anual est.", epa.get("annual_cost", "N/A")], + ] + if "range_miles" in epa: + epa_rows.append(["Autonomía eléctrica", f"{epa['range_miles']} mi"]) + story.append(_table(epa_rows, [2 * inch, W - 2 * inch])) + + # ── Risk Score ──────────────────────────────────────────────────────────── + story += _section("7 — ANÁLISIS DE RIESGO") + + score = risk.get("score", 0) + level = risk.get("level", "N/A") + color = risk.get("color", "green") + story.append(_badge(f"{risk.get('emoji','')} SCORE: {score}/100 — {level}", color)) + story.append(Spacer(1, 6)) + + factors = risk.get("factors", []) + if factors: + f_rows = [["Factor", "Puntos"]] + for f in factors: + sign = "+" if f["dir"] == "+" else "−" + f_rows.append([f["label"], f"{sign}{f['points']}"]) + story.append(_table(f_rows, [W - 1.2 * inch, 1.2 * inch])) + + # ── AI Analysis ─────────────────────────────────────────────────────────── + story += _section("8 — ANÁLISIS INTELIGENTE (IA Local)") + + ai_text = data.get("ai_analysis") + + STYLE_AI_LABEL = ParagraphStyle( + "ai_label", fontSize=7, fontName="Helvetica-Oblique", + textColor=DGRAY, spaceAfter=6, + ) + STYLE_AI_BODY = ParagraphStyle( + "ai_body", fontSize=8.5, fontName="Helvetica", + textColor=NAVY2, leading=13, spaceAfter=6, + ) + + story.append(Paragraph( + "Generado por DealAnalyzer — Ollama local  |  Solo orientativo, verificar con inspección física", + STYLE_AI_LABEL, + )) + + if not ai_text: + story.append(Paragraph( + "⚠️ Ollama no disponible — análisis IA omitido", + STYLE_BODY, + )) + else: + # Detect recommendation keyword for badge + ai_upper = ai_text.upper() + if "EVITAR" in ai_upper: + rec_text, rec_color = "🔴 RECOMENDACIÓN: EVITAR", "red" + elif "INVESTIGAR" in ai_upper: + rec_text, rec_color = "🟡 RECOMENDACIÓN: INVESTIGAR MÁS", "yellow" + elif "COMPRAR" in ai_upper: + rec_text, rec_color = "🟢 RECOMENDACIÓN: COMPRAR", "green" + else: + rec_text, rec_color = None, None + + # AI text box with light-blue border + ai_box = Table( + [[Paragraph(ai_text, STYLE_AI_BODY)]], + colWidths=[W], + ) + ai_box.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), HexColor("#f0f4f8")), + ("LINEABOVE", (0, 0), (-1, 0), 1.5, HexColor("#2980b9")), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ])) + story.append(ai_box) + story.append(Spacer(1, 6)) + + if rec_text: + story.append(_badge(rec_text, rec_color)) + + # ── Footer ──────────────────────────────────────────────────────────────── + story.append(Spacer(1, 12)) + story.append(HRFlowable(width=W, thickness=0.5, color=MGRAY)) + story.append(Spacer(1, 4)) + story.append(Paragraph( + "Datos de NHTSA.gov y EPA.gov — Información pública oficial del gobierno de EE.UU.", + STYLE_FOOT, + )) + story.append(Paragraph( + "Este reporte no reemplaza un Carfax o inspección física profesional.", + STYLE_FOOT, + )) + + doc.build(story) + return filename diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/risk.py b/src/utils/risk.py new file mode 100644 index 0000000..3cd9bed --- /dev/null +++ b/src/utils/risk.py @@ -0,0 +1,132 @@ +_CRITICAL_RECALL_KEYWORDS = ( + "engine", "motor", "transmission", "fuel", "fire", "crash", + "brake", "steering", "airbag", "rollover", +) + +_ENGINE_COMPLAINT_KEYWORDS = ("engine", "fuel system", "motor", "cooling") +_POWERTRAIN_COMPLAINT_KEYWORDS = ("power train", "powertrain", "transmission", "driveline") + + +def _component_text(item: dict) -> str: + return ( + (item.get("Component") or item.get("component") or "") + + " " + + (item.get("components") or "") + ).lower() + + +def compute_extended_fields( + title: str, + recalls: list, + complaints: list, + odometer: str, + secondary_damage: str, +) -> dict: + critical_recalls = [ + r for r in recalls + if any(kw in _component_text(r) for kw in _CRITICAL_RECALL_KEYWORDS) + ] + + engine_complaints = sum( + 1 for c in complaints + if any(kw in _component_text(c) for kw in _ENGINE_COMPLAINT_KEYWORDS) + ) + + transmission_complaints = sum( + 1 for c in complaints + if any(kw in _component_text(c) for kw in _POWERTRAIN_COMPLAINT_KEYWORDS) + ) + + alerts: list[str] = [] + title_lower = (title or "").lower() + sec_lower = (secondary_damage or "").lower() + + if ("salvage" in title_lower or "rebuilt" in title_lower) and critical_recalls: + alerts.append("Título salvage + recalls críticos de seguridad") + if engine_complaints > 20: + alerts.append(f"Alto número de quejas de motor ({engine_complaints})") + if transmission_complaints > 15: + alerts.append(f"Alto número de quejas de transmisión ({transmission_complaints})") + if len(recalls) >= 5 and ("salvage" in title_lower or "rebuilt" in title_lower): + alerts.append("Salvage con 5+ recalls simultáneos") + if sec_lower and sec_lower not in ("none", "no damage", "", "n/a") and critical_recalls: + alerts.append("Daño secundario + recalls críticos") + + try: + odo = int(str(odometer).replace(",", "").replace(" mi", "").replace(" miles", "").strip() or "0") + except ValueError: + odo = 0 + + if odo > 200_000: + alerts.append(f"Odómetro muy alto ({odo:,} mi)") + + return { + "critical_recalls": critical_recalls, + "critical_recall_count": len(critical_recalls), + "engine_complaints": engine_complaints, + "transmission_complaints": transmission_complaints, + "alerts": alerts, + } + + +def calculate_risk( + title: str, + recalls: list, + secondary_damage: str, + complaints: list, + odometer: str, +) -> dict: + score = 0 + factors: list[dict] = [] + + try: + odo = int(str(odometer).replace(",", "").replace(" mi", "").replace(" miles", "").strip() or "0") + except ValueError: + odo = 0 + + title_lower = (title or "").lower() + if "salvage" in title_lower or "rebuilt" in title_lower: + score += 20 + factors.append({"label": "Título Salvage/Rebuilt", "points": 20, "dir": "+"}) + elif "certificate" in title_lower and "title" in title_lower: + score -= 15 + factors.append({"label": "Título limpio (Certificate)", "points": 15, "dir": "-"}) + + recall_count = len(recalls) + recall_pts = min(recall_count * 15, 30) + if recall_pts > 0: + score += recall_pts + factors.append({"label": f"{recall_count} recall(s) activos", "points": recall_pts, "dir": "+"}) + + sec = (secondary_damage or "").lower().strip() + if sec and sec not in ("none", "no damage", "", "n/a"): + score += 10 + factors.append({"label": f"Daño secundario: {secondary_damage}", "points": 10, "dir": "+"}) + + complaint_count = len(complaints) + if complaint_count > 50: + score += 10 + factors.append({"label": f"Alta cantidad de quejas ({complaint_count})", "points": 10, "dir": "+"}) + + if 0 < odo < 60_000: + score -= 10 + factors.append({"label": f"Bajo kilometraje ({odo:,} mi)", "points": 10, "dir": "-"}) + + score = max(0, min(100, score)) + + if score < 30: + level, color, emoji = "BAJO RIESGO", "green", "🟢" + elif score < 60: + level, color, emoji = "RIESGO MEDIO", "yellow", "🟡" + else: + level, color, emoji = "ALTO RIESGO", "red", "🔴" + + return { + "score": score, + "level": level, + "color": color, + "emoji": emoji, + "factors": factors, + "recall_count": recall_count, + "complaint_count": complaint_count, + } diff --git a/src/utils/validator.py b/src/utils/validator.py new file mode 100644 index 0000000..2a1c3ce --- /dev/null +++ b/src/utils/validator.py @@ -0,0 +1,34 @@ +_TRANSLITERATION = { + 'A': 1, 'B': 2, 'C': 3, 'D': 4, 'E': 5, 'F': 6, 'G': 7, 'H': 8, + 'J': 1, 'K': 2, 'L': 3, 'M': 4, 'N': 5, 'P': 7, 'R': 9, + 'S': 2, 'T': 3, 'U': 4, 'V': 5, 'W': 6, 'X': 7, 'Y': 8, 'Z': 9, + '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, + '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, +} + +_WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2] + + +def validate_vin(vin: str) -> tuple[bool, str]: + vin = vin.strip().upper() + + if len(vin) != 17: + return False, f"Debe tener 17 caracteres (tiene {len(vin)})" + + for ch in ('I', 'O', 'Q'): + if ch in vin: + return False, f"El VIN no puede contener la letra '{ch}'" + + for ch in vin: + if ch not in _TRANSLITERATION: + return False, f"Carácter inválido: '{ch}'" + + total = sum(_TRANSLITERATION[c] * _WEIGHTS[i] for i, c in enumerate(vin)) + remainder = total % 11 + expected = 'X' if remainder == 10 else str(remainder) + + if vin[8] != expected: + # Non-US vehicles may have non-standard checksums; warn but allow + pass + + return True, "" diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..53602e4 --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,284 @@ +/* ── Reset & Variables ─────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0d1117; + --bg2: #161b22; + --bg3: #1c2128; + --border: #30363d; + --text: #e6edf3; + --muted: #8b949e; + --accent: #58a6ff; + --green: #3fb950; + --yellow: #d29922; + --red: #f85149; + --navy: #1a3a5c; + --radius: 8px; + --shadow: 0 4px 16px rgba(0,0,0,.4); +} + +body { + background: var(--bg); + color: var(--text); + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ── Topbar ────────────────────────────────────────────────────────────────── */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + background: var(--bg2); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} +.topbar-logo { + font-size: 18px; + font-weight: 700; + color: var(--accent); + letter-spacing: -.3px; +} +.topbar-version { + font-size: 11px; + color: var(--muted); +} + +/* ── Layout ────────────────────────────────────────────────────────────────── */ +.layout { + display: grid; + grid-template-columns: 300px 1fr; + gap: 0; + flex: 1; + height: calc(100vh - 49px); + overflow: hidden; +} + +/* ── Left panel ────────────────────────────────────────────────────────────── */ +.panel-form { + background: var(--bg2); + border-right: 1px solid var(--border); + padding: 20px 18px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; +} +.panel-title { + font-size: 13px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .6px; + margin-bottom: 4px; +} + +/* ── Form ──────────────────────────────────────────────────────────────────── */ +form { display: flex; flex-direction: column; gap: 10px; } + +.field { display: flex; flex-direction: column; gap: 4px; } + +label { + font-size: 11px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .4px; +} +.req { color: var(--red); } + +input[type="text"], +input[type="url"] { + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 13px; + padding: 7px 10px; + outline: none; + transition: border-color .15s; + width: 100%; +} +input:focus { border-color: var(--accent); } +input::placeholder { color: var(--muted); opacity: .6; } + +.btn { + padding: 9px 14px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity .15s, transform .1s; +} +.btn:active { transform: scale(.98); } +.btn:disabled { opacity: .45; cursor: not-allowed; } + +.btn-primary { + background: var(--accent); + color: #fff; + margin-top: 4px; +} +.btn-primary:hover:not(:disabled) { opacity: .85; } + +.btn-gen { + background: var(--green); + color: #fff; + font-size: 12px; + padding: 7px 12px; +} +.btn-open { + background: var(--bg3); + border: 1px solid var(--border); + color: var(--text); + font-size: 12px; + padding: 7px 12px; +} + +.divider { border: none; border-top: 1px solid var(--border); margin: 4px 0; } +.hint { font-size: 11px; color: var(--muted); line-height: 1.6; } +.mono { font-family: 'Cascadia Code', 'Consolas', monospace; font-size: 11px; + color: var(--accent); } + +/* ── Right panel ────────────────────────────────────────────────────────────── */ +.panel-cards { + padding: 20px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +.cards-container { + display: flex; + flex-direction: column; + gap: 14px; +} + +/* ── Empty state ────────────────────────────────────────────────────────────── */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 60px 20px; + color: var(--muted); + text-align: center; +} +.empty-icon { font-size: 48px; opacity: .4; } + +/* ── VIN Card ───────────────────────────────────────────────────────────────── */ +.vin-card { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + animation: fadeIn .25s ease; +} + +@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; } } + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg3); + border-bottom: 1px solid var(--border); +} +.card-vin { + font-family: 'Cascadia Code', 'Consolas', monospace; + font-size: 13px; + font-weight: 700; + color: var(--accent); +} +.card-vehicle { font-size: 12px; color: var(--muted); margin-top: 2px; } +.card-dismiss { + background: none; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 2px 6px; + border-radius: 4px; +} +.card-dismiss:hover { background: var(--border); color: var(--text); } + +.card-body { padding: 12px 16px; display: flex; flex-direction: column; gap: 8px; } + +/* ── Progress log ───────────────────────────────────────────────────────────── */ +.progress-log { display: flex; flex-direction: column; gap: 4px; } + +.log-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + padding: 3px 0; + color: var(--muted); + transition: color .2s; +} +.log-item.success { color: var(--green); } +.log-item.error { color: var(--red); } +.log-item.warning { color: var(--yellow); } +.log-item.progress { color: var(--text); } + +.spinner { + width: 12px; height: 12px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .6s linear infinite; + flex-shrink: 0; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Risk badge (in card) ────────────────────────────────────────────────────── */ +.risk-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 700; + margin-top: 4px; + align-self: flex-start; +} +.risk-badge.green { background: rgba(63,185,80,.15); color: var(--green); border: 1px solid rgba(63,185,80,.3); } +.risk-badge.yellow { background: rgba(210,153,34,.15); color: var(--yellow); border: 1px solid rgba(210,153,34,.3); } +.risk-badge.red { background: rgba(248,81,73,.15); color: var(--red); border: 1px solid rgba(248,81,73,.3); } + +/* ── Card actions ────────────────────────────────────────────────────────────── */ +.card-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; +} + +.pdf-link { + font-size: 12px; + color: var(--accent); + text-decoration: none; + display: flex; + align-items: center; + gap: 4px; + margin-top: 4px; +} +.pdf-link:hover { text-decoration: underline; } + +/* ── Scrollbar ───────────────────────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--muted); } diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..479bb50 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,259 @@ +"use strict"; + +const form = document.getElementById("fetchForm"); +const fetchBtn = document.getElementById("fetchBtn"); +const cardsContainer = document.getElementById("cardsContainer"); + +// Remove empty state on first card +function clearEmpty() { + const empty = cardsContainer.querySelector(".empty-state"); + if (empty) empty.remove(); +} + +// ── Create a new VIN card ────────────────────────────────────────────────────── +function createCard(vin) { + clearEmpty(); + + const safeVin = vin.replace(/[^A-Z0-9]/g, ""); + + const card = document.createElement("div"); + card.className = "vin-card"; + card.id = `card-${safeVin}`; + + // Build DOM without innerHTML interpolation to prevent XSS + const header = document.createElement("div"); + header.className = "card-header"; + + const headerLeft = document.createElement("div"); + + const vinEl = document.createElement("div"); + vinEl.className = "card-vin"; + vinEl.textContent = safeVin; + + const vehicleEl = document.createElement("div"); + vehicleEl.className = "card-vehicle"; + vehicleEl.id = `vehicle-${safeVin}`; + vehicleEl.textContent = "Consultando..."; + + headerLeft.appendChild(vinEl); + headerLeft.appendChild(vehicleEl); + + const dismissBtn = document.createElement("button"); + dismissBtn.className = "card-dismiss"; + dismissBtn.title = "Cerrar"; + dismissBtn.textContent = "✕"; + dismissBtn.addEventListener("click", () => card.remove()); + + header.appendChild(headerLeft); + header.appendChild(dismissBtn); + + const body = document.createElement("div"); + body.className = "card-body"; + + const logEl = document.createElement("div"); + logEl.className = "progress-log"; + logEl.id = `log-${safeVin}`; + + const actionsEl = document.createElement("div"); + actionsEl.id = `actions-${safeVin}`; + + body.appendChild(logEl); + body.appendChild(actionsEl); + + card.appendChild(header); + card.appendChild(body); + + cardsContainer.prepend(card); + return card; +} + +// ── Add a log line to a card ─────────────────────────────────────────────────── +function addLog(vin, text, status) { + const log = document.getElementById(`log-${vin}`); + if (!log) return; + + // Remove spinner from any previous progress item + if (status !== "progress") { + const prev = log.querySelector(".log-item.progress"); + if (prev) prev.classList.replace("progress", "done"); + } + + const item = document.createElement("div"); + item.className = `log-item ${status}`; + + if (status === "progress") { + item.innerHTML = `${text}`; + } else { + item.textContent = text; + } + + log.appendChild(item); + item.scrollIntoView({ behavior: "smooth", block: "nearest" }); +} + +// ── Show card actions after fetch completes ─────────────────────────────────── +function showActions(vin, risk) { + const actionsDiv = document.getElementById(`actions-${vin}`); + if (!actionsDiv) return; + + // Risk badge + const badge = document.createElement("div"); + badge.className = `risk-badge ${risk.color}`; + badge.textContent = `${risk.emoji} ${risk.score}/100 — ${risk.level}`; + actionsDiv.appendChild(badge); + + // Buttons row + const row = document.createElement("div"); + row.className = "card-actions"; + + const genBtn = document.createElement("button"); + genBtn.className = "btn btn-gen"; + genBtn.textContent = "📄 Generar PDF"; + genBtn.addEventListener("click", () => generatePDF(vin, genBtn)); + + row.appendChild(genBtn); + actionsDiv.appendChild(row); +} + +// ── Generate PDF ────────────────────────────────────────────────────────────── +async function generatePDF(vin, btn) { + btn.disabled = true; + btn.textContent = "⏳ Generando..."; + + try { + const res = await fetch("/api/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ vin }), + }); + const data = await res.json(); + + if (data.error) { + btn.disabled = false; + btn.textContent = "📄 Generar PDF"; + addLog(vin, `❌ ${data.error}`, "error"); + return; + } + + btn.textContent = "✅ PDF listo"; + + const actionsDiv = document.getElementById(`actions-${vin}`); + + // Download link + const link = document.createElement("a"); + link.href = data.url; + link.download = data.filename; + link.className = "pdf-link"; + link.textContent = `⬇️ ${data.filename}`; + actionsDiv.appendChild(link); + + // Open locally button (Windows only via server) + const openBtn = document.createElement("button"); + openBtn.className = "btn btn-open"; + openBtn.textContent = "🗂️ Abrir localmente"; + openBtn.addEventListener("click", () => openLocally(data.filename, openBtn)); + actionsDiv.querySelector(".card-actions").appendChild(openBtn); + + addLog(vin, `✅ PDF generado: ${data.filename}`, "success"); + + } catch (e) { + btn.disabled = false; + btn.textContent = "📄 Generar PDF"; + addLog(vin, `❌ Error al generar PDF: ${e.message}`, "error"); + } +} + +// ── Open file locally via server ────────────────────────────────────────────── +async function openLocally(filename, btn) { + btn.disabled = true; + try { + const res = await fetch("/api/open", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename }), + }); + const data = await res.json(); + if (data.error) { + alert(data.error); + } + } catch (e) { + alert("Error al abrir: " + e.message); + } finally { + btn.disabled = false; + } +} + +// ── Form submission → SSE ────────────────────────────────────────────────────── +form.addEventListener("submit", async (e) => { + e.preventDefault(); + + const fd = new FormData(form); + const vin = (fd.get("vin") || "").trim().toUpperCase(); + if (!vin) return; + + // Prevent duplicate active cards + const existing = document.getElementById(`card-${vin}`); + if (existing) { + existing.scrollIntoView({ behavior: "smooth" }); + existing.style.outline = "2px solid var(--accent)"; + setTimeout(() => (existing.style.outline = ""), 1500); + return; + } + + fetchBtn.disabled = true; + fetchBtn.textContent = "⏳ Consultando..."; + + createCard(vin); + + const params = new URLSearchParams({ + vin, + odometer: fd.get("odometer") || "", + primary_damage: fd.get("primary_damage") || "", + secondary_damage: fd.get("secondary_damage") || "", + title: fd.get("title") || "", + bid: fd.get("bid") || "", + auction: fd.get("auction") || "", + photo_url: fd.get("photo_url") || "", + }); + + const es = new EventSource(`/api/fetch?${params.toString()}`); + + es.onmessage = (event) => { + let msg; + try { msg = JSON.parse(event.data); } catch { return; } + + const status = msg.status || "progress"; + + if (status === "done") { + const vehicleEl = document.getElementById(`vehicle-${vin}`); + if (vehicleEl && msg.vehicle) vehicleEl.textContent = msg.vehicle; + showActions(vin, msg.risk); + es.close(); + fetchBtn.disabled = false; + fetchBtn.textContent = "⚡ FETCH DATA"; + return; + } + + if (status === "error") { + addLog(vin, msg.step, "error"); + es.close(); + fetchBtn.disabled = false; + fetchBtn.textContent = "⚡ FETCH DATA"; + return; + } + + if (status === "success" && msg.vehicle) { + const vehicleEl = document.getElementById(`vehicle-${vin}`); + if (vehicleEl) vehicleEl.textContent = msg.vehicle; + } + + addLog(vin, msg.step, status); + }; + + es.onerror = () => { + addLog(vin, "❌ Conexión interrumpida con el servidor", "error"); + es.close(); + fetchBtn.disabled = false; + fetchBtn.textContent = "⚡ FETCH DATA"; + }; +}); diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7a99a72 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,91 @@ + + + + + + AR-VinReport + + + +
+ + v1.0 — NHTSA · EPA · PDF +
+ +
+ + + + +
+

Reportes Activos

+
+
+ 📋 +

Ingresa un VIN y haz click en FETCH DATA para comenzar.

+
+
+
+
+ + + +