diff --git a/main.py b/main.py index dcd153b..d2d000e 100644 --- a/main.py +++ b/main.py @@ -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, diff --git a/requirements.txt b/requirements.txt index c040cb9..884566b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/ai/ollama_analyzer.py b/src/ai/ollama_analyzer.py index ea624a7..a180696 100644 --- a/src/ai/ollama_analyzer.py +++ b/src/ai/ollama_analyzer.py @@ -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: diff --git a/src/api/auction_history.py b/src/api/auction_history.py new file mode 100644 index 0000000..b4f135b --- /dev/null +++ b/src/api/auction_history.py @@ -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), + } diff --git a/src/api/dmv_florida.py b/src/api/dmv_florida.py new file mode 100644 index 0000000..4a6ac88 --- /dev/null +++ b/src/api/dmv_florida.py @@ -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} diff --git a/src/api/nhtsa.py b/src/api/nhtsa.py index d330fff..d355154 100644 --- a/src/api/nhtsa.py +++ b/src/api/nhtsa.py @@ -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} diff --git a/src/api/nicb.py b/src/api/nicb.py new file mode 100644 index 0000000..a4ea136 --- /dev/null +++ b/src/api/nicb.py @@ -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}"} diff --git a/src/api/theft_stats.py b/src/api/theft_stats.py new file mode 100644 index 0000000..0f08eda --- /dev/null +++ b/src/api/theft_stats.py @@ -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} diff --git a/src/report/pdf.py b/src/report/pdf.py index 4afc0bb..baec6d6 100644 --- a/src/report/pdf.py +++ b/src/report/pdf.py @@ -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") diff --git a/src/utils/odometer_validator.py b/src/utils/odometer_validator.py new file mode 100644 index 0000000..5ebc76e --- /dev/null +++ b/src/utils/odometer_validator.py @@ -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, + } diff --git a/src/utils/risk.py b/src/utils/risk.py index 3cd9bed..12c09bd 100644 --- a/src/utils/risk.py +++ b/src/utils/risk.py @@ -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: