- 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
268 lines
9.9 KiB
Python
268 lines
9.9 KiB
Python
# /home/Autopartes/pos/services/part_vision.py
|
|
"""A4 — Vision grading for used/yonke parts.
|
|
|
|
Given a photo of a used part (plus optional metadata: name, category,
|
|
declared condition) a VLM suggests a condition grade:
|
|
|
|
A = excelente (sin daños, funcional con seguridad)
|
|
B = bueno (desgaste normal de uso, funcional)
|
|
C = regular (desgaste/defecto menor, requiere revisión o reparación)
|
|
D = con daño (daño estructural visible / funcionalidad dudosa)
|
|
|
|
Backends (reused from ``services.ai_chat``): QWEN -> Hermes -> OpenRouter.
|
|
When no backend answers (e.g. the current QWEN key returns 401) the function
|
|
degrades gracefully: it never raises and returns a dict with
|
|
``condition_grade_suggested=None`` and a human-readable ``error``.
|
|
|
|
Public API:
|
|
suggest_grade(image_bytes=None, image_base64=None, name=None,
|
|
category=None, condition='usado', notes=None) -> dict
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import re
|
|
|
|
GRADES = ('A', 'B', 'C', 'D')
|
|
|
|
GRADE_RUBRIC = """Escala de grados para piezas usadas de yonke:
|
|
- A (excelente): sin daños visibles, no requiere reparación; funcionamiento prácticamente seguro.
|
|
- B (bueno): desgaste normal por uso, sin daños estructurales; funciona; puede tener detalles cosméticos (rayones leves, óxido superficial).
|
|
- C (regular): desgaste notable o defecto menor que puede requerir reparación o cambio de componentes secundarios; funcionalidad probable pero a verificar.
|
|
- D (con daño): daño visible estructural, pieza rota/incompleta/muy corroída, o funcionalidad dudosa; se vende como refacción o no funcional."""
|
|
|
|
GRADE_INSTRUCTIONS = """Evalúa la pieza de la foto y asigna un grado.
|
|
|
|
Reglas:
|
|
1. Basa el grado SOLO en lo observable en la foto. Si la foto no muestra la pieza, está borrosa o no es de una autoparte, baja la confianza y explícalo en "notes".
|
|
2. Sé conservador: ante duda razonable entre dos grados, asigna el peor (menor).
|
|
3. "defects" enumera defectos concretos y visibles (en español). Vacío si no hay.
|
|
4. "confidence" es tu certeza global del grado (0.0 a 1.0).
|
|
|
|
Responde EXCLUSIVAMENTE con JSON válido, sin texto adicional:
|
|
{
|
|
"condition_grade_suggested": "A" | "B" | "C" | "D",
|
|
"condition_suggested": "usado",
|
|
"defects": ["..."],
|
|
"confidence": 0.0,
|
|
"notes": "una o dos frases en español"
|
|
}"""
|
|
|
|
|
|
def _build_prompt(name=None, category=None, condition=None):
|
|
"""Build the user prompt embedding the piece metadata."""
|
|
lines = [GRADE_INSTRUCTIONS, "", GRADE_RUBRIC, ""]
|
|
meta = []
|
|
if name:
|
|
meta.append(f"- Nombre declarado: {name}")
|
|
if category:
|
|
meta.append(f"- Categoría: {category}")
|
|
if condition:
|
|
meta.append(f"- Condición declarada: {condition}")
|
|
if meta:
|
|
lines.append("Metadatos de la pieza:")
|
|
lines.extend(meta)
|
|
else:
|
|
lines.append("No hay metadatos de la pieza; evalúa solo lo visible.")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _empty_result():
|
|
return {
|
|
'condition_grade_suggested': None,
|
|
'condition_suggested': 'usado',
|
|
'defects': [],
|
|
'confidence': 0.0,
|
|
'notes': '',
|
|
'source': None,
|
|
'error': None,
|
|
}
|
|
|
|
|
|
def _extract_json_object(text):
|
|
"""Best-effort JSON extraction from an LLM reply.
|
|
|
|
Handles markdown fences, leading/trailing prose and trailing commas.
|
|
Returns a dict or None.
|
|
"""
|
|
if not text:
|
|
return None
|
|
s = str(text).strip()
|
|
if s.startswith('```'):
|
|
s = re.sub(r'^```[A-Za-z0-9_-]*\s*', '', s)
|
|
s = re.sub(r'```\s*$', '', s).strip()
|
|
try:
|
|
parsed = json.loads(s)
|
|
return parsed if isinstance(parsed, dict) else None
|
|
except (json.JSONDecodeError, ValueError):
|
|
pass
|
|
m = re.search(r'\{.*\}', s, re.DOTALL)
|
|
if not m:
|
|
return None
|
|
frag = m.group(0)
|
|
for candidate in (frag, re.sub(r',\s*([}\]])', r'\1', frag)):
|
|
try:
|
|
parsed = json.loads(candidate)
|
|
if isinstance(parsed, dict):
|
|
return parsed
|
|
except (json.JSONDecodeError, ValueError):
|
|
continue
|
|
return None
|
|
|
|
|
|
def _normalize_grade(data):
|
|
"""Coerce a raw model dict into the public grade schema."""
|
|
out = _empty_result()
|
|
if not isinstance(data, dict):
|
|
return out
|
|
|
|
raw_grade = (data.get('condition_grade_suggested')
|
|
or data.get('condition_grade')
|
|
or data.get('grade'))
|
|
grade = str(raw_grade).strip().upper()[:1] if raw_grade else None
|
|
out['condition_grade_suggested'] = grade if grade in GRADES else None
|
|
|
|
cond = str(data.get('condition_suggested') or data.get('condition') or 'usado').strip().lower()
|
|
out['condition_suggested'] = 'reconstruido' if 'reconstr' in cond else 'usado'
|
|
|
|
defects = data.get('defects')
|
|
if isinstance(defects, str):
|
|
defects = re.split(r'[;\n]|,', defects)
|
|
if isinstance(defects, list):
|
|
out['defects'] = [str(d).strip() for d in defects if str(d).strip()][:20]
|
|
|
|
try:
|
|
conf = float(data.get('confidence'))
|
|
if conf > 1:
|
|
conf = conf / 100.0
|
|
out['confidence'] = max(0.0, min(1.0, conf))
|
|
except (TypeError, ValueError):
|
|
out['confidence'] = 0.0
|
|
|
|
notes = data.get('notes') or data.get('message') or data.get('reason') or ''
|
|
out['notes'] = str(notes).strip()[:1000]
|
|
return out
|
|
|
|
|
|
def _is_usable(parsed):
|
|
"""True when a normalized dict carries a real grading signal.
|
|
|
|
Deliberately ignores ``notes``: the shared vision helper falls back to a
|
|
plain ``{"message": ...}`` dict on backend errors, and treating that as a
|
|
valid grade would hide outages behind a misleading answer.
|
|
"""
|
|
return bool(parsed.get('condition_grade_suggested') or parsed.get('defects'))
|
|
|
|
|
|
def _backends():
|
|
"""Ordered vision backends (url, key, model, tag) from ai_chat config."""
|
|
from services import ai_chat
|
|
|
|
out = []
|
|
if getattr(ai_chat, 'QWEN_ENABLED', False):
|
|
out.append((ai_chat.QWEN_CHAT_URL, ai_chat.QWEN_API_KEY, ai_chat.QWEN_MODEL, 'qwen'))
|
|
if getattr(ai_chat, 'HERMES_ENABLED', False):
|
|
out.append((ai_chat.HERMES_CHAT_URL, ai_chat.HERMES_API_KEY, ai_chat.HERMES_MODEL, 'hermes'))
|
|
if getattr(ai_chat, 'OPENROUTER_API_KEY', None):
|
|
out.append((ai_chat.OPENROUTER_URL, ai_chat.OPENROUTER_API_KEY, ai_chat.VISION_MODEL, 'openrouter'))
|
|
return out
|
|
|
|
|
|
def suggest_grade(image_bytes=None, image_base64=None, name=None, category=None,
|
|
condition='usado', notes=None):
|
|
"""Suggest a condition grade for a used part photo.
|
|
|
|
Returns:
|
|
{
|
|
"condition_grade_suggested": "A"|"B"|"C"|"D"|None,
|
|
"condition_suggested": "usado"|"reconstruido",
|
|
"defects": [str, ...],
|
|
"confidence": float, # 0.0 - 1.0
|
|
"notes": str,
|
|
"source": "qwen"|"hermes"|"openrouter"|None,
|
|
"error": str|None # set when no grade could be produced
|
|
}
|
|
"""
|
|
result = _empty_result()
|
|
|
|
if not image_bytes and not image_base64:
|
|
result['error'] = 'No se proporcionó imagen de la pieza.'
|
|
return result
|
|
|
|
b64 = image_base64
|
|
if not b64:
|
|
try:
|
|
b64 = base64.b64encode(image_bytes).decode('ascii')
|
|
except Exception as e: # pragma: no cover - defensive
|
|
result['error'] = f'Imagen inválida: {e}'
|
|
return result
|
|
b64_clean = b64.split(',', 1)[1] if b64.startswith('data:') else b64
|
|
|
|
prompt = _build_prompt(name=name, category=category, condition=condition)
|
|
|
|
# 1) Reuse the shared vision entry point (handles its own JSON parsing).
|
|
try:
|
|
from services import ai_chat
|
|
res = ai_chat.chat_with_image(prompt, b64_clean)
|
|
if isinstance(res, dict):
|
|
parsed = _normalize_grade(res)
|
|
if not _is_usable(parsed) and res.get('message'):
|
|
extracted = _extract_json_object(res.get('message'))
|
|
if extracted:
|
|
parsed = _normalize_grade(extracted)
|
|
if _is_usable(parsed):
|
|
parsed['source'] = 'vision'
|
|
return parsed
|
|
except Exception as e:
|
|
print(f"[PART_VISION] chat_with_image failed: {e}")
|
|
|
|
# 2) Direct OpenAI-compatible call with each backend's own tag.
|
|
backends = _backends()
|
|
if not backends:
|
|
result['error'] = ('Servicio de visión no configurado: sin backend de IA disponible '
|
|
'(QWEN/Hermes/OpenRouter).')
|
|
return result
|
|
|
|
messages = [
|
|
{"role": "system", "content": "Eres un perito de autopartes usadas. Responde SOLO en JSON válido."},
|
|
{"role": "user", "content": [
|
|
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + b64_clean}},
|
|
{"type": "text", "text": prompt},
|
|
]},
|
|
]
|
|
|
|
last_error = None
|
|
soft = None # valid JSON with notes but no grade (model refused/unsure)
|
|
for url, key, model, tag in backends:
|
|
try:
|
|
from services import ai_chat
|
|
res = ai_chat._post_chat_completion(
|
|
url, key, model, messages, max_tokens=500, temperature=0.1, timeout=45
|
|
)
|
|
except Exception as e:
|
|
last_error = f'{tag}: {e}'
|
|
continue
|
|
if not res:
|
|
last_error = f'{tag}: sin respuesta (ver logs [AI])'
|
|
continue
|
|
parsed = _extract_json_object(res.get('content'))
|
|
if not parsed:
|
|
last_error = f'{tag}: respuesta no-JSON'
|
|
continue
|
|
normalized = _normalize_grade(parsed)
|
|
if _is_usable(normalized):
|
|
normalized['source'] = tag
|
|
return normalized
|
|
if soft is None and normalized.get('notes'):
|
|
normalized['source'] = tag
|
|
soft = normalized
|
|
last_error = f'{tag}: JSON sin grado'
|
|
|
|
if soft is not None:
|
|
soft['error'] = 'El modelo no asignó un grado; revisa notes.'
|
|
return soft
|
|
|
|
result['error'] = ('El servicio de visión no pudo graduar la pieza '
|
|
f'({last_error or "sin backend disponible"}).')
|
|
return result
|