- Fixed column names: brands.name_brand, models.name_model, engines.name_engine - Added fuzzy model matching with ILIKE %%pattern%% for TecDoc-style names - Removed erroneous double cur.fetchone() that always returned None - Added retry logic (3 attempts) for QWEN API empty responses - Added fallback engine-less query when engine description doesn't match DB - Protected _extract_json against None input
236 lines
8.2 KiB
Python
236 lines
8.2 KiB
Python
"""QWEN 3.6 Fitment Service — Vehicle compatibility lookup via AI.
|
||
|
||
Uses a private QWEN server (OpenAI-compatible API) to find compatible
|
||
vehicles for a given part number + name + brand, then validates them
|
||
against the master database (model_year_engine).
|
||
"""
|
||
import json
|
||
import re
|
||
import time
|
||
import requests
|
||
from config import QWEN_API_URL, QWEN_API_KEY, QWEN_MODEL
|
||
|
||
|
||
def get_vehicle_fitment(part_number, name, brand):
|
||
"""Ask QWEN for compatible vehicles and validate against master DB.
|
||
|
||
Returns: {
|
||
'vehicles': [{'make': 'Toyota', 'model': 'Corolla', 'year': 2015, 'engine': '1.8L', 'mye_id': 123}],
|
||
'confidence': 0.92,
|
||
'notes': ''
|
||
}
|
||
"""
|
||
if not QWEN_API_URL or not QWEN_API_KEY:
|
||
return {'vehicles': [], 'confidence': 0, 'notes': 'QWEN not configured'}
|
||
|
||
prompt = _build_prompt(part_number, name, brand)
|
||
content = ''
|
||
last_error = None
|
||
|
||
for attempt in range(3):
|
||
try:
|
||
response = requests.post(
|
||
f"{QWEN_API_URL}/chat/completions",
|
||
headers={
|
||
'Authorization': f'Bearer {QWEN_API_KEY}',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
json={
|
||
'model': QWEN_MODEL,
|
||
'messages': [
|
||
{'role': 'system', 'content': 'Eres un experto en autopartes mexicanas. Devuelve SIEMPRE JSON valido sin markdown.'},
|
||
{'role': 'user', 'content': prompt}
|
||
],
|
||
'temperature': 0.2,
|
||
'max_tokens': 2048,
|
||
},
|
||
timeout=45,
|
||
)
|
||
response.raise_for_status()
|
||
raw = response.json()
|
||
if raw.get('choices') and len(raw['choices']) > 0:
|
||
msg = raw['choices'][0].get('message', {})
|
||
if msg:
|
||
content = msg.get('content') or ''
|
||
if content:
|
||
break
|
||
except requests.RequestException as exc:
|
||
last_error = exc
|
||
# Retry on request failure
|
||
if attempt < 2:
|
||
time.sleep(1)
|
||
|
||
if not content:
|
||
err_msg = f'QWEN request failed: {last_error}' if last_error else 'Empty response from QWEN after 3 attempts'
|
||
return {'vehicles': [], 'confidence': 0, 'notes': err_msg}
|
||
|
||
# Parse JSON from QWEN response (sometimes wrapped in markdown)
|
||
parsed = _extract_json(content)
|
||
if not parsed:
|
||
return {'vehicles': [], 'confidence': 0, 'notes': 'Invalid JSON from QWEN: ' + str(content)[:200]}
|
||
|
||
# Extract vehicles list from various possible structures
|
||
vehicles_raw = _extract_vehicles(parsed)
|
||
confidence = parsed.get('confidence', 0) or parsed.get('confianza', 0)
|
||
notes = parsed.get('notes', '') or parsed.get('notas', '') or parsed.get('advertencia', '')
|
||
|
||
# Validate vehicles against master DB
|
||
validated = _validate_vehicles(vehicles_raw)
|
||
|
||
return {
|
||
'vehicles': validated,
|
||
'confidence': confidence,
|
||
'notes': notes,
|
||
}
|
||
|
||
|
||
def _build_prompt(part_number, name, brand):
|
||
brand_str = brand or 'desconocida'
|
||
return f"""Dado el siguiente repuesto automotriz:
|
||
- Numero de parte: {part_number}
|
||
- Nombre: {name}
|
||
- Marca del vehiculo: {brand_str}
|
||
|
||
Devuelve UNICAMENTE un JSON valido (sin markdown, sin backticks) con esta estructura exacta:
|
||
{{
|
||
"vehicles": [
|
||
{{"make": "Toyota", "model": "Corolla", "year": 2015, "engine": "1.8L 16V"}},
|
||
{{"make": "Toyota", "model": "Matrix", "year": 2014, "engine": "1.8L"}}
|
||
],
|
||
"confidence": 0.92,
|
||
"notes": "Compatible con motor 2ZR-FE"
|
||
}}
|
||
|
||
Reglas:
|
||
1. "make" es la marca del vehiculo (ej: Toyota, Nissan, Ford, Volkswagen).
|
||
2. "model" es el modelo exacto.
|
||
3. "year" es el ano numerico (int). Si hay rango de anos, usa el ano inicial.
|
||
4. "engine" es la descripcion del motor (ej: "1.8L", "2.0L TDI", "V6 3.5L").
|
||
5. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 30.
|
||
6. Si no conoces el motor exacto, usa "desconocido".
|
||
7. confidence entre 0.0 y 1.0.
|
||
"""
|
||
|
||
|
||
def _extract_json(text):
|
||
"""Extract JSON object from text, handling markdown fences."""
|
||
if text is None:
|
||
return None
|
||
text = text.strip()
|
||
if text.startswith('```'):
|
||
text = re.sub(r'^```(?:json)?\s*', '', text)
|
||
text = re.sub(r'\s*```$', '', text)
|
||
match = re.search(r'\{.*\}', text, re.DOTALL)
|
||
if not match:
|
||
return None
|
||
try:
|
||
return json.loads(match.group(0))
|
||
except json.JSONDecodeError:
|
||
return None
|
||
|
||
|
||
def _extract_vehicles(parsed):
|
||
"""Try multiple possible keys for the vehicle list."""
|
||
for key in ['vehicles', 'vehiculos_compatibles', 'vehiculos', 'compatible_vehicles', 'results']:
|
||
if key in parsed and isinstance(parsed[key], list):
|
||
return parsed[key]
|
||
# Deep search for any list containing objects with marca/modelo or make/model
|
||
for val in parsed.values():
|
||
if isinstance(val, list) and len(val) > 0:
|
||
first = val[0]
|
||
if isinstance(first, dict):
|
||
if any(k in first for k in ['make', 'model', 'marca', 'modelo']):
|
||
return val
|
||
return []
|
||
|
||
|
||
def _normalize_vehicle(v):
|
||
"""Normalize vehicle dict from QWEN to standard keys."""
|
||
make = v.get('make') or v.get('marca') or ''
|
||
model = v.get('model') or v.get('modelo') or ''
|
||
year_raw = v.get('year') or v.get('ano') or v.get('año') or v.get('years') or v.get('anos') or ''
|
||
engine = v.get('engine') or v.get('motor') or ''
|
||
|
||
# Parse year (may be int, string, or range like "2003-2008")
|
||
years = []
|
||
if isinstance(year_raw, int):
|
||
years = [year_raw]
|
||
elif isinstance(year_raw, str):
|
||
# Try range "2003-2008"
|
||
m = re.match(r'(\d{4})\s*[-–]\s*(\d{4})', year_raw)
|
||
if m:
|
||
start, end = int(m.group(1)), int(m.group(2))
|
||
years = list(range(start, end + 1))
|
||
else:
|
||
# Try single year
|
||
m2 = re.match(r'(\d{4})', year_raw)
|
||
if m2:
|
||
years = [int(m2.group(1))]
|
||
|
||
return make, model, years, engine
|
||
|
||
|
||
def _validate_vehicles(vehicles):
|
||
"""Look up each vehicle in master DB and enrich with mye_id."""
|
||
from tenant_db import get_master_conn
|
||
try:
|
||
master = get_master_conn()
|
||
cur = master.cursor()
|
||
except Exception:
|
||
return []
|
||
|
||
validated = []
|
||
seen_mye = set()
|
||
|
||
for v in vehicles:
|
||
make, model, years, engine = _normalize_vehicle(v)
|
||
if not make or not model or not years:
|
||
continue
|
||
|
||
for year in years:
|
||
# First try with exact engine match; if no result, fall back to
|
||
# make/model/year only. Engine descriptions rarely line up between
|
||
# QWEN and the master DB, so the fallback is the common path.
|
||
cur.execute("""
|
||
SELECT mye.id_mye
|
||
FROM model_year_engine mye
|
||
JOIN models m ON mye.model_id = m.id_model
|
||
JOIN brands b ON m.brand_id = b.id_brand
|
||
JOIN years y ON mye.year_id = y.id_year
|
||
JOIN engines e ON mye.engine_id = e.id_engine
|
||
WHERE b.name_brand ILIKE %s
|
||
AND m.name_model ILIKE %s
|
||
AND y.year_car = %s
|
||
AND e.name_engine ILIKE %s
|
||
LIMIT 1
|
||
""", (make, f'%{model}%', year, engine or '%'))
|
||
row = cur.fetchone()
|
||
|
||
if not row:
|
||
cur.execute("""
|
||
SELECT mye.id_mye
|
||
FROM model_year_engine mye
|
||
JOIN models m ON mye.model_id = m.id_model
|
||
JOIN brands b ON m.brand_id = b.id_brand
|
||
JOIN years y ON mye.year_id = y.id_year
|
||
WHERE b.name_brand ILIKE %s
|
||
AND m.name_model ILIKE %s
|
||
AND y.year_car = %s
|
||
LIMIT 1
|
||
""", (make, f'%{model}%', year))
|
||
row = cur.fetchone()
|
||
|
||
if row and row[0] not in seen_mye:
|
||
seen_mye.add(row[0])
|
||
validated.append({
|
||
'make': make,
|
||
'model': model,
|
||
'year': year,
|
||
'engine': engine,
|
||
'mye_id': row[0],
|
||
})
|
||
|
||
cur.close()
|
||
master.close()
|
||
return validated
|