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:
@@ -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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user