Files
Autoparts-DB/pos/services/qwen_fitment.py
consultoria-as ff45905b49 feat(whatsapp): QWEN primary AI backend, Hermes fallback, conversation history, vehicle persistence, demo prompts
- Add QWEN (qwen3.6) as primary AI backend with short system prompt
- Hermes remains as fallback with 45s timeout
- Increase QWEN timeout to 35s, max_tokens to 4000
- Add conversation history loading from whatsapp_messages (last 4 msgs)
- Persist detected vehicle in whatsapp_sessions table
- Add 'limpiar chat' / 'nuevo chat' / 'reset' commands to clear history
- Fix CSS conflict: rename whatsapp chat-panel classes to wa-chat-panel
- Fix JS ID conflicts with chat.js widget (waChatPanel, waChatMessages, etc.)
- Improve no-stock response: conversational with alternatives
- Split search_query by | for multi-part lookups
- Add DEMO_PROMPTS.md and DEMO_PROMPTS_V2.md
2026-05-06 20:27:14 +00:00

303 lines
12 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': 4096,
},
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 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 exacta:
{{
"vehicles": [
{{
"make": "Toyota",
"model": "Corolla",
"year": 2015,
"engine": "1.8L 16V",
"engine_code": "2ZR-FE",
"notes": "Sedan y hatchback"
}}
],
"confidence": 0.92,
"notes": "Compatible con plataforma E170. Verificar traccion delantera."
}}
Reglas obligatorias:
1. "make" = marca del vehiculo (ej: Toyota, Nissan, Ford, Volkswagen, Chevrolet, Honda, Hyundai, Kia, Mazda, Subaru).
2. "model" = modelo exacto. Si hay variantes (ej: Civic Sedan vs Civic Coupe), incluye la variante.
3. "year" = ano numerico (int). Si hay rango de anos (ej: 2003-2008), genera una entrada POR CADA ANO del rango. NO uses rangos.
4. "engine" = descripcion del motor (ej: "1.8L", "2.0L TDI", "V6 3.5L", "1.6L Turbo"). Si no conoces el motor, usa "desconocido".
5. "engine_code" = codigo exacto del motor SI LO CONOCES (ej: "2ZR-FE", "K24Z7", "EA888"). Si no lo conoces, usa "" (string vacio).
6. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 100. Para piezas genericas (bujias, filtros, balatas, amortiguadores) incluye TODOS los modelos aplicables.
7. "confidence" entre 0.0 y 1.0. Usa valores altos (>0.85) solo si estas muy seguro.
8. Incluye marcas y modelos populares en Mexico (Nissan Tsuru, VW Sedan/Vocho, Chevy Monza, Ford Ka, etc.) cuando apliquen.
9. Si la pieza es universal o de alta compatibilidad, 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."""
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 ''
engine_code = v.get('engine_code') or v.get('codigo_motor') or v.get('motor_code') 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, 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)
"""
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, 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
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,
})
cur.close()
master.close()
return validated