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