feat: initial Skeen-CRM AI Agent architecture
- FastAPI + Python 3.12 backend - Meta WhatsApp Business API client (official) - OpenAI GPT-4o with function calling - RAG vector store with pgvector - ERPNext Frappe REST client - Celery + Redis async task queue - PostgreSQL with migrations (Alembic) - Docker Compose full stack - Enterprise logging, metrics, health checks
This commit is contained in:
0
src/use_cases/__init__.py
Normal file
0
src/use_cases/__init__.py
Normal file
372
src/use_cases/handle_incoming_message.py
Normal file
372
src/use_cases/handle_incoming_message.py
Normal file
@@ -0,0 +1,372 @@
|
||||
"""Core use case: process incoming WhatsApp message with AI agent."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.config import settings
|
||||
from src.core.constants import ConversationStatus, SKEEN_SYSTEM_PROMPT, WhatsAppMessageType
|
||||
from src.infrastructure.ai.openai_client import get_openai_client
|
||||
from src.infrastructure.ai.prompts import TOOLS
|
||||
from src.infrastructure.ai.rag import RAGStore
|
||||
from src.infrastructure.erpnext.client import get_erpnext_client
|
||||
from src.infrastructure.whatsapp.client import get_whatsapp_client
|
||||
from src.infrastructure.whatsapp.webhook import WhatsAppWebhookPayload
|
||||
from src.domain.models.conversation import Conversation, Message
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
MAX_CONTEXT_MESSAGES = 10
|
||||
|
||||
|
||||
class ToolExecutor:
|
||||
"""Executes tool calls requested by the LLM."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.rag = RAGStore(session)
|
||||
self.erpnext = None # Lazy init
|
||||
|
||||
async def execute(self, tool_call: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Execute a single tool call and return result."""
|
||||
name = tool_call["function"]["name"]
|
||||
arguments = json.loads(tool_call["function"]["arguments"])
|
||||
tool_call_id = tool_call["id"]
|
||||
|
||||
logger.info("executing_tool", tool=name, args=arguments)
|
||||
|
||||
try:
|
||||
if name == "search_catalog":
|
||||
result = await self._search_catalog(arguments)
|
||||
elif name == "check_availability":
|
||||
result = await self._check_availability(arguments)
|
||||
elif name == "create_appointment":
|
||||
result = await self._create_appointment(arguments)
|
||||
elif name == "get_patient_info":
|
||||
result = await self._get_patient_info(arguments)
|
||||
elif name == "get_wallet_balance":
|
||||
result = await self._get_wallet_balance(arguments)
|
||||
elif name == "escalate_to_human":
|
||||
result = await self._escalate_to_human(arguments)
|
||||
else:
|
||||
result = {"error": f"Unknown tool: {name}"}
|
||||
except Exception as exc:
|
||||
logger.error("tool_execution_failed", tool=name, error=str(exc))
|
||||
result = {"error": f"Failed to execute {name}: {str(exc)}"}
|
||||
|
||||
return {
|
||||
"tool_call_id": tool_call_id,
|
||||
"role": "tool",
|
||||
"name": name,
|
||||
"content": json.dumps(result, ensure_ascii=False),
|
||||
}
|
||||
|
||||
async def _search_catalog(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
query = args.get("query", "")
|
||||
category = args.get("category")
|
||||
results = await self.rag.search(query, category=category, top_k=5)
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"content": r["content"],
|
||||
"category": r["category"],
|
||||
"source": r["source"],
|
||||
"similarity": round(r["similarity"], 3),
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
}
|
||||
|
||||
async def _check_availability(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
date = args.get("date")
|
||||
branch = args.get("branch")
|
||||
doctor = args.get("doctor")
|
||||
service = args.get("service")
|
||||
|
||||
# TODO: Integrate with ERPNext Healthcare scheduling
|
||||
# For now, return mock data structure
|
||||
return {
|
||||
"date": date,
|
||||
"available_slots": [
|
||||
{"time": "10:00", "doctor": "Dr. Ramos"},
|
||||
{"time": "11:30", "doctor": "Dr. Martínez"},
|
||||
{"time": "15:00", "doctor": "Dr. Ramos"},
|
||||
],
|
||||
"branch": branch or "Rosarito",
|
||||
"service": service,
|
||||
"note": "Esta es una respuesta simulada. Integrar con ERPNext Healthcare.",
|
||||
}
|
||||
|
||||
async def _create_appointment(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
# TODO: Integrate with ERPNext to create real appointments
|
||||
return {
|
||||
"status": "simulated",
|
||||
"appointment_id": f"APT-{uuid.uuid4().hex[:8].upper()}",
|
||||
"details": args,
|
||||
"note": "Cita simulada. Integrar con ERPNext Patient Appointment.",
|
||||
}
|
||||
|
||||
async def _get_patient_info(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
phone = args.get("phone", "")
|
||||
erpnext = await get_erpnext_client()
|
||||
patient = await erpnext.find_patient_by_phone(phone)
|
||||
|
||||
if not patient:
|
||||
return {"found": False, "message": "No se encontró paciente con ese número."}
|
||||
|
||||
appointments = await erpnext.get_appointments(patient=patient.get("name"))
|
||||
return {
|
||||
"found": True,
|
||||
"name": patient.get("patient_name"),
|
||||
"sex": patient.get("sex"),
|
||||
"blood_group": patient.get("blood_group"),
|
||||
"total_appointments": len(appointments),
|
||||
"last_appointments": appointments[:3],
|
||||
}
|
||||
|
||||
async def _get_wallet_balance(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
# TODO: Integrate with ERPNext custom Wallet doctype
|
||||
return {
|
||||
"balance_mxn": 0.0,
|
||||
"points": 0,
|
||||
"note": "Monedero no implementado en ERPNext aún.",
|
||||
}
|
||||
|
||||
async def _escalate_to_human(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
reason = args.get("reason", "Solicitud del paciente")
|
||||
return {
|
||||
"escalated": True,
|
||||
"reason": reason,
|
||||
"message": "Un agente humano de SKEEN se pondrá en contacto contigo pronto. ⏳",
|
||||
}
|
||||
|
||||
|
||||
async def get_or_create_conversation(
|
||||
db: AsyncSession,
|
||||
phone_number: str,
|
||||
) -> Conversation:
|
||||
"""Get existing conversation or create new one."""
|
||||
result = await db.execute(
|
||||
select(Conversation)
|
||||
.where(Conversation.phone_number == phone_number)
|
||||
.order_by(Conversation.created_at.desc())
|
||||
)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if conversation is None:
|
||||
conversation = Conversation(
|
||||
id=str(uuid.uuid4()),
|
||||
phone_number=phone_number,
|
||||
status=ConversationStatus.ACTIVE,
|
||||
context={},
|
||||
)
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
logger.info("new_conversation_created", phone=phone_number, conversation_id=conversation.id)
|
||||
else:
|
||||
# Reactivate if resolved
|
||||
if conversation.status == ConversationStatus.RESOLVED:
|
||||
conversation.status = ConversationStatus.ACTIVE
|
||||
conversation.last_message_at = datetime.now(timezone.utc)
|
||||
|
||||
return conversation
|
||||
|
||||
|
||||
async def get_conversation_history(
|
||||
db: AsyncSession,
|
||||
conversation_id: str,
|
||||
limit: int = MAX_CONTEXT_MESSAGES,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Get recent messages formatted for OpenAI context."""
|
||||
from sqlalchemy import select
|
||||
from src.domain.models.conversation import Message
|
||||
|
||||
result = await db.execute(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conversation_id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
|
||||
# Reverse to chronological order
|
||||
history = []
|
||||
for msg in reversed(messages):
|
||||
history.append({"role": msg.role, "content": msg.content})
|
||||
return history
|
||||
|
||||
|
||||
async def process_incoming_message(
|
||||
db: AsyncSession,
|
||||
webhook_data: dict[str, Any],
|
||||
client_ip: str = "unknown",
|
||||
) -> dict[str, Any]:
|
||||
"""Process an incoming WhatsApp message end-to-end.
|
||||
|
||||
1. Parse webhook
|
||||
2. Load/create conversation
|
||||
3. Build LLM context
|
||||
4. Call OpenAI with tools
|
||||
5. Execute tools if needed
|
||||
6. Send response
|
||||
7. Persist everything
|
||||
"""
|
||||
webhook = WhatsAppWebhookPayload(webhook_data)
|
||||
|
||||
if not webhook.has_messages:
|
||||
return {"status": "no_messages"}
|
||||
|
||||
phone = webhook.from_number
|
||||
message_text = webhook.text_body or "[Mensaje no textual]"
|
||||
message_type = webhook.message_type.value
|
||||
|
||||
logger.info(
|
||||
"incoming_message_received",
|
||||
from_number=phone[-4:], # Log last 4 digits only for privacy
|
||||
message_type=message_type,
|
||||
ip=client_ip,
|
||||
)
|
||||
|
||||
# Get or create conversation
|
||||
conversation = await get_or_create_conversation(db, phone)
|
||||
|
||||
# Try to find patient in ERPNext for personalization
|
||||
patient_name = None
|
||||
try:
|
||||
erpnext = await get_erpnext_client()
|
||||
patient = await erpnext.find_patient_by_phone(phone)
|
||||
if patient:
|
||||
patient_name = patient.get("patient_name")
|
||||
conversation.patient_id = patient.get("name")
|
||||
conversation.patient_name = patient_name
|
||||
except Exception as exc:
|
||||
logger.warning("patient_lookup_failed", error=str(exc))
|
||||
|
||||
# Save inbound message
|
||||
inbound_msg = Message(
|
||||
id=str(uuid.uuid4()),
|
||||
conversation_id=conversation.id,
|
||||
direction="inbound",
|
||||
role="user",
|
||||
message_type=message_type,
|
||||
content=message_text,
|
||||
whatsapp_message_id=webhook.message_id,
|
||||
metadata={"ip": client_ip, "timestamp": webhook.timestamp},
|
||||
)
|
||||
db.add(inbound_msg)
|
||||
|
||||
# Build context for OpenAI
|
||||
system_prompt = SKEEN_SYSTEM_PROMPT
|
||||
if patient_name:
|
||||
system_prompt += f"\n\nPACIENTE ACTUAL: {patient_name}. Salúdalo/a por su nombre cuando sea apropiado."
|
||||
|
||||
messages: list[dict[str, str]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
]
|
||||
|
||||
# Add conversation history
|
||||
history = await get_conversation_history(db, conversation.id)
|
||||
messages.extend(history)
|
||||
|
||||
# Add current message
|
||||
messages.append({"role": "user", "content": message_text})
|
||||
|
||||
# Call OpenAI
|
||||
openai_client = await get_openai_client()
|
||||
llm_response = await openai_client.chat_completion(
|
||||
messages=messages,
|
||||
tools=TOOLS,
|
||||
)
|
||||
|
||||
assistant_message = llm_response["content"] or ""
|
||||
tool_calls = llm_response.get("tool_calls")
|
||||
tool_results = []
|
||||
|
||||
# Execute tools if requested
|
||||
if tool_calls:
|
||||
executor = ToolExecutor(db)
|
||||
for tc in tool_calls:
|
||||
result = await executor.execute(tc)
|
||||
tool_results.append(result)
|
||||
|
||||
# Second LLM call with tool results
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": assistant_message,
|
||||
"tool_calls": tool_calls,
|
||||
})
|
||||
for tr in tool_results:
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tr["tool_call_id"],
|
||||
"name": tr["name"],
|
||||
"content": tr["content"],
|
||||
})
|
||||
|
||||
final_response = await openai_client.chat_completion(
|
||||
messages=messages,
|
||||
tools=TOOLS,
|
||||
)
|
||||
assistant_message = final_response["content"] or assistant_message
|
||||
|
||||
# Mark as read (best effort)
|
||||
try:
|
||||
wa_client = await get_whatsapp_client()
|
||||
await wa_client.mark_as_read(webhook.message_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Send response
|
||||
if assistant_message:
|
||||
try:
|
||||
wa_client = await get_whatsapp_client()
|
||||
send_result = await wa_client.send_text_message(phone, assistant_message)
|
||||
response_message_id = send_result.get("messages", [{}])[0].get("id")
|
||||
except Exception as exc:
|
||||
logger.error("failed_to_send_response", error=str(exc))
|
||||
response_message_id = None
|
||||
else:
|
||||
response_message_id = None
|
||||
|
||||
# Save outbound message
|
||||
outbound_msg = Message(
|
||||
id=str(uuid.uuid4()),
|
||||
conversation_id=conversation.id,
|
||||
direction="outbound",
|
||||
role="assistant",
|
||||
message_type="text",
|
||||
content=assistant_message,
|
||||
whatsapp_message_id=response_message_id,
|
||||
tool_calls=tool_calls,
|
||||
tool_results=tool_results,
|
||||
tokens_used=llm_response.get("usage", {}).get("total_tokens", 0),
|
||||
metadata={"model": settings.OPENAI_MODEL},
|
||||
)
|
||||
db.add(outbound_msg)
|
||||
|
||||
# Update conversation
|
||||
conversation.last_message_at = datetime.now(timezone.utc)
|
||||
if any(tr.get("name") == "escalate_to_human" for tr in tool_results):
|
||||
conversation.status = ConversationStatus.ESCALATED
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"message_processed",
|
||||
conversation_id=conversation.id,
|
||||
patient=patient_name,
|
||||
tools_used=len(tool_calls) if tool_calls else 0,
|
||||
response_length=len(assistant_message),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "processed",
|
||||
"conversation_id": conversation.id,
|
||||
"response": assistant_message,
|
||||
"tools_executed": [tr["name"] for tr in tool_results],
|
||||
}
|
||||
Reference in New Issue
Block a user