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:
235
pos/services/qwen_fitment.py
Normal file
235
pos/services/qwen_fitment.py
Normal 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
|
||||
Reference in New Issue
Block a user