Files
Autoparts-DB/pos/services/qwen_fitment.py
consultoria-as 623c57bb08 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
2026-04-29 08:38:17 +00:00

236 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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