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

336
pos/services/yonke_ai.py Normal file
View File

@@ -0,0 +1,336 @@
# /home/Autopartes/pos/services/yonke_ai.py
"""Non-conversational AI helpers for the Nexus Yonke module.
Four capabilities, each one isolated and best-effort:
* ``identify_piece`` — vision: name + category of a photo
* ``suggest_price`` — rule engine over cost/grade/category + local comparables
* ``find_duplicate_groups`` — semantic grouping of used-piece names
* ``validate_photo`` — vision: is it an auto part? is the photo usable?
Golden rule (same as ``semantic_search``): **no function raises**. When the AI
provider is not configured or does not answer, each function returns a
well-formed dict with ``available=False`` so the caller can keep going.
"""
import base64
import json
import re
import statistics
# Multipliers applied to a reference price depending on the piece grade.
GRADE_FACTOR = {'A': 1.15, 'B': 1.0, 'C': 0.8, 'D': 0.6}
# Target markup over acquisition cost for a grade-B used piece.
TARGET_MARKUP = 1.6
# Weight of the local market reference vs. the cost-based reference.
MARKET_WEIGHT = 0.6
def _coerce_float(val, default=0.0):
try:
return float(val)
except (TypeError, ValueError):
return default
def _b64(image_bytes):
return base64.b64encode(image_bytes).decode('ascii')
def _extract_json(text):
"""Return the first JSON object found in ``text``, or None."""
if not text:
return None
stripped = str(text).strip()
if stripped.startswith('```'):
lines = stripped.split('\n')
stripped = '\n'.join(lines[1:-1] if len(lines) > 2 else lines)
try:
parsed = json.loads(stripped)
return parsed if isinstance(parsed, dict) else None
except Exception:
pass
match = re.search(r'\{.*\}', stripped, re.DOTALL)
if match:
try:
parsed = json.loads(match.group(0))
return parsed if isinstance(parsed, dict) else None
except Exception:
return None
return None
def _normalize_confidence(value):
conf = _coerce_float(value, 0.0)
if conf > 1:
conf = conf / 100.0
return round(max(0.0, min(1.0, conf)), 2)
def _vision_json(user_message, image_bytes=None, image_base64=None):
"""Ask the vision model for a JSON object. Returns (dict|None, error|None)."""
image = image_base64 or (_b64(image_bytes) if image_bytes else None)
if not image:
return None, 'no image'
try:
from services import ai_chat
except Exception as e: # pragma: no cover
return None, f'ai_chat unavailable: {e}'
try:
result = ai_chat.chat_with_image(user_message, image)
except Exception as e:
return None, str(e)
if not isinstance(result, dict):
return None, 'unexpected AI response'
if 'name' in result or 'category' in result or 'is_part' in result:
return result, None
if result.get('message'):
parsed = _extract_json(result['message'])
if parsed:
return parsed, None
return None, result.get('message') or 'AI did not return JSON'
def identify_piece(image_bytes=None, image_base64=None, hint_name=None):
"""Identify an auto part from a photo.
Returns ``{name, category, confidence, available, source}``; ``available``
is False when the provider is unreachable (the endpoint then answers 503).
"""
empty = {'name': None, 'category': None, 'confidence': 0.0,
'available': False, 'source': 'vision'}
if not image_bytes and not image_base64:
return dict(empty, error='no image')
prompt = (
'Eres un experto en autopartes (partes usadas de deshuesadero). '
'Identifica la pieza de la imagen. Responde SOLO con JSON válido, sin texto extra: '
'{"name": "nombre en español", "category": "categoría (Frenos, Motor, Eléctrico, '
'Suspensión, Carrocería, Enfriamiento, Luces, Transmisión, etc.)", '
'"confidence": 0.0}'
)
if hint_name:
prompt += f' Pista del usuario: "{hint_name}".'
parsed, error = _vision_json(prompt, image_bytes, image_base64)
if not parsed:
return dict(empty, error=error or 'AI unavailable')
name = (parsed.get('name') or '').strip() or None
category = (parsed.get('category') or '').strip() or None
if isinstance(name, str) and name.lower() in ('null', 'none', 'desconocido'):
name = None
return {
'name': name,
'category': category,
'confidence': _normalize_confidence(parsed.get('confidence')),
'available': bool(name or category),
'source': 'vision',
}
def validate_photo(image_bytes=None, image_base64=None):
"""Validate that a photo shows an auto part with usable quality.
Returns ``{ok, issues, quality, available}``. When the provider is down the
photo is NOT validated: ``available=False`` and ``ok=True`` (do not block).
"""
if not image_bytes and not image_base64:
return {'ok': False, 'issues': ['No se recibió imagen'], 'available': False}
prompt = (
'Evalúa esta foto destinada a un catálogo de autopartes usadas. Responde SOLO '
'con JSON válido: {"is_part": true, "quality": "good", '
'"issues": [], "confidence": 0.0}. '
'"quality" debe ser "good", "fair" o "poor". En "issues" enumera problemas '
'(borrosa, oscura, no se ve la pieza, fondo con ruido, etc.).'
)
parsed, error = _vision_json(prompt, image_bytes, image_base64)
if not parsed:
return {'ok': True, 'issues': [], 'available': False,
'error': error or 'AI unavailable'}
is_part = parsed.get('is_part')
if isinstance(is_part, str):
is_part = is_part.strip().lower() in ('true', '1', 'yes', 'sí', 'si')
quality = (parsed.get('quality') or '').strip().lower() or None
issues = parsed.get('issues') or []
if isinstance(issues, str):
issues = [issues]
issues = [str(i).strip() for i in issues if str(i).strip()]
if is_part is False:
issues.append('No parece una autoparte')
if quality == 'poor':
issues.append('Calidad de imagen deficiente')
return {
'ok': bool(is_part) and quality != 'poor',
'issues': issues,
'quality': quality,
'confidence': _normalize_confidence(parsed.get('confidence')),
'available': True,
}
def _local_comparables(conn, piece, limit=100):
"""Median price_1 of active local items sharing category or name tokens."""
name = (piece.get('name') or '').strip()
tokens = [t for t in re.split(r'\W+', name.lower()) if len(t) >= 4][:3]
patterns = [f'%{t}%' for t in tokens]
category_id = piece.get('category_id')
piece_id = piece.get('id') or 0
cur = conn.cursor()
try:
if category_id is None:
cur.execute("SELECT category_id FROM inventory WHERE id = %s", (piece_id,))
row = cur.fetchone()
category_id = row[0] if row else None
cur.execute("""
SELECT i.price_1
FROM inventory i
WHERE i.is_active = true AND i.price_1 > 0
AND i.id <> %s
AND (i.category_id = %s OR i.name ILIKE ANY(%s))
LIMIT %s
""", (piece_id, category_id, patterns, limit))
prices = [float(r[0]) for r in cur.fetchall() if r[0] is not None]
except Exception:
try:
conn.rollback()
except Exception:
pass
prices = []
finally:
cur.close()
return prices
def suggest_price(conn, piece):
"""Suggest a sale price for a used piece.
Rule engine: local comparables (median price of active items in the same
category / with a shared name token) scaled by grade, blended with a
cost-based reference (acquisition cost × grade factor × target markup).
Returns ``{suggested_price, low, high, basis, available}``.
"""
piece = piece or {}
grade = (piece.get('condition_grade') or 'B').upper()
grade = grade if grade in GRADE_FACTOR else 'B'
factor = GRADE_FACTOR[grade]
cost = _coerce_float(piece.get('acquisition_cost') or piece.get('cost') or 0)
current_price = _coerce_float(piece.get('price_1') or 0)
comparables = _local_comparables(conn, piece)
market_ref = statistics.median(comparables) if comparables else None
market_price = market_ref * factor if market_ref else None
cost_price = cost * factor * TARGET_MARKUP if cost > 0 else None
parts = []
if market_price is not None:
parts.append((MARKET_WEIGHT, market_price))
parts.append((1 - MARKET_WEIGHT, cost_price if cost_price else market_price))
elif cost_price is not None:
parts = [(1.0, cost_price)]
if parts:
total_w = sum(w for w, _ in parts)
suggested = sum(w * v for w, v in parts) / total_w
available = True
else:
suggested = current_price
available = bool(current_price)
suggested = round(suggested, 2)
low = round(suggested * 0.85, 2)
high = round(suggested * 1.2, 2)
basis_bits = [f'grado {grade}', f'costo {cost:.2f}']
if comparables:
basis_bits.append(
f'{len(comparables)} comparables locales (mediana {market_ref:.2f})'
)
else:
basis_bits.append('sin comparables locales')
if not available:
basis_bits.append('sin costo ni comparables: se usó el precio actual')
return {
'suggested_price': suggested,
'low': low,
'high': high,
'basis': ', '.join(basis_bits),
'available': available,
'grade': grade,
'comparables': len(comparables),
}
def find_duplicate_groups(conn, threshold=0.85, limit=300):
"""Group used pieces whose names are semantically almost identical.
Uses ``semantic_search.embed`` (one call for every name) and cosine
similarity, then union-find. Returns ``{'groups': [[{id,part_number,name,
donor_vin}]]}``. Degrades to ``groups=[]`` + ``available=False`` when
embeddings are not available.
"""
try:
threshold = float(threshold)
except (TypeError, ValueError):
threshold = 0.85
threshold = max(0.0, min(1.0, threshold))
cur = conn.cursor()
cur.execute("""
SELECT id, part_number, name, donor_vin
FROM inventory
WHERE is_active = true AND condition IN ('usado', 'reconstruido')
AND name IS NOT NULL AND name <> ''
ORDER BY id
LIMIT %s
""", (limit,))
rows = cur.fetchall()
cur.close()
items = [{'id': r[0], 'part_number': r[1], 'name': r[2], 'donor_vin': r[3]}
for r in rows]
if len(items) < 2:
return {'groups': [], 'available': True, 'threshold': threshold}
try:
from services import semantic_search
vectors = semantic_search.embed([it['name'] for it in items])
except Exception:
vectors = None
if not vectors or len(vectors) != len(items):
return {'groups': [], 'available': False, 'threshold': threshold,
'error': 'embeddings no disponibles'}
parent = list(range(len(items)))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[rb] = ra
for i in range(len(items)):
for j in range(i + 1, len(items)):
sim = semantic_search.cosine_similarity(vectors[i], vectors[j])
if sim is not None and sim >= threshold:
union(i, j)
grouped = {}
for idx in range(len(items)):
grouped.setdefault(find(idx), []).append(items[idx])
groups = [g for g in grouped.values() if len(g) >= 2]
groups.sort(key=len, reverse=True)
return {'groups': groups, 'available': True, 'threshold': threshold}