Spaces:
Sleeping
Sleeping
Commit
·
f810b2f
1
Parent(s):
2ec61dd
init project
Browse files- README.md +97 -11
- agent.py +115 -0
- app.py +414 -0
- data/carta.md +247 -0
- model.py +77 -0
- requirements.txt +29 -0
- supabase_client.py +99 -0
- tools.py +226 -0
- utils/classes.py +35 -0
- utils/functions.py +60 -0
- utils/logger.py +36 -0
README.md
CHANGED
@@ -1,13 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
---
|
2 |
-
title: WAIter
|
3 |
-
emoji: 🏢
|
4 |
-
colorFrom: indigo
|
5 |
-
colorTo: red
|
6 |
-
sdk: gradio
|
7 |
-
sdk_version: 5.31.0
|
8 |
-
app_file: app.py
|
9 |
-
pinned: false
|
10 |
-
short_description: A conversational assistant with voice capabilities and tools
|
11 |
-
---
|
12 |
|
13 |
-
|
|
|
|
|
|
1 |
+
# 🧑🍳 WAIter: Voice Chatbot for Restaurants
|
2 |
+
|
3 |
+
WAIter es un chatbot interactivo con capacidades de voz diseñado para escenarios de restaurantes. Simula un camarero amigable y servicial que puede responder a las preguntas habladas de los clientes en tiempo real, utilizando tecnologías de reconocimiento de voz, modelos de lenguaje avanzados y síntesis de voz natural.
|
4 |
+
|
5 |
+
## 🛠️ Stack Tecnológico
|
6 |
+
|
7 |
+
- **Procesamiento de Voz**:
|
8 |
+
- 🎙️ [FastRTC](https://fastrtc.org) para streaming de audio en tiempo real
|
9 |
+
- 🔊 [ElevenLabs](https://elevenlabs.io/) para síntesis de voz expresiva (TTS)
|
10 |
+
- 🗣️ [Whisper](https://openai.com/research/whisper) (vía Groq) para reconocimiento de voz
|
11 |
+
|
12 |
+
- **Inteligencia Artificial de Lenguaje**:
|
13 |
+
- 💬 [LangChain](https://www.langchain.com/) para orquestación de LLMs
|
14 |
+
- 🔄 [LangGraph](https://github.com/langchain-ai/langgraph) para flujos de trabajo de agentes
|
15 |
+
- 🧠 [Gemini 2.5](https://deepmind.google/technologies/gemini/) (vía OpenRouter) para procesamiento de lenguaje natural
|
16 |
+
|
17 |
+
- **Gestión de Datos**:
|
18 |
+
- 🔍 Embeddings vectoriales con HuggingFace (BAAI/bge-m3)
|
19 |
+
- 💾 Datos de menú almacenados en formato Markdown
|
20 |
+
- 🗄️ [Supabase](https://supabase.com/) para almacenamiento y procesamiento de pedidos
|
21 |
+
|
22 |
+
- **Interfaz**:
|
23 |
+
- 🧪 [Gradio](https://www.gradio.app/) para la interfaz web interactiva
|
24 |
+
|
25 |
+
## 🚀 Características
|
26 |
+
|
27 |
+
- **Interacción por voz en tiempo real** con un asistente virtual de camarero
|
28 |
+
- **Consulta de información del menú** con respuestas contextuales sobre platos, precios y opciones
|
29 |
+
- **Procesamiento de pedidos** con capacidad para enviar órdenes directamente a sistemas de cocina
|
30 |
+
- **Flujo conversacional natural** usando LangGraph para orquestación compleja de agentes
|
31 |
+
- **Integración de base de conocimientos** con RAG (Retrieval Augmented Generation)
|
32 |
+
- **Memoria de conversación multi-turno** para respuestas contextuales
|
33 |
+
- **Gestión de pedidos** con almacenamiento en Supabase y sistema de seguimiento
|
34 |
+
- **Síntesis de voz expresiva** con ElevenLabs para respuestas naturales y fluidas
|
35 |
+
|
36 |
+
## 🚀 Primeros Pasos
|
37 |
+
|
38 |
+
1. Clona este repositorio
|
39 |
+
2. Instala las dependencias: `pip install -r requirements.txt` (requiere Python 3.10+)
|
40 |
+
3. Crea un archivo `.env` con tus claves API:
|
41 |
+
```
|
42 |
+
OPENROUTER_API_KEY=tu_clave_openrouter
|
43 |
+
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
44 |
+
GROQ_API_KEY=tu_clave_groq
|
45 |
+
ELEVENLABS_API_KEY=tu_clave_elevenlabs
|
46 |
+
HELICONE_API_KEY=tu_clave_helicone_opcional
|
47 |
+
SUPABASE_URL=tu_url_supabase
|
48 |
+
SUPABASE_KEY=tu_clave_supabase
|
49 |
+
```
|
50 |
+
4. Ejecuta la aplicación: `python app.py`
|
51 |
+
5. Abre la interfaz de Gradio en tu navegador
|
52 |
+
|
53 |
+
## 🗂️ Estructura del Proyecto
|
54 |
+
|
55 |
+
- `app.py` - Punto de entrada principal con interfaz Gradio
|
56 |
+
- `agent.py` - Implementación del agente LangGraph para asistente de restaurante
|
57 |
+
- `model.py` - Gestor para la creación y configuración de modelos de lenguaje
|
58 |
+
- `tools.py` - Herramientas personalizadas para consulta de menú y envío de pedidos
|
59 |
+
- `supabase_client.py` - Cliente para operaciones con la base de datos Supabase
|
60 |
+
- `data/carta.md` - Datos del menú del restaurante en formato Markdown
|
61 |
+
- `utils/` - Módulos de utilidad:
|
62 |
+
- `functions.py` - Funciones auxiliares para manejo de mensajes y modelos
|
63 |
+
- `logger.py` - Sistema de registro con colores para depuración
|
64 |
+
- `classes.py` - Clases de datos como `Order` para representar pedidos
|
65 |
+
|
66 |
+
## 🧠 Cómo Funciona
|
67 |
+
|
68 |
+
1. **Reconocimiento de Voz**: FastRTC captura audio del micrófono del usuario y lo envía a la API Whisper de Groq para transcripción
|
69 |
+
2. **Procesamiento de Lenguaje Natural**: El texto transcrito se pasa al agente LangGraph
|
70 |
+
3. **Procesamiento RAG**: El agente utiliza búsqueda vectorial para encontrar información relevante del menú
|
71 |
+
4. **Herramientas del Agente**: El sistema utiliza herramientas especializadas para:
|
72 |
+
- Búsqueda de información en el menú (vectores)
|
73 |
+
- Envío de pedidos a cocina (integración Supabase)
|
74 |
+
5. **Generación de Respuesta**: Gemini 2.5 (a través de OpenRouter) genera una respuesta contextual y útil
|
75 |
+
6. **Síntesis de Voz**: ElevenLabs convierte la respuesta de texto a voz natural
|
76 |
+
7. **Interfaz Web**: Gradio renderiza la conversación y reproduce la respuesta de audio
|
77 |
+
|
78 |
+
## 📦 Requisitos del Sistema
|
79 |
+
|
80 |
+
Los requisitos detallados se encuentran en el archivo `requirements.txt`. Las principales dependencias incluyen:
|
81 |
+
|
82 |
+
- Python 3.10+
|
83 |
+
- gradio >= 4.26.0
|
84 |
+
- langchain >= 0.1.0 (con varios componentes adicionales)
|
85 |
+
- fastrtc >= 0.6.0
|
86 |
+
- elevenlabs >= 0.2.24
|
87 |
+
- groq >= 0.4.0
|
88 |
+
- supabase >= 2.0.0
|
89 |
+
- sentence-transformers >= 2.2.2
|
90 |
+
|
91 |
+
## 📝 Licencia
|
92 |
+
|
93 |
+
[MIT License](LICENSE)
|
94 |
+
|
95 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
96 |
|
97 |
+
🌐 Desarrollado por Roberto - 2024
|
98 |
+
|
99 |
+
|
agent.py
ADDED
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from typing import TypedDict, Annotated, List, Dict, Any
|
2 |
+
from langgraph.graph.message import add_messages
|
3 |
+
from langchain_core.messages import AnyMessage, SystemMessage
|
4 |
+
from langgraph.prebuilt import ToolNode
|
5 |
+
from langgraph.graph import START, StateGraph
|
6 |
+
from langgraph.prebuilt import tools_condition
|
7 |
+
from langchain_openai import ChatOpenAI
|
8 |
+
from langchain.tools import Tool
|
9 |
+
|
10 |
+
from utils.logger import log_info, log_warn, log_error, log_debug
|
11 |
+
|
12 |
+
class RestaurantAgent:
|
13 |
+
def __init__(self, llm: ChatOpenAI, restaurant_name: str, tools: List[Tool]):
|
14 |
+
"""
|
15 |
+
Inicializa el agente del restaurante con LangGraph.
|
16 |
+
|
17 |
+
Args:
|
18 |
+
llm: Modelo de lenguaje a utilizar
|
19 |
+
restaurant_name: Nombre del restaurante
|
20 |
+
tools: Lista de herramientas para el agente
|
21 |
+
"""
|
22 |
+
self.restaurant_name = restaurant_name
|
23 |
+
self.tools = tools
|
24 |
+
|
25 |
+
# Prompt para el asistente
|
26 |
+
self.system_prompt = f"""
|
27 |
+
Eres un camarero virtual profesional de {restaurant_name}, atendiendo la mesa 1. Combinas la calidez y simpatía gaditana con un servicio excelente y eficiente.
|
28 |
+
|
29 |
+
## Tu personalidad
|
30 |
+
- Profesional pero cercano, con el encanto natural de Cádiz
|
31 |
+
- Confiable, simpático y resolutivo
|
32 |
+
- Transmites seguridad en cada recomendación
|
33 |
+
- Hablas con naturalidad, como si fueras un camarero experimentado
|
34 |
+
|
35 |
+
## Comunicación (optimizada para TTS)
|
36 |
+
- Frases naturales, claras y conversacionales
|
37 |
+
- Tono directo pero amable, sin rodeos innecesarios
|
38 |
+
- Evita símbolos especiales, comillas o emojis
|
39 |
+
- Respuestas concisas que fluyan bien al ser leídas en voz alta
|
40 |
+
|
41 |
+
## Protocolo de servicio OBLIGATORIO
|
42 |
+
1. **SIEMPRE verifica** la disponibilidad de platos en el menú antes de confirmar pedidos
|
43 |
+
2. **NUNCA recomiendes** productos sin consultarlos primero en la carta
|
44 |
+
3. **Informa inmediatamente** si algo no está disponible y ofrece alternativas
|
45 |
+
4. **Confirma cada pedido** antes de enviarlo a cocina
|
46 |
+
5. **Despídete cordialmente** tras completar el servicio
|
47 |
+
|
48 |
+
## Manejo de consultas del menú
|
49 |
+
- Cuando pregunten por opciones disponibles: proporciona un resumen claro y natural
|
50 |
+
- Para platos específicos: verifica existencia, precio e ingredientes principales
|
51 |
+
- Si desconoces algo: sé transparente y consulta la información necesaria
|
52 |
+
- Presenta las opciones de forma atractiva pero honesta
|
53 |
+
|
54 |
+
## Gestión de pedidos
|
55 |
+
- Confirma cada plato solicitado existe en el menú
|
56 |
+
- Repite el pedido completo antes de enviarlo
|
57 |
+
- Informa el tiempo estimado si es relevante
|
58 |
+
- Mantén un registro mental del estado del pedido
|
59 |
+
|
60 |
+
Recuerda: tu objetivo es brindar una experiencia gastronómica excepcional combinando profesionalidad, eficiencia y ese toque especial gaditano que hace sentir como en casa.
|
61 |
+
"""
|
62 |
+
|
63 |
+
# Configurar el LLM con las herramientas
|
64 |
+
self.llm_with_tools = llm.bind_tools(tools=tools)
|
65 |
+
|
66 |
+
# Construir el grafo
|
67 |
+
self.graph = self._build_graph()
|
68 |
+
|
69 |
+
def _build_graph(self) -> StateGraph:
|
70 |
+
"""Construye el grafo de estados del agente."""
|
71 |
+
|
72 |
+
# Definición del tipo de estado para nuestro grafo
|
73 |
+
class AgentState(TypedDict):
|
74 |
+
"""Tipo para el estado del agente de LangGraph."""
|
75 |
+
messages: Annotated[list[AnyMessage], add_messages]
|
76 |
+
|
77 |
+
# Nodo para el asistente que invoca al LLM
|
78 |
+
def assistant(state: AgentState):
|
79 |
+
"""Procesa los mensajes usando el LLM y devuelve una respuesta."""
|
80 |
+
log_info("Assistant processing messages")
|
81 |
+
|
82 |
+
# Añadir el mensaje del sistema al principio de la lista de mensajes
|
83 |
+
messages = [SystemMessage(content=self.system_prompt)] + state["messages"]
|
84 |
+
|
85 |
+
return {
|
86 |
+
"messages": [self.llm_with_tools.invoke(messages)],
|
87 |
+
}
|
88 |
+
|
89 |
+
# Crear el grafo con una estructura mucho más simple
|
90 |
+
builder = StateGraph(AgentState)
|
91 |
+
|
92 |
+
# Definir nodos: el asistente y el nodo para herramientas
|
93 |
+
builder.add_node("assistant", assistant)
|
94 |
+
builder.add_node("tools", ToolNode(self.tools))
|
95 |
+
|
96 |
+
# Definir bordes con enrutamiento condicional automático
|
97 |
+
builder.add_edge(START, "assistant")
|
98 |
+
builder.add_conditional_edges(
|
99 |
+
"assistant",
|
100 |
+
# Si el último mensaje requiere una herramienta, enrutar a "tools"
|
101 |
+
# De lo contrario, terminar el flujo y devolver la respuesta
|
102 |
+
tools_condition,
|
103 |
+
)
|
104 |
+
builder.add_edge("tools", "assistant")
|
105 |
+
|
106 |
+
# Compilar y retornar el grafo
|
107 |
+
return builder.compile()
|
108 |
+
|
109 |
+
def invoke(self, messages: List[AnyMessage]) -> Dict[str, Any]:
|
110 |
+
"""
|
111 |
+
Procesa la consulta del usuario y genera una respuesta usando el grafo LangGraph.
|
112 |
+
"""
|
113 |
+
log_info(f"Processing query: {messages}")
|
114 |
+
|
115 |
+
return self.graph.invoke({"messages": messages})
|
app.py
ADDED
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
from os import getenv, environ
|
3 |
+
from dotenv import load_dotenv
|
4 |
+
import os
|
5 |
+
from model import ModelManager
|
6 |
+
from utils.functions import fetch_openrouter_models
|
7 |
+
# Configurar la variable de entorno para evitar advertencias de tokenizers (huggingface opcional)
|
8 |
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
9 |
+
|
10 |
+
from groq import AsyncClient
|
11 |
+
from fastrtc import WebRTC, ReplyOnPause, audio_to_bytes, AdditionalOutputs
|
12 |
+
import numpy as np
|
13 |
+
import asyncio
|
14 |
+
from elevenlabs.client import ElevenLabs
|
15 |
+
from elevenlabs import VoiceSettings
|
16 |
+
|
17 |
+
from langchain_text_splitters.markdown import MarkdownHeaderTextSplitter
|
18 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
19 |
+
from langchain_core.vectorstores import InMemoryVectorStore
|
20 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
21 |
+
|
22 |
+
from agent import RestaurantAgent
|
23 |
+
# Importar las herramientas
|
24 |
+
from tools import create_menu_info_tool, create_send_to_kitchen_tool
|
25 |
+
|
26 |
+
from utils.logger import log_info, log_warn, log_error, log_success, log_debug
|
27 |
+
|
28 |
+
load_dotenv()
|
29 |
+
|
30 |
+
# Constantes
|
31 |
+
RESTAURANT = "Bar paco"
|
32 |
+
|
33 |
+
# Initialize clients and models to None, will be set during runtime
|
34 |
+
groq_client = None
|
35 |
+
eleven_client = None
|
36 |
+
llm = None
|
37 |
+
waiter_agent = None
|
38 |
+
|
39 |
+
# region RAG
|
40 |
+
md_path = "data/carta.md"
|
41 |
+
|
42 |
+
with open(md_path, "r", encoding="utf-8") as file:
|
43 |
+
md_content = file.read()
|
44 |
+
|
45 |
+
splitter = MarkdownHeaderTextSplitter(
|
46 |
+
headers_to_split_on=[
|
47 |
+
("#", "seccion_principal"),
|
48 |
+
("##", "subseccion"),
|
49 |
+
("###", "apartado")
|
50 |
+
],
|
51 |
+
strip_headers=False)
|
52 |
+
splits = splitter.split_text(md_content)
|
53 |
+
embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-m3", model_kwargs = {'device': 'cpu'})
|
54 |
+
vector_store = InMemoryVectorStore.from_documents(splits, embeddings)
|
55 |
+
|
56 |
+
retriever = vector_store.as_retriever(search_kwargs={"k": 4})
|
57 |
+
# endregion
|
58 |
+
|
59 |
+
# Initialize tools to None
|
60 |
+
guest_info_tool = None
|
61 |
+
send_to_kitchen_tool = None
|
62 |
+
tools = None
|
63 |
+
|
64 |
+
# Function to initialize all components with provided API keys
|
65 |
+
def initialize_components(openrouter_key, groq_key, elevenlabs_key, model_name):
|
66 |
+
global groq_client, eleven_client, llm, waiter_agent, guest_info_tool, send_to_kitchen_tool, tools
|
67 |
+
|
68 |
+
log_info("Initializing components with provided API keys...")
|
69 |
+
|
70 |
+
# Initialize clients with provided keys
|
71 |
+
if groq_key:
|
72 |
+
groq_client = AsyncClient(api_key=groq_key)
|
73 |
+
|
74 |
+
if elevenlabs_key:
|
75 |
+
eleven_client = ElevenLabs(api_key=elevenlabs_key)
|
76 |
+
|
77 |
+
if openrouter_key:
|
78 |
+
# Initialize LLM
|
79 |
+
model_manager = ModelManager(
|
80 |
+
api_key=openrouter_key,
|
81 |
+
api_base=getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
|
82 |
+
model_name=model_name,
|
83 |
+
helicone_api_key=getenv("HELICONE_API_KEY", "")
|
84 |
+
)
|
85 |
+
llm = model_manager.create_model()
|
86 |
+
|
87 |
+
# Initialize tools
|
88 |
+
guest_info_tool = create_menu_info_tool(retriever)
|
89 |
+
send_to_kitchen_tool = create_send_to_kitchen_tool(llm=llm)
|
90 |
+
tools = [guest_info_tool, send_to_kitchen_tool]
|
91 |
+
|
92 |
+
# Initialize the agent
|
93 |
+
waiter_agent = RestaurantAgent(
|
94 |
+
llm=llm,
|
95 |
+
restaurant_name=RESTAURANT,
|
96 |
+
tools=tools
|
97 |
+
)
|
98 |
+
|
99 |
+
log_success("Components initialized successfully.")
|
100 |
+
else:
|
101 |
+
log_warn("OpenRouter API key is required for LLM initialization.")
|
102 |
+
|
103 |
+
return {
|
104 |
+
"groq_client": groq_client is not None,
|
105 |
+
"eleven_client": eleven_client is not None,
|
106 |
+
"llm": llm is not None,
|
107 |
+
"agent": waiter_agent is not None
|
108 |
+
}
|
109 |
+
|
110 |
+
# region FUNCTIONS
|
111 |
+
async def handle_text_input(message, history, openrouter_key, groq_key, elevenlabs_key, model_name):
|
112 |
+
"""Handles text input, generates response, updates chat history."""
|
113 |
+
global waiter_agent, llm
|
114 |
+
|
115 |
+
# Initialize components if needed
|
116 |
+
if waiter_agent is None or llm is None or model_name != getattr(llm, "model_name", ""):
|
117 |
+
status = initialize_components(openrouter_key, groq_key, elevenlabs_key, model_name)
|
118 |
+
if not status["agent"]:
|
119 |
+
return history + [
|
120 |
+
{"role": "user", "content": message},
|
121 |
+
{"role": "assistant", "content": "Error: Could not initialize the agent. Please check your API keys."}
|
122 |
+
]
|
123 |
+
|
124 |
+
current_history = history if isinstance(history, list) else []
|
125 |
+
log_info("-" * 20)
|
126 |
+
log_info(f"Received text input: '{message}', current history: {current_history}")
|
127 |
+
|
128 |
+
try:
|
129 |
+
# 1. Actualizar el historial con el mensaje del usuario
|
130 |
+
user_message = {"role": "user", "content": message}
|
131 |
+
history_with_user = current_history + [user_message]
|
132 |
+
|
133 |
+
# 2. Invocar al agente con la consulta del usuario
|
134 |
+
log_info("Iniciando procesamiento con LangGraph...")
|
135 |
+
|
136 |
+
# Invocar el agente con el texto de la consulta
|
137 |
+
langchain_messages = []
|
138 |
+
for msg in current_history:
|
139 |
+
if msg["role"] == "user":
|
140 |
+
langchain_messages.append(HumanMessage(content=msg["content"]))
|
141 |
+
elif msg["role"] == "assistant":
|
142 |
+
langchain_messages.append(AIMessage(content=msg["content"]))
|
143 |
+
|
144 |
+
langchain_messages.append(HumanMessage(content=message))
|
145 |
+
|
146 |
+
graph_result = waiter_agent.invoke(langchain_messages)
|
147 |
+
|
148 |
+
log_debug(f"Resultado del agente: {graph_result}")
|
149 |
+
|
150 |
+
messages = graph_result.get("messages", [])
|
151 |
+
assistant_text = ""
|
152 |
+
|
153 |
+
for msg in reversed(messages):
|
154 |
+
# LangChain puede devolver diferentes clases de mensajes
|
155 |
+
if hasattr(msg, "__class__") and msg.__class__.__name__ == "AIMessage":
|
156 |
+
assistant_text = msg.content
|
157 |
+
break
|
158 |
+
|
159 |
+
if not assistant_text:
|
160 |
+
log_warn("No se encontró respuesta del asistente en los mensajes.")
|
161 |
+
assistant_text = "Lo siento, no sé cómo responder a eso."
|
162 |
+
|
163 |
+
log_info(f"Assistant text: '{assistant_text}'")
|
164 |
+
|
165 |
+
# 3. Actualizar el historial con el mensaje del asistente
|
166 |
+
assistant_message = {"role": "assistant", "content": assistant_text}
|
167 |
+
final_history = history_with_user + [assistant_message]
|
168 |
+
|
169 |
+
log_success("Tarea completada con éxito.")
|
170 |
+
return final_history
|
171 |
+
|
172 |
+
except Exception as e:
|
173 |
+
log_error(f"Error in handle_text_input function: {e}")
|
174 |
+
import traceback
|
175 |
+
traceback.print_exc()
|
176 |
+
return current_history + [
|
177 |
+
{"role": "user", "content": message},
|
178 |
+
{"role": "assistant", "content": f"Error: {str(e)}"}
|
179 |
+
]
|
180 |
+
|
181 |
+
async def response(audio: tuple[int, np.ndarray], history, openrouter_key, groq_key, elevenlabs_key, model_name):
|
182 |
+
"""Handles audio input, generates response, yields UI updates and audio."""
|
183 |
+
global waiter_agent, llm, groq_client, eleven_client
|
184 |
+
|
185 |
+
# Initialize components if needed
|
186 |
+
if waiter_agent is None or llm is None or groq_client is None or eleven_client is None or model_name != getattr(llm, "model_name", ""):
|
187 |
+
status = initialize_components(openrouter_key, groq_key, elevenlabs_key, model_name)
|
188 |
+
if not status["groq_client"]:
|
189 |
+
yield AdditionalOutputs(history + [{"role": "assistant", "content": "Error: Groq API key is required for audio processing."}])
|
190 |
+
return
|
191 |
+
if not status["eleven_client"]:
|
192 |
+
yield AdditionalOutputs(history + [{"role": "assistant", "content": "Error: ElevenLabs API key is required for audio processing."}])
|
193 |
+
return
|
194 |
+
if not status["agent"]:
|
195 |
+
yield AdditionalOutputs(history + [{"role": "assistant", "content": "Error: Could not initialize the agent. Please check your OpenRouter API key."}])
|
196 |
+
return
|
197 |
+
|
198 |
+
current_history = history if isinstance(history, list) else []
|
199 |
+
log_info("-" * 20)
|
200 |
+
log_info(f"Received audio, current history: {current_history}")
|
201 |
+
|
202 |
+
try:
|
203 |
+
# 1. Transcribir el audio a texto
|
204 |
+
audio_bytes = audio_to_bytes(audio)
|
205 |
+
transcript = await groq_client.audio.transcriptions.create(
|
206 |
+
file=("audio-file.mp3", audio_bytes),
|
207 |
+
model="whisper-large-v3-turbo",
|
208 |
+
response_format="verbose_json",
|
209 |
+
)
|
210 |
+
user_text = transcript.text.strip()
|
211 |
+
|
212 |
+
log_info(f"Transcription: '{user_text}'")
|
213 |
+
|
214 |
+
# 2. Actualizar el historial con el mensaje del usuario
|
215 |
+
user_message = {"role": "user", "content": user_text}
|
216 |
+
history_with_user = current_history + [user_message]
|
217 |
+
|
218 |
+
log_info(f"Yielding user message update to UI: {history_with_user}")
|
219 |
+
yield AdditionalOutputs(history_with_user)
|
220 |
+
await asyncio.sleep(0.04) # Permite que la UI se actualice antes de continuar
|
221 |
+
|
222 |
+
# 4. Invocar al agente con la consulta del usuario
|
223 |
+
log_info("Iniciando procesamiento con LangGraph...")
|
224 |
+
|
225 |
+
langchain_messages = []
|
226 |
+
for msg in current_history:
|
227 |
+
if msg["role"] == "user":
|
228 |
+
langchain_messages.append(HumanMessage(content=msg["content"]))
|
229 |
+
elif msg["role"] == "assistant":
|
230 |
+
langchain_messages.append(AIMessage(content=msg["content"]))
|
231 |
+
|
232 |
+
langchain_messages.append(HumanMessage(content=user_text))
|
233 |
+
|
234 |
+
graph_result = waiter_agent.invoke(langchain_messages)
|
235 |
+
|
236 |
+
log_debug(f"Resultado del agente: {graph_result}")
|
237 |
+
|
238 |
+
# Extraer la respuesta del último mensaje del asistente
|
239 |
+
messages = graph_result.get("messages", [])
|
240 |
+
assistant_text = ""
|
241 |
+
|
242 |
+
# Buscar el último mensaje del asistente
|
243 |
+
for msg in reversed(messages):
|
244 |
+
if hasattr(msg, "__class__") and msg.__class__.__name__ == "AIMessage":
|
245 |
+
assistant_text = msg.content
|
246 |
+
break
|
247 |
+
|
248 |
+
if not assistant_text:
|
249 |
+
log_warn("No se encontró respuesta del asistente en los mensajes.")
|
250 |
+
assistant_text = "Lo siento, no sé cómo responder a eso."
|
251 |
+
|
252 |
+
log_info(f"Assistant text: '{assistant_text}'")
|
253 |
+
|
254 |
+
# 5. Actualizar el historial con el mensaje del asistente
|
255 |
+
assistant_message = {"role": "assistant", "content": assistant_text}
|
256 |
+
final_history = history_with_user + [assistant_message]
|
257 |
+
|
258 |
+
# 6. Generar la respuesta de voz
|
259 |
+
log_info("Generating TTS...")
|
260 |
+
TARGET_SAMPLE_RATE = 24000 # <<< --- Tasa de muestreo deseada
|
261 |
+
tts_stream_generator = eleven_client.text_to_speech.convert(
|
262 |
+
text=assistant_text,
|
263 |
+
voice_id="Nh2zY9kknu6z4pZy6FhD",
|
264 |
+
model_id="eleven_flash_v2_5",
|
265 |
+
output_format="pcm_24000",
|
266 |
+
voice_settings=VoiceSettings(
|
267 |
+
stability=0.0,
|
268 |
+
similarity_boost=1.0,
|
269 |
+
style=0.0,
|
270 |
+
use_speaker_boost=True,
|
271 |
+
speed=1.1,
|
272 |
+
)
|
273 |
+
)
|
274 |
+
|
275 |
+
# --- Procesar los chunks a medida que llegan ---
|
276 |
+
log_info("Receiving and processing TTS audio chunks...")
|
277 |
+
audio_chunks = []
|
278 |
+
total_bytes = 0
|
279 |
+
|
280 |
+
for chunk in tts_stream_generator:
|
281 |
+
total_bytes += len(chunk)
|
282 |
+
|
283 |
+
# Convertir chunk actual de bytes PCM (int16) a float32 normalizado
|
284 |
+
if chunk:
|
285 |
+
audio_int16 = np.frombuffer(chunk, dtype=np.int16)
|
286 |
+
audio_float32 = audio_int16.astype(np.float32) / 32768.0
|
287 |
+
audio_float32 = np.clip(audio_float32, -1.0, 1.0) # Asegurar rango
|
288 |
+
audio_chunks.append(audio_float32)
|
289 |
+
|
290 |
+
log_info(f"Received {total_bytes} bytes of TTS audio in total.")
|
291 |
+
|
292 |
+
# Concatenar todos los chunks procesados
|
293 |
+
if audio_chunks:
|
294 |
+
final_audio = np.concatenate(audio_chunks)
|
295 |
+
log_info(f"Processed {len(final_audio)} audio samples.")
|
296 |
+
else:
|
297 |
+
log_warn("Warning: TTS returned empty audio stream.")
|
298 |
+
final_audio = np.array([], dtype=np.float32)
|
299 |
+
|
300 |
+
# Crear la tupla final
|
301 |
+
tts_output_tuple = (TARGET_SAMPLE_RATE, final_audio)
|
302 |
+
|
303 |
+
log_debug(f"TTS output: {tts_output_tuple}")
|
304 |
+
log_success("Tarea completada con éxito.")
|
305 |
+
yield tts_output_tuple
|
306 |
+
yield AdditionalOutputs(final_history)
|
307 |
+
|
308 |
+
except Exception as e:
|
309 |
+
log_error(f"Error in response function: {e}")
|
310 |
+
import traceback
|
311 |
+
traceback.print_exc()
|
312 |
+
|
313 |
+
yield np.array([]).astype(np.int16).tobytes()
|
314 |
+
yield AdditionalOutputs(current_history + [{"role": "assistant", "content": f"Error: {str(e)}"}])
|
315 |
+
|
316 |
+
def load_model_ids():
|
317 |
+
# Use asyncio to run the async function
|
318 |
+
try:
|
319 |
+
models = asyncio.run(fetch_openrouter_models())
|
320 |
+
# Extract model IDs and names
|
321 |
+
model_ids = [model["id"] for model in models]
|
322 |
+
return model_ids
|
323 |
+
except Exception as e:
|
324 |
+
log_error(f"Error loading model IDs: {e}")
|
325 |
+
return ["openai/gpt-4o-mini", "google/gemini-2.5-flash-preview", "anthropic/claude-3-5-sonnet"] # Fallback models
|
326 |
+
# endregion
|
327 |
+
|
328 |
+
with gr.Blocks() as demo:
|
329 |
+
gr.Markdown("# WAIter Chatbot")
|
330 |
+
with gr.Row():
|
331 |
+
text_openrouter_api_key = gr.Textbox(
|
332 |
+
label="OpenRouter API Key (required)",
|
333 |
+
placeholder="Enter your OpenRouter API key",
|
334 |
+
value=getenv("OPENROUTER_API_KEY") or "",
|
335 |
+
type="password",
|
336 |
+
)
|
337 |
+
text_groq_api_key = gr.Textbox(
|
338 |
+
label="Groq API Key (required for audio)",
|
339 |
+
placeholder="Enter your Groq API key",
|
340 |
+
value=getenv("GROQ_API_KEY") or "",
|
341 |
+
type="password",
|
342 |
+
)
|
343 |
+
text_elevenlabs_api_key = gr.Textbox(
|
344 |
+
label="Elevenlabs API Key (required for audio)",
|
345 |
+
placeholder="Enter your Elevenlabs API key",
|
346 |
+
value=getenv("ELEVENLABS_API_KEY") or "",
|
347 |
+
type="password",
|
348 |
+
)
|
349 |
+
|
350 |
+
chatbot = gr.Chatbot(
|
351 |
+
label="Agent",
|
352 |
+
type="messages",
|
353 |
+
value=[],
|
354 |
+
avatar_images=(
|
355 |
+
None, # User avatar
|
356 |
+
"https://em-content.zobj.net/source/twitter/376/hugging-face_1f917.png", # Assistant
|
357 |
+
),
|
358 |
+
)
|
359 |
+
|
360 |
+
with gr.Row():
|
361 |
+
model_dropdown = gr.Dropdown(
|
362 |
+
label="Select Model",
|
363 |
+
choices=load_model_ids(),
|
364 |
+
value=getenv("MODEL") or "openai/gpt-4o-mini",
|
365 |
+
interactive=True
|
366 |
+
)
|
367 |
+
|
368 |
+
text_input = gr.Textbox(
|
369 |
+
label="Type your message",
|
370 |
+
placeholder="Type here and press Enter...",
|
371 |
+
show_label=True,
|
372 |
+
)
|
373 |
+
|
374 |
+
audio = WebRTC(
|
375 |
+
label="Speak Here",
|
376 |
+
mode="send-receive",
|
377 |
+
modality="audio",
|
378 |
+
)
|
379 |
+
|
380 |
+
text_input.submit(
|
381 |
+
fn=handle_text_input,
|
382 |
+
inputs=[
|
383 |
+
text_input,
|
384 |
+
chatbot,
|
385 |
+
text_openrouter_api_key,
|
386 |
+
text_groq_api_key,
|
387 |
+
text_elevenlabs_api_key,
|
388 |
+
model_dropdown
|
389 |
+
],
|
390 |
+
outputs=[chatbot],
|
391 |
+
api_name="submit_text"
|
392 |
+
).then(
|
393 |
+
fn=lambda: "", # Limpiar el campo de texto
|
394 |
+
outputs=[text_input]
|
395 |
+
)
|
396 |
+
|
397 |
+
# Se encarga de manejar la entrada de audio
|
398 |
+
audio.stream(
|
399 |
+
fn=ReplyOnPause(
|
400 |
+
response,
|
401 |
+
can_interrupt=True,
|
402 |
+
),
|
403 |
+
inputs=[audio, chatbot, text_openrouter_api_key, text_groq_api_key, text_elevenlabs_api_key, model_dropdown],
|
404 |
+
outputs=[audio],
|
405 |
+
)
|
406 |
+
|
407 |
+
# Actualiza el historial de la conversación
|
408 |
+
audio.on_additional_outputs(
|
409 |
+
fn=lambda history_update: history_update, # Envia el historial actualizado
|
410 |
+
outputs=[chatbot], # Actualiza el chatbot
|
411 |
+
)
|
412 |
+
|
413 |
+
if __name__ == "__main__":
|
414 |
+
demo.launch()
|
data/carta.md
ADDED
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# BAR PACO
|
2 |
+
*Tradición y Sabor en el Corazón de la Ciudad*
|
3 |
+
|
4 |
+
📍 Calle Ficticia, 123, Madrid | 🕛 Abierto todos los días: 12:00 - 00:00
|
5 |
+
---
|
6 |
+
|
7 |
+
## 🐖 LAS CHACINAS DE CARDESA
|
8 |
+
*Selección de los mejores ibéricos y quesos, cortados al momento.*
|
9 |
+
|
10 |
+
- **Bandeja de Jamón Ibérico de Bellota D.O. Extremadura** – **24,00 €**
|
11 |
+
*(100% Raza Ibérica, curación mínima 36 meses)*
|
12 |
+
(½ ración **14,00 €**)
|
13 |
+
- **Bandeja de Caña de Lomo Ibérico de Bellota D.O. Guijuelo** – **24,00 €**
|
14 |
+
*(Embuchado natural, sabor intenso y delicado)*
|
15 |
+
(½ ración **12,50 €**)
|
16 |
+
- **Tabla de Ibéricos de Bellota Variados** – **24,00 €**
|
17 |
+
*(Jamón, lomo, chorizo y salchichón ibérico de bellota)*
|
18 |
+
(½ ración **14,00 €**)
|
19 |
+
- **Plato de Cecina de León I.G.P. "Reserva"** – **20,00 €**
|
20 |
+
*(Curada al aire de la montaña leonesa, con un toque ahumado)*
|
21 |
+
(½ ración **12,00 €**)
|
22 |
+
- **Plato de Queso Curado de Oveja Artesano** – **16,00 €**
|
23 |
+
*(Selección de quesos manchegos y zamoranos con D.O.)*
|
24 |
+
(Porción individual **9,00 €**)
|
25 |
+
- **Mojama de Atún Extra de Barbate con Almendras Marconas** – **18,00 €**
|
26 |
+
|
27 |
+
> *Nuestras chacinas se sirven con picos camperos y regañás. Para una experiencia superior, recomendamos nuestro Pan de Cristal con Tomate Rallado y AOVE Premium – **4,00 €***
|
28 |
+
|
29 |
+
---
|
30 |
+
|
31 |
+
## 🍽️ PARA COMPARTIR (Tapas y Raciones)
|
32 |
+
|
33 |
+
**DE LA HUERTA Y CLÁSICOS:**
|
34 |
+
- **Ensalada Bar Paco** – **13,50 €**
|
35 |
+
*(Mezclum de lechugas, tomate, cebolla tierna, atún, huevo duro, espárragos y aceitunas)*
|
36 |
+
- **Tomates Rosados de Temporada con Ventresca de Bonito del Norte** – **15,90 €**
|
37 |
+
- **Tomate Rosado con Berberechos Gallegos al Natural** – **15,90 €**
|
38 |
+
- **Tomate Rosado con Burrata Fresca, Pesto Genovés Casero y Rúcula** – **17,00 €**
|
39 |
+
- **Tomate "Huevo de Toro" Aliñado (en temporada)** – **11,00 €**
|
40 |
+
*(Con ajo, comino, orégano y AOVE)*
|
41 |
+
- **Parrillada de Verduras de Temporada a la Brasa** – **16,90 €**
|
42 |
+
*(Espárragos, calabacín, berenjena, pimientos, cebolla, champiñones... con salsa romesco casera)*
|
43 |
+
- **Flores de Alcachofas Frescas a la Plancha con Lascas de Jamón Ibérico** – **17,90 €**
|
44 |
+
- **Alcachofas Confitadas a la Andaluza con Reducción de Pedro Ximénez** – **16,90 €**
|
45 |
+
- **Ensaladilla Rusa "Estilo Piera"** – **8,90 €**
|
46 |
+
*(Nuestra receta tradicional con ingredientes frescos y mayonesa casera)*
|
47 |
+
- **Patatas Bravas "Estilo Paco"** - **8,50 €**
|
48 |
+
*(Con nuestra salsa brava secreta y alioli suave)*
|
49 |
+
- **Morcilla de Burgos con Piñones Frita y Pimientos de Padrón** – **8,00 €**
|
50 |
+
- **Torreznos de Soria Crujientes D.O.** – **11,90 €**
|
51 |
+
- **Croquetas Cremosas Caseras de Jamón Ibérico de Bellota (8 uds.)** – **12,00 €**
|
52 |
+
- **Croquetas Caseras de Boletus Edulis y Trufa (8 uds.)** – **13,00 €**
|
53 |
+
- **Degustación de Croquetas (4 Jamón, 4 Boletus)** – **12,50 €**
|
54 |
+
- **Huevos Estrellados con Jamón Ibérico y Patatas Paja** – **15,50 €**
|
55 |
+
- **Huevos Estrellados con Gulas del Norte Salteadas y Gambas al Ajillo** – **15,90 €**
|
56 |
+
- **Huevos Estrellados con Chistorra de Navarra y Pimientos del Piquillo** – **14,90 €**
|
57 |
+
|
58 |
+
**DEL MAR:**
|
59 |
+
- **Anchoas del Cantábrico "00" en Salazón (8 lomos)** – **18,90 €**
|
60 |
+
*(Sobre lecho de tomate rallado y pan tostado)*
|
61 |
+
- **Boquerones de Málaga Frescos Fritos a la Andaluza** – **10,90 €**
|
62 |
+
- **Pulpo a la Brasa sobre Parmentier de Patata y Pimentón de la Vera** – **22,90 €**
|
63 |
+
- **Pulpo Frito con Alioli de Ajo Negro** - **22,90 €**
|
64 |
+
- **Chipirones Encebollados a Fuego Lento con Tinta y Arroz Blanco** – **18,90 €**
|
65 |
+
- **Chipirones Frescos Fritos a la Andaluza con Limón** – **18,90 €**
|
66 |
+
(½ ración **10,50 €**)
|
67 |
+
- **Calamar de Potera Fresco a la Plancha con Ajo y Perejil** – **20,50 €**
|
68 |
+
- **Calamar de Potera Fresco Frito a la Andaluza** – **19,00 €**
|
69 |
+
(½ ración **12,00 €**)
|
70 |
+
- **Mejillones de las Rías Baixas al Vapor o a la Marinera** – **12,90 €**
|
71 |
+
- **Gamba Blanca de Huelva Cocida, a la Plancha o al Ajillo (200g)** – **21,00 €**
|
72 |
+
- **Zamburiñas Frescas Gratinadas con Jamón Ibérico y Cebolla Caramelizada (6 uds.)** – **19,90 €**
|
73 |
+
- **Almejas Finas a la Marinera o al Ajillo** – **21,50 €**
|
74 |
+
|
75 |
+
---
|
76 |
+
|
77 |
+
## 🍲 LOS PUCHEROS DE LA ABUELA
|
78 |
+
*Platos de cuchara con sabor a tradición. Disponibles según temporada.*
|
79 |
+
|
80 |
+
- **Fabada Asturiana Tradicional con su Compango Casero** – **15,50 €**
|
81 |
+
- **Sopa Castellana con Huevo Escalfado y Virutas de Jamón** – **9,50 €**
|
82 |
+
- **Lentejas Estofadas con Chorizo y Verduras de la Huerta** – **12,00 €**
|
83 |
+
- **Callos a la Madrileña (receta de la casa)** – **14,50 €**
|
84 |
+
- **Potaje de Garbanzos con Espinacas y Bacalao (Viernes de Cuaresma y temporada)** - **13,50 €**
|
85 |
+
|
86 |
+
> *En verano, no te pierdas nuestro **Salmorejo Cordobés Casero** con virutas de jamón y huevo duro – **7,50 €** y nuestro **Gazpacho Andaluz Tradicional** - **7,00 €***
|
87 |
+
|
88 |
+
---
|
89 |
+
|
90 |
+
## 🥩 NUESTRAS CARNES A LA BRASA Y GUISADAS
|
91 |
+
*Selección de las mejores carnes nacionales, maduradas y cocinadas en nuestra parrilla de carbón de encina o guisadas con mimo.*
|
92 |
+
*(Todas nuestras carnes a la brasa se sirven con patatas fritas caseras y pimientos de Padrón)*
|
93 |
+
|
94 |
+
- **Entrecot de Vaca Madurada (350g aprox.)** – **23,00 €**
|
95 |
+
*(Maduración dry-aged mínima 30 días)*
|
96 |
+
- **Chuletón de Vaca Madurada (1,300 kg aprox., para 2 personas)** – **49,00 €**
|
97 |
+
*(Maduración dry-aged mínima 45 días, trinchado en mesa)*
|
98 |
+
- **Solomillo de Ternera Charra a la Parrilla (250g aprox.)** – **25,00 €**
|
99 |
+
- **Chuletillas de Cordero Lechal de Aranda D.O.** – **24,50 €**
|
100 |
+
- **Presa Ibérica de Bellota a la Parrilla** – **20,50 €**
|
101 |
+
*(Con mojo picón casero)*
|
102 |
+
- **Secreto Ibérico de Bellota a la Parrilla** – **19,50 €**
|
103 |
+
*(Con chimichurri artesanal)*
|
104 |
+
- **Parrillada de Cordero Lechal Deshuesado** – **19,50 €**
|
105 |
+
- **Rabo de Toro Estofado al Vino Tinto (Disponible según mercado, consultar)** – **25,00 €**
|
106 |
+
- **Mollejas de Cordero Lechal Salteadas al Ajillo o Empanadas** – **15,90 €**
|
107 |
+
- **Steak Tartar de Solomillo de Ternera (Preparado al momento a su gusto)** – **24,50 €**
|
108 |
+
- **Cochinillo Confitado y Crujiente (Cochifrito)** – **18,90 €**
|
109 |
+
- **Huevos Fritos de Corral con Jamón Ibérico de Bellota y Patatas Fritas Caseras** – **16,90 €**
|
110 |
+
- **Carrilleras de Cerdo Ibérico Estofadas al Pedro Ximénez con Puré de Boniato** – **18,50 €**
|
111 |
+
|
112 |
+
---
|
113 |
+
|
114 |
+
## 🔥 NUESTROS ASADOS TRADICIONALES (POR ENCARGO)
|
115 |
+
*Asados en horno de leña. Se recomienda encargar con 24h de antelación.*
|
116 |
+
|
117 |
+
- **Cordero Lechal Asado Entero (para 4-6 personas)** – Consultar precio
|
118 |
+
*(Cuarto delantero o trasero – **48,50 €**)*
|
119 |
+
- **Paletilla de Cordero Lechal Asada Individual** – **24,90 €**
|
120 |
+
- **¼ de Cabrito Lechal Asado (para 2-3 personas)** – **48,50 €**
|
121 |
+
- **Cochinillo Asado Entero (para 4-6 personas)** – Consultar precio
|
122 |
+
- **Ración de Cochinillo Tostón Asado** – **24,90 €**
|
123 |
+
|
124 |
+
> *Todos los asados se acompañan de patatas panaderas y ensalada mixta.*
|
125 |
+
|
126 |
+
---
|
127 |
+
|
128 |
+
## 🐟 PESCADOS FRESCOS DEL DÍA
|
129 |
+
*Directos de lonja, preparados a la brasa, al horno o según la tradición. Pregunte por nuestras sugerencias fuera de carta.*
|
130 |
+
|
131 |
+
- **Rodaballo Salvaje a la Parrilla con Bilbaína (pieza entera, para 2 pers.)** – S.P.M. (Según Precio de Mercado)
|
132 |
+
- **Bacalao Confitado a Baja Temperatura sobre Vizcaína o al Pil Pil** – **23,00 €**
|
133 |
+
- **Merluza de Pincho del Cantábrico a la Gallega, a la Romana o a la Plancha** – **19,90 €**
|
134 |
+
- **Lomo de Atún Rojo de Almadraba a la Plancha con Salsa de Soja y Sésamo** – **24,90 €**
|
135 |
+
- **Tartar de Atún Rojo de Almadraba con Aguacate y Mango** – **24,90 €**
|
136 |
+
- **Fritura Malagueña Variada (Boquerones, calamares, salmonetes, adobo...)** – **22,50 €**
|
137 |
+
(Para 2 personas)
|
138 |
+
- **Dados de Bacalao Fresco Fritos con Pimientos Asados** – **19,90 €**
|
139 |
+
- **Rodaballo Salvaje al Horno con Patatas Panaderas y Verduras (mín. 2-4 personas, pieza entera)** – S.P.M.
|
140 |
+
- **Lubina Salvaje a la Sal o a la Espalda (mín. 2 personas, pieza entera)** – **44,00 €**
|
141 |
+
- **Pargo Salvaje al Horno con Limón y Hierbas Provenzales (pieza entera)** – S.P.M.
|
142 |
+
- **Lenguado Fresco a la Plancha "Meunière"** – S.P.M.
|
143 |
+
|
144 |
+
---
|
145 |
+
|
146 |
+
## 🍚 ARROCES Y PAELLAS (MÍNIMO 2 PERSONAS)
|
147 |
+
*Nuestros arroces se elaboran al momento con ingredientes frescos. Tiempo de preparación aprox. 30-40 min. Precio por persona (P/P).*
|
148 |
+
|
149 |
+
- **Arroz a Banda Tradicional** – **17,90 €** P/P
|
150 |
+
*(Con delicioso fumet de pescado de roca y marisco, servido con alioli casero)*
|
151 |
+
- **Arroz del Senyoret (Marisco Pelado)** – **19,50 €** P/P
|
152 |
+
- **Arroz Negro con Sepia de Potera y Chipirones** – **18,50 €** P/P
|
153 |
+
- **Paella Valenciana Auténtica (Pollo de corral, conejo, judía verde y garrofó)** – **18,90 €** P/P
|
154 |
+
*(Por encargo preferiblemente)*
|
155 |
+
- **Paella de Marisco Fresco (Gambas, cigalas, mejillones, almejas...)** – **21,90 €** P/P
|
156 |
+
- **Arroz con Secreto Ibérico, Setas de Temporada y Espárragos Trigueros** – **17,90 €** P/P
|
157 |
+
- **Fideuá de Gandía con Marisco y Alioli** - **18,90 €** P/P
|
158 |
+
|
159 |
+
**ARROCES CALDOSOS Y MELOSOS:**
|
160 |
+
- **Arroz Caldoso de Bogavante Azul** – **23,90 €** P/P
|
161 |
+
- **Arroz Meloso de Carabineros y Nécoras** – **26,90 €** P/P
|
162 |
+
- **Arroz Meloso con Verduras de Temporada y Alcachofas** – **16,50 €** P/P (Opción Vegetariana)
|
163 |
+
|
164 |
+
---
|
165 |
+
|
166 |
+
## 🍰 POSTRES CASEROS
|
167 |
+
*El broche de oro perfecto para tu comida.*
|
168 |
+
|
169 |
+
- **Tarta de Queso Cremosa Casera al Horno con Frutos Rojos** – **7,50 €**
|
170 |
+
- **Flan de Huevo Casero con Nata Montada y Caramelo** – **6,50 €**
|
171 |
+
- **Arroz con Leche Asturiano Tradicional Caramelizado** – **7,00 €**
|
172 |
+
- **Tarta Fina de Manzana Reineta con Helado de Vainilla Bourbon** – **7,50 €**
|
173 |
+
- **Coulant de Chocolate Negro Belga (70%) con Corazón Fluido y Helado de Mandarina** – **7,50 €**
|
174 |
+
- **Milhojas de Hojaldre Crujiente con Crema Pastelera y Nata** – **7,50 €**
|
175 |
+
- **Torrijas Caseras de Leche y Canela con Helado de Leche Merengada (en temporada)** – **7,90 €**
|
176 |
+
- **Selección de Helados Artesanos (dos bolas a elegir)** – **6,50 €**
|
177 |
+
*(Vainilla, Chocolate, Fresa, Limón, Turrón, Leche Merengada)*
|
178 |
+
- **Sorbete de Limón al Cava o Mandarina** – **6,00 €**
|
179 |
+
- **Fruta Fresca de Temporada Preparada** – **5,50 €**
|
180 |
+
|
181 |
+
---
|
182 |
+
|
183 |
+
## 🥤 BEBIDAS
|
184 |
+
|
185 |
+
**AGUAS Y REFRESCOS:**
|
186 |
+
- Agua Mineral Natural (50cl) – **2,00 €**
|
187 |
+
- Agua con Gas (25cl) – **2,50 €**
|
188 |
+
- Refrescos (Coca-cola, Cola, Fanta Naranja, Limón, Tónica, etc.) – **2,80 €**
|
189 |
+
- Zumos Naturales (Naranja recién exprimido) – **4,00 €**
|
190 |
+
- Zumos Embotellados (Melocotón, Piña, Tomate) – **2,80 €**
|
191 |
+
|
192 |
+
**CERVEZAS:**
|
193 |
+
- Caña (Mahou Clásica / Tostada 0,0) – **2,50 €**
|
194 |
+
- Doble (Mahou Clásica / Tostada 0,0) – **3,20 €**
|
195 |
+
- Tercio Nacional (Mahou Cinco Estrellas, Alhambra Reserva 1925, Estrella Galicia) – **3,50 €**
|
196 |
+
- Cerveza Artesanal Local (Consultar selección) – desde **4,50 €**
|
197 |
+
- Clara con Limón o Casera – **3,00 €**
|
198 |
+
- Cerveza Sin Alcohol / Sin Gluten – **3,50 €**
|
199 |
+
|
200 |
+
**VINOS (Consultar nuestra Carta de Vinos completa):**
|
201 |
+
- **Vino de la Casa (Tinto, Blanco, Rosado D.O. La Mancha)**
|
202 |
+
- Copa – **2,80 €**
|
203 |
+
- Botella – **14,00 €**
|
204 |
+
- **Generosos de Jerez y Montilla-Moriles:**
|
205 |
+
- Fino, Manzanilla – Copa **2,50 €**
|
206 |
+
- Amontillado, Oloroso, Palo Cortado – Copa desde **3,50 €**
|
207 |
+
- Pedro Ximénez – Copa **4,00 €**
|
208 |
+
- *Amplia selección de D.O. Rioja, Ribera del Duero, Rueda, Rías Baixas, Toro, Priorat, Cava, Champagne, etc.*
|
209 |
+
|
210 |
+
**VERMUTS Y APERITIVOS:**
|
211 |
+
- Vermut de Grifo Casero (Rojo o Blanco) – **3,50 €**
|
212 |
+
- Selección de Vermuts Premium (Yzaguirre, Lustau, Miró...) – desde **4,50 €**
|
213 |
+
- Aperol Spritz, Campari Soda – **7,00 €**
|
214 |
+
|
215 |
+
**CAFÉS E INFUSIONES:**
|
216 |
+
- Café Solo, Cortado, Americano – **1,80 €**
|
217 |
+
- Café con Leche, Capuccino – **2,20 €**
|
218 |
+
- Carajillo (Brandy, Ron, Whisky, Anís) – **3,00 €**
|
219 |
+
- Infusiones Variadas (Té, Manzanilla, Poleo-Menta) – **2,00 €**
|
220 |
+
|
221 |
+
**LICORES Y COMBINADOS:**
|
222 |
+
- Chupito de Licor de Hierbas, Orujo, Pacharán – **3,00 €**
|
223 |
+
- Combinados Nacionales (Ron, Ginebra, Whisky) – **7,00 €**
|
224 |
+
- Combinados Premium (Consultar) – desde **9,00 €**
|
225 |
+
|
226 |
+
---
|
227 |
+
|
228 |
+
## 📝 INFORMACIÓN ADICIONAL
|
229 |
+
|
230 |
+
- **MENÚ DEL DÍA:** Disponible de Lunes a Viernes (excepto festivos).
|
231 |
+
*Incluye primero, segundo, pan, bebida y postre o café. Consultar pizarra.* – **14,50 €**
|
232 |
+
- **SUGERENCIAS DEL CHEF:** Pregunte por nuestras especialidades fuera de carta y productos de temporada.
|
233 |
+
- **PAN Y APERITIVO:** Servicio de pan artesano y aperitivo de la casa por persona – **1,80 €** (Opcional, comuníquelo si no lo desea).
|
234 |
+
- **ALÉRGENOS:** Este establecimiento dispone de información detallada sobre la presencia de alérgenos en nuestros productos. Por favor, consulte a nuestro personal.
|
235 |
+
- **RESERVAS:** Se recomienda reservar, especialmente fines de semana y festivos.
|
236 |
+
- **WIFI:** Disponemos de Wi-Fi gratuito para clientes.
|
237 |
+
- **IVA INCLUIDO** en todos los precios.
|
238 |
+
|
239 |
+
---
|
240 |
+
|
241 |
+
📞 **Reservas:** 91 579 10 18
|
242 |
+
📧 **Email:** [email protected]
|
243 |
+
🌐 **Web:** [www.elpacorestaurante.com](http://www.elpacorestaurante.com)
|
244 |
+
📷 **Instagram:** @barpacorestaurante
|
245 |
+
🐦 **Twitter:** @barpacorestaurante
|
246 |
+
|
247 |
+
*¡Gracias por su visita y buen provecho!*
|
model.py
ADDED
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from os import getenv
|
2 |
+
from typing import Optional
|
3 |
+
from langchain_openai import ChatOpenAI
|
4 |
+
|
5 |
+
class ModelManager:
|
6 |
+
"""
|
7 |
+
A class to manage the creation and configuration of language model instances.
|
8 |
+
Handles API keys and provides fallbacks if environment variables are not found.
|
9 |
+
"""
|
10 |
+
|
11 |
+
def __init__(
|
12 |
+
self,
|
13 |
+
api_key: Optional[str] = None,
|
14 |
+
api_base: Optional[str] = None,
|
15 |
+
model_name: str = "google/gemini-2.5-flash-preview",
|
16 |
+
helicone_api_key: Optional[str] = None,
|
17 |
+
):
|
18 |
+
"""
|
19 |
+
Initialize the ModelManager with the specified parameters.
|
20 |
+
|
21 |
+
Args:
|
22 |
+
api_key: The API key for the language model service. If None, will try to get from environment.
|
23 |
+
api_base: The base URL for the language model service. If None, will try to get from environment.
|
24 |
+
model_name: The name of the model to use.
|
25 |
+
helicone_api_key: The Helicone API key for logging. If None, will try to get from environment.
|
26 |
+
"""
|
27 |
+
|
28 |
+
self.api_key = api_key
|
29 |
+
if not self.api_key:
|
30 |
+
print("Warning: No API key found.")
|
31 |
+
|
32 |
+
self.api_base = api_base
|
33 |
+
if not self.api_base:
|
34 |
+
print("Warning: No API base URL found.")
|
35 |
+
|
36 |
+
self.helicone_api_key = helicone_api_key
|
37 |
+
|
38 |
+
self.model_name = model_name
|
39 |
+
|
40 |
+
def create_model(self, **kwargs) -> Optional[ChatOpenAI]:
|
41 |
+
"""
|
42 |
+
Create and return a configured language model instance.
|
43 |
+
|
44 |
+
Args:
|
45 |
+
**kwargs: Additional keyword arguments to pass to the model constructor.
|
46 |
+
|
47 |
+
Returns:
|
48 |
+
A configured ChatOpenAI instance, or None if required parameters are missing.
|
49 |
+
"""
|
50 |
+
# Check if required parameters are available
|
51 |
+
if not self.api_key or not self.api_base:
|
52 |
+
print("Error: Cannot create model. Missing required API key or base URL.")
|
53 |
+
return None
|
54 |
+
|
55 |
+
# Prepare model kwargs
|
56 |
+
model_kwargs = kwargs.get("model_kwargs", {})
|
57 |
+
|
58 |
+
# Add Helicone headers if available
|
59 |
+
if self.helicone_api_key:
|
60 |
+
extra_headers = model_kwargs.get("extra_headers", {})
|
61 |
+
extra_headers["Helicone-Auth"] = f"Bearer {self.helicone_api_key}"
|
62 |
+
model_kwargs["extra_headers"] = extra_headers
|
63 |
+
|
64 |
+
# Update kwargs with new model_kwargs
|
65 |
+
kwargs["model_kwargs"] = model_kwargs
|
66 |
+
|
67 |
+
# Create and return the model
|
68 |
+
try:
|
69 |
+
return ChatOpenAI(
|
70 |
+
openai_api_key=self.api_key,
|
71 |
+
openai_api_base=self.api_base,
|
72 |
+
model_name=self.model_name,
|
73 |
+
**kwargs
|
74 |
+
)
|
75 |
+
except Exception as e:
|
76 |
+
print(f"Error creating model: {str(e)}")
|
77 |
+
return None
|
requirements.txt
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# WAIter dependencies
|
2 |
+
|
3 |
+
# Core
|
4 |
+
gradio>=4.26.0
|
5 |
+
langchain>=0.1.0
|
6 |
+
langchain-openai>=0.0.5
|
7 |
+
langchain-huggingface>=0.0.2
|
8 |
+
langchain-text-splitters>=0.0.1
|
9 |
+
langchain-core>=0.1.17
|
10 |
+
|
11 |
+
# Language models
|
12 |
+
openai>=1.10.0
|
13 |
+
groq>=0.4.0
|
14 |
+
|
15 |
+
# Vector store
|
16 |
+
sentence-transformers>=2.2.2
|
17 |
+
|
18 |
+
# Audio processing
|
19 |
+
fastrtc>=0.6.0
|
20 |
+
elevenlabs>=0.2.24
|
21 |
+
numpy>=1.24.0
|
22 |
+
|
23 |
+
# Database
|
24 |
+
supabase>=2.0.0
|
25 |
+
|
26 |
+
# Utilities
|
27 |
+
python-dotenv>=1.0.0
|
28 |
+
qrcode>=7.4.2
|
29 |
+
pillow>=10.0.0
|
supabase_client.py
ADDED
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from supabase import create_client
|
2 |
+
import os
|
3 |
+
from dotenv import load_dotenv
|
4 |
+
from typing import Dict, List, Any, Optional
|
5 |
+
import json
|
6 |
+
import logging
|
7 |
+
|
8 |
+
from utils.classes import Order
|
9 |
+
# Configurar logging
|
10 |
+
logging.basicConfig(level=logging.INFO)
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
+
|
13 |
+
class SupabaseOrderManager:
|
14 |
+
def __init__(self, url: Optional[str] = None, key: Optional[str] = None):
|
15 |
+
"""
|
16 |
+
Inicializa el gestor de órdenes con Supabase.
|
17 |
+
|
18 |
+
Args:
|
19 |
+
url: URL de Supabase (opcional, por defecto usa variables de entorno)
|
20 |
+
key: Clave de API de Supabase (opcional, por defecto usa variables de entorno)
|
21 |
+
"""
|
22 |
+
# Cargar variables de entorno
|
23 |
+
load_dotenv()
|
24 |
+
|
25 |
+
# Usar parámetros proporcionados o variables de entorno
|
26 |
+
self.supabase_url = url or os.getenv("SUPABASE_URL")
|
27 |
+
self.supabase_key = key or os.getenv("SUPABASE_KEY")
|
28 |
+
|
29 |
+
if not self.supabase_url or not self.supabase_key:
|
30 |
+
raise ValueError("SUPABASE_URL y SUPABASE_KEY deben estar definidos como variables de entorno o proporcionados como parámetros")
|
31 |
+
|
32 |
+
# Inicializar cliente
|
33 |
+
self.supabase = create_client(self.supabase_url, self.supabase_key)
|
34 |
+
logger.info("Cliente Supabase inicializado correctamente")
|
35 |
+
|
36 |
+
async def send_order(self, order: Order) -> Dict[str, Any]:
|
37 |
+
"""
|
38 |
+
Envía una orden a Supabase.
|
39 |
+
|
40 |
+
Args:
|
41 |
+
order: Objeto Order del sistema del restaurante
|
42 |
+
|
43 |
+
Returns:
|
44 |
+
Diccionario con el resultado de la operación
|
45 |
+
"""
|
46 |
+
try:
|
47 |
+
# Convertir el objeto Order a un diccionario
|
48 |
+
order_dict = order.to_dict()
|
49 |
+
|
50 |
+
# 1. Insertar la orden principal
|
51 |
+
order_data = {
|
52 |
+
"order_id": order_dict["order_id"],
|
53 |
+
"table_number": str(order_dict["table_number"]),
|
54 |
+
"special_instructions": order_dict["special_instructions"],
|
55 |
+
"status": "pending"
|
56 |
+
}
|
57 |
+
|
58 |
+
# Realizar la inserción de la orden
|
59 |
+
order_response = self.supabase.table("orders").insert(order_data).execute()
|
60 |
+
|
61 |
+
if not order_response.data:
|
62 |
+
raise Exception("Error al insertar orden en Supabase")
|
63 |
+
|
64 |
+
# Obtener el ID de la orden insertada
|
65 |
+
db_order_id = order_response.data[0]["id"]
|
66 |
+
|
67 |
+
# 2. Insertar los elementos de la orden
|
68 |
+
items_to_insert = []
|
69 |
+
for item in order_dict["items"]:
|
70 |
+
items_to_insert.append({
|
71 |
+
"order_id": db_order_id,
|
72 |
+
"name": item["name"],
|
73 |
+
"quantity": item.get("quantity", 1),
|
74 |
+
"variations": item.get("variations", "")
|
75 |
+
})
|
76 |
+
|
77 |
+
# Realizar la inserción de los elementos
|
78 |
+
if items_to_insert:
|
79 |
+
items_response = self.supabase.table("order_items").insert(items_to_insert).execute()
|
80 |
+
|
81 |
+
if not items_response.data:
|
82 |
+
# Si falla la inserción de items, eliminamos la orden para mantener consistencia
|
83 |
+
self.supabase.table("orders").delete().eq("id", db_order_id).execute()
|
84 |
+
raise Exception("Error al insertar elementos de la orden en Supabase")
|
85 |
+
|
86 |
+
logger.info(f"Orden {order_dict['order_id']} enviada correctamente a Supabase")
|
87 |
+
return {
|
88 |
+
"success": True,
|
89 |
+
"order_id": order_dict["order_id"],
|
90 |
+
"database_id": db_order_id
|
91 |
+
}
|
92 |
+
|
93 |
+
except Exception as e:
|
94 |
+
logger.error(f"Error al enviar orden a Supabase: {str(e)}")
|
95 |
+
return {
|
96 |
+
"success": False,
|
97 |
+
"error": str(e)
|
98 |
+
}
|
99 |
+
|
tools.py
ADDED
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from langchain.tools import Tool
|
2 |
+
from langchain_core.vectorstores import VectorStoreRetriever
|
3 |
+
from typing import Optional
|
4 |
+
|
5 |
+
from utils.logger import log_info, log_warn, log_error, log_debug
|
6 |
+
import json
|
7 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
8 |
+
from langchain_openai import ChatOpenAI
|
9 |
+
from utils.classes import Order
|
10 |
+
|
11 |
+
from supabase_client import SupabaseOrderManager
|
12 |
+
import asyncio
|
13 |
+
|
14 |
+
try:
|
15 |
+
supabase = SupabaseOrderManager()
|
16 |
+
except Exception as e:
|
17 |
+
log_error(f"Error al inicializar el cliente de Supabase: {e}")
|
18 |
+
supabase = None
|
19 |
+
|
20 |
+
|
21 |
+
def create_menu_info_tool(retriever: VectorStoreRetriever) -> Tool:
|
22 |
+
"""
|
23 |
+
Crea una herramienta para extraer información relevante del menú del restaurante.
|
24 |
+
|
25 |
+
Args:
|
26 |
+
retriever: Un retriever configurado para buscar en la base de conocimiento del menú
|
27 |
+
|
28 |
+
Returns:
|
29 |
+
Una herramienta de LangChain para consultar información del menú
|
30 |
+
"""
|
31 |
+
def extract_text(query: str) -> str:
|
32 |
+
"""Extrae texto relevante del menú basado en la consulta."""
|
33 |
+
results = retriever.invoke(query)
|
34 |
+
log_info("ENTRADA DE EXTRACCIÓN DE TEXTO")
|
35 |
+
|
36 |
+
result_texts = []
|
37 |
+
if results:
|
38 |
+
log_info("\n=== Fragmentos de documento utilizados para la respuesta ===")
|
39 |
+
for i, result in enumerate(results):
|
40 |
+
log_info(f"Fragmento {i+1}: {result.page_content[:100]}...")
|
41 |
+
|
42 |
+
# Comprobar si tiene score (algunos retrievers no incluyen este atributo)
|
43 |
+
if hasattr(result, 'score'):
|
44 |
+
log_info(f"Score: {result.score}")
|
45 |
+
|
46 |
+
result_texts.append(result.page_content)
|
47 |
+
log_info("=========================================================\n")
|
48 |
+
|
49 |
+
# Unir los resultados relevantes
|
50 |
+
return "\n\n".join(result_texts)
|
51 |
+
else:
|
52 |
+
return "Lo siento, no tengo información sobre eso."
|
53 |
+
|
54 |
+
return Tool(
|
55 |
+
name="guest_info_tool",
|
56 |
+
description="""Herramienta para consultar información detallada del menú del restaurante.
|
57 |
+
Úsala cuando necesites:
|
58 |
+
- Buscar platos específicos y verificar su disponibilidad
|
59 |
+
- Consultar precios exactos de productos
|
60 |
+
- Obtener información sobre ingredientes, alérgenos o composición de platos
|
61 |
+
- Explorar secciones del menú (entrantes, principales, postres, bebidas, etc.)
|
62 |
+
- Verificar la existencia de productos antes de recomendarlos
|
63 |
+
- Responder preguntas específicas sobre la carta del restaurante
|
64 |
+
|
65 |
+
Esta herramienta accede al contenido completo del menú para proporcionar información precisa y actualizada.""",
|
66 |
+
func=extract_text,
|
67 |
+
)
|
68 |
+
|
69 |
+
def create_send_to_kitchen_tool(llm: ChatOpenAI) -> Tool:
|
70 |
+
"""
|
71 |
+
Crea una herramienta para enviar pedidos a la cocina.
|
72 |
+
|
73 |
+
Args:
|
74 |
+
llm: Un modelo de lenguaje para analizar la conversación
|
75 |
+
|
76 |
+
Returns:
|
77 |
+
Una herramienta de LangChain para enviar pedidos a la cocina
|
78 |
+
"""
|
79 |
+
def extract_order_from_summary(conversation_summary: str) -> Order:
|
80 |
+
"""
|
81 |
+
Usa un LLM para extraer detalles del pedido a partir de un resumen de la conversación.
|
82 |
+
|
83 |
+
Args:
|
84 |
+
conversation_summary: Resumen de la conversación entre cliente y camarero
|
85 |
+
|
86 |
+
Returns:
|
87 |
+
Objeto Order con los detalles del pedido extraído
|
88 |
+
"""
|
89 |
+
# Crear mensaje para el LLM
|
90 |
+
messages = [
|
91 |
+
SystemMessage(content="""
|
92 |
+
Eres un asistente especializado en extraer información de pedidos de restaurante.
|
93 |
+
Analiza el siguiente resumen de conversación entre un cliente y un camarero.
|
94 |
+
Extrae SOLO los elementos del pedido (platos, bebidas, etc.), cantidades, y cualquier instrucción especial.
|
95 |
+
Devuelve los resultados en formato JSON entre las etiquetas <order> </order> estrictamente con esta estructura:
|
96 |
+
{
|
97 |
+
"table_number": número_de_mesa (entero o "desconocida" si no se especifica),
|
98 |
+
"items": [
|
99 |
+
{
|
100 |
+
"name": "nombre_del_plato",
|
101 |
+
"quantity": cantidad (entero, por defecto 1),
|
102 |
+
"variations": "variaciones o personalizaciones"
|
103 |
+
},
|
104 |
+
...
|
105 |
+
],
|
106 |
+
"special_instructions": "instrucciones especiales generales"
|
107 |
+
}
|
108 |
+
No incluyas ninguna otra información o explicación, SOLO el JSON entre las etiquetas.
|
109 |
+
"""),
|
110 |
+
HumanMessage(content=f"Resumen de la conversación: {conversation_summary}")
|
111 |
+
]
|
112 |
+
|
113 |
+
# Invocar el LLM para obtener el análisis del pedido
|
114 |
+
response = llm.invoke(messages)
|
115 |
+
response_text = response.content
|
116 |
+
|
117 |
+
# Extraer el JSON de la respuesta usando las etiquetas <order></order>
|
118 |
+
try:
|
119 |
+
# Buscar contenido entre etiquetas <order> y </order>
|
120 |
+
import re
|
121 |
+
order_pattern = re.compile(r'<order>(.*?)</order>', re.DOTALL)
|
122 |
+
order_match = order_pattern.search(response_text)
|
123 |
+
|
124 |
+
if order_match:
|
125 |
+
# Extraer el contenido JSON de las etiquetas
|
126 |
+
json_str = order_match.group(1).strip()
|
127 |
+
order_data = json.loads(json_str)
|
128 |
+
|
129 |
+
# Crear objeto Order con los datos extraídos
|
130 |
+
return Order(
|
131 |
+
items=order_data.get("items", []),
|
132 |
+
special_instructions=order_data.get("special_instructions", ""),
|
133 |
+
table_number=order_data.get("table_number", "desconocida")
|
134 |
+
)
|
135 |
+
else:
|
136 |
+
# Si no hay etiquetas, reportar error
|
137 |
+
log_error("No se encontraron etiquetas <order> en la respuesta del LLM")
|
138 |
+
# Devolver un objeto Order vacío con un flag de error
|
139 |
+
empty_order = Order(table_number="desconocida")
|
140 |
+
empty_order.error = "NO_TAGS_FOUND"
|
141 |
+
return empty_order
|
142 |
+
|
143 |
+
except json.JSONDecodeError as e:
|
144 |
+
log_error(f"Error al parsear JSON de la respuesta del LLM: {e}")
|
145 |
+
log_debug(f"Respuesta problemática: {response_text}")
|
146 |
+
empty_order = Order(table_number="desconocida")
|
147 |
+
empty_order.error = "JSON_PARSE_ERROR"
|
148 |
+
return empty_order
|
149 |
+
except Exception as e:
|
150 |
+
log_error(f"Error inesperado al procesar la respuesta: {e}")
|
151 |
+
log_debug(f"Respuesta completa: {response_text}")
|
152 |
+
empty_order = Order(table_number="desconocida")
|
153 |
+
empty_order.error = "UNKNOWN_ERROR"
|
154 |
+
return empty_order
|
155 |
+
|
156 |
+
def send_to_kitchen(conversation_summary: str) -> str:
|
157 |
+
"""
|
158 |
+
Procesa el resumen de la conversación para extraer el pedido y enviarlo a la cocina.
|
159 |
+
|
160 |
+
Args:
|
161 |
+
conversation_summary: Resumen de la conversación cliente-camarero
|
162 |
+
|
163 |
+
Returns:
|
164 |
+
Mensaje de confirmación
|
165 |
+
"""
|
166 |
+
try:
|
167 |
+
log_info(f"Procesando resumen para enviar pedido a cocina...")
|
168 |
+
log_debug(f"Resumen recibido: {conversation_summary}")
|
169 |
+
|
170 |
+
# Extraer el pedido a partir del resumen
|
171 |
+
order = extract_order_from_summary(conversation_summary)
|
172 |
+
|
173 |
+
# Verificar si hay un error en el procesamiento
|
174 |
+
if hasattr(order, 'error') and order.error:
|
175 |
+
if order.error == "NO_TAGS_FOUND":
|
176 |
+
log_error("No se encontraron las etiquetas <order> en la respuesta del LLM")
|
177 |
+
return "Lo siento, ha ocurrido un problema al procesar su pedido. Por favor, inténtelo de nuevo."
|
178 |
+
elif order.error == "JSON_PARSE_ERROR":
|
179 |
+
log_error("Error al analizar el JSON en las etiquetas <order>")
|
180 |
+
return "Ha ocurrido un error técnico al procesar su pedido. ¿Podría repetirlo de otra forma?"
|
181 |
+
else:
|
182 |
+
log_error(f"Error desconocido: {order.error}")
|
183 |
+
return "Lo siento, algo salió mal al procesar su pedido. Por favor, inténtelo de nuevo."
|
184 |
+
|
185 |
+
# Verificar si hay elementos en el pedido
|
186 |
+
if not order.items:
|
187 |
+
log_warn("No se identificaron artículos en el pedido")
|
188 |
+
return "No se pudo identificar ningún artículo en el pedido. ¿Podría repetir su pedido, por favor?"
|
189 |
+
|
190 |
+
# Simular envío a la cocina
|
191 |
+
order_dict = order.to_dict()
|
192 |
+
log_info(f"ENVIANDO PEDIDO A COCINA: {json.dumps(order_dict, indent=2, ensure_ascii=False)}")
|
193 |
+
|
194 |
+
# Aquí iría la integración real con el sistema de la cocina
|
195 |
+
# Por ejemplo, enviar a una API, base de datos, etc.
|
196 |
+
async def async_send_and_get_result(order):
|
197 |
+
return await supabase.send_order(order)
|
198 |
+
|
199 |
+
res = asyncio.run(async_send_and_get_result(order))
|
200 |
+
if res.get("success"):
|
201 |
+
log_info(f"Pedido enviado correctamente a la cocina: {res['order_id']}")
|
202 |
+
return f"Su pedido ha sido enviado a la cocina. ID de pedido: {res['order_id']}"
|
203 |
+
else:
|
204 |
+
log_error(f"Error al enviar el pedido a la cocina: {res.get('error', 'Desconocido')}")
|
205 |
+
return "Lo siento, hubo un problema al enviar su pedido a la cocina. ¿Podría intentarlo de nuevo?"
|
206 |
+
|
207 |
+
except Exception as e:
|
208 |
+
log_error(f"Error al procesar pedido: {e}")
|
209 |
+
log_debug(f"Error detallado: {str(e)}")
|
210 |
+
import traceback
|
211 |
+
log_debug(traceback.format_exc())
|
212 |
+
return "Lo siento, hubo un problema al procesar su pedido. ¿Podría intentarlo de nuevo?"
|
213 |
+
|
214 |
+
# Retornar la herramienta configurada con la función send_to_kitchen
|
215 |
+
return Tool(
|
216 |
+
name="send_to_kitchen_tool",
|
217 |
+
description="""
|
218 |
+
Envía el pedido completo a la cocina. Usa esta herramienta SOLAMENTE cuando el cliente haya terminado de hacer su pedido
|
219 |
+
completo y esté listo para enviarlo.
|
220 |
+
|
221 |
+
Esta herramienta espera recibir un RESUMEN de la conversación que describe los elementos del pedido.
|
222 |
+
No envíes la conversación completa, solo un resumen claro de lo que el cliente ha pedido, la mesa,
|
223 |
+
y cualquier instrucción especial relevante.
|
224 |
+
""",
|
225 |
+
func=send_to_kitchen,
|
226 |
+
)
|
utils/classes.py
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from typing import Dict, Any
|
2 |
+
from datetime import datetime
|
3 |
+
|
4 |
+
class Order:
|
5 |
+
"""Representa un pedido de comida para la cocina."""
|
6 |
+
def __init__(self, items=None, special_instructions=None, table_number=None):
|
7 |
+
self.items = items or []
|
8 |
+
self.special_instructions = special_instructions or ""
|
9 |
+
self.table_number = table_number
|
10 |
+
self.timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
11 |
+
self.order_id = f"ORD-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
12 |
+
self.error = None # Campo para indicar si hay un error
|
13 |
+
|
14 |
+
def add_item(self, item: Dict[str, Any]):
|
15 |
+
"""Añade un elemento al pedido."""
|
16 |
+
self.items.append(item)
|
17 |
+
|
18 |
+
def to_dict(self):
|
19 |
+
"""Convierte el pedido a un diccionario."""
|
20 |
+
return {
|
21 |
+
"order_id": self.order_id,
|
22 |
+
"timestamp": self.timestamp,
|
23 |
+
"table_number": self.table_number,
|
24 |
+
"items": self.items,
|
25 |
+
"special_instructions": self.special_instructions,
|
26 |
+
"error": self.error
|
27 |
+
}
|
28 |
+
|
29 |
+
def __str__(self):
|
30 |
+
"""Representación en texto del pedido."""
|
31 |
+
if self.error:
|
32 |
+
return f"Pedido con error: {self.error}"
|
33 |
+
|
34 |
+
items_str = ", ".join([f"{item.get('quantity', 1)}x {item.get('name', 'item')}" for item in self.items])
|
35 |
+
return f"Pedido {self.order_id} - Mesa {self.table_number}: {items_str}"
|
utils/functions.py
ADDED
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import aiohttp
|
2 |
+
from typing import List, Dict, Any
|
3 |
+
|
4 |
+
async def fetch_openrouter_models() -> List[Dict[str, Any]]:
|
5 |
+
"""
|
6 |
+
Asynchronously fetch available models from the OpenRouter API.
|
7 |
+
|
8 |
+
Returns:
|
9 |
+
A list of dictionaries containing model information.
|
10 |
+
Empty list if the request fails.
|
11 |
+
"""
|
12 |
+
# Define the endpoint
|
13 |
+
models_endpoint = "https://openrouter.ai/api/v1/models"
|
14 |
+
|
15 |
+
try:
|
16 |
+
# Prepare headers
|
17 |
+
headers = {
|
18 |
+
"Content-Type": "application/json"
|
19 |
+
}
|
20 |
+
|
21 |
+
# Make the async request
|
22 |
+
async with aiohttp.ClientSession() as session:
|
23 |
+
async with session.get(models_endpoint, headers=headers) as response:
|
24 |
+
# Check if request was successful
|
25 |
+
if response.status == 200:
|
26 |
+
data = await response.json()
|
27 |
+
return data.get("data", [])
|
28 |
+
else:
|
29 |
+
error_text = await response.text()
|
30 |
+
print(f"Error fetching models: HTTP {response.status} - {error_text}")
|
31 |
+
return []
|
32 |
+
|
33 |
+
except Exception as e:
|
34 |
+
print(f"Error fetching models: {str(e)}")
|
35 |
+
return []
|
36 |
+
|
37 |
+
|
38 |
+
async def list_openrouter_models_summary() -> List[Dict[str, Any]]:
|
39 |
+
"""
|
40 |
+
Asynchronously returns a simplified list of available OpenRouter models with key information.
|
41 |
+
|
42 |
+
Returns:
|
43 |
+
A list of dictionaries with model ID, name, context length, and pricing.
|
44 |
+
"""
|
45 |
+
models = await fetch_openrouter_models()
|
46 |
+
|
47 |
+
summary = []
|
48 |
+
for model in models:
|
49 |
+
summary.append({
|
50 |
+
"id": model.get("id"),
|
51 |
+
"name": model.get("name"),
|
52 |
+
"context_length": model.get("context_length"),
|
53 |
+
"modality": model.get("architecture", {}).get("modality"),
|
54 |
+
"pricing": {
|
55 |
+
"prompt": model.get("pricing", {}).get("prompt"),
|
56 |
+
"completion": model.get("pricing", {}).get("completion")
|
57 |
+
}
|
58 |
+
})
|
59 |
+
|
60 |
+
return summary
|
utils/logger.py
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Módulo de logging para la aplicación wAIter.
|
3 |
+
Proporciona funciones para imprimir logs en colores según su importancia.
|
4 |
+
"""
|
5 |
+
|
6 |
+
class Colors:
|
7 |
+
"""Constantes de colores ANSI para los logs en terminal."""
|
8 |
+
RESET = "\033[0m"
|
9 |
+
RED = "\033[91m"
|
10 |
+
GREEN = "\033[92m"
|
11 |
+
YELLOW = "\033[93m"
|
12 |
+
BLUE = "\033[94m"
|
13 |
+
MAGENTA = "\033[95m"
|
14 |
+
CYAN = "\033[96m"
|
15 |
+
WHITE = "\033[97m"
|
16 |
+
BOLD = "\033[1m"
|
17 |
+
|
18 |
+
def log_info(msg):
|
19 |
+
"""Log información general (azul cian)."""
|
20 |
+
print(f"{Colors.CYAN}[INFO] {msg}{Colors.RESET}")
|
21 |
+
|
22 |
+
def log_warn(msg):
|
23 |
+
"""Log advertencias (amarillo)."""
|
24 |
+
print(f"{Colors.YELLOW}[WARN] {msg}{Colors.RESET}")
|
25 |
+
|
26 |
+
def log_error(msg):
|
27 |
+
"""Log errores (rojo)."""
|
28 |
+
print(f"{Colors.RED}[ERROR] {msg}{Colors.RESET}")
|
29 |
+
|
30 |
+
def log_success(msg):
|
31 |
+
"""Log éxitos/completados (verde)."""
|
32 |
+
print(f"{Colors.GREEN}[SUCCESS] {msg}{Colors.RESET}")
|
33 |
+
|
34 |
+
def log_debug(msg):
|
35 |
+
"""Log depuración detallada (magenta)."""
|
36 |
+
print(f"{Colors.MAGENTA}[DEBUG] {msg}{Colors.RESET}")
|