342 lines
14 KiB
Python
342 lines
14 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 y del mercado aftermarket norteamericano. Devuelve SIEMPRE JSON valido sin markdown.'},
|
||
{'role': 'user', 'content': prompt}
|
||
],
|
||
'temperature': 0.2,
|
||
'max_tokens': 8192,
|
||
},
|
||
timeout=120,
|
||
)
|
||
response.raise_for_status()
|
||
raw = response.json()
|
||
finish_reason = None
|
||
if raw.get('choices') and len(raw['choices']) > 0:
|
||
choice = raw['choices'][0]
|
||
msg = choice.get('message', {})
|
||
finish_reason = choice.get('finish_reason')
|
||
if msg:
|
||
content = msg.get('content') or ''
|
||
if not content:
|
||
# Fallback for reasoning models that return output in reasoning_content
|
||
content = msg.get('reasoning_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'
|
||
if finish_reason == 'length':
|
||
err_msg += ' (response truncated by token limit — consider reducing prompt or increasing max_tokens)'
|
||
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 para el mercado mexicano y aftermarket norteamericano:
|
||
- Numero de parte: {part_number}
|
||
- Nombre/descripcion: {name}
|
||
- Marca del fabricante: {brand_str}
|
||
|
||
Devuelve UNICAMENTE un JSON valido (sin markdown, sin backticks, sin texto adicional) con esta estructura:
|
||
{{"vehicles":[{{"make":"Toyota","model":"Corolla","year_range":"2014-2019","engine":"1.8L","engine_code":"2ZR-FE","notes":""}}],"confidence":0.92,"notes":""}}
|
||
|
||
REGLAS OBLIGATORIAS:
|
||
1. "make" = marca del vehiculo.
|
||
2. "model" = modelo exacto (incluye variante si aplica).
|
||
3. USA "year_range" = string "YYYY-YYYY" cuando el MISMO modelo/motor abarca multiples anos consecutivos. Esto ahorra tokens y permite mas resultados.
|
||
4. USA "year" = int SOLO cuando sea un ano aislado sin rangos adyacentes.
|
||
5. "engine" = descripcion corta del motor (ej: "1.8L", "V6 3.5L"). Si no lo conoces, usa "".
|
||
6. "engine_code" = codigo exacto SI LO CONOCES. Si no, usa "".
|
||
7. "notes" = string vacio "" para ahorrar tokens, salvo que haya una advertencia critica.
|
||
8. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 200. Para piezas genericas (filtros de aceite, bujias, balatas, amortiguadores) incluye TODOS los modelos aplicables.
|
||
9. "confidence" entre 0.0 y 1.0. Valores >0.85 solo si estas muy seguro.
|
||
10. Incluye marcas y modelos populares en Mexico cuando apliquen.
|
||
11. Si la pieza es universal, indicalo en "notes".
|
||
"""
|
||
|
||
|
||
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.
|
||
|
||
Supports:
|
||
- year: int or str (single year)
|
||
- year_range: str like "2003-2008" or "2003-2008"
|
||
- legacy: year as range string
|
||
"""
|
||
make = v.get('make') or v.get('marca') or ''
|
||
model = v.get('model') or v.get('modelo') or ''
|
||
engine = v.get('engine') or v.get('motor') or ''
|
||
engine_code = v.get('engine_code') or v.get('codigo_motor') or v.get('motor_code') or ''
|
||
|
||
years = []
|
||
|
||
# Prefer explicit year_range
|
||
year_range = v.get('year_range') or v.get('rango_ano') or ''
|
||
if isinstance(year_range, str):
|
||
m = re.match(r'(\d{4})\s*[-–]\s*(\d{4})', year_range)
|
||
if m:
|
||
start, end = int(m.group(1)), int(m.group(2))
|
||
years = list(range(start, end + 1))
|
||
|
||
# Fallback to year (int or str)
|
||
if not years:
|
||
year_raw = v.get('year') or v.get('ano') or v.get('año') or v.get('years') or v.get('anos') or ''
|
||
if isinstance(year_raw, int):
|
||
years = [year_raw]
|
||
elif isinstance(year_raw, str):
|
||
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:
|
||
m2 = re.match(r'(\d{4})', year_raw)
|
||
if m2:
|
||
years = [int(m2.group(1))]
|
||
|
||
return make, model, years, engine, engine_code
|
||
|
||
|
||
def _extract_displacement(engine):
|
||
"""Extract numeric displacement (L) from engine string, e.g. '1.8L 16V' -> 1.8."""
|
||
if not engine or engine.lower() == 'desconocido':
|
||
return None
|
||
# Match patterns like 1.8L, 2.0L, 3.5L, 1.6, etc.
|
||
match = re.search(r'(\d+\.?\d*)\s*[Ll]', engine)
|
||
if match:
|
||
try:
|
||
return float(match.group(1))
|
||
except ValueError:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _validate_vehicles(vehicles):
|
||
"""Look up each vehicle in master DB and enrich with mye_id.
|
||
|
||
Validation strategy (in order of preference):
|
||
1. Exact engine_code match (most precise)
|
||
2. Displacement-based match (e.g. all 1.8L engines for that make/model/year)
|
||
3. Broad make/model/year match (all engines for that make/model/year)
|
||
|
||
If the master DB does not contain the vehicle (e.g. North-American models
|
||
missing from TecDoc), the vehicle is returned with mye_id=None so it can
|
||
be stored as a text-only QWEN record.
|
||
"""
|
||
from tenant_db import get_master_conn
|
||
try:
|
||
master = get_master_conn()
|
||
cur = master.cursor()
|
||
except Exception:
|
||
# Master DB unreachable — return all vehicles as unmatched text
|
||
return [
|
||
{'make': v.get('make') or v.get('marca') or '',
|
||
'model': v.get('model') or v.get('modelo') or '',
|
||
'year': v.get('year') or v.get('ano') or v.get('año') or 0,
|
||
'engine': v.get('engine') or v.get('motor') or '',
|
||
'engine_code': v.get('engine_code') or v.get('codigo_motor') or '',
|
||
'mye_id': None}
|
||
for v in vehicles
|
||
]
|
||
|
||
validated = []
|
||
seen_mye = set()
|
||
seen_text = set() # (make, model, year) for text-only dedup
|
||
|
||
for v in vehicles:
|
||
make, model, years, engine, engine_code = _normalize_vehicle(v)
|
||
if not make or not model or not years:
|
||
continue
|
||
|
||
for year in years:
|
||
matched_myes = []
|
||
|
||
# Strategy 1: engine_code match (most precise)
|
||
if engine_code:
|
||
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.engine_code ILIKE %s
|
||
""", (make, f'%{model}%', year, f'%{engine_code}%'))
|
||
matched_myes = [r[0] for r in cur.fetchall()]
|
||
|
||
# Strategy 2: displacement-based match
|
||
if not matched_myes:
|
||
disp = _extract_displacement(engine)
|
||
if disp is not None:
|
||
disp_pattern = f'{disp:.1f}L'
|
||
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
|
||
""", (make, f'%{model}%', year, f'%{disp_pattern}%'))
|
||
matched_myes = [r[0] for r in cur.fetchall()]
|
||
|
||
# Strategy 3: exact engine string match (legacy)
|
||
if not matched_myes and engine and engine.lower() != 'desconocido':
|
||
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
|
||
""", (make, f'%{model}%', year, engine))
|
||
matched_myes = [r[0] for r in cur.fetchall()]
|
||
|
||
# Strategy 4: broad make/model/year fallback (all engines)
|
||
if not matched_myes:
|
||
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
|
||
""", (make, f'%{model}%', year))
|
||
matched_myes = [r[0] for r in cur.fetchall()]
|
||
|
||
# Deduplicate and add to results
|
||
if matched_myes:
|
||
for mye_id in matched_myes:
|
||
if mye_id not in seen_mye:
|
||
seen_mye.add(mye_id)
|
||
validated.append({
|
||
'make': make,
|
||
'model': model,
|
||
'year': year,
|
||
'engine': engine,
|
||
'engine_code': engine_code,
|
||
'mye_id': mye_id,
|
||
})
|
||
else:
|
||
# No match in master DB — store as text-only QWEN record
|
||
text_key = (make.upper(), model.upper(), year)
|
||
if text_key not in seen_text:
|
||
seen_text.add(text_key)
|
||
validated.append({
|
||
'make': make,
|
||
'model': model,
|
||
'year': year,
|
||
'engine': engine,
|
||
'engine_code': engine_code,
|
||
'mye_id': None,
|
||
})
|
||
|
||
cur.close()
|
||
master.close()
|
||
return validated
|