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:
2026-07-03 13:54:16 -04:00
parent 078128f7b1
commit df09627ccb
11 changed files with 549 additions and 18 deletions
+53 -14
View File
@@ -21,10 +21,15 @@ from src.ai.ollama_analyzer import analyze_vehicle
from src.api.epa import fetch_epa_data
from src.api.nhtsa import (
decode_vin, fetch_complaints, fetch_investigations, fetch_recalls,
fetch_vin_recalls, fetch_safety_ratings,
fetch_vin_recalls, fetch_safety_ratings, check_odometer_complaints,
)
from src.api.nicb import check_nicb
from src.api.auction_history import search_auction_history
from src.api.dmv_florida import check_florida_dmv
from src.api.theft_stats import get_theft_risk
from src.report.pdf import generate_pdf
from src.utils.risk import calculate_risk, compute_extended_fields
from src.utils.odometer_validator import validate_odometer
from src.utils.validator import validate_vin
Path("output").mkdir(exist_ok=True)
@@ -117,31 +122,44 @@ async def fetch(
yield evt(f"✅ Vehículo: {year} {make} {model}", "success")
await asyncio.sleep(0.05)
# 3 — Recalls + Complaints + Investigations + VIN check + Safety (paralelo)
yield evt("⏳ Consultando NHTSA: recalls, quejas, VIN check, seguridad...", "progress")
recalls, complaints, invests, vin_recalls, safety_ratings = await asyncio.gather(
# 3 — NHTSA + EPA + NICB + Subastas + DMV (todo en paralelo)
yield evt("⏳ Consultando NHTSA, EPA, NICB, historial web, Florida DMV...", "progress")
(
recalls, complaints, invests,
vin_recalls, safety_ratings, epa,
nicb, auction_hist, dmv_fl,
) = await asyncio.gather(
fetch_recalls(make, model, year),
fetch_complaints(make, model, year),
fetch_investigations(make, model, year),
fetch_vin_recalls(vin_clean),
fetch_safety_ratings(year, make, model),
fetch_epa_data(year, make, model),
check_nicb(vin_clean),
search_auction_history(vin_clean),
check_florida_dmv(vin_clean),
)
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_txt = f" · {stars}★ seguridad" if stars and stars != "NR" else ""
nicb_status = nicb.get("status", "NO_DISPONIBLE")
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",
)
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
# 4 — Photo
photo_bytes = None
if photo_url and photo_url.strip():
yield evt("⏳ Descargando foto del vehículo...", "progress")
@@ -166,6 +184,15 @@ async def fetch(
secondary_damage=secondary_damage or "",
complaints=complaints,
odometer=odometer or "0",
nicb_status=nicb_status,
safety_overall=stars,
auction_flags=auction_hist.get("flags", []),
auction_pts=auction_hist.get("extra_points", 0),
odo_extra_pts=odo_check.get("extra_points", 0),
theft_level=theft_info.get("level", ""),
odo_fraud_count=odo_fraud.get("count", 0),
dmv_has_lien=dmv_fl.get("has_lien", False),
open_vin_recalls=open_vin_recalls,
)
extended = compute_extended_fields(
title=title or "",
@@ -195,6 +222,12 @@ async def fetch(
"complaints": complaints,
"vin_recalls": vin_recalls,
"safety_ratings": safety_ratings,
"nicb": nicb,
"auction_hist": auction_hist,
"dmv_fl": dmv_fl,
"theft_info": theft_info,
"odo_check": odo_check,
"odo_fraud": odo_fraud,
"critical_recall_count": extended["critical_recall_count"],
"engine_complaints": extended["engine_complaints"],
"transmission_complaints": extended["transmission_complaints"],
@@ -224,6 +257,12 @@ async def fetch(
"investigations": invests,
"vin_recalls": vin_recalls,
"safety_ratings": safety_ratings,
"nicb": nicb,
"auction_hist": auction_hist,
"dmv_fl": dmv_fl,
"theft_info": theft_info,
"odo_check": odo_check,
"odo_fraud": odo_fraud,
"epa": epa,
"risk": risk,
"ai_analysis": ai_analysis,
+2
View File
@@ -7,3 +7,5 @@ Pillow==10.1.0
jinja2==3.1.2
python-multipart==0.0.6
aiofiles==23.2.1
beautifulsoup4==4.12.3
lxml==5.1.0
+16
View File
@@ -48,6 +48,14 @@ def _build_prompt(data: dict) -> str:
trans_q = data.get("transmission_complaints", 0)
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_recalls = data.get("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}
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:
+72
View File
@@ -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),
}
+70
View File
@@ -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}
+12
View File
@@ -129,6 +129,18 @@ async def fetch_complaints(make: str, model: str, year: str) -> list:
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:
url = f"{_NHTSA_BASE}/investigations/investigationsByVehicle"
params = {"make": make, "model": model, "modelYear": year}
+41
View File
@@ -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}"}
+52
View File
@@ -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
View File
@@ -174,6 +174,12 @@ def generate_pdf(data: dict) -> str:
invests = data.get("investigations", [])
vin_recalls = data.get("vin_recalls", [])
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", {})
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)])
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 ────────────────────────────────────────────
story += _section("4 — ESTADO DE RECALL ESPECÍFICO PARA ESTE VIN")
@@ -368,6 +389,21 @@ def generate_pdf(data: dict) -> str:
else:
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 ────────────────────────────────────────────────────────
story += _section("6 — INVESTIGACIONES DE SEGURIDAD")
@@ -387,6 +423,27 @@ def generate_pdf(data: dict) -> str:
else:
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 ───────────────────────────────────────────────────────────────────
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]))
# ── 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",
textColor=NAVY2, alignment=TA_CENTER, leading=16)
@@ -483,8 +540,80 @@ def generate_pdf(data: dict) -> str:
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 ────────────────────────────────────────────────────────────
story += _section("9 — ANÁLISIS DE RIESGO")
story += _section("10 — ANÁLISIS DE RIESGO")
score = risk.get("score", 0)
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]))
# ── AI Analysis ───────────────────────────────────────────────────────────
story += _section("10 — ANÁLISIS INTELIGENTE (IA Local)")
story += _section("11 — ANÁLISIS INTELIGENTE (IA Local)")
ai_text = data.get("ai_analysis")
+49
View File
@@ -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
View File
@@ -75,6 +75,16 @@ def calculate_risk(
secondary_damage: str,
complaints: list,
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:
score = 0
factors: list[dict] = []
@@ -96,7 +106,12 @@ def calculate_risk(
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": "+"})
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()
if sec and sec not in ("none", "no damage", "", "n/a"):
@@ -112,6 +127,40 @@ def calculate_risk(
score -= 10
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))
if score < 30: