"""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