Files
Autoparts-DB/pos/services/semantic_search.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
2026-10-12 00:57:31 +00:00

305 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# /home/Autopartes/pos/services/semantic_search.py
"""Búsqueda semántica sobre el catálogo: rerank + embeddings vía API QWEN.
El proveedor QWEN (``QWEN_API_URL``) expone, además de chat, dos endpoints
compatibles con OpenAI/Cohere y ya verificados en producción:
POST {QWEN_API_URL}/rerank
{"model": "rerank", "query": "...", "documents": ["a", "b", ...]}
→ {"results": [{"index": 1, "relevance_score": 0.70, "document": {...}}, ...]}
(ordenado por relevancia descendente)
POST {QWEN_API_URL}/embeddings
{"model": "qwen3-embedding", "input": "texto"}
→ {"data": [{"embedding": [4096 floats]}, ...]} (OpenAI-compatible)
Regla de oro: **ninguna función lanza excepción**. Si falta configuración, hay
timeout o cualquier error de red/API, ``rerank()`` devuelve el orden original y
``embed()``/``find_similar()`` devuelven ``None``. Así el buscador del POS
nunca se rompe ni se cae por culpa del rerank; en el peor caso la búsqueda es
la de siempre.
Variables de entorno (ver ``pos/config.py``):
QWEN_API_URL URL base del proveedor (igual que ``ai_chat``)
QWEN_API_KEY API key (igual que ``ai_chat``)
QWEN_RERANK_MODEL default ``rerank``
QWEN_EMBED_MODEL default ``qwen3-embedding``
SEARCH_RERANK_ENABLED default ``true`` — scope ``catalog`` (público)
SEARCH_RERANK_POS_ENABLED default ``false`` — scope ``pos`` (caja/POS)
SEARCH_RERANK_TIMEOUT default ``2`` segundos (ambos scopes)
SEARCH_RERANK_MAX_CANDIDATES default ``50`` documentos por llamada
Nota de rendimiento (medido 2026-10-11 contra api.nan.builders, RTT ~40 ms):
el endpoint /rerank tarda ~0.6–1.8 s independientemente del número de
documentos (5 a 51). Por eso el POS va apagado por defecto: su búsqueda en
Meilisearch ya responde en ~18 ms y el rerank la encarecería 30–80×.
"""
import math
import os
import requests
try: # config del POS; con sys.path del dashboard puede resolver el config raíz
from config import QWEN_API_URL, QWEN_API_KEY
from config import QWEN_RERANK_MODEL, QWEN_EMBED_MODEL
from config import SEARCH_RERANK_ENABLED, SEARCH_RERANK_POS_ENABLED
from config import SEARCH_RERANK_TIMEOUT, SEARCH_RERANK_MAX_CANDIDATES
except ImportError:
QWEN_API_URL = os.environ.get('QWEN_API_URL', '')
QWEN_API_KEY = os.environ.get('QWEN_API_KEY', '')
QWEN_RERANK_MODEL = os.environ.get('QWEN_RERANK_MODEL', 'rerank')
QWEN_EMBED_MODEL = os.environ.get('QWEN_EMBED_MODEL', 'qwen3-embedding')
SEARCH_RERANK_ENABLED = os.environ.get('SEARCH_RERANK_ENABLED', 'true').lower() == 'true'
SEARCH_RERANK_POS_ENABLED = os.environ.get('SEARCH_RERANK_POS_ENABLED', 'false').lower() == 'true'
SEARCH_RERANK_TIMEOUT = float(os.environ.get('SEARCH_RERANK_TIMEOUT', '2'))
SEARCH_RERANK_MAX_CANDIDATES = int(os.environ.get('SEARCH_RERANK_MAX_CANDIDATES', '50'))
DEFAULT_RERANK_MODEL = 'rerank'
DEFAULT_EMBED_MODEL = 'qwen3-embedding'
DEFAULT_TIMEOUT = 2.0
DEFAULT_MAX_CANDIDATES = 50
# Scopes del flag: 'catalog' = catálogo público / API / dashboard;
# 'pos' = caja y chat del POS, donde la latencia es crítica.
SCOPE_CATALOG = 'catalog'
SCOPE_POS = 'pos'
def is_configured():
"""True si hay URL y API key (condición previa a cualquier llamada)."""
return bool((QWEN_API_URL or '').strip() and (QWEN_API_KEY or '').strip())
def is_rerank_enabled(scope=SCOPE_CATALOG):
"""Flag de rerank para un scope.
``SEARCH_RERANK_ENABLED`` es el interruptor maestro (default ``true``) y
aplica al scope ``catalog``. El POS requiere además
``SEARCH_RERANK_POS_ENABLED=true`` (default ``false``) porque el rerank
agrega cientos de ms a una búsqueda que hoy es de ~18 ms.
"""
if not SEARCH_RERANK_ENABLED:
return False
if (scope or SCOPE_CATALOG).lower() == SCOPE_POS:
return bool(SEARCH_RERANK_POS_ENABLED)
return True
def rerank_model():
return QWEN_RERANK_MODEL or DEFAULT_RERANK_MODEL
def embed_model():
return QWEN_EMBED_MODEL or DEFAULT_EMBED_MODEL
def max_candidates():
"""Tope de documentos por llamada (acota payload y latencia)."""
try:
value = int(SEARCH_RERANK_MAX_CANDIDATES)
except (TypeError, ValueError):
return DEFAULT_MAX_CANDIDATES
return value if value > 0 else DEFAULT_MAX_CANDIDATES
def _timeout(timeout=None):
if timeout is not None:
return timeout
try:
return float(SEARCH_RERANK_TIMEOUT)
except (TypeError, ValueError):
return DEFAULT_TIMEOUT
def _endpoint(path):
url = (QWEN_API_URL or '').strip()
if not url:
return None
return url.rstrip('/') + path
def _headers():
return {
'Authorization': f"Bearer {QWEN_API_KEY}",
'Content-Type': 'application/json',
}
def _identity_order(count):
return [{'index': i, 'relevance_score': None} for i in range(count)]
def rerank(query, documents, top_n=None, timeout=None):
"""Reordena ``documents`` por relevancia semántica respecto a ``query``.
Args:
query: texto de búsqueda del usuario.
documents: lista de textos candidatos.
top_n: opcional, trunca el resultado a los N más relevantes. Por
defecto devuelve **todos** los documentos: el caller pagina/muestra
y no debe perder items.
timeout: segundos (default ``SEARCH_RERANK_TIMEOUT``).
Returns:
list[dict]: ``[{'index': <int>, 'relevance_score': <float|None>}, ...]``
en orden de relevancia descendente. En degradación (sin URL/key,
timeout, HTTP error, respuesta vacía o índice inválido) devuelve el
orden original con ``relevance_score=None``. Nunca lanza.
"""
docs = list(documents or [])
identity = _identity_order(len(docs))
if not docs or not query:
return identity[:top_n] if top_n else identity
url = _endpoint('/rerank')
if not url or not (QWEN_API_KEY or '').strip():
return identity[:top_n] if top_n else identity
try:
resp = requests.post(
url,
headers=_headers(),
json={
'model': rerank_model(),
'query': query,
'documents': docs,
},
timeout=_timeout(timeout),
)
if resp.status_code >= 400:
print(f"[semantic_search] rerank HTTP {resp.status_code}: {resp.text[:200]}")
return identity[:top_n] if top_n else identity
results = (resp.json() or {}).get('results') or []
except Exception as e:
print(f"[semantic_search] rerank error: {e}")
return identity[:top_n] if top_n else identity
ordered = []
seen = set()
for item in results:
try:
idx = int(item.get('index'))
except (TypeError, ValueError, AttributeError):
continue
if idx < 0 or idx >= len(docs) or idx in seen:
continue
seen.add(idx)
score = item.get('relevance_score')
ordered.append({
'index': idx,
'relevance_score': float(score) if isinstance(score, (int, float)) else None,
})
if not ordered:
return identity[:top_n] if top_n else identity
# Documentos que la API no devolvió (p.ej. si aplicó su propio tope)
# conservan su orden original al final — nunca se pierden.
for i in range(len(docs)):
if i not in seen:
ordered.append({'index': i, 'relevance_score': None})
return ordered[:top_n] if top_n else ordered
def rerank_order(query, documents, top_n=None, timeout=None):
"""Igual que ``rerank()`` pero devuelve solo los índices reordenados."""
return [r['index'] for r in rerank(query, documents, top_n=top_n, timeout=timeout)]
def embed(texts, timeout=None):
"""Devuelve los embeddings de ``texts`` vía el endpoint /embeddings.
Args:
texts: str o iterable de str.
timeout: segundos (default ``SEARCH_RERANK_TIMEOUT``).
Returns:
list[list[float]] en el mismo orden que la entrada, o ``None`` si no
hay configuración, hay timeout, error HTTP o respuesta malformada.
Nunca lanza.
"""
if isinstance(texts, str):
items = [texts]
else:
items = list(texts or [])
if not items:
return []
url = _endpoint('/embeddings')
if not url or not (QWEN_API_KEY or '').strip():
return None
try:
resp = requests.post(
url,
headers=_headers(),
json={'model': embed_model(), 'input': items},
timeout=_timeout(timeout),
)
if resp.status_code >= 400:
print(f"[semantic_search] embeddings HTTP {resp.status_code}: {resp.text[:200]}")
return None
data = (resp.json() or {}).get('data') or []
vectors = [row.get('embedding') for row in data]
if len(vectors) != len(items) or any(not isinstance(v, list) or not v for v in vectors):
print(f"[semantic_search] embeddings respuesta inesperada ({len(vectors)} de {len(items)})")
return None
return vectors
except Exception as e:
print(f"[semantic_search] embeddings error: {e}")
return None
def cosine_similarity(vec_a, vec_b):
"""Similitud coseno entre dos vectores (None si no es calculable)."""
if not vec_a or not vec_b or len(vec_a) != len(vec_b):
return None
try:
dot = 0.0
norm_a = 0.0
norm_b = 0.0
for a, b in zip(vec_a, vec_b):
dot += a * b
norm_a += a * a
norm_b += b * b
if norm_a <= 0 or norm_b <= 0:
return None
return dot / (math.sqrt(norm_a) * math.sqrt(norm_b))
except (TypeError, ValueError):
return None
def similar_texts(text_a, text_b, timeout=None):
"""Similitud coseno entre dos textos (None si no hay embeddings)."""
vectors = embed([text_a, text_b], timeout=timeout)
if not vectors:
return None
return cosine_similarity(vectors[0], vectors[1])
def find_similar(query, candidates, top_n=None, timeout=None):
"""Ordena ``candidates`` por similitud coseno con ``query``.
Returns:
list[dict] ``[{'index', 'similarity'}, ...]`` ordenado desc, o ``None``
si no hay embeddings disponibles. Nunca lanza.
"""
items = list(candidates or [])
if not items or not query:
return None
vectors = embed([query] + items, timeout=timeout)
if not vectors:
return None
query_vec = vectors[0]
scored = []
for i, vec in enumerate(vectors[1:]):
sim = cosine_similarity(query_vec, vec)
if sim is not None:
scored.append({'index': i, 'similarity': sim})
if not scored:
return None
scored.sort(key=lambda r: r['similarity'], reverse=True)
return scored[:top_n] if top_n else scored