fix(qwen_fitment): resolve DB schema mismatch and double-fetchone bug

- 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
This commit is contained in:
2026-04-29 08:38:17 +00:00
parent 3cd2874ed7
commit 623c57bb08

View File

@@ -0,0 +1,235 @@
"""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