feat(inventory): Qwen vehicle compatibility — store AI unmatched vehicles as text, Celery background sync, fix brand filter fallback, increase vehicle limits

This commit is contained in:
2026-05-18 19:32:35 +00:00
parent 0b1dc89faf
commit 50c0dbe7d4
6 changed files with 330 additions and 109 deletions

View File

@@ -178,18 +178,31 @@ def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, par
JOIN brands b ON b.id_brand = m.brand_id
WHERE vp.part_id = ANY(%s)
AND b.name_brand = %s
LIMIT 200
LIMIT 500
""", (oem_ids, brand_hint))
mye_ids = [r[0] for r in cur.fetchall()]
# Fallback: if brand filter yields nothing, the brand hint may be an
# aftermarket supplier (e.g. Motorcraft, NGK, Bosch) rather than an
# OEM vehicle brand. Search without brand filter.
if not mye_ids:
cur.execute("""
SELECT DISTINCT model_year_engine_id
FROM vehicle_parts
WHERE part_id = ANY(%s)
LIMIT 500
""", (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()]
else:
# No brand hint — return all MYEs for these parts
cur.execute("""
SELECT DISTINCT model_year_engine_id
FROM vehicle_parts
WHERE part_id = ANY(%s)
LIMIT 200
LIMIT 500
""", (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()]
mye_ids = [r[0] for r in cur.fetchall()]
cur.close()
# ── Insert into tenant table ─────────────────────────────────────────
@@ -243,6 +256,20 @@ def remove_compatibility(tenant_conn, inventory_id, model_year_engine_id):
return deleted
def remove_compatibility_by_id(tenant_conn, compat_id):
"""Remove a compatibility by its primary key (works for both MYE-linked
and text-only QWEN records)."""
cur = tenant_conn.cursor()
cur.execute("""
DELETE FROM inventory_vehicle_compat
WHERE id = %s
""", (compat_id,))
deleted = cur.rowcount
tenant_conn.commit()
cur.close()
return deleted
def remove_all_compatibility(tenant_conn, inventory_id):
cur = tenant_conn.cursor()
cur.execute("""
@@ -259,14 +286,18 @@ def get_compatibility(tenant_conn, master_conn, inventory_id):
Queries inventory_vehicle_compat from the tenant DB, then resolves
vehicle details (brand/model/year/engine) from the master DB.
Vehicles with model_year_engine_id IS NULL are text-only QWEN records
(master DB lacks the vehicle) and are returned using their stored text.
"""
# 1. Get MYE IDs + metadata from tenant
# 1. Get all rows from tenant
cur_t = tenant_conn.cursor()
cur_t.execute("""
SELECT model_year_engine_id, source, confidence, created_at
SELECT id, model_year_engine_id, make, model, year, engine, engine_code,
source, confidence, created_at
FROM inventory_vehicle_compat
WHERE inventory_id = %s
ORDER BY model_year_engine_id
ORDER BY COALESCE(make, ''), COALESCE(model, ''), COALESCE(year, 0)
""", (inventory_id,))
rows = cur_t.fetchall()
cur_t.close()
@@ -274,34 +305,52 @@ def get_compatibility(tenant_conn, master_conn, inventory_id):
if not rows:
return []
mye_ids = [r[0] for r in rows]
# 2. Resolve vehicle details from master DB
cur_m = master_conn.cursor()
cur_m.execute("""
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
FROM model_year_engine mye
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
JOIN years y ON y.id_year = mye.year_id
JOIN engines e ON e.id_engine = mye.engine_id
WHERE mye.id_mye = ANY(%s)
ORDER BY b.name_brand, m.name_model, y.year_car
""", (mye_ids,))
details = {r[0]: r for r in cur_m.fetchall()}
cur_m.close()
# 2. Resolve MYE-linked vehicles from master DB
mye_ids = [r[0] for r in rows if r[0] is not None]
details = {}
if mye_ids:
cur_m = master_conn.cursor()
cur_m.execute("""
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
FROM model_year_engine mye
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
JOIN years y ON y.id_year = mye.year_id
JOIN engines e ON e.id_engine = mye.engine_id
WHERE mye.id_mye = ANY(%s)
ORDER BY b.name_brand, m.name_model, y.year_car
""", (mye_ids,))
details = {r[0]: r for r in cur_m.fetchall()}
cur_m.close()
# 3. Merge
result = []
for mye_id, source, confidence, created_at in rows:
d = details.get(mye_id)
if d:
for (compat_id, mye_id, make, model, year, engine, engine_code,
source, confidence, created_at) in rows:
if mye_id is not None and mye_id in details:
d = details[mye_id]
result.append({
'id': compat_id,
'model_year_engine_id': mye_id,
'brand': d[1],
'model': d[2],
'year': d[3],
'engine': d[4],
'engine_code': '',
'source': source,
'confidence': float(confidence),
'created_at': str(created_at),
})
else:
# Text-only QWEN record
result.append({
'id': compat_id,
'model_year_engine_id': None,
'brand': make or '',
'model': model or '',
'year': year,
'engine': engine or '',
'engine_code': engine_code or '',
'source': source,
'confidence': float(confidence),
'created_at': str(created_at),
@@ -374,6 +423,9 @@ def batch_add_compatibilities(tenant_conn, inventory_id, mye_ids, source='manual
def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
"""Save QWEN fitment results into inventory_vehicle_compat.
Supports both TecDoc-linked vehicles (mye_id present) and text-only
QWEN vehicles (mye_id=None) when the master DB lacks the vehicle.
Args:
tenant_conn: Connection to tenant DB.
inventory_id: The inventory item ID.
@@ -390,14 +442,30 @@ def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
cur = tenant_conn.cursor()
for v in vehicles:
mye_id = v.get('mye_id')
if not mye_id:
continue
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence, created_at)
VALUES (%s, %s, 'qwen_ai', %s, NOW())
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING
""", (inventory_id, mye_id, fitment_result.get('confidence', 0)))
if mye_id is not None and mye_id:
# TecDoc-linked vehicle
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence, created_at)
VALUES (%s, %s, 'qwen_ai', %s, NOW())
ON CONFLICT (inventory_id, model_year_engine_id, make, model, year) DO NOTHING
""", (inventory_id, mye_id, fitment_result.get('confidence', 0)))
else:
# Text-only QWEN vehicle (master DB doesn't have this vehicle)
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, make, model, year, engine, engine_code, source, confidence, created_at)
VALUES (%s, NULL, %s, %s, %s, %s, %s, 'qwen_ai', %s, NOW())
ON CONFLICT (inventory_id, model_year_engine_id, make, model, year) DO NOTHING
""", (
inventory_id,
v.get('make', '') or '',
v.get('model', '') or '',
v.get('year', 0) or 0,
v.get('engine', '') or '',
v.get('engine_code', '') or '',
fitment_result.get('confidence', 0),
))
if cur.rowcount > 0:
inserted += 1
tenant_conn.commit()

View File

@@ -42,16 +42,22 @@ def get_vehicle_fitment(part_number, name, brand):
{'role': 'user', 'content': prompt}
],
'temperature': 0.2,
'max_tokens': 4096,
'max_tokens': 8192,
},
timeout=45,
timeout=120,
)
response.raise_for_status()
raw = response.json()
finish_reason = None
if raw.get('choices') and len(raw['choices']) > 0:
msg = raw['choices'][0].get('message', {})
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:
@@ -62,6 +68,8 @@ def get_vehicle_fitment(part_number, name, brand):
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)
@@ -91,32 +99,21 @@ def _build_prompt(part_number, name, brand):
- 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."
}}
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 (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".
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".
"""
@@ -153,28 +150,42 @@ def _extract_vehicles(parsed):
def _normalize_vehicle(v):
"""Normalize vehicle dict from QWEN to standard keys."""
"""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 ''
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)
# 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))
else:
# Try single year
m2 = re.match(r'(\d{4})', year_raw)
if m2:
years = [int(m2.group(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
@@ -200,16 +211,30 @@ def _validate_vehicles(vehicles):
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:
return []
# 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)
@@ -285,16 +310,30 @@ def _validate_vehicles(vehicles):
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)
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': mye_id,
'mye_id': None,
})
cur.close()