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:
73
.env.example
Normal file
73
.env.example
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# FASTAPI APPLICATION
|
||||||
|
# =============================================================================
|
||||||
|
APP_NAME=Skeen-CRM-Agent
|
||||||
|
APP_ENV=development
|
||||||
|
DEBUG=true
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
SECRET_KEY=change-me-in-production-skeen-2024
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SERVER
|
||||||
|
# =============================================================================
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE (PostgreSQL + pgvector)
|
||||||
|
# =============================================================================
|
||||||
|
DATABASE_URL=postgresql+asyncpg://skeen:skeen123@localhost:5432/skeen_crm
|
||||||
|
DATABASE_POOL_SIZE=20
|
||||||
|
DATABASE_MAX_OVERFLOW=10
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REDIS
|
||||||
|
# =============================================================================
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# META / WHATSAPP BUSINESS API (Oficial)
|
||||||
|
# =============================================================================
|
||||||
|
META_API_VERSION=v18.0
|
||||||
|
META_ACCESS_TOKEN=EAAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
|
META_PHONE_NUMBER_ID=123456789012345
|
||||||
|
META_BUSINESS_ACCOUNT_ID=987654321098765
|
||||||
|
META_WEBHOOK_VERIFY_TOKEN=skeen-webhook-verify-token-2024
|
||||||
|
META_APP_SECRET=your-app-secret-for-signature-verification
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OPENAI
|
||||||
|
# =============================================================================
|
||||||
|
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
OPENAI_MODEL=gpt-4o
|
||||||
|
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
OPENAI_TEMPERATURE=0.3
|
||||||
|
OPENAI_MAX_TOKENS=1500
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RAG / VECTOR STORE
|
||||||
|
# =============================================================================
|
||||||
|
VECTOR_DIMENSION=1536
|
||||||
|
RAG_TOP_K=5
|
||||||
|
RAG_SIMILARITY_THRESHOLD=0.75
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ERPNEXT INTEGRATION
|
||||||
|
# =============================================================================
|
||||||
|
ERPNEXT_BASE_URL=https://skeen.erpnext.com
|
||||||
|
ERPNEXT_API_KEY=xxxxxxxxxxxxxxxx
|
||||||
|
ERPNEXT_API_SECRET=xxxxxxxxxxxxxxxx
|
||||||
|
ERPNEXT_VERIFY_SSL=true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CELERY
|
||||||
|
# =============================================================================
|
||||||
|
CELERY_BROKER_URL=redis://localhost:6379/1
|
||||||
|
CELERY_RESULT_BACKEND=redis://localhost:6379/2
|
||||||
|
CELERY_WORKER_CONCURRENCY=4
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MONITORING
|
||||||
|
# =============================================================================
|
||||||
|
ENABLE_METRICS=true
|
||||||
|
SENTRY_DSN=
|
||||||
149
.gitignore
vendored
Normal file
149
.gitignore
vendored
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# PEP 582
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Docker volumes
|
||||||
|
postgres_data/
|
||||||
|
redis_data/
|
||||||
57
Dockerfile
Normal file
57
Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# SKEEN CRM Agent - Production Dockerfile
|
||||||
|
# =============================================================================
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install uv (modern Python package manager)
|
||||||
|
RUN pip install --no-cache-dir uv
|
||||||
|
|
||||||
|
# Set workdir
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependency definitions
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
|
||||||
|
# Create virtual environment and install dependencies
|
||||||
|
RUN uv venv .venv && \
|
||||||
|
uv pip install --no-cache -r pyproject.toml
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Production stage
|
||||||
|
# =============================================================================
|
||||||
|
FROM python:3.12-slim AS production
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq5 \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN groupadd -r skeen && useradd -r -g skeen skeen
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy virtual environment from builder
|
||||||
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY --chown=skeen:skeen src/ ./src/
|
||||||
|
COPY --chown=skeen:skeen alembic/ ./alembic/
|
||||||
|
COPY --chown=skeen:skeen alembic.ini ./
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER skeen
|
||||||
|
|
||||||
|
# Healthcheck
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
210
README.md
Normal file
210
README.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# SKEEN CRM AI Agent
|
||||||
|
|
||||||
|
Agente de inteligencia artificial para WhatsApp Business API + ERPNext Healthcare.
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────────────┐ ┌─────────────┐
|
||||||
|
│ Paciente │────▶│ WhatsApp Business │────▶│ Meta API │
|
||||||
|
│ (WhatsApp) │◀────│ API │◀────│ Webhooks │
|
||||||
|
└─────────────┘ └─────────────────────┘ └──────┬──────┘
|
||||||
|
│
|
||||||
|
┌────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ SKEEN AI Agent │ FastAPI + Python 3.12
|
||||||
|
│ (Este Repo) │
|
||||||
|
│ │
|
||||||
|
│ • OpenAI GPT-4o │
|
||||||
|
│ • RAG (pgvector) │
|
||||||
|
│ • Celery + Redis │
|
||||||
|
│ • PostgreSQL │
|
||||||
|
└──────────┬──────────┘
|
||||||
|
│
|
||||||
|
┌──────────┴──────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────┐ ┌─────────────┐
|
||||||
|
│ ERPNext │ │ PostgreSQL │
|
||||||
|
│ Healthcare │ │ + pgvector │
|
||||||
|
└─────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
|
||||||
|
| Capa | Tecnología |
|
||||||
|
|------|-----------|
|
||||||
|
| Web Framework | FastAPI + Uvicorn (HTTP/2) |
|
||||||
|
| Base de datos | PostgreSQL 16 + pgvector |
|
||||||
|
| Cola de tareas | Celery + Redis |
|
||||||
|
| IA / LLM | OpenAI GPT-4o + Embeddings |
|
||||||
|
| WhatsApp | Meta Business API (oficial) |
|
||||||
|
| CRM | ERPNext (Frappe Framework) |
|
||||||
|
| Observabilidad | Structlog + Prometheus |
|
||||||
|
| Deploy | Docker Compose |
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
Skeen-CRM/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.py # Entry point FastAPI
|
||||||
|
│ ├── config.py # Pydantic Settings
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── v1/
|
||||||
|
│ │ │ ├── webhooks.py # Meta WhatsApp webhooks
|
||||||
|
│ │ │ ├── messages.py # API envío manual
|
||||||
|
│ │ │ └── health.py # Health checks
|
||||||
|
│ │ └── deps.py # Dependency injection
|
||||||
|
│ ├── domain/
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ └── conversation.py # Entidades SQLAlchemy
|
||||||
|
│ │ └── services/
|
||||||
|
│ ├── use_cases/
|
||||||
|
│ │ └── handle_incoming_message.py # Pipeline AI
|
||||||
|
│ ├── infrastructure/
|
||||||
|
│ │ ├── db.py # PostgreSQL async
|
||||||
|
│ │ ├── redis.py # Redis client
|
||||||
|
│ │ ├── whatsapp/
|
||||||
|
│ │ │ ├── client.py # Meta Graph API client
|
||||||
|
│ │ │ └── webhook.py # Webhook parser/validator
|
||||||
|
│ │ ├── ai/
|
||||||
|
│ │ │ ├── openai_client.py # OpenAI async client
|
||||||
|
│ │ │ ├── rag.py # Vector store pgvector
|
||||||
|
│ │ │ └── prompts.py # Tools & prompts
|
||||||
|
│ │ └── erpnext/
|
||||||
|
│ │ └── client.py # Frappe REST API client
|
||||||
|
│ └── workers/
|
||||||
|
│ ├── celery_app.py # Celery configuration
|
||||||
|
│ └── tasks.py # Background tasks
|
||||||
|
├── alembic/ # Database migrations
|
||||||
|
├── docker-compose.yml # Full stack local
|
||||||
|
├── Dockerfile # Production image
|
||||||
|
└── pyproject.toml # Dependencies (uv)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Cuenta de WhatsApp Business API (Meta)
|
||||||
|
- API Key de OpenAI
|
||||||
|
- Instancia de ERPNext (para integración completa)
|
||||||
|
|
||||||
|
## Instalación Local
|
||||||
|
|
||||||
|
### 1. Clonar y entrar al directorio
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Skeen-CRM
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Copiar variables de entorno
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Editar .env con tus credenciales de Meta, OpenAI y ERPNext
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Levantar infraestructura (PostgreSQL + Redis)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres redis
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Instalar dependencias con uv
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install uv
|
||||||
|
uv venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Ejecutar migraciones
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Iniciar servidor de desarrollo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn src.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Iniciar worker de Celery (en otra terminal)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
celery -A src.workers.celery_app worker --loglevel=info -Q default,whatsapp,erpnext,ai
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy con Docker Compose (Producción)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto levanta:
|
||||||
|
- `api`: FastAPI (2 workers)
|
||||||
|
- `worker`: Celery worker (4 concurrentes)
|
||||||
|
- `scheduler`: Celery beat
|
||||||
|
- `postgres`: PostgreSQL + pgvector
|
||||||
|
- `redis`: Redis persistente
|
||||||
|
|
||||||
|
## Configuración de Webhook en Meta
|
||||||
|
|
||||||
|
1. Ir a [Meta Developers](https://developers.facebook.com/)
|
||||||
|
2. Seleccionar tu app de WhatsApp Business
|
||||||
|
3. En **Webhooks**, configurar:
|
||||||
|
- **Callback URL**: `https://tu-dominio.com/api/v1/webhooks/whatsapp`
|
||||||
|
- **Verify Token**: El valor de `META_WEBHOOK_VERIFY_TOKEN` en `.env`
|
||||||
|
- **Suscripción**: `messages`
|
||||||
|
|
||||||
|
## Tools / Funciones del Agente
|
||||||
|
|
||||||
|
El agente usa **OpenAI Function Calling** para ejecutar acciones:
|
||||||
|
|
||||||
|
| Tool | Descripción |
|
||||||
|
|------|-------------|
|
||||||
|
| `search_catalog` | Busca servicios/productos en catálogo vía RAG |
|
||||||
|
| `check_availability` | Consulta disponibilidad de citas en ERPNext |
|
||||||
|
| `create_appointment` | Crea cita médica en ERPNext Healthcare |
|
||||||
|
| `get_patient_info` | Consulta historial del paciente |
|
||||||
|
| `get_wallet_balance` | Consulta saldo de monedero |
|
||||||
|
| `escalate_to_human` | Escalar a agente humano |
|
||||||
|
|
||||||
|
## Endpoints API
|
||||||
|
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/health` | Liveness probe |
|
||||||
|
| GET | `/ready` | Readiness probe (incluye DB) |
|
||||||
|
| GET | `/metrics` | Métricas Prometheus |
|
||||||
|
| GET | `/api/v1/webhooks/whatsapp` | Verificación Meta |
|
||||||
|
| POST | `/api/v1/webhooks/whatsapp` | Recepción mensajes |
|
||||||
|
| POST | `/api/v1/messages/text` | Enviar texto manual |
|
||||||
|
| POST | `/api/v1/messages/template` | Enviar plantilla |
|
||||||
|
| POST | `/api/v1/messages/buttons` | Enviar botones |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit tests
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# With coverage
|
||||||
|
pytest tests/ --cov=src --cov-report=html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
- ✅ Verificación de firma HMAC en webhooks (producción)
|
||||||
|
- ✅ Tokens JWT no usados (usa API Keys de Meta)
|
||||||
|
- ✅ Rate limiting implementado por número de teléfono
|
||||||
|
- ✅ Sanitización de inputs antes de enviar a LLM
|
||||||
|
- ✅ Logging sin exponer datos PHI (solo últimos 4 dígitos de teléfono)
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
Propietario - SKEEN Clínica de Belleza
|
||||||
42
alembic.ini
Normal file
42
alembic.ini
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
sqlalchemy.url = postgresql+asyncpg://skeen:skeen123@localhost:5432/skeen_crm
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
1
alembic/README
Normal file
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
76
alembic/env.py
Normal file
76
alembic/env.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from src.config import settings
|
||||||
|
from src.infrastructure.db import Base
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode."""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
"""In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
"""
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode."""
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
alembic/script.py.mako
Normal file
26
alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
100
alembic/versions/20260428_init.py
Normal file
100
alembic/versions/20260428_init.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Initial migration: conversations, messages, knowledge_chunks.
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-04-28 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "001"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Enable pgvector extension
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||||
|
|
||||||
|
# Conversations table
|
||||||
|
op.create_table(
|
||||||
|
"conversations",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("phone_number", sa.String(20), nullable=False, index=True),
|
||||||
|
sa.Column("patient_id", sa.String(100), nullable=True, index=True),
|
||||||
|
sa.Column("patient_name", sa.String(255), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.Enum("active", "paused", "resolved", "escalated", "appointment_confirmed", name="conversationstatus"),
|
||||||
|
nullable=False,
|
||||||
|
server_default="active",
|
||||||
|
),
|
||||||
|
sa.Column("context", postgresql.JSONB(astext_type=sa.Text()), server_default="{}"),
|
||||||
|
sa.Column("last_message_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Messages table
|
||||||
|
op.create_table(
|
||||||
|
"messages",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("conversation_id", sa.String(36), nullable=False, index=True),
|
||||||
|
sa.Column("direction", sa.String(10), nullable=False),
|
||||||
|
sa.Column("role", sa.String(20), nullable=False),
|
||||||
|
sa.Column("message_type", sa.String(50), server_default="text"),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("whatsapp_message_id", sa.String(100), nullable=True),
|
||||||
|
sa.Column("tool_calls", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column("tool_results", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column("tokens_used", sa.Integer(), server_default="0"),
|
||||||
|
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), server_default="{}"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Knowledge chunks table (for RAG)
|
||||||
|
op.create_table(
|
||||||
|
"knowledge_chunks",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, server_default=sa.text("gen_random_uuid()::text")),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), server_default="{}"),
|
||||||
|
sa.Column("category", sa.String(50), server_default="general"),
|
||||||
|
sa.Column("source", sa.String(255), server_default=""),
|
||||||
|
sa.Column("embedding", sa.String(), nullable=True), # Stored as string; pgvector uses special type
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create pgvector column properly using raw SQL
|
||||||
|
op.execute("""
|
||||||
|
ALTER TABLE knowledge_chunks
|
||||||
|
ALTER COLUMN embedding TYPE vector(1536)
|
||||||
|
USING embedding::vector(1536)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
op.create_index("idx_knowledge_category", "knowledge_chunks", ["category"])
|
||||||
|
op.create_index("idx_knowledge_source", "knowledge_chunks", ["source"])
|
||||||
|
op.execute("""
|
||||||
|
CREATE INDEX idx_knowledge_embedding
|
||||||
|
ON knowledge_chunks
|
||||||
|
USING ivfflat (embedding vector_cosine_ops)
|
||||||
|
WITH (lists = 100)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_knowledge_embedding", table_name="knowledge_chunks")
|
||||||
|
op.drop_index("idx_knowledge_source", table_name="knowledge_chunks")
|
||||||
|
op.drop_index("idx_knowledge_category", table_name="knowledge_chunks")
|
||||||
|
op.drop_table("knowledge_chunks")
|
||||||
|
op.drop_table("messages")
|
||||||
|
op.drop_table("conversations")
|
||||||
|
op.execute("DROP TYPE IF EXISTS conversationstatus")
|
||||||
|
op.execute("DROP EXTENSION IF EXISTS vector")
|
||||||
145
docker-compose.yml
Normal file
145
docker-compose.yml
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: skeen-api
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://skeen:skeen123@postgres:5432/skeen_crm
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||||
|
- CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- skeen-network
|
||||||
|
command: >
|
||||||
|
uvicorn src.main:app
|
||||||
|
--host 0.0.0.0
|
||||||
|
--port 8000
|
||||||
|
--workers 2
|
||||||
|
--loop uvloop
|
||||||
|
--http httptools
|
||||||
|
--log-level info
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: skeen-worker
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://skeen:skeen123@postgres:5432/skeen_crm
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||||
|
- CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- skeen-network
|
||||||
|
command: >
|
||||||
|
celery -A src.workers.celery_app worker
|
||||||
|
--loglevel=info
|
||||||
|
--concurrency=4
|
||||||
|
-Q default,whatsapp,erpnext,ai
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: skeen-scheduler
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://skeen:skeen123@postgres:5432/skeen_crm
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||||
|
- CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- skeen-network
|
||||||
|
command: >
|
||||||
|
celery -A src.workers.celery_app beat
|
||||||
|
--loglevel=info
|
||||||
|
--scheduler celery.beat.PersistentScheduler
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: skeen-migrate
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://skeen:skeen123@postgres:5432/skeen_crm
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- skeen-network
|
||||||
|
command: >
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: ankane/pgvector:latest
|
||||||
|
container_name: skeen-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: skeen
|
||||||
|
POSTGRES_PASSWORD: skeen123
|
||||||
|
POSTGRES_DB: skeen_crm
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
networks:
|
||||||
|
- skeen-network
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "pg_isready -U skeen -d skeen_crm" ]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: skeen-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
networks:
|
||||||
|
- skeen-network
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
skeen-network:
|
||||||
|
driver: bridge
|
||||||
102
pyproject.toml
Normal file
102
pyproject.toml
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
[project]
|
||||||
|
name = "skeen-crm-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "SKEEN CRM AI Agent for WhatsApp Business API + ERPNext"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
# Web Framework
|
||||||
|
"fastapi>=0.111.0",
|
||||||
|
"uvicorn[standard]>=0.30.0",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
|
"python-jose[cryptography]>=3.3.0",
|
||||||
|
"passlib[bcrypt]>=1.7.4",
|
||||||
|
"httpx[http2]>=0.27.0",
|
||||||
|
"tenacity>=8.3.0",
|
||||||
|
|
||||||
|
# Database
|
||||||
|
"sqlalchemy[asyncio]>=2.0.30",
|
||||||
|
"asyncpg>=0.29.0",
|
||||||
|
"sqlmodel>=0.0.19",
|
||||||
|
"alembic>=1.13.0",
|
||||||
|
"pgvector>=0.2.5",
|
||||||
|
|
||||||
|
# Redis & Celery
|
||||||
|
"redis>=5.0.0",
|
||||||
|
"celery>=5.4.0",
|
||||||
|
|
||||||
|
# AI / LLM
|
||||||
|
"openai>=1.30.0",
|
||||||
|
"tiktoken>=0.7.0",
|
||||||
|
|
||||||
|
# Configuration & Validation
|
||||||
|
"pydantic>=2.7.0",
|
||||||
|
"pydantic-settings>=2.2.0",
|
||||||
|
"email-validator>=2.1.0",
|
||||||
|
|
||||||
|
# Observability
|
||||||
|
"structlog>=24.1.0",
|
||||||
|
"prometheus-client>=0.20.0",
|
||||||
|
"asgi-correlation-id>=4.3.0",
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
"orjson>=3.10.0",
|
||||||
|
"python-dateutil>=2.9.0",
|
||||||
|
"pendulum>=3.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.2.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"pytest-cov>=5.0.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"respx>=0.21.0",
|
||||||
|
"ruff>=0.4.0",
|
||||||
|
"mypy>=1.10.0",
|
||||||
|
"pre-commit>=3.7.0",
|
||||||
|
"types-python-dateutil>=2.9.0",
|
||||||
|
"types-passlib>=1.7.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 100
|
||||||
|
select = [
|
||||||
|
"E", "F", "W", "I", "N", "D", "UP", "B", "C4", "SIM", "ARG",
|
||||||
|
"PTH", "ERA", "RUF", "ASYNC", "S", "C90",
|
||||||
|
]
|
||||||
|
ignore = ["D100", "D104", "D107", "S101"]
|
||||||
|
|
||||||
|
[tool.ruff.pydocstyle]
|
||||||
|
convention = "google"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
strict = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
addopts = "-v --tb=short --strict-markers"
|
||||||
|
markers = [
|
||||||
|
"unit: Unit tests",
|
||||||
|
"integration: Integration tests",
|
||||||
|
"slow: Slow tests",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["src"]
|
||||||
|
omit = ["*/tests/*", "*/migrations/*"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
precision = 2
|
||||||
|
fail_under = 80
|
||||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
0
src/api/__init__.py
Normal file
0
src/api/__init__.py
Normal file
36
src/api/deps.py
Normal file
36
src/api/deps.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""FastAPI dependency injection providers."""
|
||||||
|
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.infrastructure.db import AsyncSessionLocal
|
||||||
|
from src.infrastructure.redis import RedisCache, get_redis
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Yield a database session for FastAPI dependency injection."""
|
||||||
|
session = AsyncSessionLocal()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_cache() -> AsyncGenerator[RedisCache, None]:
|
||||||
|
"""Yield a Redis cache instance."""
|
||||||
|
redis = await get_redis()
|
||||||
|
yield RedisCache(redis)
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> str:
|
||||||
|
"""Extract client IP from request, handling proxies."""
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
0
src/api/v1/__init__.py
Normal file
0
src/api/v1/__init__.py
Normal file
39
src/api/v1/health.py
Normal file
39
src/api/v1/health.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Health check endpoints."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.deps import get_db_session
|
||||||
|
from src.config import settings
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health", status_code=status.HTTP_200_OK)
|
||||||
|
async def health_check() -> dict:
|
||||||
|
"""Liveness probe."""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"version": "0.1.0",
|
||||||
|
"environment": settings.APP_ENV,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ready", status_code=status.HTTP_200_OK)
|
||||||
|
async def readiness_check(db: AsyncSession = Depends(get_db_session)) -> dict:
|
||||||
|
"""Readiness probe including database connectivity."""
|
||||||
|
try:
|
||||||
|
await db.execute(text("SELECT 1"))
|
||||||
|
db_status = "connected"
|
||||||
|
except Exception as exc:
|
||||||
|
db_status = f"error: {exc}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ready" if db_status == "connected" else "not_ready",
|
||||||
|
"database": db_status,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
109
src/api/v1/messages.py
Normal file
109
src/api/v1/messages.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""API endpoints for sending WhatsApp messages manually."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.deps import get_db_session
|
||||||
|
from src.infrastructure.whatsapp.client import get_whatsapp_client
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
class SendTextMessageRequest(BaseModel):
|
||||||
|
to: str = Field(..., description="Phone number in international format (e.g., 5216641234567)")
|
||||||
|
text: str = Field(..., max_length=4096, description="Message text")
|
||||||
|
preview_url: bool = Field(False, description="Show URL preview")
|
||||||
|
|
||||||
|
|
||||||
|
class SendTemplateMessageRequest(BaseModel):
|
||||||
|
to: str = Field(..., description="Phone number in international format")
|
||||||
|
template_name: str = Field(..., description="Registered template name")
|
||||||
|
language_code: str = Field("es_MX", description="Template language code")
|
||||||
|
|
||||||
|
|
||||||
|
class SendButtonsRequest(BaseModel):
|
||||||
|
to: str
|
||||||
|
body: str = Field(..., max_length=1024)
|
||||||
|
buttons: list[dict[str, str]] = Field(..., min_length=1, max_length=3)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
message_id: str | None = None
|
||||||
|
details: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/text", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def send_text_message(
|
||||||
|
request: SendTextMessageRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> MessageResponse:
|
||||||
|
"""Send a text message to a WhatsApp user."""
|
||||||
|
client = await get_whatsapp_client()
|
||||||
|
try:
|
||||||
|
result = await client.send_text_message(
|
||||||
|
to=request.to,
|
||||||
|
text=request.text,
|
||||||
|
preview_url=request.preview_url,
|
||||||
|
)
|
||||||
|
return MessageResponse(
|
||||||
|
status="sent",
|
||||||
|
message_id=result.get("messages", [{}])[0].get("id"),
|
||||||
|
details=result,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/template", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def send_template_message(
|
||||||
|
request: SendTemplateMessageRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> MessageResponse:
|
||||||
|
"""Send a template message (for 24h+ window)."""
|
||||||
|
client = await get_whatsapp_client()
|
||||||
|
try:
|
||||||
|
result = await client.send_template_message(
|
||||||
|
to=request.to,
|
||||||
|
template_name=request.template_name,
|
||||||
|
language_code=request.language_code,
|
||||||
|
)
|
||||||
|
return MessageResponse(
|
||||||
|
status="sent",
|
||||||
|
message_id=result.get("messages", [{}])[0].get("id"),
|
||||||
|
details=result,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/buttons", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def send_buttons(
|
||||||
|
request: SendButtonsRequest,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> MessageResponse:
|
||||||
|
"""Send an interactive button message."""
|
||||||
|
client = await get_whatsapp_client()
|
||||||
|
try:
|
||||||
|
result = await client.send_interactive_buttons(
|
||||||
|
to=request.to,
|
||||||
|
body=request.body,
|
||||||
|
buttons=request.buttons,
|
||||||
|
)
|
||||||
|
return MessageResponse(
|
||||||
|
status="sent",
|
||||||
|
message_id=result.get("messages", [{}])[0].get("id"),
|
||||||
|
details=result,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
98
src/api/v1/webhooks.py
Normal file
98
src/api/v1/webhooks.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Meta WhatsApp webhook endpoints."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.deps import get_client_ip, get_db_session
|
||||||
|
from src.config import settings
|
||||||
|
from src.core.exceptions import ValidationError
|
||||||
|
from src.infrastructure.whatsapp.webhook import (
|
||||||
|
WebhookVerifier,
|
||||||
|
parse_webhook_payload,
|
||||||
|
)
|
||||||
|
from src.use_cases.handle_incoming_message import process_incoming_message
|
||||||
|
from src.workers.celery_app import process_whatsapp_message_task
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookVerificationResponse(BaseModel):
|
||||||
|
challenge: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/whatsapp")
|
||||||
|
async def verify_whatsapp_webhook(
|
||||||
|
hub_mode: str | None = None,
|
||||||
|
hub_verify_token: str | None = None,
|
||||||
|
hub_challenge: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Verify webhook subscription with Meta.
|
||||||
|
|
||||||
|
Meta sends a GET request to verify the endpoint during webhook setup.
|
||||||
|
"""
|
||||||
|
is_valid = WebhookVerifier.verify_subscription(hub_mode, hub_verify_token)
|
||||||
|
if not is_valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Invalid verification token",
|
||||||
|
)
|
||||||
|
return hub_challenge or ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/whatsapp", status_code=status.HTTP_200_OK)
|
||||||
|
async def receive_whatsapp_webhook(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
x_hub_signature_256: str | None = Header(None),
|
||||||
|
x_forwarded_for: str | None = Header(None),
|
||||||
|
) -> dict:
|
||||||
|
"""Receive incoming WhatsApp messages and status updates.
|
||||||
|
|
||||||
|
In production, this endpoint should return 200 immediately and
|
||||||
|
process the message asynchronously via Celery to avoid timeouts.
|
||||||
|
"""
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
# Verify signature in production
|
||||||
|
if settings.is_production:
|
||||||
|
is_valid = WebhookVerifier.verify_signature(body, x_hub_signature_256)
|
||||||
|
if not is_valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid signature",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
webhook = parse_webhook_payload(payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
# Return 200 for non-message events to prevent Meta retries
|
||||||
|
return {"status": "ignored", "reason": exc.message}
|
||||||
|
except Exception:
|
||||||
|
return {"status": "ignored", "reason": "invalid_payload"}
|
||||||
|
|
||||||
|
client_ip = x_forwarded_for or get_client_ip(request)
|
||||||
|
|
||||||
|
# Handle message statuses (delivered, read, etc.)
|
||||||
|
if webhook.has_statuses:
|
||||||
|
# TODO: Update message status in database
|
||||||
|
return {"status": "acknowledged", "type": "status_update"}
|
||||||
|
|
||||||
|
# Handle incoming messages
|
||||||
|
if webhook.has_messages:
|
||||||
|
if settings.is_production:
|
||||||
|
# Async processing via Celery for production
|
||||||
|
process_whatsapp_message_task.delay(
|
||||||
|
webhook.raw,
|
||||||
|
client_ip=client_ip,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Sync processing for development/testing
|
||||||
|
await process_incoming_message(
|
||||||
|
db=db,
|
||||||
|
webhook_data=webhook.raw,
|
||||||
|
client_ip=client_ip,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "processed"}
|
||||||
96
src/config.py
Normal file
96
src/config.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Application configuration using Pydantic Settings."""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic import Field, PostgresDsn, RedisDsn, SecretStr
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings loaded from environment variables."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# App
|
||||||
|
APP_NAME: str = "Skeen-CRM-Agent"
|
||||||
|
APP_ENV: str = "development"
|
||||||
|
DEBUG: bool = False
|
||||||
|
LOG_LEVEL: str = "INFO"
|
||||||
|
SECRET_KEY: SecretStr = SecretStr("change-me")
|
||||||
|
|
||||||
|
# Server
|
||||||
|
HOST: str = "0.0.0.0"
|
||||||
|
PORT: int = 8000
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: PostgresDsn = PostgresDsn(
|
||||||
|
"postgresql+asyncpg://skeen:skeen123@localhost:5432/skeen_crm"
|
||||||
|
)
|
||||||
|
DATABASE_POOL_SIZE: int = 20
|
||||||
|
DATABASE_MAX_OVERFLOW: int = 10
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL: RedisDsn = RedisDsn("redis://localhost:6379/0")
|
||||||
|
|
||||||
|
# Meta / WhatsApp Business API
|
||||||
|
META_API_VERSION: str = "v18.0"
|
||||||
|
META_ACCESS_TOKEN: SecretStr = SecretStr("")
|
||||||
|
META_PHONE_NUMBER_ID: str = ""
|
||||||
|
META_BUSINESS_ACCOUNT_ID: str = ""
|
||||||
|
META_WEBHOOK_VERIFY_TOKEN: SecretStr = SecretStr("")
|
||||||
|
META_APP_SECRET: SecretStr = SecretStr("")
|
||||||
|
|
||||||
|
# OpenAI
|
||||||
|
OPENAI_API_KEY: SecretStr = SecretStr("")
|
||||||
|
OPENAI_MODEL: str = "gpt-4o"
|
||||||
|
OPENAI_EMBEDDING_MODEL: str = "text-embedding-3-small"
|
||||||
|
OPENAI_TEMPERATURE: float = 0.3
|
||||||
|
OPENAI_MAX_TOKENS: int = 1500
|
||||||
|
|
||||||
|
# RAG
|
||||||
|
VECTOR_DIMENSION: int = 1536
|
||||||
|
RAG_TOP_K: int = 5
|
||||||
|
RAG_SIMILARITY_THRESHOLD: float = 0.75
|
||||||
|
|
||||||
|
# ERPNext
|
||||||
|
ERPNEXT_BASE_URL: str = ""
|
||||||
|
ERPNEXT_API_KEY: SecretStr = SecretStr("")
|
||||||
|
ERPNEXT_API_SECRET: SecretStr = SecretStr("")
|
||||||
|
ERPNEXT_VERIFY_SSL: bool = True
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
CELERY_BROKER_URL: RedisDsn = RedisDsn("redis://localhost:6379/1")
|
||||||
|
CELERY_RESULT_BACKEND: RedisDsn = RedisDsn("redis://localhost:6379/2")
|
||||||
|
CELERY_WORKER_CONCURRENCY: int = 4
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
ENABLE_METRICS: bool = True
|
||||||
|
SENTRY_DSN: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database_url_str(self) -> str:
|
||||||
|
"""Return database URL as plain string for SQLAlchemy."""
|
||||||
|
return str(self.DATABASE_URL)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def meta_api_base_url(self) -> str:
|
||||||
|
"""Return Meta Graph API base URL."""
|
||||||
|
return f"https://graph.facebook.com/{self.META_API_VERSION}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_production(self) -> bool:
|
||||||
|
"""Check if running in production environment."""
|
||||||
|
return self.APP_ENV.lower() == "production"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Return cached settings instance."""
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
0
src/core/__init__.py
Normal file
0
src/core/__init__.py
Normal file
100
src/core/constants.py
Normal file
100
src/core/constants.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Application-wide constants."""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class MessageRole(str, Enum):
|
||||||
|
"""Conversation message roles."""
|
||||||
|
|
||||||
|
SYSTEM = "system"
|
||||||
|
USER = "user"
|
||||||
|
ASSISTANT = "assistant"
|
||||||
|
TOOL = "tool"
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationStatus(str, Enum):
|
||||||
|
"""Status of a patient conversation."""
|
||||||
|
|
||||||
|
ACTIVE = "active"
|
||||||
|
PAUSED = "paused"
|
||||||
|
RESOLVED = "resolved"
|
||||||
|
ESCALATED = "escalated"
|
||||||
|
APPOINTMENT_CONFIRMED = "appointment_confirmed"
|
||||||
|
|
||||||
|
|
||||||
|
class AppointmentType(str, Enum):
|
||||||
|
"""Types of medical appointments."""
|
||||||
|
|
||||||
|
PRIMERA_VEZ = "primera_vez"
|
||||||
|
SUBSECUENTE = "subsecuente"
|
||||||
|
PAQUETE = "paquete"
|
||||||
|
VALORACION = "valoracion"
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentMethod(str, Enum):
|
||||||
|
"""Supported payment methods."""
|
||||||
|
|
||||||
|
EFECTIVO_MN = "efectivo_mn"
|
||||||
|
EFECTIVO_USD = "efectivo_usd"
|
||||||
|
TARJETA = "tarjeta"
|
||||||
|
TRANSFERENCIA = "transferencia"
|
||||||
|
MONEDERO = "monedero"
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppMessageType(str, Enum):
|
||||||
|
"""WhatsApp message types from Meta webhook."""
|
||||||
|
|
||||||
|
TEXT = "text"
|
||||||
|
IMAGE = "image"
|
||||||
|
AUDIO = "audio"
|
||||||
|
VIDEO = "video"
|
||||||
|
DOCUMENT = "document"
|
||||||
|
LOCATION = "location"
|
||||||
|
CONTACTS = "contacts"
|
||||||
|
INTERACTIVE = "interactive"
|
||||||
|
BUTTON_REPLY = "button_reply"
|
||||||
|
LIST_REPLY = "list_reply"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppMessageStatus(str, Enum):
|
||||||
|
"""WhatsApp message delivery statuses."""
|
||||||
|
|
||||||
|
SENT = "sent"
|
||||||
|
DELIVERED = "delivered"
|
||||||
|
READ = "read"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
# System prompts
|
||||||
|
SKEEN_SYSTEM_PROMPT = """Eres SKEEN Assistant, el agente de inteligencia artificial oficial de SKEEN Clínica de Belleza y Dermatología Estética.
|
||||||
|
|
||||||
|
INFORMACIÓN DEL NEGOCIO:
|
||||||
|
- SKEEN es una clínica dermatológica y estética con sedes en Rosarito y Tijuana, B.C., México.
|
||||||
|
- Ofrecemos tratamientos faciales, corporales, depilación láser, toxina botulínica, ácido hialurónico, y consultas dermatológicas.
|
||||||
|
- Horario de atención: Lunes a Sábado 9:00 - 18:00, Domingos 10:00 - 14:00.
|
||||||
|
- Teléfono: (664) 123-4567
|
||||||
|
|
||||||
|
TU ROL:
|
||||||
|
1. Atiende a pacientes y prospectos por WhatsApp de manera cálida, profesional y eficiente.
|
||||||
|
2. Agenda citas verificando disponibilidad con el CRM.
|
||||||
|
3. Responde preguntas sobre servicios, precios, paquetes y promociones usando el catálogo (RAG).
|
||||||
|
4. Procesa pagos y consulta saldo de monedero electrónico.
|
||||||
|
5. Escal a un humano cuando el paciente lo solicite o el caso sea complejo.
|
||||||
|
|
||||||
|
REGLAS CRÍTICAS:
|
||||||
|
- SIEMPRE saluda por el nombre del paciente si lo conoces.
|
||||||
|
- NUNCA inventes precios ni disponibilidad. Consulta el RAG o el CRM.
|
||||||
|
- SIEMPRE confirma los detalles de la cita (fecha, hora, sucursal, servicio, doctor) antes de agendar.
|
||||||
|
- NUNCA compartas información médica de otros pacientes.
|
||||||
|
- Usa emojis con moderación para mantener un tono cálido pero profesional.
|
||||||
|
- El idioma principal es español (México).
|
||||||
|
|
||||||
|
FORMATO DE RESPUESTA:
|
||||||
|
- Sé conciso pero completo (máximo 3 párrafos cortos para WhatsApp).
|
||||||
|
- Usa viñetas cuando listes opciones.
|
||||||
|
- Incluye llamados a la acción claros.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Webhook
|
||||||
|
WHATSAPP_WEBHOOK_SUBSCRIBE_MODE = "subscribe"
|
||||||
66
src/core/exceptions.py
Normal file
66
src/core/exceptions.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Custom application exceptions."""
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenException(Exception):
|
||||||
|
"""Base exception for SKEEN application."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int = 500, details: dict | None = None) -> None:
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
self.details = details or {}
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppAPIError(SkeenException):
|
||||||
|
"""Error communicating with Meta WhatsApp Business API."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int = 502, details: dict | None = None) -> None:
|
||||||
|
super().__init__(message, status_code, details)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIError(SkeenException):
|
||||||
|
"""Error communicating with OpenAI API."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int = 502, details: dict | None = None) -> None:
|
||||||
|
super().__init__(message, status_code, details)
|
||||||
|
|
||||||
|
|
||||||
|
class ERPNextError(SkeenException):
|
||||||
|
"""Error communicating with ERPNext API."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int = 502, details: dict | None = None) -> None:
|
||||||
|
super().__init__(message, status_code, details)
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationNotFoundError(SkeenException):
|
||||||
|
"""Requested conversation does not exist."""
|
||||||
|
|
||||||
|
def __init__(self, conversation_id: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
f"Conversation {conversation_id} not found",
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PatientNotFoundError(SkeenException):
|
||||||
|
"""Requested patient does not exist."""
|
||||||
|
|
||||||
|
def __init__(self, patient_id: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
f"Patient {patient_id} not found",
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(SkeenException):
|
||||||
|
"""Input validation error."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, details: dict | None = None) -> None:
|
||||||
|
super().__init__(message, status_code=422, details=details)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(SkeenException):
|
||||||
|
"""Rate limit exceeded."""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Rate limit exceeded") -> None:
|
||||||
|
super().__init__(message, status_code=429)
|
||||||
0
src/domain/__init__.py
Normal file
0
src/domain/__init__.py
Normal file
0
src/domain/models/__init__.py
Normal file
0
src/domain/models/__init__.py
Normal file
74
src/domain/models/conversation.py
Normal file
74
src/domain/models/conversation.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Conversation and message domain models."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Column, DateTime, Enum, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from src.core.constants import ConversationStatus
|
||||||
|
from src.infrastructure.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Conversation(Base):
|
||||||
|
"""A WhatsApp conversation with a patient."""
|
||||||
|
|
||||||
|
__tablename__ = "conversations"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||||
|
phone_number: Mapped[str] = mapped_column(String(20), index=True, nullable=False)
|
||||||
|
patient_id: Mapped[str | None] = mapped_column(String(100), index=True, nullable=True)
|
||||||
|
patient_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[ConversationStatus] = mapped_column(
|
||||||
|
Enum(ConversationStatus),
|
||||||
|
default=ConversationStatus.ACTIVE,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
context: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
last_message_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
"""Individual message within a conversation."""
|
||||||
|
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||||
|
conversation_id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
index=True,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
direction: Mapped[str] = mapped_column(
|
||||||
|
String(10),
|
||||||
|
nullable=False,
|
||||||
|
) # 'inbound' or 'outbound'
|
||||||
|
role: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
message_type: Mapped[str] = mapped_column(String(50), default="text")
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
whatsapp_message_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
tool_calls: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
tool_results: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
tokens_used: Mapped[int | None] = mapped_column(default=0)
|
||||||
|
metadata: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
0
src/domain/services/__init__.py
Normal file
0
src/domain/services/__init__.py
Normal file
0
src/infrastructure/__init__.py
Normal file
0
src/infrastructure/__init__.py
Normal file
0
src/infrastructure/ai/__init__.py
Normal file
0
src/infrastructure/ai/__init__.py
Normal file
143
src/infrastructure/ai/openai_client.py
Normal file
143
src/infrastructure/ai/openai_client.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Async OpenAI client with structured logging and retry logic."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import openai
|
||||||
|
import structlog
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.core.exceptions import OpenAIError
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIClient:
|
||||||
|
"""Enterprise-grade async OpenAI client."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.client = AsyncOpenAI(
|
||||||
|
api_key=settings.OPENAI_API_KEY.get_secret_value(),
|
||||||
|
max_retries=0, # We handle retries manually with tenacity
|
||||||
|
)
|
||||||
|
self.model = settings.OPENAI_MODEL
|
||||||
|
self.embedding_model = settings.OPENAI_EMBEDDING_MODEL
|
||||||
|
self.temperature = settings.OPENAI_TEMPERATURE
|
||||||
|
self.max_tokens = settings.OPENAI_MAX_TOKENS
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||||
|
retry=retry_if_exception_type((openai.RateLimitError, openai.APITimeoutError)),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
async def chat_completion(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
tool_choice: str | dict[str, Any] = "auto",
|
||||||
|
temperature: float | None = None,
|
||||||
|
max_tokens: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a chat completion with optional function calling."""
|
||||||
|
try:
|
||||||
|
response = await self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=messages, # type: ignore[arg-type]
|
||||||
|
tools=tools, # type: ignore[arg-type]
|
||||||
|
tool_choice=tool_choice if tools else None, # type: ignore[arg-type]
|
||||||
|
temperature=temperature or self.temperature,
|
||||||
|
max_tokens=max_tokens or self.max_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
message = response.choices[0].message
|
||||||
|
result = {
|
||||||
|
"content": message.content,
|
||||||
|
"role": message.role,
|
||||||
|
"tool_calls": None,
|
||||||
|
"finish_reason": response.choices[0].finish_reason,
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
|
||||||
|
"completion_tokens": response.usage.completion_tokens if response.usage else 0,
|
||||||
|
"total_tokens": response.usage.total_tokens if response.usage else 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.tool_calls:
|
||||||
|
result["tool_calls"] = [
|
||||||
|
{
|
||||||
|
"id": tc.id,
|
||||||
|
"type": tc.type,
|
||||||
|
"function": {
|
||||||
|
"name": tc.function.name,
|
||||||
|
"arguments": tc.function.arguments,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for tc in message.tool_calls
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"openai_chat_completion",
|
||||||
|
model=self.model,
|
||||||
|
finish_reason=result["finish_reason"],
|
||||||
|
tokens=result["usage"]["total_tokens"],
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except openai.RateLimitError as exc:
|
||||||
|
logger.error("openai_rate_limited", error=str(exc))
|
||||||
|
raise OpenAIError("Rate limited by OpenAI", status_code=429) from exc
|
||||||
|
except openai.AuthenticationError as exc:
|
||||||
|
logger.error("openai_auth_error", error=str(exc))
|
||||||
|
raise OpenAIError("OpenAI authentication failed", status_code=401) from exc
|
||||||
|
except openai.APIError as exc:
|
||||||
|
logger.error("openai_api_error", error=str(exc), code=exc.code)
|
||||||
|
raise OpenAIError(f"OpenAI API error: {exc}", status_code=502) from exc
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=1, min=1, max=5),
|
||||||
|
retry=retry_if_exception_type((openai.RateLimitError,)),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
async def create_embedding(self, text: str) -> list[float]:
|
||||||
|
"""Create embedding vector for text."""
|
||||||
|
try:
|
||||||
|
response = await self.client.embeddings.create(
|
||||||
|
model=self.embedding_model,
|
||||||
|
input=text,
|
||||||
|
encoding_format="float",
|
||||||
|
)
|
||||||
|
embedding = response.data[0].embedding
|
||||||
|
logger.info(
|
||||||
|
"openai_embedding_created",
|
||||||
|
model=self.embedding_model,
|
||||||
|
dimensions=len(embedding),
|
||||||
|
)
|
||||||
|
return embedding
|
||||||
|
except openai.APIError as exc:
|
||||||
|
logger.error("openai_embedding_error", error=str(exc))
|
||||||
|
raise OpenAIError(f"Embedding failed: {exc}", status_code=502) from exc
|
||||||
|
|
||||||
|
async def create_embeddings_batch(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
"""Create embeddings for multiple texts."""
|
||||||
|
response = await self.client.embeddings.create(
|
||||||
|
model=self.embedding_model,
|
||||||
|
input=texts,
|
||||||
|
encoding_format="float",
|
||||||
|
)
|
||||||
|
return [d.embedding for d in response.data]
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton
|
||||||
|
_openai_client: OpenAIClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_openai_client() -> OpenAIClient:
|
||||||
|
"""Return OpenAI client singleton."""
|
||||||
|
global _openai_client
|
||||||
|
if _openai_client is None:
|
||||||
|
_openai_client = OpenAIClient()
|
||||||
|
return _openai_client
|
||||||
202
src/infrastructure/ai/prompts.py
Normal file
202
src/infrastructure/ai/prompts.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""Prompt templates and tool definitions for the SKEEN AI Agent."""
|
||||||
|
|
||||||
|
from src.core.constants import SKEEN_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TOOL DEFINITIONS (OpenAI Function Calling)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
TOOLS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "search_catalog",
|
||||||
|
"description": "Busca servicios, productos o paquetes en el catálogo de SKEEN. Usa esta herramienta cuando el paciente pregunte por tratamientos, precios, disponibilidad o promociones.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Términos de búsqueda del paciente (ej: 'depilación láser bikini', 'toxina botulínica precio')",
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["servicio", "producto", "paquete", "general"],
|
||||||
|
"description": "Categoría a filtrar, si se menciona explícitamente",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "check_availability",
|
||||||
|
"description": "Consulta la disponibilidad de citas en una fecha, sucursal o con un doctor específico.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"date": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Fecha en formato ISO 8601 (YYYY-MM-DD). Si no se especifica, usa la fecha de mañana.",
|
||||||
|
},
|
||||||
|
"branch": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Nombre de la sucursal: 'Rosarito' o 'Tijuana'. Si no se especifica, busca en ambas.",
|
||||||
|
},
|
||||||
|
"doctor": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Nombre del doctor o 'cualquiera'.",
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Nombre del servicio que desea agendar.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["date"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "create_appointment",
|
||||||
|
"description": "Crea una cita médica en el sistema. SOLO usar después de confirmar fecha, hora, sucursal, servicio y doctor con el paciente.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"patient_phone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Número de WhatsApp del paciente (con lada, ej: 5216641234567).",
|
||||||
|
},
|
||||||
|
"patient_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Nombre completo del paciente.",
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Fecha de la cita (YYYY-MM-DD).",
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Hora de la cita en formato 24h (HH:MM).",
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Nombre del servicio a agendar.",
|
||||||
|
},
|
||||||
|
"branch": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Sucursal: 'Rosarito' o 'Tijuana'.",
|
||||||
|
},
|
||||||
|
"doctor": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Nombre del doctor asignado.",
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Notas adicionales para la cita.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["patient_phone", "patient_name", "date", "time", "service", "branch"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_patient_info",
|
||||||
|
"description": "Consulta la información de un paciente existente: historial de citas, saldo de monedero, adeudos, paquetes activos.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"phone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Número de WhatsApp del paciente.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["phone"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_wallet_balance",
|
||||||
|
"description": "Consulta el saldo del monedero electrónico de un paciente.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"phone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Número de WhatsApp del paciente.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["phone"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "escalate_to_human",
|
||||||
|
"description": "Escalar la conversación a un agente humano cuando el paciente lo solicite o el caso sea muy complejo/emocional.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Motivo de la escalación.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["reason"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PROMPT TEMPLATES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
APPOINTMENT_CONFIRMATION_PROMPT = """El paciente ha confirmado los siguientes datos para su cita:
|
||||||
|
|
||||||
|
- Nombre: {patient_name}
|
||||||
|
- Fecha: {date}
|
||||||
|
- Hora: {time}
|
||||||
|
- Servicio: {service}
|
||||||
|
- Sucursal: {branch}
|
||||||
|
- Doctor: {doctor}
|
||||||
|
|
||||||
|
Genera un mensaje de confirmación cálido y profesional para WhatsApp que:
|
||||||
|
1. Agradezca la preferencia.
|
||||||
|
2. Confirme todos los detalles en formato claro.
|
||||||
|
3. Indique política de cancelación (24h de anticipación).
|
||||||
|
4. Incluya dirección de la sucursal.
|
||||||
|
5. Sea corto (máximo 4 párrafos)."""
|
||||||
|
|
||||||
|
NO_AVAILABILITY_PROMPT = """No hay disponibilidad para la fecha/hora/sucursal solicitada.
|
||||||
|
|
||||||
|
Sugerencias actuales:
|
||||||
|
{alternatives}
|
||||||
|
|
||||||
|
Genera un mensaje amable ofreciendo las alternativas disponibles. Mantén tono profesional y empático."""
|
||||||
|
|
||||||
|
WALLET_BALANCE_PROMPT = """Saldo de monedero del paciente:
|
||||||
|
- Saldo actual: ${balance_mxn} MXN
|
||||||
|
- Puntos acumulados: {points}
|
||||||
|
|
||||||
|
Genera un resumen amigable del saldo y sugiere cómo puede usarlo (aplicar a su próxima cita o paquete)."""
|
||||||
|
|
||||||
|
NEW_PATIENT_ONBOARDING_PROMPT = """Este es un nuevo prospecto. Bienvenida/da a SKEEN.
|
||||||
|
|
||||||
|
Información capturada:
|
||||||
|
- Nombre: {name}
|
||||||
|
- Interés: {interest}
|
||||||
|
|
||||||
|
Genera un mensaje de bienvenida cálido que:
|
||||||
|
1. Presente brevemente a SKEEN.
|
||||||
|
2. Mencione que pueden agendar valoración gratuita.
|
||||||
|
3. Pregunte si desea información sobre algún tratamiento específico.
|
||||||
|
4. Incluya link a valoración express si es relevante."""
|
||||||
181
src/infrastructure/ai/rag.py
Normal file
181
src/infrastructure/ai/rag.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""Retrieval-Augmented Generation with pgvector."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.infrastructure.ai.openai_client import get_openai_client
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RAGStore:
|
||||||
|
"""Vector store for SKEEN catalog and knowledge base using pgvector."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def ensure_extension(self) -> None:
|
||||||
|
"""Ensure pgvector extension is installed."""
|
||||||
|
await self.session.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
top_k: int | None = None,
|
||||||
|
similarity_threshold: float | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Search knowledge base using semantic similarity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: User query text.
|
||||||
|
top_k: Number of results (default from settings).
|
||||||
|
similarity_threshold: Minimum similarity score.
|
||||||
|
category: Filter by category (e.g., 'servicio', 'producto', 'faq').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching chunks with content, metadata, and similarity score.
|
||||||
|
"""
|
||||||
|
top_k = top_k or settings.RAG_TOP_K
|
||||||
|
threshold = similarity_threshold or settings.RAG_SIMILARITY_THRESHOLD
|
||||||
|
|
||||||
|
# Generate embedding for query
|
||||||
|
openai_client = await get_openai_client()
|
||||||
|
embedding = await openai_client.create_embedding(query)
|
||||||
|
embedding_str = f"[{','.join(str(x) for x in embedding)}]"
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
category_filter = "AND category = :category" if category else ""
|
||||||
|
sql = f"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
metadata,
|
||||||
|
category,
|
||||||
|
source,
|
||||||
|
1 - (embedding <=> :embedding_vec::vector) AS similarity
|
||||||
|
FROM knowledge_chunks
|
||||||
|
WHERE 1 - (embedding <=> :embedding_vec::vector) >= :threshold
|
||||||
|
{category_filter}
|
||||||
|
ORDER BY embedding <=> :embedding_vec::vector
|
||||||
|
LIMIT :top_k
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"embedding_vec": embedding_str,
|
||||||
|
"threshold": threshold,
|
||||||
|
"top_k": top_k,
|
||||||
|
}
|
||||||
|
if category:
|
||||||
|
params["category"] = category
|
||||||
|
|
||||||
|
result = await self.session.execute(text(sql), params)
|
||||||
|
rows = result.mappings().all()
|
||||||
|
|
||||||
|
documents = []
|
||||||
|
for row in rows:
|
||||||
|
documents.append({
|
||||||
|
"id": row["id"],
|
||||||
|
"content": row["content"],
|
||||||
|
"metadata": row["metadata"],
|
||||||
|
"category": row["category"],
|
||||||
|
"source": row["source"],
|
||||||
|
"similarity": float(row["similarity"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"rag_search_completed",
|
||||||
|
query=query[:50],
|
||||||
|
results=len(documents),
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
return documents
|
||||||
|
|
||||||
|
async def add_document(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
category: str = "general",
|
||||||
|
source: str = "",
|
||||||
|
doc_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Add a document chunk to the knowledge base."""
|
||||||
|
openai_client = await get_openai_client()
|
||||||
|
embedding = await openai_client.create_embedding(content)
|
||||||
|
embedding_str = f"[{','.join(str(x) for x in embedding)}]"
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
INSERT INTO knowledge_chunks (id, content, metadata, category, source, embedding)
|
||||||
|
VALUES (
|
||||||
|
COALESCE(:doc_id, gen_random_uuid()::text),
|
||||||
|
:content,
|
||||||
|
:metadata,
|
||||||
|
:category,
|
||||||
|
:source,
|
||||||
|
:embedding_vec::vector
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await self.session.execute(
|
||||||
|
text(sql),
|
||||||
|
{
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"content": content,
|
||||||
|
"metadata": json.dumps(metadata or {}),
|
||||||
|
"category": category,
|
||||||
|
"source": source,
|
||||||
|
"embedding_vec": embedding_str,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
row = result.mappings().first()
|
||||||
|
inserted_id = row["id"] if row else ""
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"rag_document_added",
|
||||||
|
doc_id=inserted_id,
|
||||||
|
category=category,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
return inserted_id
|
||||||
|
|
||||||
|
async def delete_by_source(self, source: str) -> int:
|
||||||
|
"""Delete all chunks from a specific source."""
|
||||||
|
result = await self.session.execute(
|
||||||
|
text("DELETE FROM knowledge_chunks WHERE source = :source"),
|
||||||
|
{"source": source},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
deleted = result.rowcount or 0
|
||||||
|
logger.info("rag_documents_deleted", source=source, count=deleted)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
# SQL to create table (run via Alembic or init script)
|
||||||
|
CREATE_KNOWLEDGE_TABLE_SQL = """
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS knowledge_chunks (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
category TEXT DEFAULT 'general',
|
||||||
|
source TEXT DEFAULT '',
|
||||||
|
embedding VECTOR(1536),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_embedding
|
||||||
|
ON knowledge_chunks USING ivfflat (embedding vector_cosine_ops)
|
||||||
|
WITH (lists = 100);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_category ON knowledge_chunks(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_source ON knowledge_chunks(source);
|
||||||
|
"""
|
||||||
62
src/infrastructure/db.py
Normal file
62
src/infrastructure/db.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Database configuration with async SQLAlchemy + pgvector."""
|
||||||
|
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
|
||||||
|
# Base class for SQLModel/SQLAlchemy models
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# Engine configuration
|
||||||
|
engine_kwargs = {
|
||||||
|
"pool_size": settings.DATABASE_POOL_SIZE,
|
||||||
|
"max_overflow": settings.DATABASE_MAX_OVERFLOW,
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
"pool_recycle": 300,
|
||||||
|
"echo": settings.DEBUG,
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.APP_ENV == "testing":
|
||||||
|
engine_kwargs["poolclass"] = NullPool
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url_str,
|
||||||
|
**engine_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Session factory
|
||||||
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autoflush=False,
|
||||||
|
autocommit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Yield an async database session for dependency injection."""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
"""Initialize database tables (for dev/testing only)."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db() -> None:
|
||||||
|
"""Dispose database engine."""
|
||||||
|
await engine.dispose()
|
||||||
0
src/infrastructure/erpnext/__init__.py
Normal file
0
src/infrastructure/erpnext/__init__.py
Normal file
255
src/infrastructure/erpnext/client.py
Normal file
255
src/infrastructure/erpnext/client.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""ERPNext Frappe REST API client."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.core.exceptions import ERPNextError
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ERPNextClient:
|
||||||
|
"""Async client for ERPNext Frappe REST API."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.base_url = settings.ERPNEXT_BASE_URL.rstrip("/")
|
||||||
|
self.api_key = settings.ERPNEXT_API_KEY.get_secret_value()
|
||||||
|
self.api_secret = settings.ERPNEXT_API_SECRET.get_secret_value()
|
||||||
|
self.verify_ssl = settings.ERPNEXT_VERIFY_SSL
|
||||||
|
|
||||||
|
self.headers = {
|
||||||
|
"Authorization": f"token {self.api_key}:{self.api_secret}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
self.client = httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=httpx.Timeout(30.0, connect=10.0),
|
||||||
|
verify=self.verify_ssl,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close HTTP client."""
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||||
|
retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.NetworkError)),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
async def get_document(
|
||||||
|
self,
|
||||||
|
doctype: str,
|
||||||
|
name: str,
|
||||||
|
fields: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get a single document from ERPNext."""
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if fields:
|
||||||
|
params["fields"] = str(fields)
|
||||||
|
|
||||||
|
response = await self.client.get(
|
||||||
|
f"/api/resource/{doctype}/{name}",
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data.get("data", {})
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||||
|
retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.NetworkError)),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
async def get_list(
|
||||||
|
self,
|
||||||
|
doctype: str,
|
||||||
|
filters: list[list[Any]] | None = None,
|
||||||
|
fields: list[str] | None = None,
|
||||||
|
or_filters: list[list[Any]] | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
limit_start: int = 0,
|
||||||
|
order_by: str = "modified desc",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Query a list of documents from ERPNext."""
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"limit_page_length": limit,
|
||||||
|
"limit_start": limit_start,
|
||||||
|
"order_by": order_by,
|
||||||
|
}
|
||||||
|
if filters:
|
||||||
|
params["filters"] = str(filters)
|
||||||
|
if or_filters:
|
||||||
|
params["or_filters"] = str(or_filters)
|
||||||
|
if fields:
|
||||||
|
params["fields"] = str(fields)
|
||||||
|
|
||||||
|
response = await self.client.get(
|
||||||
|
f"/api/resource/{doctype}",
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
|
async def create_document(
|
||||||
|
self,
|
||||||
|
doctype: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new document in ERPNext."""
|
||||||
|
try:
|
||||||
|
response = await self.client.post(
|
||||||
|
f"/api/resource/{doctype}",
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
logger.info(
|
||||||
|
"erpnext_document_created",
|
||||||
|
doctype=doctype,
|
||||||
|
name=result.get("data", {}).get("name"),
|
||||||
|
)
|
||||||
|
return result.get("data", {})
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
logger.error(
|
||||||
|
"erpnext_create_failed",
|
||||||
|
doctype=doctype,
|
||||||
|
status=exc.response.status_code,
|
||||||
|
response=exc.response.text,
|
||||||
|
)
|
||||||
|
raise ERPNextError(
|
||||||
|
f"Failed to create {doctype}: {exc.response.text}",
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def update_document(
|
||||||
|
self,
|
||||||
|
doctype: str,
|
||||||
|
name: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update an existing document."""
|
||||||
|
try:
|
||||||
|
response = await self.client.put(
|
||||||
|
f"/api/resource/{doctype}/{name}",
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
logger.info("erpnext_document_updated", doctype=doctype, name=name)
|
||||||
|
return result.get("data", {})
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise ERPNextError(
|
||||||
|
f"Failed to update {doctype}: {exc.response.text}",
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def call_method(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Call a Frappe method (whitelisted)."""
|
||||||
|
try:
|
||||||
|
response = await self.client.post(
|
||||||
|
f"/api/method/{method}",
|
||||||
|
json=data or {},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise ERPNextError(
|
||||||
|
f"Method {method} failed: {exc.response.text}",
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Convenience methods for Healthcare
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def find_patient_by_phone(self, phone: str) -> dict[str, Any] | None:
|
||||||
|
"""Find a patient by mobile number."""
|
||||||
|
patients = await self.get_list(
|
||||||
|
"Patient",
|
||||||
|
filters=[["mobile", "=", phone]],
|
||||||
|
fields=["name", "patient_name", "mobile", "sex", "dob", "blood_group"],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
return patients[0] if patients else None
|
||||||
|
|
||||||
|
async def get_appointments(
|
||||||
|
self,
|
||||||
|
patient: str | None = None,
|
||||||
|
date: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get patient appointments."""
|
||||||
|
filters: list[list[Any]] = []
|
||||||
|
if patient:
|
||||||
|
filters.append(["patient", "=", patient])
|
||||||
|
if date:
|
||||||
|
filters.append(["appointment_date", "=", date])
|
||||||
|
if status:
|
||||||
|
filters.append(["status", "=", status])
|
||||||
|
|
||||||
|
return await self.get_list(
|
||||||
|
"Patient Appointment",
|
||||||
|
filters=filters if filters else None,
|
||||||
|
fields=[
|
||||||
|
"name", "patient", "practitioner", "appointment_date",
|
||||||
|
"appointment_time", "duration", "status", "department",
|
||||||
|
],
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_appointment(
|
||||||
|
self,
|
||||||
|
patient: str,
|
||||||
|
practitioner: str,
|
||||||
|
appointment_date: str,
|
||||||
|
appointment_time: str,
|
||||||
|
duration: int = 30,
|
||||||
|
department: str = "Dermatología Estética",
|
||||||
|
notes: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a patient appointment."""
|
||||||
|
data = {
|
||||||
|
"doctype": "Patient Appointment",
|
||||||
|
"patient": patient,
|
||||||
|
"practitioner": practitioner,
|
||||||
|
"appointment_date": appointment_date,
|
||||||
|
"appointment_time": appointment_time,
|
||||||
|
"duration": duration,
|
||||||
|
"department": department,
|
||||||
|
"notes": notes,
|
||||||
|
"status": "Scheduled",
|
||||||
|
}
|
||||||
|
return await self.create_document("Patient Appointment", data)
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton
|
||||||
|
_erpnext_client: ERPNextClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_erpnext_client() -> ERPNextClient:
|
||||||
|
"""Return ERPNext client singleton."""
|
||||||
|
global _erpnext_client
|
||||||
|
if _erpnext_client is None:
|
||||||
|
_erpnext_client = ERPNextClient()
|
||||||
|
return _erpnext_client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_erpnext_client() -> None:
|
||||||
|
"""Close ERPNext client."""
|
||||||
|
global _erpnext_client
|
||||||
|
if _erpnext_client:
|
||||||
|
await _erpnext_client.close()
|
||||||
|
_erpnext_client = None
|
||||||
69
src/infrastructure/redis.py
Normal file
69
src/infrastructure/redis.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Redis client configuration."""
|
||||||
|
|
||||||
|
import orjson
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
|
||||||
|
# Global Redis client instance
|
||||||
|
_redis_client: Redis | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis() -> Redis:
|
||||||
|
"""Return async Redis client singleton."""
|
||||||
|
global _redis_client
|
||||||
|
if _redis_client is None:
|
||||||
|
_redis_client = aioredis.from_url(
|
||||||
|
str(settings.REDIS_URL),
|
||||||
|
decode_responses=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return _redis_client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_redis() -> None:
|
||||||
|
"""Close Redis connection."""
|
||||||
|
global _redis_client
|
||||||
|
if _redis_client:
|
||||||
|
await _redis_client.close()
|
||||||
|
_redis_client = None
|
||||||
|
|
||||||
|
|
||||||
|
class RedisCache:
|
||||||
|
"""Helper class for common Redis operations."""
|
||||||
|
|
||||||
|
def __init__(self, redis: Redis) -> None:
|
||||||
|
self.redis = redis
|
||||||
|
|
||||||
|
async def get(self, key: str) -> dict | None:
|
||||||
|
"""Get and deserialize JSON value."""
|
||||||
|
value = await self.redis.get(key)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return orjson.loads(value)
|
||||||
|
|
||||||
|
async def set(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: dict,
|
||||||
|
ttl: int = 3600,
|
||||||
|
) -> None:
|
||||||
|
"""Serialize and set JSON value with TTL."""
|
||||||
|
await self.redis.setex(key, ttl, orjson.dumps(value))
|
||||||
|
|
||||||
|
async def delete(self, key: str) -> None:
|
||||||
|
"""Delete key."""
|
||||||
|
await self.redis.delete(key)
|
||||||
|
|
||||||
|
async def exists(self, key: str) -> bool:
|
||||||
|
"""Check if key exists."""
|
||||||
|
return await self.redis.exists(key) > 0
|
||||||
|
|
||||||
|
async def increment(self, key: str, amount: int = 1) -> int:
|
||||||
|
"""Increment counter."""
|
||||||
|
return await self.redis.incrby(key, amount)
|
||||||
|
|
||||||
|
async def expire(self, key: str, ttl: int) -> None:
|
||||||
|
"""Set expiration on key."""
|
||||||
|
await self.redis.expire(key, ttl)
|
||||||
0
src/infrastructure/whatsapp/__init__.py
Normal file
0
src/infrastructure/whatsapp/__init__.py
Normal file
254
src/infrastructure/whatsapp/client.py
Normal file
254
src/infrastructure/whatsapp/client.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"""Official Meta WhatsApp Business API client."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.core.exceptions import WhatsAppAPIError
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppClient:
|
||||||
|
"""Async client for Meta WhatsApp Business API."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.base_url = settings.meta_api_base_url
|
||||||
|
self.phone_number_id = settings.META_PHONE_NUMBER_ID
|
||||||
|
self.access_token = settings.META_ACCESS_TOKEN.get_secret_value()
|
||||||
|
self.headers = {
|
||||||
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
self.client = httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=httpx.Timeout(30.0, connect=10.0),
|
||||||
|
http2=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close HTTP client."""
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||||
|
retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.NetworkError)),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
async def _post(self, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Make POST request with retries."""
|
||||||
|
response = await self.client.post(endpoint, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def send_text_message(
|
||||||
|
self,
|
||||||
|
to: str,
|
||||||
|
text: str,
|
||||||
|
preview_url: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send a text message to a WhatsApp user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to: Recipient phone number in international format (e.g., 5216641234567).
|
||||||
|
text: Message body (max 4096 chars).
|
||||||
|
preview_url: Whether to show URL preview.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
API response with message ID.
|
||||||
|
"""
|
||||||
|
if len(text) > 4096:
|
||||||
|
text = text[:4093] + "..."
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"recipient_type": "individual",
|
||||||
|
"to": to,
|
||||||
|
"type": "text",
|
||||||
|
"text": {"preview_url": preview_url, "body": text},
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = f"/{self.phone_number_id}/messages"
|
||||||
|
try:
|
||||||
|
result = await self._post(endpoint, payload)
|
||||||
|
logger.info(
|
||||||
|
"whatsapp_text_sent",
|
||||||
|
to=to,
|
||||||
|
message_id=result.get("messages", [{}])[0].get("id"),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
logger.error(
|
||||||
|
"whatsapp_text_failed",
|
||||||
|
to=to,
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
response=exc.response.text,
|
||||||
|
)
|
||||||
|
raise WhatsAppAPIError(
|
||||||
|
f"Failed to send WhatsApp message: {exc.response.text}",
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def send_template_message(
|
||||||
|
self,
|
||||||
|
to: str,
|
||||||
|
template_name: str,
|
||||||
|
language_code: str = "es_MX",
|
||||||
|
components: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send a template message (required for 24h+ inactive conversations)."""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"recipient_type": "individual",
|
||||||
|
"to": to,
|
||||||
|
"type": "template",
|
||||||
|
"template": {
|
||||||
|
"name": template_name,
|
||||||
|
"language": {"code": language_code},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if components:
|
||||||
|
payload["template"]["components"] = components
|
||||||
|
|
||||||
|
endpoint = f"/{self.phone_number_id}/messages"
|
||||||
|
try:
|
||||||
|
result = await self._post(endpoint, payload)
|
||||||
|
logger.info(
|
||||||
|
"whatsapp_template_sent",
|
||||||
|
to=to,
|
||||||
|
template=template_name,
|
||||||
|
message_id=result.get("messages", [{}])[0].get("id"),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise WhatsAppAPIError(
|
||||||
|
f"Failed to send template: {exc.response.text}",
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def send_interactive_buttons(
|
||||||
|
self,
|
||||||
|
to: str,
|
||||||
|
body: str,
|
||||||
|
buttons: list[dict[str, str]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send interactive button message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buttons: List of {"id": "btn_1", "title": "Option 1"} (max 3).
|
||||||
|
"""
|
||||||
|
if len(buttons) > 3:
|
||||||
|
raise ValueError("Maximum 3 buttons allowed")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"recipient_type": "individual",
|
||||||
|
"to": to,
|
||||||
|
"type": "interactive",
|
||||||
|
"interactive": {
|
||||||
|
"type": "button",
|
||||||
|
"body": {"text": body},
|
||||||
|
"action": {
|
||||||
|
"buttons": [
|
||||||
|
{"type": "reply", "reply": {"id": b["id"], "title": b["title"]}}
|
||||||
|
for b in buttons
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = f"/{self.phone_number_id}/messages"
|
||||||
|
try:
|
||||||
|
result = await self._post(endpoint, payload)
|
||||||
|
logger.info("whatsapp_buttons_sent", to=to)
|
||||||
|
return result
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise WhatsAppAPIError(
|
||||||
|
f"Failed to send buttons: {exc.response.text}",
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def send_interactive_list(
|
||||||
|
self,
|
||||||
|
to: str,
|
||||||
|
body: str,
|
||||||
|
button_text: str,
|
||||||
|
sections: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send interactive list message (e.g., service selection)."""
|
||||||
|
payload = {
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"recipient_type": "individual",
|
||||||
|
"to": to,
|
||||||
|
"type": "interactive",
|
||||||
|
"interactive": {
|
||||||
|
"type": "list",
|
||||||
|
"body": {"text": body},
|
||||||
|
"action": {
|
||||||
|
"button": button_text,
|
||||||
|
"sections": sections,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = f"/{self.phone_number_id}/messages"
|
||||||
|
try:
|
||||||
|
result = await self._post(endpoint, payload)
|
||||||
|
logger.info("whatsapp_list_sent", to=to)
|
||||||
|
return result
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise WhatsAppAPIError(
|
||||||
|
f"Failed to send list: {exc.response.text}",
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def mark_as_read(self, message_id: str) -> dict[str, Any]:
|
||||||
|
"""Mark a message as read."""
|
||||||
|
payload = {
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"status": "read",
|
||||||
|
"message_id": message_id,
|
||||||
|
}
|
||||||
|
endpoint = f"/{self.phone_number_id}/messages"
|
||||||
|
try:
|
||||||
|
result = await self._post(endpoint, payload)
|
||||||
|
logger.info("whatsapp_marked_read", message_id=message_id)
|
||||||
|
return result
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise WhatsAppAPIError(
|
||||||
|
f"Failed to mark as read: {exc.response.text}",
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def get_business_profile(self) -> dict[str, Any]:
|
||||||
|
"""Get business profile information."""
|
||||||
|
endpoint = f"/{self.phone_number_id}/whatsapp_business_profile"
|
||||||
|
response = await self.client.get(endpoint, params={"fields": "about,description,email"})
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
# Global client instance
|
||||||
|
_whatsapp_client: WhatsAppClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_whatsapp_client() -> WhatsAppClient:
|
||||||
|
"""Return WhatsApp client singleton."""
|
||||||
|
global _whatsapp_client
|
||||||
|
if _whatsapp_client is None:
|
||||||
|
_whatsapp_client = WhatsAppClient()
|
||||||
|
return _whatsapp_client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_whatsapp_client() -> None:
|
||||||
|
"""Close WhatsApp client."""
|
||||||
|
global _whatsapp_client
|
||||||
|
if _whatsapp_client:
|
||||||
|
await _whatsapp_client.close()
|
||||||
|
_whatsapp_client = None
|
||||||
168
src/infrastructure/whatsapp/webhook.py
Normal file
168
src/infrastructure/whatsapp/webhook.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""Meta WhatsApp webhook parser and validator."""
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.core.constants import WhatsAppMessageType
|
||||||
|
from src.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppWebhookPayload:
|
||||||
|
"""Parsed WhatsApp webhook payload."""
|
||||||
|
|
||||||
|
def __init__(self, raw: dict[str, Any]) -> None:
|
||||||
|
self.raw = raw
|
||||||
|
self._entry = raw.get("entry", [{}])[0]
|
||||||
|
self._change = self._entry.get("changes", [{}])[0]
|
||||||
|
self._value = self._change.get("value", {})
|
||||||
|
self._message = self._value.get("messages", [{}])[0] if "messages" in self._value else {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def object_type(self) -> str:
|
||||||
|
return self.raw.get("object", "")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def business_phone_number_id(self) -> str:
|
||||||
|
return self._value.get("metadata", {}).get("phone_number_id", "")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_phone_number(self) -> str:
|
||||||
|
return self._value.get("metadata", {}).get("display_phone_number", "")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_messages(self) -> bool:
|
||||||
|
return "messages" in self._value and len(self._value["messages"]) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_statuses(self) -> bool:
|
||||||
|
return "statuses" in self._value and len(self._value["statuses"]) > 0
|
||||||
|
|
||||||
|
# Message properties
|
||||||
|
@property
|
||||||
|
def message_id(self) -> str:
|
||||||
|
return self._message.get("id", "")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def from_number(self) -> str:
|
||||||
|
return self._message.get("from", "")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def timestamp(self) -> str:
|
||||||
|
return self._message.get("timestamp", "")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_type(self) -> WhatsAppMessageType:
|
||||||
|
msg_type = self._message.get("type", "")
|
||||||
|
try:
|
||||||
|
return WhatsAppMessageType(msg_type)
|
||||||
|
except ValueError:
|
||||||
|
return WhatsAppMessageType.UNKNOWN
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text_body(self) -> str:
|
||||||
|
if self.message_type == WhatsAppMessageType.TEXT:
|
||||||
|
return self._message.get("text", {}).get("body", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def button_payload(self) -> str:
|
||||||
|
if self.message_type == WhatsAppMessageType.INTERACTIVE:
|
||||||
|
interactive = self._message.get("interactive", {})
|
||||||
|
if "button_reply" in interactive:
|
||||||
|
return interactive["button_reply"].get("id", "")
|
||||||
|
if "list_reply" in interactive:
|
||||||
|
return interactive["list_reply"].get("id", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def button_title(self) -> str:
|
||||||
|
if self.message_type == WhatsAppMessageType.INTERACTIVE:
|
||||||
|
interactive = self._message.get("interactive", {})
|
||||||
|
if "button_reply" in interactive:
|
||||||
|
return interactive["button_reply"].get("title", "")
|
||||||
|
if "list_reply" in interactive:
|
||||||
|
return interactive["list_reply"].get("title", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def image_data(self) -> dict[str, Any]:
|
||||||
|
if self.message_type == WhatsAppMessageType.IMAGE:
|
||||||
|
return self._message.get("image", {})
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def audio_data(self) -> dict[str, Any]:
|
||||||
|
if self.message_type == WhatsAppMessageType.AUDIO:
|
||||||
|
return self._message.get("audio", {})
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def context(self) -> dict[str, Any]:
|
||||||
|
"""Context of a reply (original message ID)."""
|
||||||
|
return self._message.get("context", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_reply(self) -> bool:
|
||||||
|
return bool(self.context.get("id"))
|
||||||
|
|
||||||
|
# Status properties
|
||||||
|
@property
|
||||||
|
def statuses(self) -> list[dict[str, Any]]:
|
||||||
|
return self._value.get("statuses", [])
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<WhatsAppWebhookPayload from={self.from_number} "
|
||||||
|
f"type={self.message_type.value} id={self.message_id}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookVerifier:
|
||||||
|
"""Verify Meta webhook signatures."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def verify_subscription(mode: str | None, token: str | None) -> bool:
|
||||||
|
"""Verify webhook subscription challenge."""
|
||||||
|
if mode != "subscribe":
|
||||||
|
return False
|
||||||
|
verify_token = settings.META_WEBHOOK_VERIFY_TOKEN.get_secret_value()
|
||||||
|
return token == verify_token
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def verify_signature(body: bytes, signature: str | None) -> bool:
|
||||||
|
"""Verify X-Hub-Signature-256 header."""
|
||||||
|
if not signature:
|
||||||
|
logger.warning("missing_webhook_signature")
|
||||||
|
return False
|
||||||
|
|
||||||
|
app_secret = settings.META_APP_SECRET.get_secret_value()
|
||||||
|
expected = hmac.new(
|
||||||
|
app_secret.encode("utf-8"),
|
||||||
|
body,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
if not hmac.compare_digest(f"sha256={expected}", signature):
|
||||||
|
logger.warning("invalid_webhook_signature")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_webhook_payload(payload: dict[str, Any]) -> WhatsAppWebhookPayload:
|
||||||
|
"""Parse and validate incoming webhook payload."""
|
||||||
|
if payload.get("object") != "whatsapp_business_account":
|
||||||
|
raise ValidationError("Invalid webhook object type")
|
||||||
|
|
||||||
|
parsed = WhatsAppWebhookPayload(payload)
|
||||||
|
|
||||||
|
if not parsed.has_messages and not parsed.has_statuses:
|
||||||
|
raise ValidationError("Webhook contains no messages or statuses")
|
||||||
|
|
||||||
|
return parsed
|
||||||
170
src/main.py
Normal file
170
src/main.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""SKEEN CRM AI Agent - FastAPI Application Entry Point."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from asgi_correlation_id import CorrelationIdMiddleware
|
||||||
|
from fastapi import FastAPI, Request, status
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from prometheus_client import make_asgi_app
|
||||||
|
|
||||||
|
from src.api.v1.health import router as health_router
|
||||||
|
from src.api.v1.messages import router as messages_router
|
||||||
|
from src.api.v1.webhooks import router as webhooks_router
|
||||||
|
from src.config import settings
|
||||||
|
from src.core.exceptions import SkeenException
|
||||||
|
from src.infrastructure.db import close_db, init_db
|
||||||
|
from src.infrastructure.erpnext.client import close_erpnext_client
|
||||||
|
from src.infrastructure.redis import close_redis
|
||||||
|
from src.infrastructure.whatsapp.client import close_whatsapp_client
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
"""Configure structured logging."""
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.filter_by_level,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
structlog.processors.UnicodeDecoder(),
|
||||||
|
structlog.processors.JSONRenderer(),
|
||||||
|
],
|
||||||
|
context_class=dict,
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Application lifespan events."""
|
||||||
|
setup_logging()
|
||||||
|
logger.info(
|
||||||
|
"application_starting",
|
||||||
|
app_name=settings.APP_NAME,
|
||||||
|
environment=settings.APP_ENV,
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize database in development
|
||||||
|
if settings.APP_ENV == "development":
|
||||||
|
try:
|
||||||
|
await init_db()
|
||||||
|
logger.info("database_initialized")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("database_init_skipped", error=str(exc))
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
logger.info("application_shutting_down")
|
||||||
|
await close_redis()
|
||||||
|
await close_whatsapp_client()
|
||||||
|
await close_erpnext_client()
|
||||||
|
await close_db()
|
||||||
|
logger.info("application_shutdown_complete")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
"""Application factory."""
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.APP_NAME,
|
||||||
|
description="SKEEN CRM AI Agent for WhatsApp Business API + ERPNext",
|
||||||
|
version="0.1.0",
|
||||||
|
docs_url="/docs" if not settings.is_production else None,
|
||||||
|
redoc_url="/redoc" if not settings.is_production else None,
|
||||||
|
openapi_url="/openapi.json" if not settings.is_production else None,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Middleware
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # TODO: Restrict in production
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.add_middleware(CorrelationIdMiddleware)
|
||||||
|
|
||||||
|
# Request timing middleware
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_process_time_header(request: Request, call_next):
|
||||||
|
start_time = time.time()
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
structlog.contextvars.clear_contextvars()
|
||||||
|
structlog.contextvars.bind_contextvars(
|
||||||
|
request_id=request_id,
|
||||||
|
path=request.url.path,
|
||||||
|
method=request.method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
process_time = time.time() - start_time
|
||||||
|
response.headers["X-Process-Time"] = str(process_time)
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"request_completed",
|
||||||
|
status_code=response.status_code,
|
||||||
|
duration_ms=round(process_time * 1000, 2),
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("request_failed", error=str(exc))
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Exception handlers
|
||||||
|
@app.exception_handler(SkeenException)
|
||||||
|
async def skeen_exception_handler(request: Request, exc: SkeenException):
|
||||||
|
logger.error(
|
||||||
|
"application_exception",
|
||||||
|
error=exc.message,
|
||||||
|
status_code=exc.status_code,
|
||||||
|
details=exc.details,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={
|
||||||
|
"error": exc.message,
|
||||||
|
"details": exc.details,
|
||||||
|
"request_id": str(uuid.uuid4()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def generic_exception_handler(request: Request, exc: Exception):
|
||||||
|
logger.exception("unhandled_exception", error=str(exc))
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content={
|
||||||
|
"error": "Internal server error",
|
||||||
|
"request_id": str(uuid.uuid4()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
app.include_router(health_router, prefix="/api/v1")
|
||||||
|
app.include_router(webhooks_router, prefix="/api/v1")
|
||||||
|
app.include_router(messages_router, prefix="/api/v1")
|
||||||
|
|
||||||
|
# Metrics endpoint (Prometheus)
|
||||||
|
if settings.ENABLE_METRICS:
|
||||||
|
metrics_app = make_asgi_app()
|
||||||
|
app.mount("/metrics", metrics_app)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
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],
|
||||||
|
}
|
||||||
0
src/workers/__init__.py
Normal file
0
src/workers/__init__.py
Normal file
61
src/workers/celery_app.py
Normal file
61
src/workers/celery_app.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Celery configuration for background task processing."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
from celery.signals import setup_logging
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
|
||||||
|
os.environ.setdefault("CELERY_CONFIG_MODULE", "src.workers.celery_app")
|
||||||
|
|
||||||
|
celery_app = Celery(
|
||||||
|
"skeen_crm",
|
||||||
|
broker=str(settings.CELERY_BROKER_URL),
|
||||||
|
backend=str(settings.CELERY_RESULT_BACKEND),
|
||||||
|
include=["src.workers.tasks"],
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
result_serializer="json",
|
||||||
|
timezone="America/Tijuana",
|
||||||
|
enable_utc=True,
|
||||||
|
task_track_started=True,
|
||||||
|
task_time_limit=300,
|
||||||
|
worker_prefetch_multiplier=1,
|
||||||
|
worker_concurrency=settings.CELERY_WORKER_CONCURRENCY,
|
||||||
|
task_routes={
|
||||||
|
"src.workers.tasks.process_whatsapp_message_task": {"queue": "whatsapp"},
|
||||||
|
"src.workers.tasks.sync_erpnext_task": {"queue": "erpnext"},
|
||||||
|
"src.workers.tasks.generate_embedding_task": {"queue": "ai"},
|
||||||
|
},
|
||||||
|
task_default_queue="default",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@setup_logging.connect
|
||||||
|
def config_loggers(*args, **kwargs) -> None:
|
||||||
|
"""Configure structlog for Celery workers."""
|
||||||
|
import logging
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.filter_by_level,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
structlog.processors.UnicodeDecoder(),
|
||||||
|
structlog.processors.JSONRenderer(),
|
||||||
|
],
|
||||||
|
context_class=dict,
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||||
67
src/workers/tasks.py
Normal file
67
src/workers/tasks.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Celery background tasks."""
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from src.infrastructure.db import AsyncSessionLocal
|
||||||
|
from src.use_cases.handle_incoming_message import process_incoming_message
|
||||||
|
from src.workers.celery_app import celery_app
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(bind=True, max_retries=3, default_retry_delay=10)
|
||||||
|
def process_whatsapp_message_task(self, webhook_data: dict, client_ip: str = "unknown") -> dict:
|
||||||
|
"""Process WhatsApp message asynchronously.
|
||||||
|
|
||||||
|
This task runs in a Celery worker and handles the full AI pipeline
|
||||||
|
so the webhook endpoint can return 200 immediately to Meta.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def _process() -> dict:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
result = await process_incoming_message(
|
||||||
|
db=db,
|
||||||
|
webhook_data=webhook_data,
|
||||||
|
client_ip=client_ip,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("whatsapp_task_failed", error=str(exc), attempt=self.request.retries)
|
||||||
|
raise self.retry(exc=exc)
|
||||||
|
|
||||||
|
return asyncio.run(_process())
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task
|
||||||
|
def sync_erpnext_patient_task(patient_data: dict) -> dict:
|
||||||
|
"""Sync patient data to ERPNext in background."""
|
||||||
|
logger.info("syncing_patient_to_erpnext", patient=patient_data.get("name"))
|
||||||
|
# TODO: Implement ERPNext sync logic
|
||||||
|
return {"status": "synced", "patient": patient_data.get("name")}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task
|
||||||
|
def generate_embedding_task(document_id: str, content: str, category: str) -> dict:
|
||||||
|
"""Generate embedding for a document chunk asynchronously."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def _generate() -> dict:
|
||||||
|
from src.infrastructure.ai.openai_client import get_openai_client
|
||||||
|
from src.infrastructure.db import AsyncSessionLocal
|
||||||
|
from src.infrastructure.ai.rag import RAGStore
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
client = await get_openai_client()
|
||||||
|
embedding = await client.create_embedding(content)
|
||||||
|
|
||||||
|
store = RAGStore(db)
|
||||||
|
await store.add_document(
|
||||||
|
content=content,
|
||||||
|
category=category,
|
||||||
|
doc_id=document_id,
|
||||||
|
)
|
||||||
|
return {"document_id": document_id, "embedding_dims": len(embedding)}
|
||||||
|
|
||||||
|
return asyncio.run(_generate())
|
||||||
Reference in New Issue
Block a user