Spaces:
Sleeping
Sleeping
| """ | |
| Atención al Cliente Multiagente — Caso práctico Clase 11 | |
| Caso: El usuario manda UN solo mensaje con preguntas de DISTINTOS temas: | |
| "Hola cómo estás? Cuál es la dirección de la sede de Medellín, | |
| cuál es la misión de la empresa y cuánto vale la torta americana?" | |
| Flujo: | |
| 1. SUPERVISOR analiza el mensaje y clasifica cada pregunta por tema | |
| 2. Despacha a los WORKERS especializados (solo los que apliquen) | |
| 3. Cada WORKER responde usando su base de conocimiento | |
| 4. SUPERVISOR recopila todo y arma UNA respuesta unificada | |
| Arquitectura (grafo): | |
| START → supervisor → greeting_worker ─┐ | |
| → company_worker ──┤→ aggregator → END | |
| → products_worker ──┘ | |
| Usa GPT-OSS-120B vía Groq (OpenAI-compatible endpoint) | |
| """ | |
| import os | |
| import operator | |
| from typing import Literal | |
| from typing_extensions import TypedDict, Annotated | |
| from dotenv import load_dotenv | |
| from pydantic import BaseModel, Field | |
| from langchain_openai import ChatOpenAI | |
| from langchain_core.messages import ( | |
| AnyMessage, | |
| SystemMessage, | |
| HumanMessage, | |
| ) | |
| from langgraph.graph import StateGraph, START, END | |
| load_dotenv() | |
| # ─── Colores ANSI ─────────────────────────────────────────── | |
| _BOLD = "\033[1m" | |
| _DIM = "\033[2m" | |
| _RESET = "\033[0m" | |
| _BLUE = "\033[34m" | |
| _CYAN = "\033[36m" | |
| _YELLOW = "\033[33m" | |
| _MAGENTA = "\033[35m" | |
| _GREEN = "\033[32m" | |
| _RED = "\033[31m" | |
| _WHITE = "\033[97m" | |
| AGENT_META = { | |
| "supervisor": { | |
| "color": _BLUE, | |
| "icon": "🧠", | |
| "label": "SUPERVISOR", | |
| "desc": "Analiza el mensaje, clasifica cada pregunta por tema y asigna workers", | |
| }, | |
| "greeting_worker": { | |
| "color": _GREEN, | |
| "icon": "👋", | |
| "label": "WORKER SALUDOS", | |
| "desc": "Maneja saludos y conversación casual con el cliente", | |
| }, | |
| "company_worker": { | |
| "color": _CYAN, | |
| "icon": "🏢", | |
| "label": "WORKER EMPRESA", | |
| "desc": "Responde sobre sedes, misión, horarios y datos de la empresa", | |
| }, | |
| "products_worker": { | |
| "color": _YELLOW, | |
| "icon": "🛒", | |
| "label": "WORKER PRODUCTOS", | |
| "desc": "Consulta catálogo de productos, precios y disponibilidad", | |
| }, | |
| "aggregator": { | |
| "color": _MAGENTA, | |
| "icon": "📝", | |
| "label": "AGREGADOR", | |
| "desc": "Combina las respuestas de todos los workers en una respuesta unificada", | |
| }, | |
| } | |
| # ─── LLM via Groq (OpenAI-compatible) ─────────────────────── | |
| llm = ChatOpenAI( | |
| model=os.getenv("GROQ_MODEL", "openai/gpt-oss-120b"), | |
| api_key=os.getenv("GROQ_API_KEY"), | |
| base_url=os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1"), | |
| temperature=0, | |
| max_tokens=2048, | |
| ) | |
| # ─── BASES DE CONOCIMIENTO (simuladas) ────────────────────── | |
| # En producción esto vendría de ChromaDB / RAG pipeline (clases 5-7) | |
| KB_EMPRESA = """ | |
| INFORMACIÓN DE LA EMPRESA — Pastelería La Delicia | |
| MISIÓN: Endulzar la vida de nuestros clientes con productos artesanales | |
| de la más alta calidad, elaborados con ingredientes frescos y recetas | |
| tradicionales, brindando una experiencia cálida y memorable en cada visita. | |
| VISIÓN: Ser la cadena de pastelerías artesanales más querida de Colombia | |
| para 2027, reconocida por su calidad, innovación y servicio excepcional. | |
| SEDES: | |
| - Medellín: Calle 10 #43A-25, El Poblado. Tel: (604) 444-5678. Horario: Lun-Sáb 7am-8pm, Dom 8am-6pm. | |
| - Bogotá: Carrera 11 #82-71, Zona G. Tel: (601) 321-9876. Horario: Lun-Sáb 7am-9pm, Dom 8am-7pm. | |
| - Cali: Avenida 6N #17-50, Granada. Tel: (602) 555-1234. Horario: Lun-Sáb 8am-8pm, Dom 9am-5pm. | |
| FUNDACIÓN: 2015, Medellín, Colombia. | |
| CONTACTO: info@ladelicia.com.co | WhatsApp: +57 300 123 4567 | |
| """ | |
| KB_PRODUCTOS = """ | |
| CATÁLOGO DE PRODUCTOS — Pastelería La Delicia (precios en COP) | |
| TORTAS: | |
| - Torta Americana (chocolate con frosting de vainilla): $45.000 porción / $180.000 entera (20 porciones) | |
| - Torta de Zanahoria: $42.000 porción / $165.000 entera | |
| - Torta Red Velvet: $48.000 porción / $190.000 entera | |
| - Torta de Tres Leches: $38.000 porción / $150.000 entera | |
| - Torta Selva Negra: $50.000 porción / $200.000 entera | |
| PANES: | |
| - Pan artesanal (hogaza): $12.000 | |
| - Croissant de mantequilla: $8.500 | |
| - Pan de queso (6 unidades): $15.000 | |
| BEBIDAS: | |
| - Café americano: $6.000 | |
| - Cappuccino: $9.500 | |
| - Chocolate caliente: $8.000 | |
| - Jugo natural: $10.000 | |
| COMBOS: | |
| - Combo Desayuno (croissant + café): $14.000 | |
| - Combo Tarde (porción de torta + bebida caliente): $18.000 (ahorro de $5.500) | |
| Nota: Precios incluyen IVA. Domicilios disponibles vía app con costo adicional. | |
| """ | |
| # ─── CONTRATO: Clasificación del Supervisor ────────────────── | |
| class QuestionClassification(BaseModel): | |
| """El supervisor clasifica cada pregunta del usuario por tema.""" | |
| greeting: str = Field( | |
| default="", | |
| description="Texto del saludo o conversación casual. Vacío si no hay saludo.", | |
| ) | |
| company: str = Field( | |
| default="", | |
| description="Preguntas sobre la empresa: sedes, misión, visión, horarios, contacto. Vacío si no hay.", | |
| ) | |
| products: str = Field( | |
| default="", | |
| description="Preguntas sobre productos, precios, menú, disponibilidad. Vacío si no hay.", | |
| ) | |
| #Fallback | |
| # ─── ESTADO COMPARTIDO ────────────────────────────────────── | |
| class CustomerState(TypedDict): | |
| """Estado compartido entre el supervisor y los workers.""" | |
| messages: Annotated[list[AnyMessage], operator.add] | |
| # Clasificación del supervisor | |
| greeting_question: str | |
| company_question: str | |
| products_question: str | |
| # Respuestas de cada worker | |
| greeting_response: str | |
| company_response: str | |
| products_response: str | |
| # Respuesta final | |
| final_response: str | |
| # ─── NODO 1: SUPERVISOR ───────────────────────────────────── | |
| def supervisor(state: CustomerState) -> dict: | |
| """ | |
| Analiza el mensaje del cliente y clasifica cada parte por tema. | |
| Es el "cerebro" que decide qué workers necesitan trabajar. | |
| """ | |
| user_msg = state["messages"][-1].content | |
| classifier = llm.with_structured_output(QuestionClassification) | |
| try: | |
| result = classifier.invoke([ | |
| SystemMessage(content=( | |
| "Eres el supervisor de atención al cliente de una pastelería.\n" | |
| "El cliente puede mandar UN solo mensaje con varias preguntas mezcladas.\n\n" | |
| "Tu trabajo: separar y clasificar cada parte del mensaje en categorías:\n" | |
| "- greeting: saludos, despedidas, conversación casual\n" | |
| "- company: preguntas sobre la empresa (sedes, dirección, misión, visión, horarios)\n" | |
| "- products: preguntas sobre productos, precios, menú, disponibilidad\n\n" | |
| "Copia el texto EXACTO de cada pregunta en su campo correspondiente.\n" | |
| "Si una categoría no aplica, déjala vacía.\n" | |
| "Una pregunta puede pertenecer a UNA sola categoría." | |
| )), | |
| HumanMessage(content=f"Mensaje del cliente:\n{user_msg}"), | |
| ]) | |
| return { | |
| "greeting_question": result.greeting, | |
| "company_question": result.company, | |
| "products_question": result.products, | |
| } | |
| except Exception as e: | |
| # Fallback: todo va al worker de empresa | |
| return { | |
| "greeting_question": "", | |
| "company_question": user_msg, | |
| "products_question": "", | |
| } | |
| # ─── NODO 2: WORKER DE SALUDOS ────────────────────────────── | |
| def greeting_worker(state: CustomerState) -> dict: | |
| """ | |
| Maneja la parte social: saludos, despedidas, conversación casual. | |
| No necesita base de conocimiento — solo ser amable. | |
| """ | |
| question = state.get("greeting_question", "") | |
| if not question.strip(): | |
| return {"greeting_response": ""} | |
| response = llm.invoke([ | |
| SystemMessage(content=( | |
| "Eres un agente amable de atención al cliente de 'Pastelería La Delicia'.\n" | |
| "Responde SOLO al saludo o conversación casual del cliente.\n" | |
| "Sé cálido, breve y profesional. Máximo 1-2 oraciones.\n" | |
| "NO respondas preguntas sobre productos o la empresa aquí." | |
| )), | |
| HumanMessage(content=question), | |
| ]) | |
| return {"greeting_response": response.content} | |
| # ─── NODO 3: WORKER DE EMPRESA ────────────────────────────── | |
| def company_worker(state: CustomerState) -> dict: | |
| """ | |
| Responde preguntas sobre la empresa usando la base de conocimiento. | |
| Sedes, misión, visión, horarios, contacto. | |
| """ | |
| question = state.get("company_question", "") | |
| if not question.strip(): | |
| return {"company_response": ""} | |
| response = llm.invoke([ | |
| SystemMessage(content=( | |
| "Eres un agente de atención al cliente de 'Pastelería La Delicia'.\n" | |
| "Responde SOLO con información de la base de conocimiento proporcionada.\n" | |
| "Si la información no está en la base, di que no tienes esa información.\n" | |
| "Sé preciso y conciso.\n\n" | |
| f"BASE DE CONOCIMIENTO:\n{KB_EMPRESA}" | |
| )), | |
| HumanMessage(content=question), | |
| ]) | |
| return {"company_response": response.content} | |
| # ─── NODO 4: WORKER DE PRODUCTOS ──────────────────────────── | |
| def products_worker(state: CustomerState) -> dict: | |
| """ | |
| Responde preguntas sobre productos, precios y disponibilidad. | |
| Consulta el catálogo de la pastelería. | |
| """ | |
| question = state.get("products_question", "") | |
| if not question.strip(): | |
| return {"products_response": ""} | |
| response = llm.invoke([ | |
| SystemMessage(content=( | |
| "Eres un agente de atención al cliente de 'Pastelería La Delicia'.\n" | |
| "Responde SOLO con información del catálogo de productos.\n" | |
| "Incluye precios exactos cuando los pregunten.\n" | |
| "Si el producto no está en el catálogo, dilo amablemente.\n" | |
| "Sé preciso y conciso.\n\n" | |
| f"CATÁLOGO:\n{KB_PRODUCTOS}" | |
| )), | |
| HumanMessage(content=question), | |
| ]) | |
| return {"products_response": response.content} | |
| # ─── NODO 5: AGREGADOR ────────────────────────────────────── | |
| def aggregator(state: CustomerState) -> dict: | |
| """ | |
| Combina las respuestas de todos los workers en UNA sola | |
| respuesta coherente y natural para el cliente. | |
| """ | |
| parts = [] | |
| if state.get("greeting_response"): | |
| parts.append(f"[Saludo]\n{state['greeting_response']}") | |
| if state.get("company_response"): | |
| parts.append(f"[Info empresa]\n{state['company_response']}") | |
| if state.get("products_response"): | |
| parts.append(f"[Productos]\n{state['products_response']}") | |
| if not parts: | |
| return {"final_response": "Lo siento, no pude procesar tu consulta."} | |
| combined = "\n\n".join(parts) | |
| response = llm.invoke([ | |
| SystemMessage(content=( | |
| "Eres el agente final de atención al cliente de 'Pastelería La Delicia'.\n\n" | |
| "Recibiste las respuestas individuales de varios especialistas.\n" | |
| "Tu trabajo: combinarlas en UNA SOLA respuesta natural y fluida.\n\n" | |
| "Reglas:\n" | |
| "- Empieza con el saludo (si lo hay)\n" | |
| "- Luego responde cada tema en orden natural\n" | |
| "- No uses etiquetas como [Saludo] o [Productos]\n" | |
| "- Que se lea como si fuera UNA sola persona respondiendo\n" | |
| "- Cierra con un ofrecimiento de ayuda adicional\n" | |
| "- Tono: cálido, profesional, conciso" | |
| )), | |
| HumanMessage(content=( | |
| f"Mensaje original del cliente: {state['messages'][-1].content}\n\n" | |
| f"Respuestas de los especialistas:\n{combined}" | |
| )), | |
| ]) | |
| return {"final_response": response.content} | |
| # ─── CONSTRUCCIÓN DEL GRAFO ───────────────────────────────── | |
| def build_customer_agent(): | |
| """ | |
| Construye el grafo multiagente de atención al cliente. | |
| Flujo: | |
| START → supervisor → greeting_worker → company_worker | |
| → products_worker → aggregator → END | |
| Los workers corren en secuencia pero cada uno solo procesa | |
| su parte (si no tiene preguntas asignadas, retorna vacío). | |
| """ | |
| workflow = StateGraph(CustomerState) | |
| # Registrar nodos | |
| workflow.add_node("supervisor", supervisor) | |
| workflow.add_node("greeting_worker", greeting_worker) | |
| workflow.add_node("company_worker", company_worker) | |
| workflow.add_node("products_worker", products_worker) | |
| workflow.add_node("aggregator", aggregator) | |
| # Flujo: supervisor clasifica → workers procesan → agregador combina | |
| workflow.add_edge(START, "supervisor") | |
| workflow.add_edge("supervisor", "greeting_worker") | |
| workflow.add_edge("greeting_worker", "company_worker") | |
| workflow.add_edge("company_worker", "products_worker") | |
| workflow.add_edge("products_worker", "aggregator") | |
| workflow.add_edge("aggregator", END) | |
| return workflow.compile() | |
| # Instancia global | |
| customer_agent = build_customer_agent() | |
| # ─── UTILIDADES ────────────────────────────────────────────── | |
| def invoke_customer_agent(query: str, verbose: bool = False) -> dict: | |
| """ | |
| Invoca el sistema multiagente de atención al cliente. | |
| Args: | |
| query: Mensaje del cliente (puede tener varias preguntas mezcladas) | |
| verbose: Si True, imprime el estado de cada paso con colores | |
| Returns: | |
| dict con keys: answer, classification, worker_responses | |
| """ | |
| initial_state = { | |
| "messages": [HumanMessage(content=query)], | |
| "greeting_question": "", | |
| "company_question": "", | |
| "products_question": "", | |
| "greeting_response": "", | |
| "company_response": "", | |
| "products_response": "", | |
| "final_response": "", | |
| } | |
| if verbose: | |
| print(f"\n{_BOLD}{_WHITE}{'═'*60}{_RESET}") | |
| print(f"{_BOLD}{_WHITE} 🏪 PASTELERÍA LA DELICIA — Atención al Cliente{_RESET}") | |
| print(f"{_BOLD}{_WHITE}{'═'*60}{_RESET}") | |
| print(f" {_DIM}Cliente dice:{_RESET} {_WHITE}{query}{_RESET}") | |
| print() | |
| print(f" {_DIM}Flujo:{_RESET} {_BLUE}Supervisor{_RESET} → {_GREEN}Saludos{_RESET} → {_CYAN}Empresa{_RESET} → {_YELLOW}Productos{_RESET} → {_MAGENTA}Agregador{_RESET}") | |
| print(f"{_BOLD}{_WHITE}{'═'*60}{_RESET}") | |
| step = 0 | |
| for event in customer_agent.stream(initial_state, stream_mode="updates"): | |
| for node_name, node_output in event.items(): | |
| step += 1 | |
| meta = AGENT_META.get(node_name, {}) | |
| color = meta.get("color", _WHITE) | |
| icon = meta.get("icon", "▸") | |
| label = meta.get("label", node_name.upper()) | |
| desc = meta.get("desc", "") | |
| print(f"\n{color}{_BOLD}{'─'*60}{_RESET}") | |
| print(f"{color}{_BOLD} {icon} [{step}] {label}{_RESET}") | |
| print(f"{color}{_DIM} {desc}{_RESET}") | |
| print(f"{color}{'─'*60}{_RESET}") | |
| for key, value in node_output.items(): | |
| if key == "messages": | |
| continue | |
| val_str = str(value).strip() | |
| if not val_str: | |
| print(f" {_DIM}↳ {key}: (sin contenido — no aplica){_RESET}") | |
| else: | |
| preview = val_str[:300] | |
| print(f" {_DIM}↳ {key}:{_RESET} {color}{preview}{_RESET}") | |
| print(f"\n{_GREEN}{_BOLD}{'═'*60}{_RESET}") | |
| print(f"{_GREEN}{_BOLD} ✔ ATENCIÓN COMPLETADA{_RESET}") | |
| print(f"{_GREEN}{_BOLD}{'═'*60}{_RESET}") | |
| result = customer_agent.invoke(initial_state) | |
| return { | |
| "answer": result["final_response"], | |
| "classification": { | |
| "saludo": result["greeting_question"], | |
| "empresa": result["company_question"], | |
| "productos": result["products_question"], | |
| }, | |
| "worker_responses": { | |
| "saludo": result["greeting_response"], | |
| "empresa": result["company_response"], | |
| "productos": result["products_response"], | |
| }, | |
| } | |
| # ─── MAIN ──────────────────────────────────────────────────── | |
| if __name__ == "__main__": | |
| print(f"\n{_BOLD}{_WHITE}{'═'*60}{_RESET}") | |
| print(f"{_BOLD}{_WHITE} Caso práctico: Mensaje multi-tema de un cliente{_RESET}") | |
| print(f"{_BOLD}{_WHITE}{'═'*60}{_RESET}") | |
| print(f" {_DIM}Modelo:{_RESET} {_CYAN}{os.getenv('GROQ_MODEL', 'openai/gpt-oss-120b')} via Groq{_RESET}") | |
| print() | |
| # ── El caso exacto de la clase ── | |
| query = ( | |
| "Hola cómo estás?" | |
| "cuánto vale la torta americana?" | |
| ) | |
| result = invoke_customer_agent(query, verbose=True) | |
| # ── Respuesta final unificada ── | |
| print(f"\n{_GREEN}{_BOLD}{'─'*60}{_RESET}") | |
| print(f"{_GREEN}{_BOLD} 💬 RESPUESTA AL CLIENTE{_RESET}") | |
| print(f"{_GREEN}{_BOLD}{'─'*60}{_RESET}") | |
| print(f"{_WHITE}{result['answer']}{_RESET}") | |
| # ── Resumen de clasificación (para que los alumnos vean el desglose) ── | |
| print(f"\n{_BLUE}{_BOLD}{'─'*60}{_RESET}") | |
| print(f"{_BLUE}{_BOLD} 🔎 DESGLOSE (cómo el Supervisor clasificó el mensaje){_RESET}") | |
| print(f"{_BLUE}{_BOLD}{'─'*60}{_RESET}") | |
| for tema, pregunta in result["classification"].items(): | |
| color = {"saludo": _GREEN, "empresa": _CYAN, "productos": _YELLOW}[tema] | |
| if pregunta: | |
| print(f" {color}● {tema.upper()}: {pregunta}{_RESET}") | |
| else: | |
| print(f" {_DIM}○ {tema.upper()}: (no detectado){_RESET}") | |
| print() | |