DocAgentSystem / agents /atencion_cliente_multiagent.py
RamsesCamas's picture
Initial clean commit for HF Space deployment
d0d2f42
"""
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()