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

@@ -249,6 +249,97 @@ def admin_js():
def enhanced_search_js():
return send_from_directory('.', 'enhanced-search.js')
@app.route('/chat-public.js')
def chat_public_js():
return send_from_directory('.', 'chat-public.js')
@app.route('/chat-public.css')
def chat_public_css():
return send_from_directory('.', 'chat-public.css')
# ============================================================================
# Public Chat Endpoint (rate limited, no auth)
# ============================================================================
# Simple in-memory rate limiting for public chat
_chat_rate = {} # ip -> [timestamps]
def _check_chat_rate(ip, max_per_min=10):
"""Return True if request is allowed, False if rate limited."""
import time
now = time.time()
if ip not in _chat_rate:
_chat_rate[ip] = []
# Clean old entries
_chat_rate[ip] = [t for t in _chat_rate[ip] if now - t < 60]
if len(_chat_rate[ip]) >= max_per_min:
return False
_chat_rate[ip].append(now)
return True
@app.route('/api/chat', methods=['POST'])
def public_chat():
"""Public chatbot endpoint — searches the public catalog (no auth required)."""
client_ip = request.remote_addr or '0.0.0.0'
if not _check_chat_rate(client_ip):
return jsonify({'error': 'Demasiadas solicitudes. Intenta en un minuto.'}), 429
body = request.get_json(force=True) if request.is_json else {}
user_message = (body.get('message') or '').strip()
if not user_message:
return jsonify({'error': 'message required'}), 400
chat_history = body.get('history') or []
# Import AI chat service
from services.ai_chat import chat as ai_chat_fn
ai_response = ai_chat_fn(user_message, chat_history)
search_results = []
try:
search_query = ai_response.get('search_query')
if search_query:
session = Session()
try:
query_terms = [q.strip() for q in search_query.split('|') if q.strip()]
for qt in query_terms[:3]:
# Search parts in public catalog
rows = session.execute(text("""
SELECT p.id_part, p.oem_part_number, p.name_part,
pg.name_part_group, pc.name_part_category
FROM parts p
LEFT JOIN part_groups pg ON p.group_id = pg.id_part_group
LEFT JOIN part_categories pc ON pg.category_id = pc.id_part_category
WHERE p.name_part ILIKE :q
OR p.oem_part_number ILIKE :q
OR pg.name_part_group ILIKE :q
ORDER BY p.name_part
LIMIT 5
"""), {'q': f'%{qt}%'}).mappings().all()
for r in rows:
search_results.append({
'id_part': r['id_part'],
'oem_part_number': r['oem_part_number'],
'name_part': r['name_part'],
'name_es': r['name_part_group'],
'category': r['name_part_category'],
'source': 'catalog'
})
finally:
session.close()
except Exception:
pass # search failure is non-fatal
return jsonify({
'response': ai_response.get('message', ''),
'search_results': search_results,
'vehicle': ai_response.get('vehicle')
})
# ============================================================================
# Public Catalog API — No auth required