feat: 7-module VINchecker v2 — NICB, auction history, Florida DMV, theft stats, odometer fraud, safety ratings, risk score expansion
New modules: nicb.py, auction_history.py, dmv_florida.py, theft_stats.py, odometer_validator.py Expanded risk.py with 8 new factors (NICB alert, safety rating, auction flags, odo validation, theft level, odo fraud, DMV lien, open VIN recalls) PDF now has 11 sections including NICB check, theft stats, odometer fraud, odometer validation, auction history, Florida HSMV AI prompt enriched with all new data fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,10 +21,15 @@ from src.ai.ollama_analyzer import analyze_vehicle
|
|||||||
from src.api.epa import fetch_epa_data
|
from src.api.epa import fetch_epa_data
|
||||||
from src.api.nhtsa import (
|
from src.api.nhtsa import (
|
||||||
decode_vin, fetch_complaints, fetch_investigations, fetch_recalls,
|
decode_vin, fetch_complaints, fetch_investigations, fetch_recalls,
|
||||||
fetch_vin_recalls, fetch_safety_ratings,
|
fetch_vin_recalls, fetch_safety_ratings, check_odometer_complaints,
|
||||||
)
|
)
|
||||||
|
from src.api.nicb import check_nicb
|
||||||
|
from src.api.auction_history import search_auction_history
|
||||||
|
from src.api.dmv_florida import check_florida_dmv
|
||||||
|
from src.api.theft_stats import get_theft_risk
|
||||||
from src.report.pdf import generate_pdf
|
from src.report.pdf import generate_pdf
|
||||||
from src.utils.risk import calculate_risk, compute_extended_fields
|
from src.utils.risk import calculate_risk, compute_extended_fields
|
||||||
|
from src.utils.odometer_validator import validate_odometer
|
||||||
from src.utils.validator import validate_vin
|
from src.utils.validator import validate_vin
|
||||||
|
|
||||||
Path("output").mkdir(exist_ok=True)
|
Path("output").mkdir(exist_ok=True)
|
||||||
@@ -117,31 +122,44 @@ async def fetch(
|
|||||||
yield evt(f"✅ Vehículo: {year} {make} {model}", "success")
|
yield evt(f"✅ Vehículo: {year} {make} {model}", "success")
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
# 3 — Recalls + Complaints + Investigations + VIN check + Safety (paralelo)
|
# 3 — NHTSA + EPA + NICB + Subastas + DMV (todo en paralelo)
|
||||||
yield evt("⏳ Consultando NHTSA: recalls, quejas, VIN check, seguridad...", "progress")
|
yield evt("⏳ Consultando NHTSA, EPA, NICB, historial web, Florida DMV...", "progress")
|
||||||
recalls, complaints, invests, vin_recalls, safety_ratings = await asyncio.gather(
|
(
|
||||||
|
recalls, complaints, invests,
|
||||||
|
vin_recalls, safety_ratings, epa,
|
||||||
|
nicb, auction_hist, dmv_fl,
|
||||||
|
) = await asyncio.gather(
|
||||||
fetch_recalls(make, model, year),
|
fetch_recalls(make, model, year),
|
||||||
fetch_complaints(make, model, year),
|
fetch_complaints(make, model, year),
|
||||||
fetch_investigations(make, model, year),
|
fetch_investigations(make, model, year),
|
||||||
fetch_vin_recalls(vin_clean),
|
fetch_vin_recalls(vin_clean),
|
||||||
fetch_safety_ratings(year, make, model),
|
fetch_safety_ratings(year, make, model),
|
||||||
|
fetch_epa_data(year, make, model),
|
||||||
|
check_nicb(vin_clean),
|
||||||
|
search_auction_history(vin_clean),
|
||||||
|
check_florida_dmv(vin_clean),
|
||||||
)
|
)
|
||||||
open_count = len(vin_recalls)
|
|
||||||
|
# Local enrichment (no I/O)
|
||||||
|
theft_info = get_theft_risk(make, model)
|
||||||
|
odo_check = validate_odometer(year, odometer or "0")
|
||||||
|
odo_fraud = check_odometer_complaints(complaints)
|
||||||
|
|
||||||
|
open_vin_recalls = len([
|
||||||
|
r for r in vin_recalls
|
||||||
|
if not (r.get("RemedyAvailability") or "").lower().startswith("remedy")
|
||||||
|
])
|
||||||
stars = safety_ratings.get("overall", "")
|
stars = safety_ratings.get("overall", "")
|
||||||
stars_txt = f" · {stars}★ seguridad" if stars and stars != "NR" else ""
|
nicb_status = nicb.get("status", "NO_DISPONIBLE")
|
||||||
yield evt(
|
yield evt(
|
||||||
f"✅ {len(recalls)} recalls · {len(complaints)} quejas · {open_count} VIN-recalls{stars_txt}",
|
f"✅ {len(recalls)} recalls · {len(complaints)} quejas · "
|
||||||
|
f"NICB: {nicb_status} · "
|
||||||
|
f"{stars+'★' if stars and stars != 'NR' else 'seg. N/A'}",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
# 4 — EPA
|
# 4 — Photo
|
||||||
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
|
photo_bytes = None
|
||||||
if photo_url and photo_url.strip():
|
if photo_url and photo_url.strip():
|
||||||
yield evt("⏳ Descargando foto del vehículo...", "progress")
|
yield evt("⏳ Descargando foto del vehículo...", "progress")
|
||||||
@@ -166,6 +184,15 @@ async def fetch(
|
|||||||
secondary_damage=secondary_damage or "",
|
secondary_damage=secondary_damage or "",
|
||||||
complaints=complaints,
|
complaints=complaints,
|
||||||
odometer=odometer or "0",
|
odometer=odometer or "0",
|
||||||
|
nicb_status=nicb_status,
|
||||||
|
safety_overall=stars,
|
||||||
|
auction_flags=auction_hist.get("flags", []),
|
||||||
|
auction_pts=auction_hist.get("extra_points", 0),
|
||||||
|
odo_extra_pts=odo_check.get("extra_points", 0),
|
||||||
|
theft_level=theft_info.get("level", ""),
|
||||||
|
odo_fraud_count=odo_fraud.get("count", 0),
|
||||||
|
dmv_has_lien=dmv_fl.get("has_lien", False),
|
||||||
|
open_vin_recalls=open_vin_recalls,
|
||||||
)
|
)
|
||||||
extended = compute_extended_fields(
|
extended = compute_extended_fields(
|
||||||
title=title or "",
|
title=title or "",
|
||||||
@@ -195,6 +222,12 @@ async def fetch(
|
|||||||
"complaints": complaints,
|
"complaints": complaints,
|
||||||
"vin_recalls": vin_recalls,
|
"vin_recalls": vin_recalls,
|
||||||
"safety_ratings": safety_ratings,
|
"safety_ratings": safety_ratings,
|
||||||
|
"nicb": nicb,
|
||||||
|
"auction_hist": auction_hist,
|
||||||
|
"dmv_fl": dmv_fl,
|
||||||
|
"theft_info": theft_info,
|
||||||
|
"odo_check": odo_check,
|
||||||
|
"odo_fraud": odo_fraud,
|
||||||
"critical_recall_count": extended["critical_recall_count"],
|
"critical_recall_count": extended["critical_recall_count"],
|
||||||
"engine_complaints": extended["engine_complaints"],
|
"engine_complaints": extended["engine_complaints"],
|
||||||
"transmission_complaints": extended["transmission_complaints"],
|
"transmission_complaints": extended["transmission_complaints"],
|
||||||
@@ -224,6 +257,12 @@ async def fetch(
|
|||||||
"investigations": invests,
|
"investigations": invests,
|
||||||
"vin_recalls": vin_recalls,
|
"vin_recalls": vin_recalls,
|
||||||
"safety_ratings": safety_ratings,
|
"safety_ratings": safety_ratings,
|
||||||
|
"nicb": nicb,
|
||||||
|
"auction_hist": auction_hist,
|
||||||
|
"dmv_fl": dmv_fl,
|
||||||
|
"theft_info": theft_info,
|
||||||
|
"odo_check": odo_check,
|
||||||
|
"odo_fraud": odo_fraud,
|
||||||
"epa": epa,
|
"epa": epa,
|
||||||
"risk": risk,
|
"risk": risk,
|
||||||
"ai_analysis": ai_analysis,
|
"ai_analysis": ai_analysis,
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ Pillow==10.1.0
|
|||||||
jinja2==3.1.2
|
jinja2==3.1.2
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
|
lxml==5.1.0
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ def _build_prompt(data: dict) -> str:
|
|||||||
trans_q = data.get("transmission_complaints", 0)
|
trans_q = data.get("transmission_complaints", 0)
|
||||||
alerts_text = "; ".join(alerts) if alerts else "Ninguna"
|
alerts_text = "; ".join(alerts) if alerts else "Ninguna"
|
||||||
|
|
||||||
|
# New enrichment fields
|
||||||
|
nicb_status = data.get("nicb", {}).get("status", "NO_DISPONIBLE")
|
||||||
|
theft_level = data.get("theft_info", {}).get("level", "DESCONOCIDO")
|
||||||
|
odo_status = data.get("odo_check", {}).get("status", "DESCONOCIDO")
|
||||||
|
odo_fraud_ct = data.get("odo_fraud", {}).get("count", 0)
|
||||||
|
dmv_lien = data.get("dmv_fl", {}).get("has_lien", False)
|
||||||
|
auc_flags = data.get("auction_hist", {}).get("flags", [])
|
||||||
|
|
||||||
# VIN-specific recalls
|
# VIN-specific recalls
|
||||||
vin_recalls = data.get("vin_recalls", [])
|
vin_recalls = data.get("vin_recalls", [])
|
||||||
open_vr = [r for r in vin_recalls
|
open_vr = [r for r in vin_recalls
|
||||||
@@ -120,6 +128,14 @@ QUEJAS DE PROPIETARIOS ({risk.get('complaint_count', 0)} total):
|
|||||||
→ Quejas de transmisión: {trans_q}
|
→ Quejas de transmisión: {trans_q}
|
||||||
|
|
||||||
ALERTAS DETECTADAS: {alerts_text}
|
ALERTAS DETECTADAS: {alerts_text}
|
||||||
|
|
||||||
|
VERIFICACIONES ADICIONALES:
|
||||||
|
NICB (robo/salvage): {nicb_status}
|
||||||
|
Riesgo robo del modelo: {theft_level}
|
||||||
|
Odómetro validación: {odo_status}{' ⚠ SOSPECHOSO DE ROLLBACK' if odo_status == 'SOSPECHOSO' else ''}
|
||||||
|
Quejas fraude odómetro NHTSA: {odo_fraud_ct}
|
||||||
|
Lien Florida HSMV: {'⚠ ACTIVO' if dmv_lien else 'Sin lien registrado'}
|
||||||
|
Alertas historial web: {', '.join(auc_flags) if auc_flags else 'Ninguna'}
|
||||||
═══════════════════════════════════════════
|
═══════════════════════════════════════════
|
||||||
|
|
||||||
Genera el análisis con EXACTAMENTE estas 4 secciones:
|
Genera el análisis con EXACTAMENTE estas 4 secciones:
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
_HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"Accept": "text/html,application/xhtml+xml",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
}
|
||||||
|
|
||||||
|
_RED_FLAGS = {
|
||||||
|
"salvage": 25,
|
||||||
|
"flood": 50,
|
||||||
|
"fire damage": 40,
|
||||||
|
"stolen": 40,
|
||||||
|
"total loss": 35,
|
||||||
|
"hail": 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def search_auction_history(vin: str) -> dict:
|
||||||
|
results: list[str] = []
|
||||||
|
flags: list[str] = []
|
||||||
|
extra_points = 0
|
||||||
|
|
||||||
|
queries = [
|
||||||
|
# Bing (more scraping-tolerant than Google)
|
||||||
|
f"https://www.bing.com/search?q=%22{vin}%22+copart+OR+iaai+OR+manheim+OR+auction",
|
||||||
|
# DuckDuckGo HTML endpoint
|
||||||
|
f"https://html.duckduckgo.com/html/?q=%22{vin}%22+copart+OR+iaai+OR+salvage",
|
||||||
|
]
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||||
|
for url in queries:
|
||||||
|
if results:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
r = await client.get(url, headers=_HEADERS)
|
||||||
|
soup = BeautifulSoup(r.text, "lxml")
|
||||||
|
# Bing result snippets
|
||||||
|
for tag in soup.find_all(["p", "span", "div"],
|
||||||
|
class_=lambda c: c and any(
|
||||||
|
x in c for x in ("b_caption", "b_lineclamp",
|
||||||
|
"result__snippet", "snippet"))):
|
||||||
|
txt = tag.get_text(" ", strip=True)
|
||||||
|
if vin in txt or any(w in txt.lower() for w in
|
||||||
|
("copart", "iaai", "manheim", "salvage", "auction", "damage")):
|
||||||
|
results.append(txt[:250])
|
||||||
|
if len(results) >= 6:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Score the snippets
|
||||||
|
all_text = " ".join(results).lower()
|
||||||
|
for kw, pts in _RED_FLAGS.items():
|
||||||
|
if kw in all_text:
|
||||||
|
flags.append(kw.upper())
|
||||||
|
extra_points += pts
|
||||||
|
|
||||||
|
# Appearing multiple times is itself a flag
|
||||||
|
if len(results) >= 3 and not flags:
|
||||||
|
flags.append("MÚLTIPLES APARICIONES EN SUBASTAS")
|
||||||
|
extra_points += 20
|
||||||
|
|
||||||
|
return {
|
||||||
|
"found": len(results) > 0,
|
||||||
|
"count": len(results),
|
||||||
|
"results": results[:5],
|
||||||
|
"flags": flags,
|
||||||
|
"extra_points": min(extra_points, 80),
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
_BASE = "https://services.flhsmv.gov/MVCheckWeb/"
|
||||||
|
_HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"Accept": "text/html,application/xhtml+xml",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def check_florida_dmv(vin: str) -> dict:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
||||||
|
r0 = await client.get(_BASE, headers=_HEADERS)
|
||||||
|
soup0 = BeautifulSoup(r0.text, "lxml")
|
||||||
|
|
||||||
|
def _val(name: str) -> str:
|
||||||
|
tag = soup0.find("input", {"id": name}) or soup0.find("input", {"name": name})
|
||||||
|
return tag["value"] if tag and tag.get("value") else ""
|
||||||
|
|
||||||
|
viewstate = _val("__VIEWSTATE")
|
||||||
|
eventvalidation = _val("__EVENTVALIDATION")
|
||||||
|
viewstategen = _val("__VIEWSTATEGENERATOR")
|
||||||
|
|
||||||
|
if not viewstate:
|
||||||
|
return {"status": "NO_DISPONIBLE", "detail": "No se pudo acceder al formulario HSMV"}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"__VIEWSTATE": viewstate,
|
||||||
|
"__VIEWSTATEGENERATOR": viewstategen,
|
||||||
|
"__EVENTVALIDATION": eventvalidation,
|
||||||
|
"ctl00$cphMain$txtVIN": vin,
|
||||||
|
"ctl00$cphMain$btnSearch": "Search",
|
||||||
|
}
|
||||||
|
headers = dict(_HEADERS)
|
||||||
|
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
headers["Referer"] = _BASE
|
||||||
|
r1 = await client.post(_BASE, data=payload, headers=headers)
|
||||||
|
soup1 = BeautifulSoup(r1.text, "lxml")
|
||||||
|
|
||||||
|
result_div = (
|
||||||
|
soup1.find("div", {"id": "cphMain_divResults"})
|
||||||
|
or soup1.find("div", {"id": "divResults"})
|
||||||
|
or soup1.find("table", {"id": lambda i: i and "result" in i.lower()})
|
||||||
|
)
|
||||||
|
|
||||||
|
if result_div:
|
||||||
|
text = result_div.get_text(" ", strip=True)[:600]
|
||||||
|
has_lien = "lien" in text.lower() or "lienholder" in text.lower()
|
||||||
|
return {
|
||||||
|
"status": "ENCONTRADO",
|
||||||
|
"detail": text,
|
||||||
|
"has_lien": has_lien,
|
||||||
|
"source": "Florida HSMV",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for "no record" messages
|
||||||
|
body = soup1.get_text(" ", strip=True).lower()
|
||||||
|
if "no record" in body or "not found" in body or "no match" in body:
|
||||||
|
return {"status": "SIN_REGISTRO_FL",
|
||||||
|
"detail": "No se encontraron registros en Florida HSMV",
|
||||||
|
"has_lien": False}
|
||||||
|
|
||||||
|
return {"status": "NO_DISPONIBLE",
|
||||||
|
"detail": "Florida HSMV no retornó resultado claro",
|
||||||
|
"has_lien": False}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "NO_DISPONIBLE", "detail": f"Error: {exc}", "has_lien": False}
|
||||||
@@ -129,6 +129,18 @@ async def fetch_complaints(make: str, model: str, year: str) -> list:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def check_odometer_complaints(complaints: list) -> dict:
|
||||||
|
"""Filter already-fetched complaints for odometer/speedometer fraud mentions."""
|
||||||
|
_ODO_KW = ("odometer", "speedometer", "mileage", "rollback", "rolled back", "fraud")
|
||||||
|
hits = []
|
||||||
|
for c in complaints:
|
||||||
|
comp = (c.get("Component") or c.get("components") or c.get("component") or "").lower()
|
||||||
|
desc = (c.get("summary") or c.get("Summary") or c.get("description") or "").lower()
|
||||||
|
if any(kw in comp or kw in desc for kw in _ODO_KW):
|
||||||
|
hits.append(c)
|
||||||
|
return {"count": len(hits), "samples": hits[:3]}
|
||||||
|
|
||||||
|
|
||||||
async def fetch_investigations(make: str, model: str, year: str) -> list:
|
async def fetch_investigations(make: str, model: str, year: str) -> list:
|
||||||
url = f"{_NHTSA_BASE}/investigations/investigationsByVehicle"
|
url = f"{_NHTSA_BASE}/investigations/investigationsByVehicle"
|
||||||
params = {"make": make, "model": model, "modelYear": year}
|
params = {"make": make, "model": model, "modelYear": year}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
_BASE = "https://www.nicb.org/vincheck"
|
||||||
|
_HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"Accept": "text/html,application/xhtml+xml",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
"Referer": "https://www.nicb.org/vincheck",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def check_nicb(vin: str) -> dict:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
||||||
|
# GET first to pick up cookies and any hidden tokens
|
||||||
|
r0 = await client.get(_BASE, headers=_HEADERS)
|
||||||
|
soup0 = BeautifulSoup(r0.text, "lxml")
|
||||||
|
|
||||||
|
# Look for a CSRF token or form token
|
||||||
|
token_input = soup0.find("input", {"name": lambda n: n and "token" in n.lower()})
|
||||||
|
token_val = token_input["value"] if token_input else ""
|
||||||
|
|
||||||
|
payload = {"vin": vin}
|
||||||
|
if token_val and token_input:
|
||||||
|
payload[token_input["name"]] = token_val
|
||||||
|
|
||||||
|
headers = dict(_HEADERS)
|
||||||
|
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
r1 = await client.post(_BASE, data=payload, headers=headers)
|
||||||
|
text = r1.text.lower()
|
||||||
|
|
||||||
|
if "no record" in text or "no stolen" in text or "not found" in text:
|
||||||
|
return {"status": "LIMPIO", "detail": "Sin registros de robo o salvage en NICB"}
|
||||||
|
if "record found" in text or "stolen" in text or "salvage" in text:
|
||||||
|
return {"status": "ALERTA", "detail": "NICB reporta registros de robo o salvage"}
|
||||||
|
return {"status": "NO_DISPONIBLE", "detail": "NICB no retornó resultado claro"}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "NO_DISPONIBLE", "detail": f"Error: {exc}"}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# NICB Hot Wheels Report 2023 — top stolen models in USA
|
||||||
|
# Source: National Insurance Crime Bureau annual report (public data)
|
||||||
|
|
||||||
|
_THEFT_RISK: dict[tuple[str, str], str] = {
|
||||||
|
("CHEVROLET", "SILVERADO"): "MUY ALTO",
|
||||||
|
("FORD", "F-SERIES"): "MUY ALTO",
|
||||||
|
("FORD", "F150"): "MUY ALTO",
|
||||||
|
("FORD", "F-150"): "MUY ALTO",
|
||||||
|
("HONDA", "CIVIC"): "ALTO",
|
||||||
|
("HONDA", "ACCORD"): "ALTO",
|
||||||
|
("HYUNDAI", "ELANTRA"): "MUY ALTO",
|
||||||
|
("HYUNDAI", "SONATA"): "MUY ALTO",
|
||||||
|
("KIA", "OPTIMA"): "MUY ALTO",
|
||||||
|
("KIA", "FORTE"): "MUY ALTO",
|
||||||
|
("KIA", "SOUL"): "ALTO",
|
||||||
|
("TOYOTA", "CAMRY"): "MEDIO",
|
||||||
|
("TOYOTA", "COROLLA"): "MEDIO",
|
||||||
|
("TOYOTA", "PRIUS"): "ALTO",
|
||||||
|
("JEEP", "CHEROKEE"): "ALTO",
|
||||||
|
("JEEP", "GRAND CHEROKEE"): "MEDIO",
|
||||||
|
("DODGE", "RAM"): "ALTO",
|
||||||
|
("DODGE", "CHARGER"): "ALTO",
|
||||||
|
("DODGE", "CHALLENGER"): "MEDIO",
|
||||||
|
("GMC", "SIERRA"): "ALTO",
|
||||||
|
("FORD", "EXPLORER"): "MEDIO",
|
||||||
|
("FORD", "FUSION"): "BAJO",
|
||||||
|
("NISSAN", "ALTIMA"): "MEDIO",
|
||||||
|
("NISSAN", "SENTRA"): "BAJO",
|
||||||
|
("SUBARU", "OUTBACK"): "BAJO",
|
||||||
|
("TOYOTA", "TUNDRA"): "BAJO",
|
||||||
|
("TESLA", "MODEL 3"): "BAJO",
|
||||||
|
("BMW", "3 SERIES"): "MEDIO",
|
||||||
|
("MERCEDES-BENZ", "C-CLASS"): "BAJO",
|
||||||
|
("LAND ROVER", "RANGE ROVER"): "ALTO",
|
||||||
|
("CADILLAC", "ESCALADE"): "ALTO",
|
||||||
|
}
|
||||||
|
|
||||||
|
_RISK_POINTS = {"MUY ALTO": 10, "ALTO": 5, "MEDIO": 2, "BAJO": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def get_theft_risk(make: str, model: str) -> dict:
|
||||||
|
make_u = (make or "").upper().strip()
|
||||||
|
model_u = (model or "").upper().strip()
|
||||||
|
level = _THEFT_RISK.get((make_u, model_u), "DESCONOCIDO")
|
||||||
|
# Try partial model match (e.g. "F-150 SUPERCREW" → matches F-150)
|
||||||
|
if level == "DESCONOCIDO":
|
||||||
|
for (m, mo), lv in _THEFT_RISK.items():
|
||||||
|
if m == make_u and (mo in model_u or model_u.startswith(mo)):
|
||||||
|
level = lv
|
||||||
|
break
|
||||||
|
points = _RISK_POINTS.get(level, 0)
|
||||||
|
return {"level": level, "extra_points": points}
|
||||||
+132
-3
@@ -174,6 +174,12 @@ def generate_pdf(data: dict) -> str:
|
|||||||
invests = data.get("investigations", [])
|
invests = data.get("investigations", [])
|
||||||
vin_recalls = data.get("vin_recalls", [])
|
vin_recalls = data.get("vin_recalls", [])
|
||||||
safety_ratings = data.get("safety_ratings", {})
|
safety_ratings = data.get("safety_ratings", {})
|
||||||
|
nicb = data.get("nicb", {})
|
||||||
|
auction_hist = data.get("auction_hist", {})
|
||||||
|
dmv_fl = data.get("dmv_fl", {})
|
||||||
|
theft_info = data.get("theft_info", {})
|
||||||
|
odo_check = data.get("odo_check", {})
|
||||||
|
odo_fraud = data.get("odo_fraud", {})
|
||||||
epa = data.get("epa", {})
|
epa = data.get("epa", {})
|
||||||
risk = data.get("risk", {})
|
risk = data.get("risk", {})
|
||||||
|
|
||||||
@@ -316,6 +322,21 @@ def generate_pdf(data: dict) -> str:
|
|||||||
r_rows.append([P(i), P(campaign), P(comp), P(summary)])
|
r_rows.append([P(i), P(campaign), P(comp), P(summary)])
|
||||||
story.append(_table(r_rows, [0.25*inch, 1.0*inch, 1.9*inch, W-3.15*inch]))
|
story.append(_table(r_rows, [0.25*inch, 1.0*inch, 1.9*inch, W-3.15*inch]))
|
||||||
|
|
||||||
|
# ── NICB Check ────────────────────────────────────────────────────────────
|
||||||
|
story += _section("3b — NICB CHECK (ROBO / SALVAGE / FRAUDE)")
|
||||||
|
|
||||||
|
nicb_status = nicb.get("status", "NO_DISPONIBLE")
|
||||||
|
nicb_detail = nicb.get("detail", "")
|
||||||
|
if nicb_status == "LIMPIO":
|
||||||
|
story.append(_badge("✅ NICB — Sin registros de robo ni salvage", "green"))
|
||||||
|
elif nicb_status == "ALERTA":
|
||||||
|
story.append(_badge("🔴 NICB — ALERTA: Registro de robo o salvage detectado", "red"))
|
||||||
|
else:
|
||||||
|
story.append(_badge("⚠️ NICB — No disponible o sin resultado claro", "yellow"))
|
||||||
|
if nicb_detail:
|
||||||
|
story.append(Spacer(1, 4))
|
||||||
|
story.append(Paragraph(nicb_detail, STYLE_BODY))
|
||||||
|
|
||||||
# ── VIN-specific recall status ────────────────────────────────────────────
|
# ── VIN-specific recall status ────────────────────────────────────────────
|
||||||
story += _section("4 — ESTADO DE RECALL ESPECÍFICO PARA ESTE VIN")
|
story += _section("4 — ESTADO DE RECALL ESPECÍFICO PARA ESTE VIN")
|
||||||
|
|
||||||
@@ -368,6 +389,21 @@ def generate_pdf(data: dict) -> str:
|
|||||||
else:
|
else:
|
||||||
story.append(Paragraph("Sin quejas registradas.", STYLE_BODY))
|
story.append(Paragraph("Sin quejas registradas.", STYLE_BODY))
|
||||||
|
|
||||||
|
# ── Theft Statistics ─────────────────────────────────────────────────────
|
||||||
|
story += _section("5b — ESTADÍSTICAS DE ROBO DEL MODELO (NICB)")
|
||||||
|
|
||||||
|
theft_level = theft_info.get("level", "DESCONOCIDO")
|
||||||
|
theft_colors = {"MUY ALTO": "red", "ALTO": "yellow", "MEDIO": "yellow", "BAJO": "green", "DESCONOCIDO": "yellow"}
|
||||||
|
story.append(_badge(
|
||||||
|
f"Tasa de robo histórica para {make} {model}: {theft_level}",
|
||||||
|
theft_colors.get(theft_level, "yellow"),
|
||||||
|
))
|
||||||
|
story.append(Spacer(1, 4))
|
||||||
|
story.append(Paragraph(
|
||||||
|
"Fuente: NICB Hot Wheels Report 2023 — datos públicos anuales.",
|
||||||
|
STYLE_BODY,
|
||||||
|
))
|
||||||
|
|
||||||
# ── Investigations ────────────────────────────────────────────────────────
|
# ── Investigations ────────────────────────────────────────────────────────
|
||||||
story += _section("6 — INVESTIGACIONES DE SEGURIDAD")
|
story += _section("6 — INVESTIGACIONES DE SEGURIDAD")
|
||||||
|
|
||||||
@@ -387,6 +423,27 @@ def generate_pdf(data: dict) -> str:
|
|||||||
else:
|
else:
|
||||||
story.append(Paragraph("Sin investigaciones activas.", STYLE_BODY))
|
story.append(Paragraph("Sin investigaciones activas.", STYLE_BODY))
|
||||||
|
|
||||||
|
# ── Odometer Fraud ───────────────────────────────────────────────────────
|
||||||
|
story += _section("6b — FRAUDE DE ODÓMETRO (QUEJAS NHTSA)")
|
||||||
|
|
||||||
|
odo_fraud_count = odo_fraud.get("count", 0)
|
||||||
|
if odo_fraud_count > 0:
|
||||||
|
story.append(_badge(
|
||||||
|
f"🔴 ALERTA: {odo_fraud_count} queja(s) de fraude de odómetro registradas en NHTSA para este modelo",
|
||||||
|
"red",
|
||||||
|
))
|
||||||
|
story.append(Spacer(1, 4))
|
||||||
|
PO = lambda txt, bold=False: Paragraph(
|
||||||
|
str(txt),
|
||||||
|
ParagraphStyle("of", fontSize=7, fontName="Helvetica-Bold" if bold else "Helvetica",
|
||||||
|
textColor=NAVY2, leading=9, wordWrap="LTR"),
|
||||||
|
)
|
||||||
|
for sample in odo_fraud.get("samples", [])[:3]:
|
||||||
|
desc = (sample.get("summary") or sample.get("Summary") or "—")[:200]
|
||||||
|
story.append(Paragraph(f"• {desc}", STYLE_BODY))
|
||||||
|
else:
|
||||||
|
story.append(Paragraph("Sin quejas de fraude de odómetro registradas en NHTSA para este modelo.", STYLE_BODY))
|
||||||
|
|
||||||
# ── EPA ───────────────────────────────────────────────────────────────────
|
# ── EPA ───────────────────────────────────────────────────────────────────
|
||||||
story += _section("7 — EFICIENCIA EPA")
|
story += _section("7 — EFICIENCIA EPA")
|
||||||
|
|
||||||
@@ -403,7 +460,7 @@ def generate_pdf(data: dict) -> str:
|
|||||||
story.append(_table(epa_rows, [2 * inch, W - 2 * inch]))
|
story.append(_table(epa_rows, [2 * inch, W - 2 * inch]))
|
||||||
|
|
||||||
# ── Safety Ratings ────────────────────────────────────────────────────────
|
# ── Safety Ratings ────────────────────────────────────────────────────────
|
||||||
story += _section("8 — CALIFICACIONES DE SEGURIDAD NHTSA (5 ESTRELLAS)")
|
story += _section("7b — CALIFICACIONES DE SEGURIDAD NHTSA (5 ESTRELLAS)")
|
||||||
|
|
||||||
STYLE_STAR = ParagraphStyle("star", fontSize=13, fontName="Helvetica-Bold",
|
STYLE_STAR = ParagraphStyle("star", fontSize=13, fontName="Helvetica-Bold",
|
||||||
textColor=NAVY2, alignment=TA_CENTER, leading=16)
|
textColor=NAVY2, alignment=TA_CENTER, leading=16)
|
||||||
@@ -483,8 +540,80 @@ def generate_pdf(data: dict) -> str:
|
|||||||
STYLE_BODY,
|
STYLE_BODY,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# ── Odometer Validation ───────────────────────────────────────────────────
|
||||||
|
story += _section("8 — VALIDACIÓN DE ODÓMETRO")
|
||||||
|
|
||||||
|
odo_status = odo_check.get("status", "DESCONOCIDO")
|
||||||
|
odo_colors = {"SOSPECHOSO": "red", "BAJO": "yellow", "ALTO": "yellow", "NORMAL": "green", "DESCONOCIDO": "yellow"}
|
||||||
|
story.append(_badge(
|
||||||
|
f"Odómetro: {data.get('odometer','—')} mi — Estado: {odo_status}",
|
||||||
|
odo_colors.get(odo_status, "yellow"),
|
||||||
|
))
|
||||||
|
story.append(Spacer(1, 4))
|
||||||
|
odo_detail_rows = [["Parámetro", "Valor"]]
|
||||||
|
if odo_check.get("expected_miles"):
|
||||||
|
odo_detail_rows.append(["Millas esperadas (promedio)", f"{odo_check['expected_miles']:,} mi"])
|
||||||
|
if odo_check.get("ratio") is not None:
|
||||||
|
odo_detail_rows.append(["Ratio uso/esperado", f"{odo_check['ratio']:.2f}x"])
|
||||||
|
if len(odo_detail_rows) > 1:
|
||||||
|
story.append(_table(odo_detail_rows, [2.5 * inch, W - 2.5 * inch]))
|
||||||
|
|
||||||
|
# ── Auction History ───────────────────────────────────────────────────────
|
||||||
|
story += _section("9 — HISTORIAL WEB DEL VIN (SUBASTAS / REGISTROS)")
|
||||||
|
|
||||||
|
auction_found = auction_hist.get("found", False)
|
||||||
|
auction_flags = auction_hist.get("flags", [])
|
||||||
|
auction_count = auction_hist.get("count", 0)
|
||||||
|
auction_extra = auction_hist.get("extra_points", 0)
|
||||||
|
|
||||||
|
if auction_flags:
|
||||||
|
story.append(_badge(
|
||||||
|
f"⚠️ ALERTAS encontradas en búsqueda web: {', '.join(auction_flags)}",
|
||||||
|
"red",
|
||||||
|
))
|
||||||
|
elif auction_found:
|
||||||
|
story.append(_badge(f"⚠️ {auction_count} referencia(s) web del VIN encontradas — revisar", "yellow"))
|
||||||
|
else:
|
||||||
|
story.append(_badge("✅ Sin referencias negativas del VIN en búsqueda web", "green"))
|
||||||
|
|
||||||
|
results = auction_hist.get("results", [])
|
||||||
|
if results:
|
||||||
|
story.append(Spacer(1, 4))
|
||||||
|
PA = lambda txt: Paragraph(str(txt),
|
||||||
|
ParagraphStyle("ah", fontSize=7, fontName="Helvetica",
|
||||||
|
textColor=NAVY2, leading=9, wordWrap="LTR"))
|
||||||
|
a_rows = [[PA("Fuente"), PA("Título / Referencia")]]
|
||||||
|
for r in results[:5]:
|
||||||
|
src = (r.get("source") or "Web")[:30]
|
||||||
|
title = (r.get("title") or r.get("url") or "—")[:200]
|
||||||
|
a_rows.append([PA(src), PA(title)])
|
||||||
|
story.append(_table(a_rows, [1.2 * inch, W - 1.2 * inch]))
|
||||||
|
|
||||||
|
# ── Florida HSMV ──────────────────────────────────────────────────────────
|
||||||
|
story += _section("9b — REGISTRO FLORIDA HSMV")
|
||||||
|
|
||||||
|
dmv_status = dmv_fl.get("status", "NO_CONSULTADO")
|
||||||
|
dmv_detail = dmv_fl.get("detail", "")
|
||||||
|
dmv_lien = dmv_fl.get("has_lien", False)
|
||||||
|
|
||||||
|
if dmv_lien:
|
||||||
|
story.append(_badge("🔴 LIEN ACTIVO registrado en Florida HSMV", "red"))
|
||||||
|
elif dmv_status in ("LIMPIO", "OK", "REGISTRADO"):
|
||||||
|
story.append(_badge("✅ Sin lien activo en Florida — título limpio", "green"))
|
||||||
|
else:
|
||||||
|
story.append(_badge(f"⚠️ Florida HSMV: {dmv_status}", "yellow"))
|
||||||
|
|
||||||
|
if dmv_detail:
|
||||||
|
story.append(Spacer(1, 4))
|
||||||
|
story.append(Paragraph(dmv_detail, STYLE_BODY))
|
||||||
|
story.append(Spacer(1, 4))
|
||||||
|
story.append(Paragraph(
|
||||||
|
f"Fuente: {dmv_fl.get('source', 'services.flhsmv.gov')}",
|
||||||
|
STYLE_BODY,
|
||||||
|
))
|
||||||
|
|
||||||
# ── Risk Score ────────────────────────────────────────────────────────────
|
# ── Risk Score ────────────────────────────────────────────────────────────
|
||||||
story += _section("9 — ANÁLISIS DE RIESGO")
|
story += _section("10 — ANÁLISIS DE RIESGO")
|
||||||
|
|
||||||
score = risk.get("score", 0)
|
score = risk.get("score", 0)
|
||||||
level = risk.get("level", "N/A")
|
level = risk.get("level", "N/A")
|
||||||
@@ -501,7 +630,7 @@ def generate_pdf(data: dict) -> str:
|
|||||||
story.append(_table(f_rows, [W - 1.2 * inch, 1.2 * inch]))
|
story.append(_table(f_rows, [W - 1.2 * inch, 1.2 * inch]))
|
||||||
|
|
||||||
# ── AI Analysis ───────────────────────────────────────────────────────────
|
# ── AI Analysis ───────────────────────────────────────────────────────────
|
||||||
story += _section("10 — ANÁLISIS INTELIGENTE (IA Local)")
|
story += _section("11 — ANÁLISIS INTELIGENTE (IA Local)")
|
||||||
|
|
||||||
ai_text = data.get("ai_analysis")
|
ai_text = data.get("ai_analysis")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
_AVG_MILES_PER_YEAR = 13_500 # USA average (FHWA)
|
||||||
|
_CURRENT_YEAR = 2026
|
||||||
|
|
||||||
|
|
||||||
|
def validate_odometer(year: str, odometer: str) -> dict:
|
||||||
|
try:
|
||||||
|
yr = int(str(year or 0).strip())
|
||||||
|
odo = int(str(odometer or 0).replace(",", "")
|
||||||
|
.replace(" mi", "").replace(" miles", "").strip() or "0")
|
||||||
|
except ValueError:
|
||||||
|
return {"status": "SIN_DATOS", "detail": "No se pudo interpretar el odómetro o año.", "extra_points": 0}
|
||||||
|
|
||||||
|
if yr < 1980 or yr > _CURRENT_YEAR:
|
||||||
|
return {"status": "SIN_DATOS", "detail": "Año fuera de rango.", "extra_points": 0}
|
||||||
|
if odo <= 0:
|
||||||
|
return {"status": "SIN_DATOS", "detail": "Odómetro no ingresado.", "extra_points": 0}
|
||||||
|
|
||||||
|
age = _CURRENT_YEAR - yr
|
||||||
|
expected = age * _AVG_MILES_PER_YEAR
|
||||||
|
if expected <= 0:
|
||||||
|
return {"status": "SIN_DATOS", "detail": "Vehículo nuevo — sin referencia.", "extra_points": 0}
|
||||||
|
|
||||||
|
ratio = odo / expected
|
||||||
|
|
||||||
|
if ratio < 0.30:
|
||||||
|
return {
|
||||||
|
"status": "SOSPECHOSO",
|
||||||
|
"detail": (f"Solo {odo:,} millas en {age} años. "
|
||||||
|
f"Promedio esperado: {expected:,} mi. "
|
||||||
|
f"Posible rollback de odómetro."),
|
||||||
|
"extra_points": 30,
|
||||||
|
}
|
||||||
|
if ratio < 0.50:
|
||||||
|
return {
|
||||||
|
"status": "BAJO",
|
||||||
|
"detail": f"{odo:,} millas en {age} años — por debajo del promedio. Verificar con historial.",
|
||||||
|
"extra_points": 10,
|
||||||
|
}
|
||||||
|
if ratio > 2.0:
|
||||||
|
return {
|
||||||
|
"status": "ALTO",
|
||||||
|
"detail": f"{odo:,} millas en {age} años — uso intensivo (posible fleet / taxi / rental).",
|
||||||
|
"extra_points": 5,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "NORMAL",
|
||||||
|
"detail": f"{odo:,} millas en {age} años — dentro del rango normal ({ratio:.1f}× promedio).",
|
||||||
|
"extra_points": 0,
|
||||||
|
}
|
||||||
+50
-1
@@ -75,6 +75,16 @@ def calculate_risk(
|
|||||||
secondary_damage: str,
|
secondary_damage: str,
|
||||||
complaints: list,
|
complaints: list,
|
||||||
odometer: str,
|
odometer: str,
|
||||||
|
# optional enrichment — safe to omit (default None)
|
||||||
|
nicb_status: str = None,
|
||||||
|
safety_overall: str = None,
|
||||||
|
auction_flags: list = None,
|
||||||
|
auction_pts: int = 0,
|
||||||
|
odo_extra_pts: int = 0,
|
||||||
|
theft_level: str = None,
|
||||||
|
odo_fraud_count: int = 0,
|
||||||
|
dmv_has_lien: bool = False,
|
||||||
|
open_vin_recalls: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
score = 0
|
score = 0
|
||||||
factors: list[dict] = []
|
factors: list[dict] = []
|
||||||
@@ -96,7 +106,12 @@ def calculate_risk(
|
|||||||
recall_pts = min(recall_count * 15, 30)
|
recall_pts = min(recall_count * 15, 30)
|
||||||
if recall_pts > 0:
|
if recall_pts > 0:
|
||||||
score += recall_pts
|
score += recall_pts
|
||||||
factors.append({"label": f"{recall_count} recall(s) activos", "points": recall_pts, "dir": "+"})
|
factors.append({"label": f"{recall_count} recall(s) del modelo", "points": recall_pts, "dir": "+"})
|
||||||
|
|
||||||
|
if open_vin_recalls > 0:
|
||||||
|
pts = min(open_vin_recalls * 20, 40)
|
||||||
|
score += pts
|
||||||
|
factors.append({"label": f"{open_vin_recalls} recall(s) abierto(s) en este VIN", "points": pts, "dir": "+"})
|
||||||
|
|
||||||
sec = (secondary_damage or "").lower().strip()
|
sec = (secondary_damage or "").lower().strip()
|
||||||
if sec and sec not in ("none", "no damage", "", "n/a"):
|
if sec and sec not in ("none", "no damage", "", "n/a"):
|
||||||
@@ -112,6 +127,40 @@ def calculate_risk(
|
|||||||
score -= 10
|
score -= 10
|
||||||
factors.append({"label": f"Bajo kilometraje ({odo:,} mi)", "points": 10, "dir": "-"})
|
factors.append({"label": f"Bajo kilometraje ({odo:,} mi)", "points": 10, "dir": "-"})
|
||||||
|
|
||||||
|
# ── New enrichment factors ────────────────────────────────────────────────
|
||||||
|
if nicb_status == "ALERTA":
|
||||||
|
score += 40
|
||||||
|
factors.append({"label": "NICB: registro de robo/salvage detectado", "points": 40, "dir": "+"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
stars = int(safety_overall or 0)
|
||||||
|
if 1 <= stars <= 2:
|
||||||
|
score += 15
|
||||||
|
factors.append({"label": f"Safety rating bajo ({stars}★/5)", "points": 15, "dir": "+"})
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if auction_pts > 0:
|
||||||
|
score += auction_pts
|
||||||
|
flags_str = ", ".join(auction_flags) if auction_flags else "banderas negativas"
|
||||||
|
factors.append({"label": f"Historial web: {flags_str}", "points": auction_pts, "dir": "+"})
|
||||||
|
|
||||||
|
if odo_extra_pts > 0:
|
||||||
|
score += odo_extra_pts
|
||||||
|
factors.append({"label": "Odómetro sospechoso (posible rollback)", "points": odo_extra_pts, "dir": "+"})
|
||||||
|
|
||||||
|
if theft_level == "MUY ALTO":
|
||||||
|
score += 10
|
||||||
|
factors.append({"label": "Modelo en lista NICB más robados", "points": 10, "dir": "+"})
|
||||||
|
|
||||||
|
if odo_fraud_count > 0:
|
||||||
|
score += 45
|
||||||
|
factors.append({"label": f"Quejas NHTSA de fraude de odómetro ({odo_fraud_count})", "points": 45, "dir": "+"})
|
||||||
|
|
||||||
|
if dmv_has_lien:
|
||||||
|
score += 25
|
||||||
|
factors.append({"label": "Lien activo registrado en Florida HSMV", "points": 25, "dir": "+"})
|
||||||
|
|
||||||
score = max(0, min(100, score))
|
score = max(0, min(100, score))
|
||||||
|
|
||||||
if score < 30:
|
if score < 30:
|
||||||
|
|||||||
Reference in New Issue
Block a user