Files
Autoparts-DB/pos/services/qwen_fitment.py

342 lines
14 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 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