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:
root
2026-04-29 05:30:59 +00:00
commit d30b22b50c
44 changed files with 3603 additions and 0 deletions

View File

View 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],
}