Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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
This commit is contained in:
2026-10-12 00:57:31 +00:00
parent f2d4558b80
commit a386ca5b4a
165 changed files with 31313 additions and 2203 deletions

View File

@@ -0,0 +1,304 @@
# /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