feat: AR-VINchecker v1.0 — VIN report generator with AI analysis
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 <noreply@anthropic.com>
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.env
|
||||
.env.*
|
||||
output/
|
||||
*.pdf
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -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*
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
@@ -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: <b>{len(complaints)}</b>",
|
||||
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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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, ""
|
||||
@@ -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); }
|
||||
@@ -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 = `<span class="spinner"></span><span>${text}</span>`;
|
||||
} 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";
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AR-VinReport</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<span class="topbar-logo">🚗 AR-VinReport</span>
|
||||
<span class="topbar-version">v1.0 — NHTSA · EPA · PDF</span>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<!-- ── Left panel: form ──────────────────────────────────────────── -->
|
||||
<aside class="panel-form">
|
||||
<h2 class="panel-title">Nuevo Reporte</h2>
|
||||
|
||||
<form id="fetchForm" autocomplete="off">
|
||||
<div class="field">
|
||||
<label for="vin">VIN <span class="req">*</span></label>
|
||||
<input id="vin" name="vin" type="text" maxlength="17"
|
||||
placeholder="17 caracteres" required />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="odometer">Odómetro (mi)</label>
|
||||
<input id="odometer" name="odometer" type="text" placeholder="ej: 57,909" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="primary_damage">Daño Primario</label>
|
||||
<input id="primary_damage" name="primary_damage" type="text"
|
||||
placeholder="ej: Normal Wear" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="secondary_damage">Daño Secundario</label>
|
||||
<input id="secondary_damage" name="secondary_damage" type="text"
|
||||
placeholder="ej: Damage History" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="title">Título</label>
|
||||
<input id="title" name="title" type="text"
|
||||
placeholder="ej: NY - Certificate of Title" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="bid">Bid actual ($)</label>
|
||||
<input id="bid" name="bid" type="text" placeholder="ej: 4,100" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="auction">Subasta</label>
|
||||
<input id="auction" name="auction" type="text" placeholder="ej: Copart" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="photo_url">URL de foto (opcional)</label>
|
||||
<input id="photo_url" name="photo_url" type="url"
|
||||
placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="fetchBtn">
|
||||
⚡ FETCH DATA
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="divider" />
|
||||
<p class="hint">VIN de prueba:<br />
|
||||
<code class="mono">1FTVW1EL1NWG14881</code>
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<!-- ── Right panel: active cards ─────────────────────────────────── -->
|
||||
<main class="panel-cards">
|
||||
<h2 class="panel-title">Reportes Activos</h2>
|
||||
<div id="cardsContainer" class="cards-container">
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">📋</span>
|
||||
<p>Ingresa un VIN y haz click en <b>FETCH DATA</b> para comenzar.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user