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
This commit is contained in:
2026-05-06 20:27:14 +00:00
parent 371d72887e
commit ff45905b49
33 changed files with 3040 additions and 445 deletions

View File

@@ -17,7 +17,8 @@ import re
import redis
from services.na_models import is_na_model
from services.translations import translate_part_name, translate_category
from services.translations import translate_part_name, translate_category, PART_TRANSLATIONS
from services.nexpart_taxonomy import translate_taxonomy_node
# Lazy Redis client for catalog caches
_redis_client = None
@@ -632,6 +633,120 @@ def get_shop_supplies_parts(master_conn, group_slug, subgroup_slug, part_type_sl
)
def _normalize_es(text):
"""Lowercase and strip accents for Spanish text matching."""
if not text:
return ''
text = text.lower()
for a, b in [('á', 'a'), ('é', 'e'), ('í', 'i'), ('ó', 'o'), ('ú', 'u'),
('ü', 'u'), ('ñ', 'n')]:
text = text.replace(a, b)
return text
def _local_name_matches_part_type(name, part_type_slug):
"""Check if a local inventory item name matches a Nexpart part_type.
Uses translation layers:
1. Direct substring (original slug in name) — legacy
2. Full Spanish translation via translate_taxonomy_node
3. Sub-phrase translations via PART_TRANSLATIONS
4. Word-level matching (handles plurals and partial matches)
5. Extra synonym mappings for Mexican aftermarket terminology
"""
if not name or not part_type_slug:
return True
name_norm = _normalize_es(name)
slug_lower = part_type_slug.lower()
# 1. Legacy direct match
if slug_lower in name_norm:
return True
candidates = []
# 2. Full translation of the part_type slug
translated = translate_taxonomy_node(part_type_slug)
if translated and translated != part_type_slug:
candidates.append(_normalize_es(translated))
# 3. Sub-phrase translation: find the longest PART_TRANSLATIONS key
# that is contained in the part_type_slug.
best_key = None
best_len = 0
for en_key, es_val in PART_TRANSLATIONS.items():
if en_key.lower() in slug_lower and len(en_key) > best_len:
best_key = en_key
best_len = len(en_key)
if best_key:
candidates.append(_normalize_es(PART_TRANSLATIONS[best_key]))
# 4. Word-level matching: any significant word (4+ chars) from the
# candidate translations must appear in the local name.
# Also strip trailing 's' to handle plurals (balatas -> balata).
for cand in candidates:
if cand in name_norm:
return True
words = [w for w in cand.split() if len(w) >= 4]
for w in words:
if w in name_norm:
return True
# plural fallback
if w.endswith('s') and w[:-1] in name_norm:
return True
if w.endswith('es') and w[:-2] in name_norm:
return True
# 5. Extra synonyms for common Mexican aftermarket terms
# Map English sub-phrases to additional Spanish keywords.
EXTRA_SYNONYMS = {
'brake pad': ['balata', 'pastilla'],
'brake shoe': ['zapata', 'balata'],
'brake disc': ['disco', 'rotor'],
'brake rotor': ['disco', 'rotor'],
'shock absorber': ['amortiguador', 'amortiguadores'],
'strut': ['amortiguador', 'torre', 'estrut'],
'spark plug': ['bujia', 'bujía', 'bujias'],
'air filter': ['filtro de aire', 'filtro aire'],
'oil filter': ['filtro de aceite', 'filtro aceite'],
'fuel filter': ['filtro de gasolina', 'filtro gasolina'],
'cabin filter': ['filtro de cabina', 'filtro cabina', 'filtro de polen'],
'timing belt': ['banda de tiempo', 'banda distribucion', 'correa de distribucion'],
'drive belt': ['banda de accesorios', 'banda alternador'],
'water pump': ['bomba de agua'],
'alternator': ['alternador'],
'starter': ['marcha', 'motor de arranque'],
'radiator': ['radiador'],
'thermostat': ['termostato'],
'wheel bearing': ['balero', 'rodamiento'],
'hub assembly': ['maza', 'cubo'],
'control arm': ['horquilla', 'brazo'],
'tie rod': ['terminal', 'rotula'],
'ball joint': ['rotula', 'rotula'],
'clutch kit': ['kit de clutch', 'kit de embrague'],
'clutch disc': ['disco de clutch', 'disco de embrague'],
'axle': ['flecha', 'punta de eje', 'homocinetica'],
'cv joint': ['homocinetica', 'punta de eje'],
'oxygen sensor': ['sensor de oxigeno', 'sensor o2'],
'ignition coil': ['bobina', 'bobina de encendido'],
'wiper': ['pluma', 'limpiaparabrisas', 'escobilla'],
'headlight': ['faro', 'faro delantero'],
'taillight': ['calavera', 'faro trasero'],
'turn signal': ['direccional', 'cuarto'],
'fog light': ['faro de niebla'],
'battery': ['bateria', 'acumulador'],
'horn': ['claxon', 'bocina'],
}
for en_phrase, es_keywords in EXTRA_SYNONYMS.items():
if en_phrase in slug_lower:
for kw in es_keywords:
if _normalize_es(kw) in name_norm:
return True
return False
def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
part_type_slug, tenant_conn, branch_id,
page=1, per_page=30):
@@ -659,13 +774,14 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
)
# Inject local inventory items linked to this vehicle
# (get_parts_local with oem_part_ids skips mye_id, so we call it separately)
local_injected = 0
if tenant_conn and mye_id:
from services.inventory_vehicle_compat import get_inventory_by_vehicle
local_rows = get_inventory_by_vehicle(tenant_conn, master_conn, mye_id, branch_id)
for lr in local_rows:
inv_id, pn, name, brand, p1, p2, p3, img, desc, stock = lr
# Only include if name roughly matches the Nexpart part_type
if part_type_slug and part_type_slug.lower() not in (name or '').lower():
if part_type_slug and not _local_name_matches_part_type(name, part_type_slug):
continue
result['data'].append({
'id_part': f'inv:{inv_id}',
@@ -686,6 +802,13 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
'price_usd': None,
'source': 'local_inventory',
})
local_injected += 1
# Update pagination total to include injected local items
if local_injected:
result['pagination']['total'] = result['pagination'].get('total', 0) + local_injected
result['pagination']['total_pages'] = (
(result['pagination']['total'] + per_page - 1) // per_page
)
return result
@@ -1299,13 +1422,14 @@ def _search_meili_fallback(master_conn, q, limit):
return None
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50):
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
"""Search parts by part number or text. Enriches with local stock.
Strategy:
1. Try Meilisearch first (sub-100ms full-text + typo tolerance)
2. Fallback to PostgreSQL tsvector / ILIKE if Meilisearch is down
3. Always enriches results with local stock from tenant DB
3. Search local inventory items by part_number or name
4. Always enriches results with local stock from tenant DB
"""
q = q.strip()
if not q or len(q) < 2:
@@ -1349,10 +1473,6 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50):
""", (tsquery, f'%{q}%', f'%{q}%', tsquery, limit))
rows = cur.fetchall()
if not rows:
cur.close()
return []
part_ids = [r[0] for r in rows]
oem_numbers = [r[1] for r in rows]
@@ -1390,6 +1510,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50):
local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, part_ids)
results = []
seen_local_ids = set()
for r in rows:
part_id = r[0]
oem = r[1]
@@ -1403,10 +1524,133 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50):
'local_price': local['price_1'] if local else None,
'vehicle_info': vehicle_info_map.get(part_id, ''),
})
# Track which local inventory items are already shown via OEM link
if local:
seen_local_ids.add(local.get('inventory_id'))
# ── Inject local inventory items that match the query directly ──────────
if tenant_conn:
local_items = _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit)
for li in local_items:
if li['inventory_id'] in seen_local_ids:
continue
results.append({
'id_part': f"inv:{li['inventory_id']}",
'oem_part_number': li['part_number'],
'name': li['name'],
'image_url': li['image_url'],
'local_stock': li['stock'],
'local_price': li['price_1'],
'vehicle_info': '',
'source': 'local_inventory',
})
if len(results) >= limit:
break
return results
def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
"""Search tenant inventory items by part_number or name.
If mye_id is provided, only returns items compatible with that vehicle.
"""
if tenant_conn is None:
return []
cur = tenant_conn.cursor()
clean_q = q.replace(' ', '').upper()
# Helper to strip accents in SQL for case-insensitive matching
_SQL_UNACCENT = """
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
UPPER(i.name)
, 'Á', 'A'), 'É', 'E'), 'Í', 'I'), 'Ó', 'O'), 'Ú', 'U')
, 'À', 'A'), 'È', 'E'), 'Ì', 'I'), 'Ò', 'O'), 'Ù', 'U')
"""
_q_unaccent = q.upper()
for a, b in [('Á', 'A'), ('É', 'E'), ('Í', 'I'), ('Ó', 'O'), ('Ú', 'U'),
('À', 'A'), ('È', 'E'), ('Ì', 'I'), ('Ò', 'O'), ('Ù', 'U'),
('Ä', 'A'), ('Ë', 'E'), ('Ï', 'I'), ('Ö', 'O'), ('Ü', 'U'),
('Ñ', 'N')]:
_q_unaccent = _q_unaccent.replace(a, b)
if mye_id:
# Search only items linked to the given vehicle
if branch_id:
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, COALESCE(s.stock, 0) as stock
FROM inventory i
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
LEFT JOIN inventory_stock_summary s
ON s.inventory_id = i.id AND s.branch_id = %s
WHERE ivc.model_year_engine_id = %s
AND i.is_active = true
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
OR {_SQL_UNACCENT} LIKE %s)
ORDER BY i.name
LIMIT %s
""", (branch_id, mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit))
else:
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, COALESCE(SUM(s.stock), 0) as stock
FROM inventory i
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE ivc.model_year_engine_id = %s
AND i.is_active = true
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
OR {_SQL_UNACCENT} LIKE %s)
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
ORDER BY i.name
LIMIT %s
""", (mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit))
else:
# Search all active inventory items
if branch_id:
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, COALESCE(s.stock, 0) as stock
FROM inventory i
LEFT JOIN inventory_stock_summary s
ON s.inventory_id = i.id AND s.branch_id = %s
WHERE i.is_active = true
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
OR {_SQL_UNACCENT} LIKE %s)
ORDER BY i.name
LIMIT %s
""", (branch_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit))
else:
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, COALESCE(SUM(s.stock), 0) as stock
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
OR {_SQL_UNACCENT} LIKE %s)
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
ORDER BY i.name
LIMIT %s
""", (f'%{clean_q}%', f'%{_q_unaccent}%', limit))
rows = cur.fetchall()
cur.close()
return [
{
'inventory_id': r[0],
'part_number': r[1],
'name': r[2],
'image_url': r[3],
'price_1': float(r[4]) if r[4] is not None else None,
'stock': int(r[5]) if r[5] is not None else 0,
}
for r in rows
]
# ─────────────────────────────────────────────────────────────────────────────
# LOCAL STOCK HELPERS
# ─────────────────────────────────────────────────────────────────────────────