diff --git a/pos/services/qwen_fitment.py b/pos/services/qwen_fitment.py new file mode 100644 index 0000000..aafb77e --- /dev/null +++ b/pos/services/qwen_fitment.py @@ -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