- 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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user