Files
Autoparts-DB/pos/services/part_vision.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

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