- 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
337 lines
12 KiB
Python
337 lines
12 KiB
Python
# /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}
|