feat(pos): add kiosk mode, AI vision, AI part classification, public chatbot (#19 #25 #30 #29)

- Kiosk mode: fullscreen, wake lock, auto-login, context menu block, PWA/Capacitor detection
- AI vision: camera photos analyzed by Gemma 3 27B vision model via OpenRouter
- AI part classification: auto-suggest name/brand/category when entering part number
- Public catalog chatbot: /api/chat endpoint with rate limiting, chat widget on catalog page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 04:18:37 +00:00
parent 4cc2c66208
commit 5a88d7c7ff
13 changed files with 942 additions and 5 deletions

View File

@@ -148,6 +148,176 @@ def get_inventory_context(tenant_conn, branch_id=None):
cur.close()
VISION_MODEL = "google/gemma-3-27b-it:free"
VISION_SYSTEM_PROMPT = """Eres un experto en identificación de autopartes. El usuario te envía una foto de una parte automotriz.
Tu trabajo es:
1. Identificar que parte es (nombre en español e inglés)
2. Describir características visibles (material, desgaste, marca si es visible)
3. Sugerir términos de búsqueda para encontrarla en un catálogo
IMPORTANTE: Responde SIEMPRE en formato JSON válido con esta estructura:
{
"message": "Descripción de la parte identificada en español",
"search_query": "término de búsqueda EN INGLÉS para el catálogo",
"vehicle": null
}
Ejemplos de partes comunes:
- Pastillas/balatas de freno = "Brake Pad"
- Disco de freno = "Brake Disc"
- Filtro de aceite = "Oil Filter"
- Bujía = "Spark Plug"
- Amortiguador = "Shock Absorber"
- Bomba de agua = "Water Pump"
- Sensor de oxígeno = "Oxygen Sensor"
"""
def chat_with_image(user_message, image_base64, conversation_history=None, inventory_context=None):
"""Send a message with an image to a vision-capable AI model.
Args:
user_message: The user's chat message.
image_base64: Base64-encoded image (with or without data URL prefix).
conversation_history: Previous messages in the conversation.
inventory_context: Optional inventory summary string.
"""
_validate_model(VISION_MODEL)
system_content = VISION_SYSTEM_PROMPT
if inventory_context:
system_content = VISION_SYSTEM_PROMPT + "\n\n" + inventory_context
# Ensure proper data URL format
if image_base64 and not image_base64.startswith('data:'):
image_base64 = 'data:image/jpeg;base64,' + image_base64
messages = [{"role": "system", "content": system_content}]
if conversation_history:
# Only add text-only history messages
for h in conversation_history:
if isinstance(h.get('content'), str):
messages.append(h)
# Build multimodal user message
user_content = [
{"type": "image_url", "image_url": {"url": image_base64}},
{"type": "text", "text": user_message or "Identifica esta parte automotriz y sugiere términos de búsqueda."}
]
messages.append({"role": "user", "content": user_content})
import time
max_retries = 3
for attempt in range(max_retries):
try:
resp = requests.post(
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": VISION_MODEL,
"messages": messages,
"max_tokens": 500,
"temperature": 0.3,
},
timeout=30,
)
if resp.status_code == 429:
wait = (attempt + 1) * 5
if attempt < max_retries - 1:
time.sleep(wait)
continue
return {"message": "El asistente esta ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
try:
stripped = content.strip()
if stripped.startswith("```"):
lines = stripped.split("\n")
json_str = "\n".join(lines[1:-1])
parsed = json.loads(json_str)
else:
parsed = json.loads(stripped)
return parsed
except (json.JSONDecodeError, IndexError):
return {"message": content, "search_query": None, "vehicle": None}
except Exception as e:
if attempt < max_retries - 1:
continue
return {
"message": f"Error al analizar imagen: {str(e)}",
"search_query": None,
"vehicle": None,
}
def classify_part(part_number):
"""Ask AI to identify a part by its OEM number."""
_validate_model(MODEL)
prompt = (
f"Given auto part number '{part_number}', identify:\n"
f"1) What part it is (name in Spanish)\n"
f"2) Which brand makes it\n"
f"3) What vehicle it fits\n"
f"4) What category it belongs to (e.g. Frenos, Motor, Suspensión, Eléctrico, Filtros, Transmisión)\n"
f"Respond ONLY in valid JSON: {{\"name\": \"...\", \"brand\": \"...\", \"vehicle\": \"...\", \"category\": \"...\"}}"
)
messages = [
{"role": "system", "content": "Eres un experto en autopartes. Responde SOLO en JSON válido, sin texto adicional."},
{"role": "user", "content": prompt}
]
import time
max_retries = 3
for attempt in range(max_retries):
try:
resp = requests.post(
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": MODEL,
"messages": messages,
"max_tokens": 300,
"temperature": 0.2,
},
timeout=15,
)
if resp.status_code == 429:
wait = (attempt + 1) * 5
if attempt < max_retries - 1:
time.sleep(wait)
continue
return {"name": None, "brand": None, "vehicle": None, "category": None}
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
stripped = content.strip()
if stripped.startswith("```"):
lines = stripped.split("\n")
json_str = "\n".join(lines[1:-1])
parsed = json.loads(json_str)
else:
parsed = json.loads(stripped)
return parsed
except Exception:
if attempt < max_retries - 1:
continue
return {"name": None, "brand": None, "vehicle": None, "category": None}
def chat(user_message, conversation_history=None, inventory_context=None):
"""Send a message to the AI and get a response with search suggestions.