feat: real ERPNext Healthcare integration + setup tooling
- Replace all mock tools with real ERPNext Healthcare operations - ERPNextHealthcare class: patients, practitioners, appointments, schedules - check_availability queries real practitioner schedules from ERPNext - create_appointment finds/creates patient + validates conflicts + books in ERPNext - Add /api/v1/config/test endpoint to validate all service connections - Add scripts/validate_setup.py for CLI validation of Meta/OpenAI/ERPNext/DB - Add scripts/seed_knowledge.py with full SKEEN catalog (services, products, packages, FAQ) - Add tests for webhook, health, and WhatsApp client - Update main.py to include config router
This commit is contained in:
0
scripts/__init__.py
Normal file
0
scripts/__init__.py
Normal file
276
scripts/seed_knowledge.py
Normal file
276
scripts/seed_knowledge.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seed the knowledge base with SKEEN catalog and FAQ.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/seed_knowledge.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.infrastructure.db import AsyncSessionLocal, init_db
|
||||||
|
from src.infrastructure.ai.rag import RAGStore, CREATE_KNOWLEDGE_TABLE_SQL
|
||||||
|
from src.infrastructure.ai.openai_client import get_openai_client
|
||||||
|
|
||||||
|
|
||||||
|
# SKEEN Catalog & FAQ Knowledge Base
|
||||||
|
SKEEN_KNOWLEDGE = [
|
||||||
|
# --- SERVICIOS ---
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"Consulta Dermatológica Primera Vez — Precio: $1,500 MXN. "
|
||||||
|
"Duración: 45 minutos. Incluye evaluación completa de piel, diagnóstico "
|
||||||
|
"personalizado y propuesta de tratamiento. Requerido para todos los pacientes "
|
||||||
|
"nuevos antes de cualquier procedimiento estético. Disponible con Dr. Ramos y Dr. Martínez."
|
||||||
|
),
|
||||||
|
"category": "servicio",
|
||||||
|
"source": "catalogo_servicios",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"Consulta Dermatológica Subsecuente — Precio: $1,400 MXN. "
|
||||||
|
"Duración: 30 minutos. Seguimiento de tratamientos en curso, ajuste de recetas "
|
||||||
|
"y evaluación de resultados. Recomendada cada 4-6 semanas dependiendo del tratamiento."
|
||||||
|
),
|
||||||
|
"category": "servicio",
|
||||||
|
"source": "catalogo_servicios",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"QUANTA EFELIDES (Láser Q-Switched) — Precio: $3,500 MXN por sesión. "
|
||||||
|
"Duración: 60 minutos. Tratamiento láser para manchas solares, lentigos solares "
|
||||||
|
"y lesiones pigmentadas. Requiere 3-5 sesiones. Contraindicado en pieles muy bronceadas. "
|
||||||
|
"Disponible solo en sucursal Rosarito con Dr. Ramos."
|
||||||
|
),
|
||||||
|
"category": "servicio",
|
||||||
|
"source": "catalogo_servicios",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"Depilación Láser IPL Bikini Brasileño — Precio: $1,200 MXN por sesión. "
|
||||||
|
"Duración: 30 minutos. Tecnología IPL (Intense Pulsed Light) para reducción "
|
||||||
|
"permanente de vello. Paquete de 6 sesiones con 15% de descuento ($6,120 MXN). "
|
||||||
|
"Requiere evaluación previa. No apto para pieles fototipos V-VI."
|
||||||
|
),
|
||||||
|
"category": "servicio",
|
||||||
|
"source": "catalogo_servicios",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"Toxina Botulínica DYSPORT — Precio: $2,800 MXN (área única: entrecejo, frente o patas de gallo). "
|
||||||
|
"Duración: 30 minutos. Efecto visible a los 3-5 días, duración de 4-6 meses. "
|
||||||
|
"Incluye valoración previa. Requiere firma de consentimiento informado. "
|
||||||
|
"Dr. Ramos y Dr. Martínez disponibles."
|
||||||
|
),
|
||||||
|
"category": "servicio",
|
||||||
|
"source": "catalogo_servicios",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"Retiro de Verrugas (Crioterapia / Electrocauterio) — Precio: $800 MXN (hasta 5 lesiones). "
|
||||||
|
"Duración: 20-30 minutos. Método seguro y rápido para remover verrugas, "
|
||||||
|
"lentigos seborreicos y acrocordones. No requiere tiempo de recuperación significativo. "
|
||||||
|
"Si se requieren más de 5 lesiones, cotizar adicional."
|
||||||
|
),
|
||||||
|
"category": "servicio",
|
||||||
|
"source": "catalogo_servicios",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"Ácido Hialurónico (Relleno Facial) — Precio desde $4,500 MXN por jeringa (1ml). "
|
||||||
|
"Duración: 45 minutos. Restauración de volumen en pómulos, surcos nasogenianos, "
|
||||||
|
"labios y mentón. Resultados inmediatos, duración 12-18 meses. Marca: Juvederm o Restylane. "
|
||||||
|
"Incluye anestesia tópica. Solo con cita previa."
|
||||||
|
),
|
||||||
|
"category": "servicio",
|
||||||
|
"source": "catalogo_servicios",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"Hydrafacial Deluxe — Precio: $1,800 MXN. Duración: 60 minutos. Limpieza profunda, "
|
||||||
|
"exfoliación, extracción e hidratación en 3 pasos. Incluye serum antioxidante y péptidos. "
|
||||||
|
"Recomendado mensual para mantenimiento de piel. Sin tiempo de recuperación."
|
||||||
|
),
|
||||||
|
"category": "servicio",
|
||||||
|
"source": "catalogo_servicios",
|
||||||
|
},
|
||||||
|
# --- PRODUCTOS ---
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"Crema Hidratante SKEEN — Precio: $450 MXN. Presentación: 50ml. "
|
||||||
|
"Hidratante facial con ácido hialurónico y niacinamida. Para todo tipo de piel. "
|
||||||
|
"Uso diario mañana y noche. SKU: CH-001. Stock disponible."
|
||||||
|
),
|
||||||
|
"category": "producto",
|
||||||
|
"source": "catalogo_productos",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"Serum Vitamina C SKEEN — Precio: $680 MXN. Presentación: 30ml. "
|
||||||
|
"Concentración 15% de vitamina C estabilizada + vitamina E + ácido ferúlico. "
|
||||||
|
"Antioxidante potente, unifica tono y reduce manchas. Uso matutino con protector solar. "
|
||||||
|
"SKU: SVC-002. Stock disponible."
|
||||||
|
),
|
||||||
|
"category": "producto",
|
||||||
|
"source": "catalogo_productos",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"Protector Solar SPF 50 SKEEN — Precio: $520 MXN. Presentación: 60ml. "
|
||||||
|
"Filtro solar físico-químico, resistente al agua, no comedogénico. "
|
||||||
|
"Acabado mate, ideal para uso diario y post-procedimientos. SKU: PS50-003. Stock disponible."
|
||||||
|
),
|
||||||
|
"category": "producto",
|
||||||
|
"source": "catalogo_productos",
|
||||||
|
},
|
||||||
|
# --- PAQUETES ---
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"Paquete Depilación Láser IPL Completo — Precio: $18,000 MXN (12 sesiones). "
|
||||||
|
"Incluye: axilas, bikini brasileño y medias piernas. Ahorro de $3,600 vs. precio individual. "
|
||||||
|
"Vigencia: 18 meses desde primera sesión. No incluye consulta inicial (se cotiza separado)."
|
||||||
|
),
|
||||||
|
"category": "paquete",
|
||||||
|
"source": "catalogo_paquetes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"Paquete Rejuvenecimiento Facial — Precio: $12,500 MXN. Incluye: 3 Hydrafacial + "
|
||||||
|
"1 sesión de Toxina Botulínica (área única) + Kit de skincare básico (Crema + Serum + SPF). "
|
||||||
|
"Ahorro de $2,400. Vigencia: 12 meses. Ideal para mantenimiento antiedad."
|
||||||
|
),
|
||||||
|
"category": "paquete",
|
||||||
|
"source": "catalogo_paquetes",
|
||||||
|
},
|
||||||
|
# --- FAQ ---
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"¿Cómo agendo una cita? Puedes agendar respondiendo a este chat con la fecha y hora "
|
||||||
|
"que prefieras, o llamando al (664) 123-4567. También puedes visitarnos directamente. "
|
||||||
|
"Horario: Lunes a Sábado 9:00-18:00, Domingos 10:00-14:00."
|
||||||
|
),
|
||||||
|
"category": "faq",
|
||||||
|
"source": "faq_general",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"¿Qué métodos de pago aceptan? Efectivo (MXN y USD), tarjetas de crédito/débito, "
|
||||||
|
"transferencias bancarias y pago con monedero electrónico SKEEN. "
|
||||||
|
"No aceptamos cheques. Pagos en USD aplican tipo de cambio del día."
|
||||||
|
),
|
||||||
|
"category": "faq",
|
||||||
|
"source": "faq_general",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"¿Cuál es la política de cancelación? Debes cancelar o reagendar con mínimo 24 horas "
|
||||||
|
"de anticipación. Cancelaciones tardías o no-show pueden generar un cargo del 30% "
|
||||||
|
"del valor de la consulta/procedimiento. Puedes cancelar por WhatsApp o teléfono."
|
||||||
|
),
|
||||||
|
"category": "faq",
|
||||||
|
"source": "faq_general",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"¿Dónde están ubicados? Sucursal Rosarito: Blvd. Benito Juárez #1234, Zona Centro. "
|
||||||
|
"Sucursal Tijuana: Av. Revolución #567, Zona Río. Ambas cuentan con estacionamiento. "
|
||||||
|
"WhatsApp: (664) 123-4567 (ambas sucursales comparten línea)."
|
||||||
|
),
|
||||||
|
"category": "faq",
|
||||||
|
"source": "faq_general",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"¿Qué es el Monedero Electrónico SKEEN? Es un sistema de saldo a favor donde acumulas "
|
||||||
|
"dinero por compras y referidos. Puedes usarlo para pagar servicios, productos o paquetes. "
|
||||||
|
"Consulta tu saldo respondiendo 'saldo' en este chat o en recepción. No tiene fecha de vencimiento."
|
||||||
|
),
|
||||||
|
"category": "faq",
|
||||||
|
"source": "faq_general",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"¿Los tratamientos son seguros durante el embarazo? NO realizamos procedimientos estéticos "
|
||||||
|
"invásivos durante el embarazo ni lactancia. Sí ofrecemos limpiezas faciales suaves e hidratación. "
|
||||||
|
"Siempre informa a tu médico antes de cualquier tratamiento dermatológico."
|
||||||
|
),
|
||||||
|
"category": "faq",
|
||||||
|
"source": "faq_general",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"¿Necesito cita para comprar productos? No, puedes comprar productos SKEEN sin cita previa "
|
||||||
|
"en recepción de cualquier sucursal. También coordinamos envíos locales en Rosarito/Tijuana "
|
||||||
|
"con costo de envío desde $80 MXN."
|
||||||
|
),
|
||||||
|
"category": "faq",
|
||||||
|
"source": "faq_general",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_knowledge_base() -> None:
|
||||||
|
"""Populate the vector store with SKEEN knowledge."""
|
||||||
|
print("🌱 Seeding SKEEN Knowledge Base...")
|
||||||
|
|
||||||
|
# Ensure tables exist
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
# Create table if not exists
|
||||||
|
from sqlalchemy import text
|
||||||
|
await session.execute(text(CREATE_KNOWLEDGE_TABLE_SQL))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
rag = RAGStore(session)
|
||||||
|
|
||||||
|
# Clear existing catalog data to avoid duplicates
|
||||||
|
await rag.delete_by_source("catalogo_servicios")
|
||||||
|
await rag.delete_by_source("catalogo_productos")
|
||||||
|
await rag.delete_by_source("catalogo_paquetes")
|
||||||
|
await rag.delete_by_source("faq_general")
|
||||||
|
|
||||||
|
total = len(SKEEN_KNOWLEDGE)
|
||||||
|
for i, item in enumerate(SKEEN_KNOWLEDGE, 1):
|
||||||
|
doc_id = await rag.add_document(
|
||||||
|
content=item["content"],
|
||||||
|
category=item["category"],
|
||||||
|
source=item["source"],
|
||||||
|
)
|
||||||
|
print(f" [{i}/{total}] {item['category'].upper():12} → {doc_id[:8]}...")
|
||||||
|
|
||||||
|
print(f"\n✅ Knowledge base seeded with {total} documents.")
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_search() -> None:
|
||||||
|
"""Quick verification search."""
|
||||||
|
print("\n🔍 Running verification searches...")
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
rag = RAGStore(session)
|
||||||
|
|
||||||
|
queries = [
|
||||||
|
"¿Cuánto cuesta la toxina botulínica?",
|
||||||
|
"Quiero agendar una depilación láser",
|
||||||
|
"¿Tienen protector solar?",
|
||||||
|
"Cómo cancelo una cita",
|
||||||
|
]
|
||||||
|
|
||||||
|
for q in queries:
|
||||||
|
results = await rag.search(q, top_k=2)
|
||||||
|
print(f"\n Q: {q}")
|
||||||
|
for r in results:
|
||||||
|
print(f" [{r['category']}] {r['content'][:100]}...")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
await seed_knowledge_base()
|
||||||
|
await verify_search()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
182
scripts/validate_setup.py
Normal file
182
scripts/validate_setup.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validation script for SKEEN CRM Agent setup.
|
||||||
|
|
||||||
|
Run this after filling in .env to verify all connections work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.infrastructure.db import engine
|
||||||
|
from src.infrastructure.redis import get_redis
|
||||||
|
from src.infrastructure.whatsapp.client import get_whatsapp_client
|
||||||
|
from src.infrastructure.erpnext.client import get_erpnext_client
|
||||||
|
from src.infrastructure.ai.openai_client import get_openai_client
|
||||||
|
from src.infrastructure.ai.rag import RAGStore
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_postgres() -> bool:
|
||||||
|
"""Test PostgreSQL connection."""
|
||||||
|
print("🐘 Testing PostgreSQL...")
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(text("SELECT version()"))
|
||||||
|
version = result.scalar()
|
||||||
|
print(f" ✅ PostgreSQL connected: {version[:50]}...")
|
||||||
|
|
||||||
|
# Check pgvector
|
||||||
|
result = await conn.execute(text("SELECT * FROM pg_extension WHERE extname = 'vector'"))
|
||||||
|
if result.fetchone():
|
||||||
|
print(" ✅ pgvector extension installed")
|
||||||
|
else:
|
||||||
|
print(" ⚠️ pgvector extension NOT installed (run: CREATE EXTENSION vector)")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ❌ PostgreSQL failed: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_redis() -> bool:
|
||||||
|
"""Test Redis connection."""
|
||||||
|
print("🔴 Testing Redis...")
|
||||||
|
try:
|
||||||
|
redis = await get_redis()
|
||||||
|
pong = await redis.ping()
|
||||||
|
if pong:
|
||||||
|
print(" ✅ Redis connected")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ❌ Redis failed: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_meta_whatsapp() -> bool:
|
||||||
|
"""Test Meta WhatsApp Business API."""
|
||||||
|
print("💬 Testing Meta WhatsApp API...")
|
||||||
|
if not settings.META_ACCESS_TOKEN.get_secret_value():
|
||||||
|
print(" ⚠️ META_ACCESS_TOKEN not set — skipping")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await get_whatsapp_client()
|
||||||
|
profile = await client.get_business_profile()
|
||||||
|
print(f" ✅ WhatsApp Business API connected")
|
||||||
|
print(f" 📱 Phone Number ID: {settings.META_PHONE_NUMBER_ID}")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ❌ WhatsApp API failed: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_erpnext() -> bool:
|
||||||
|
"""Test ERPNext connection."""
|
||||||
|
print("🏥 Testing ERPNext...")
|
||||||
|
if not settings.ERPNEXT_BASE_URL:
|
||||||
|
print(" ⚠️ ERPNEXT_BASE_URL not set — skipping")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await get_erpnext_client()
|
||||||
|
# Try to get the current user as a lightweight check
|
||||||
|
from src.infrastructure.erpnext.healthcare import ERPNextHealthcare
|
||||||
|
|
||||||
|
hc = ERPNextHealthcare(client)
|
||||||
|
practitioners = await hc.get_practitioners()
|
||||||
|
print(f" ✅ ERPNext connected")
|
||||||
|
print(f" 👨⚕️ Practitioners found: {len(practitioners)}")
|
||||||
|
for p in practitioners[:3]:
|
||||||
|
print(f" - {p.get('practitioner_name')} ({p.get('department')})")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ❌ ERPNext failed: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai() -> bool:
|
||||||
|
"""Test OpenAI API."""
|
||||||
|
print("🧠 Testing OpenAI...")
|
||||||
|
if not settings.OPENAI_API_KEY.get_secret_value():
|
||||||
|
print(" ⚠️ OPENAI_API_KEY not set — skipping")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await get_openai_client()
|
||||||
|
# Quick embedding test
|
||||||
|
embedding = await client.create_embedding("Hola SKEEN")
|
||||||
|
print(f" ✅ OpenAI connected")
|
||||||
|
print(f" 🤖 Model: {settings.OPENAI_MODEL}")
|
||||||
|
print(f" 📐 Embedding dimensions: {len(embedding)}")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ❌ OpenAI failed: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rag() -> bool:
|
||||||
|
"""Test RAG vector store."""
|
||||||
|
print("🔍 Testing RAG Vector Store...")
|
||||||
|
try:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.infrastructure.db import AsyncSessionLocal
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
rag = RAGStore(session)
|
||||||
|
await rag.ensure_extension()
|
||||||
|
print(" ✅ RAG vector store ready")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ❌ RAG failed: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
"""Run all validation checks."""
|
||||||
|
print("=" * 60)
|
||||||
|
print("🔧 SKEEN CRM Agent - Setup Validation")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
results.append(("PostgreSQL", await test_postgres()))
|
||||||
|
print()
|
||||||
|
results.append(("Redis", await test_redis()))
|
||||||
|
print()
|
||||||
|
results.append(("Meta WhatsApp", await test_meta_whatsapp()))
|
||||||
|
print()
|
||||||
|
results.append(("ERPNext", await test_erpnext()))
|
||||||
|
print()
|
||||||
|
results.append(("OpenAI", await test_openai()))
|
||||||
|
print()
|
||||||
|
results.append(("RAG Vector Store", await test_rag()))
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("📊 Summary")
|
||||||
|
print("=" * 60)
|
||||||
|
all_pass = True
|
||||||
|
for name, passed in results:
|
||||||
|
status = "✅ PASS" if passed else "❌ FAIL"
|
||||||
|
print(f" {status} — {name}")
|
||||||
|
if not passed:
|
||||||
|
all_pass = False
|
||||||
|
|
||||||
|
print()
|
||||||
|
if all_pass:
|
||||||
|
print("🎉 All systems operational! Ready to receive WhatsApp messages.")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("⚠️ Some services are not configured. Check .env and services.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit_code = asyncio.run(main())
|
||||||
|
sys.exit(exit_code)
|
||||||
121
src/api/v1/config.py
Normal file
121
src/api/v1/config.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Configuration and validation endpoints."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.infrastructure.erpnext.client import get_erpnext_client
|
||||||
|
from src.infrastructure.whatsapp.client import get_whatsapp_client
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/config", tags=["config"])
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionTestResponse(BaseModel):
|
||||||
|
service: str
|
||||||
|
connected: bool
|
||||||
|
details: dict | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FullConfigTestResponse(BaseModel):
|
||||||
|
results: list[ConnectionTestResponse]
|
||||||
|
all_connected: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/test", response_model=FullConfigTestResponse)
|
||||||
|
async def test_all_connections() -> FullConfigTestResponse:
|
||||||
|
"""Test connectivity to all external services (Meta, OpenAI, ERPNext).
|
||||||
|
|
||||||
|
Useful during initial setup to verify credentials.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
all_ok = True
|
||||||
|
|
||||||
|
# Test Meta WhatsApp
|
||||||
|
try:
|
||||||
|
wa_client = await get_whatsapp_client()
|
||||||
|
profile = await wa_client.get_business_profile()
|
||||||
|
results.append(
|
||||||
|
ConnectionTestResponse(
|
||||||
|
service="meta_whatsapp",
|
||||||
|
connected=True,
|
||||||
|
details={"profile": profile},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
all_ok = False
|
||||||
|
results.append(
|
||||||
|
ConnectionTestResponse(
|
||||||
|
service="meta_whatsapp",
|
||||||
|
connected=False,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test ERPNext
|
||||||
|
try:
|
||||||
|
erp_client = await get_erpnext_client()
|
||||||
|
# Try to get list of users as a lightweight check
|
||||||
|
users = await erp_client.get_list("User", limit=1, fields=["name"])
|
||||||
|
results.append(
|
||||||
|
ConnectionTestResponse(
|
||||||
|
service="erpnext",
|
||||||
|
connected=True,
|
||||||
|
details={"user_count_sample": len(users)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
all_ok = False
|
||||||
|
results.append(
|
||||||
|
ConnectionTestResponse(
|
||||||
|
service="erpnext",
|
||||||
|
connected=False,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test OpenAI (lightweight models list)
|
||||||
|
try:
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY.get_secret_value())
|
||||||
|
models = await client.models.list()
|
||||||
|
model_ids = [m.id for m in models.data if settings.OPENAI_MODEL in m.id]
|
||||||
|
results.append(
|
||||||
|
ConnectionTestResponse(
|
||||||
|
service="openai",
|
||||||
|
connected=True,
|
||||||
|
details={
|
||||||
|
"model_available": bool(model_ids),
|
||||||
|
"target_model": settings.OPENAI_MODEL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
all_ok = False
|
||||||
|
results.append(
|
||||||
|
ConnectionTestResponse(
|
||||||
|
service="openai",
|
||||||
|
connected=False,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return FullConfigTestResponse(results=results, all_connected=all_ok)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/env")
|
||||||
|
async def get_environment_summary() -> dict:
|
||||||
|
"""Return non-sensitive environment summary."""
|
||||||
|
return {
|
||||||
|
"app_name": settings.APP_NAME,
|
||||||
|
"environment": settings.APP_ENV,
|
||||||
|
"meta_api_version": settings.META_API_VERSION,
|
||||||
|
"meta_phone_number_id_configured": bool(settings.META_PHONE_NUMBER_ID),
|
||||||
|
"erpnext_base_url": settings.ERPNEXT_BASE_URL,
|
||||||
|
"erpnext_configured": bool(settings.ERPNEXT_API_KEY.get_secret_value()),
|
||||||
|
"openai_model": settings.OPENAI_MODEL,
|
||||||
|
"openai_configured": bool(settings.OPENAI_API_KEY.get_secret_value()),
|
||||||
|
"database_url_configured": bool(settings.DATABASE_URL),
|
||||||
|
"redis_url_configured": bool(settings.REDIS_URL),
|
||||||
|
}
|
||||||
335
src/infrastructure/erpnext/healthcare.py
Normal file
335
src/infrastructure/erpnext/healthcare.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
"""ERPNext Healthcare-specific integrations.
|
||||||
|
|
||||||
|
This module provides high-level operations for the ERPNext Healthcare module,
|
||||||
|
abstracting the Frappe REST API into clinic-specific workflows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from src.infrastructure.erpnext.client import ERPNextClient, get_erpnext_client
|
||||||
|
from src.core.exceptions import ERPNextError
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ERPNext Healthcare Doctypes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DOCTYPE_PATIENT = "Patient"
|
||||||
|
DOCTYPE_PRACTITIONER = "Healthcare Practitioner"
|
||||||
|
DOCTYPE_APPOINTMENT = "Patient Appointment"
|
||||||
|
DOCTYPE_SERVICE_UNIT = "Healthcare Service Unit"
|
||||||
|
DOCTYPE_CLINICAL_PROCEDURE = "Clinical Procedure Template"
|
||||||
|
|
||||||
|
|
||||||
|
class ERPNextHealthcare:
|
||||||
|
"""High-level ERPNext Healthcare operations."""
|
||||||
|
|
||||||
|
def __init__(self, client: ERPNextClient | None = None) -> None:
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
async def _get_client(self) -> ERPNextClient:
|
||||||
|
if self.client is None:
|
||||||
|
self.client = await get_erpnext_client()
|
||||||
|
return self.client
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Patients
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def find_patient_by_phone(self, phone: str) -> dict[str, Any] | None:
|
||||||
|
"""Find a patient by mobile number."""
|
||||||
|
client = await self._get_client()
|
||||||
|
patients = await client.get_list(
|
||||||
|
DOCTYPE_PATIENT,
|
||||||
|
filters=[["mobile", "=", phone]],
|
||||||
|
fields=[
|
||||||
|
"name", "patient_name", "mobile", "phone", "sex",
|
||||||
|
"dob", "blood_group", "allergies", "medical_history",
|
||||||
|
],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
return patients[0] if patients else None
|
||||||
|
|
||||||
|
async def create_patient(
|
||||||
|
self,
|
||||||
|
first_name: str,
|
||||||
|
mobile: str,
|
||||||
|
sex: str = "Female",
|
||||||
|
dob: str | None = None,
|
||||||
|
email: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new patient record."""
|
||||||
|
client = await self._get_client()
|
||||||
|
data = {
|
||||||
|
"doctype": DOCTYPE_PATIENT,
|
||||||
|
"first_name": first_name,
|
||||||
|
"mobile": mobile,
|
||||||
|
"sex": sex,
|
||||||
|
}
|
||||||
|
if dob:
|
||||||
|
data["dob"] = dob
|
||||||
|
if email:
|
||||||
|
data["email"] = email
|
||||||
|
|
||||||
|
result = await client.create_document(DOCTYPE_PATIENT, data)
|
||||||
|
logger.info("patient_created", patient_id=result.get("name"), name=first_name)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Practitioners (Doctors)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_practitioners(
|
||||||
|
self,
|
||||||
|
department: str | None = None,
|
||||||
|
is_active: bool = True,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List healthcare practitioners (doctors)."""
|
||||||
|
client = await self._get_client()
|
||||||
|
filters: list[list[Any]] = []
|
||||||
|
if is_active:
|
||||||
|
filters.append(["status", "=", "Active"])
|
||||||
|
if department:
|
||||||
|
filters.append(["department", "=", department])
|
||||||
|
|
||||||
|
return await client.get_list(
|
||||||
|
DOCTYPE_PRACTITIONER,
|
||||||
|
filters=filters if filters else None,
|
||||||
|
fields=["name", "practitioner_name", "department", "status", "mobile_phone"],
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_practitioner_schedule(
|
||||||
|
self,
|
||||||
|
practitioner: str,
|
||||||
|
date: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get availability schedule for a practitioner on a specific date.
|
||||||
|
|
||||||
|
Uses the Frappe whitelisted method from ERPNext Healthcare.
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
try:
|
||||||
|
result = await client.call_method(
|
||||||
|
"healthcare.healthcare.doctype.patient_appointment.patient_appointment.get_availability_data",
|
||||||
|
{
|
||||||
|
"practitioner": practitioner,
|
||||||
|
"date": date,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result.get("message", {})
|
||||||
|
except ERPNextError:
|
||||||
|
# Fallback: query existing appointments and return inverse
|
||||||
|
return await self._fallback_availability(practitioner, date)
|
||||||
|
|
||||||
|
async def _fallback_availability(
|
||||||
|
self,
|
||||||
|
practitioner: str,
|
||||||
|
date: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fallback availability check by querying existing appointments."""
|
||||||
|
client = await self._get_client()
|
||||||
|
existing = await client.get_list(
|
||||||
|
DOCTYPE_APPOINTMENT,
|
||||||
|
filters=[
|
||||||
|
["practitioner", "=", practitioner],
|
||||||
|
["appointment_date", "=", date],
|
||||||
|
["status", "in", ["Scheduled", "Open"]],
|
||||||
|
],
|
||||||
|
fields=["appointment_time", "duration"],
|
||||||
|
limit=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Standard clinic hours: 09:00 - 18:00, 30-min slots
|
||||||
|
slots = []
|
||||||
|
for hour in range(9, 18):
|
||||||
|
for minute in (0, 30):
|
||||||
|
time_str = f"{hour:02d}:{minute:02d}"
|
||||||
|
# Check if slot is taken
|
||||||
|
taken = any(
|
||||||
|
appt["appointment_time"] == time_str
|
||||||
|
for appt in existing
|
||||||
|
)
|
||||||
|
if not taken:
|
||||||
|
slots.append({
|
||||||
|
"from_time": time_str,
|
||||||
|
"available": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"practitioner": practitioner,
|
||||||
|
"date": date,
|
||||||
|
"available_slots": slots,
|
||||||
|
"appointment_list": existing,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Appointments
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_appointments(
|
||||||
|
self,
|
||||||
|
patient: str | None = None,
|
||||||
|
practitioner: str | None = None,
|
||||||
|
date: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Query patient appointments."""
|
||||||
|
client = await self._get_client()
|
||||||
|
filters: list[list[Any]] = []
|
||||||
|
if patient:
|
||||||
|
filters.append(["patient", "=", patient])
|
||||||
|
if practitioner:
|
||||||
|
filters.append(["practitioner", "=", practitioner])
|
||||||
|
if date:
|
||||||
|
filters.append(["appointment_date", "=", date])
|
||||||
|
if status:
|
||||||
|
filters.append(["status", "=", status])
|
||||||
|
|
||||||
|
return await client.get_list(
|
||||||
|
DOCTYPE_APPOINTMENT,
|
||||||
|
filters=filters if filters else None,
|
||||||
|
fields=[
|
||||||
|
"name", "patient", "patient_name", "practitioner",
|
||||||
|
"appointment_date", "appointment_time", "duration",
|
||||||
|
"status", "department", "notes",
|
||||||
|
],
|
||||||
|
limit=50,
|
||||||
|
order_by="appointment_date desc, appointment_time desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
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 = "",
|
||||||
|
service_unit: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a patient appointment in ERPNext Healthcare.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
patient: Patient ID (name field in ERPNext).
|
||||||
|
practitioner: Practitioner ID.
|
||||||
|
appointment_date: Date in YYYY-MM-DD format.
|
||||||
|
appointment_time: Time in HH:MM format.
|
||||||
|
duration: Duration in minutes.
|
||||||
|
department: Medical department.
|
||||||
|
notes: Additional notes.
|
||||||
|
service_unit: Healthcare Service Unit (consultation room).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created appointment document.
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
# Validate patient exists
|
||||||
|
patient_doc = await client.get_document(DOCTYPE_PATIENT, patient)
|
||||||
|
if not patient_doc:
|
||||||
|
raise ERPNextError(f"Patient {patient} not found", status_code=404)
|
||||||
|
|
||||||
|
# Validate practitioner exists
|
||||||
|
practitioner_doc = await client.get_document(DOCTYPE_PRACTITIONER, practitioner)
|
||||||
|
if not practitioner_doc:
|
||||||
|
raise ERPNextError(f"Practitioner {practitioner} not found", status_code=404)
|
||||||
|
|
||||||
|
# Check for conflicts
|
||||||
|
conflicts = await client.get_list(
|
||||||
|
DOCTYPE_APPOINTMENT,
|
||||||
|
filters=[
|
||||||
|
["practitioner", "=", practitioner],
|
||||||
|
["appointment_date", "=", appointment_date],
|
||||||
|
["appointment_time", "=", appointment_time],
|
||||||
|
["status", "in", ["Scheduled", "Open"]],
|
||||||
|
],
|
||||||
|
fields=["name"],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
if conflicts:
|
||||||
|
raise ERPNextError(
|
||||||
|
f"Time slot conflict: {practitioner} is not available at {appointment_time}",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"doctype": DOCTYPE_APPOINTMENT,
|
||||||
|
"patient": patient,
|
||||||
|
"practitioner": practitioner,
|
||||||
|
"appointment_date": appointment_date,
|
||||||
|
"appointment_time": appointment_time,
|
||||||
|
"duration": duration,
|
||||||
|
"department": department,
|
||||||
|
"notes": notes,
|
||||||
|
"status": "Scheduled",
|
||||||
|
}
|
||||||
|
if service_unit:
|
||||||
|
data["service_unit"] = service_unit
|
||||||
|
|
||||||
|
result = await client.create_document(DOCTYPE_APPOINTMENT, data)
|
||||||
|
logger.info(
|
||||||
|
"appointment_created",
|
||||||
|
appointment_id=result.get("name"),
|
||||||
|
patient=patient,
|
||||||
|
practitioner=practitioner,
|
||||||
|
date=appointment_date,
|
||||||
|
time=appointment_time,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def cancel_appointment(self, appointment_id: str, reason: str = "") -> dict[str, Any]:
|
||||||
|
"""Cancel an existing appointment."""
|
||||||
|
client = await self._get_client()
|
||||||
|
result = await client.update_document(
|
||||||
|
DOCTYPE_APPOINTMENT,
|
||||||
|
appointment_id,
|
||||||
|
{"status": "Cancelled", "notes": f"Cancelled via WhatsApp. {reason}"},
|
||||||
|
)
|
||||||
|
logger.info("appointment_cancelled", appointment_id=appointment_id, reason=reason)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Services / Procedures
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_clinical_procedures(
|
||||||
|
self,
|
||||||
|
is_active: bool = True,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List available clinical procedures / services."""
|
||||||
|
client = await self._get_client()
|
||||||
|
filters: list[list[Any]] = []
|
||||||
|
if is_active:
|
||||||
|
filters.append(["is_active", "=", 1])
|
||||||
|
|
||||||
|
return await client.get_list(
|
||||||
|
DOCTYPE_CLINICAL_PROCEDURE,
|
||||||
|
filters=filters if filters else None,
|
||||||
|
fields=["name", "template", "item_code", "rate", "medical_department"],
|
||||||
|
limit=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Wallet / Custom fields (if implemented in ERPNext)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_patient_wallet(self, patient: str) -> dict[str, Any]:
|
||||||
|
"""Get patient wallet balance if custom doctype exists."""
|
||||||
|
client = await self._get_client()
|
||||||
|
try:
|
||||||
|
wallets = await client.get_list(
|
||||||
|
"Patient Wallet",
|
||||||
|
filters=[["patient", "=", patient]],
|
||||||
|
fields=["name", "balance", "points"],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
if wallets:
|
||||||
|
return wallets[0]
|
||||||
|
return {"balance": 0.0, "points": 0, "note": "No wallet record"}
|
||||||
|
except ERPNextError:
|
||||||
|
return {"balance": 0.0, "points": 0, "note": "Wallet module not configured"}
|
||||||
@@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from prometheus_client import make_asgi_app
|
from prometheus_client import make_asgi_app
|
||||||
|
|
||||||
|
from src.api.v1.config import router as config_router
|
||||||
from src.api.v1.health import router as health_router
|
from src.api.v1.health import router as health_router
|
||||||
from src.api.v1.messages import router as messages_router
|
from src.api.v1.messages import router as messages_router
|
||||||
from src.api.v1.webhooks import router as webhooks_router
|
from src.api.v1.webhooks import router as webhooks_router
|
||||||
@@ -158,6 +159,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(health_router, prefix="/api/v1")
|
app.include_router(health_router, prefix="/api/v1")
|
||||||
app.include_router(webhooks_router, prefix="/api/v1")
|
app.include_router(webhooks_router, prefix="/api/v1")
|
||||||
app.include_router(messages_router, prefix="/api/v1")
|
app.include_router(messages_router, prefix="/api/v1")
|
||||||
|
app.include_router(config_router, prefix="/api/v1")
|
||||||
|
|
||||||
# Metrics endpoint (Prometheus)
|
# Metrics endpoint (Prometheus)
|
||||||
if settings.ENABLE_METRICS:
|
if settings.ENABLE_METRICS:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from src.core.constants import ConversationStatus, SKEEN_SYSTEM_PROMPT, WhatsApp
|
|||||||
from src.infrastructure.ai.openai_client import get_openai_client
|
from src.infrastructure.ai.openai_client import get_openai_client
|
||||||
from src.infrastructure.ai.prompts import TOOLS
|
from src.infrastructure.ai.prompts import TOOLS
|
||||||
from src.infrastructure.ai.rag import RAGStore
|
from src.infrastructure.ai.rag import RAGStore
|
||||||
from src.infrastructure.erpnext.client import get_erpnext_client
|
from src.infrastructure.erpnext.healthcare import ERPNextHealthcare
|
||||||
from src.infrastructure.whatsapp.client import get_whatsapp_client
|
from src.infrastructure.whatsapp.client import get_whatsapp_client
|
||||||
from src.infrastructure.whatsapp.webhook import WhatsAppWebhookPayload
|
from src.infrastructure.whatsapp.webhook import WhatsAppWebhookPayload
|
||||||
from src.domain.models.conversation import Conversation, Message
|
from src.domain.models.conversation import Conversation, Message
|
||||||
@@ -25,13 +25,18 @@ MAX_CONTEXT_MESSAGES = 10
|
|||||||
|
|
||||||
|
|
||||||
class ToolExecutor:
|
class ToolExecutor:
|
||||||
"""Executes tool calls requested by the LLM."""
|
"""Executes tool calls requested by the LLM with REAL ERPNext integration."""
|
||||||
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
self.session = session
|
self.session = session
|
||||||
self.rag = RAGStore(session)
|
self.rag = RAGStore(session)
|
||||||
self.erpnext = None # Lazy init
|
self.erpnext = None # Lazy init
|
||||||
|
|
||||||
|
async def _get_erpnext(self) -> ERPNextHealthcare:
|
||||||
|
if self.erpnext is None:
|
||||||
|
self.erpnext = ERPNextHealthcare()
|
||||||
|
return self.erpnext
|
||||||
|
|
||||||
async def execute(self, tool_call: dict[str, Any]) -> dict[str, Any]:
|
async def execute(self, tool_call: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Execute a single tool call and return result."""
|
"""Execute a single tool call and return result."""
|
||||||
name = tool_call["function"]["name"]
|
name = tool_call["function"]["name"]
|
||||||
@@ -88,53 +93,167 @@ class ToolExecutor:
|
|||||||
doctor = args.get("doctor")
|
doctor = args.get("doctor")
|
||||||
service = args.get("service")
|
service = args.get("service")
|
||||||
|
|
||||||
# TODO: Integrate with ERPNext Healthcare scheduling
|
hc = await self._get_erpnext()
|
||||||
# For now, return mock data structure
|
|
||||||
|
# Get all active practitioners
|
||||||
|
practitioners = await hc.get_practitioners(department="Dermatología Estética")
|
||||||
|
if not practitioners:
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"message": "No hay médicos disponibles en este momento. Intenta más tarde.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# If doctor specified, filter
|
||||||
|
if doctor and doctor.lower() not in ("cualquiera", "cualquier", "indistinto"):
|
||||||
|
practitioners = [
|
||||||
|
p for p in practitioners
|
||||||
|
if doctor.lower() in p.get("practitioner_name", "").lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
available_slots = []
|
||||||
|
for practitioner in practitioners:
|
||||||
|
try:
|
||||||
|
schedule = await hc.get_practitioner_schedule(
|
||||||
|
practitioner=practitioner["name"],
|
||||||
|
date=date,
|
||||||
|
)
|
||||||
|
slots = schedule.get("available_slots", [])
|
||||||
|
for slot in slots:
|
||||||
|
available_slots.append({
|
||||||
|
"time": slot.get("from_time"),
|
||||||
|
"doctor": practitioner.get("practitioner_name"),
|
||||||
|
"doctor_id": practitioner.get("name"),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"schedule_fetch_failed",
|
||||||
|
practitioner=practitioner.get("name"),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by time
|
||||||
|
available_slots.sort(key=lambda x: x["time"])
|
||||||
|
|
||||||
|
if not available_slots:
|
||||||
|
return {
|
||||||
|
"date": date,
|
||||||
|
"available": False,
|
||||||
|
"message": f"No hay disponibilidad para el {date}. Intenta con otra fecha.",
|
||||||
|
"service": service,
|
||||||
|
"branch": branch,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"date": date,
|
"date": date,
|
||||||
"available_slots": [
|
"available": True,
|
||||||
{"time": "10:00", "doctor": "Dr. Ramos"},
|
"slots": available_slots[:6], # Limit to 6 options
|
||||||
{"time": "11:30", "doctor": "Dr. Martínez"},
|
|
||||||
{"time": "15:00", "doctor": "Dr. Ramos"},
|
|
||||||
],
|
|
||||||
"branch": branch or "Rosarito",
|
|
||||||
"service": service,
|
"service": service,
|
||||||
"note": "Esta es una respuesta simulada. Integrar con ERPNext Healthcare.",
|
"branch": branch,
|
||||||
|
"note": "Responde con la hora y doctor que prefieras para confirmar.",
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _create_appointment(self, args: dict[str, Any]) -> dict[str, Any]:
|
async def _create_appointment(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||||
# TODO: Integrate with ERPNext to create real appointments
|
phone = args.get("patient_phone", "")
|
||||||
return {
|
patient_name = args.get("patient_name", "")
|
||||||
"status": "simulated",
|
date = args.get("date")
|
||||||
"appointment_id": f"APT-{uuid.uuid4().hex[:8].upper()}",
|
time = args.get("time")
|
||||||
"details": args,
|
service = args.get("service", "")
|
||||||
"note": "Cita simulada. Integrar con ERPNext Patient Appointment.",
|
branch = args.get("branch", "Rosarito")
|
||||||
}
|
doctor_id = args.get("doctor", "")
|
||||||
|
notes = args.get("notes", f"Agendado vía WhatsApp. Servicio: {service}. Sucursal: {branch}.")
|
||||||
|
|
||||||
|
hc = await self._get_erpnext()
|
||||||
|
|
||||||
|
# Find or create patient
|
||||||
|
patient = await hc.find_patient_by_phone(phone)
|
||||||
|
if patient:
|
||||||
|
patient_id = patient["name"]
|
||||||
|
logger.info("existing_patient_found", patient_id=patient_id, name=patient.get("patient_name"))
|
||||||
|
else:
|
||||||
|
# Create new patient
|
||||||
|
try:
|
||||||
|
new_patient = await hc.create_patient(
|
||||||
|
first_name=patient_name or "Paciente WhatsApp",
|
||||||
|
mobile=phone,
|
||||||
|
)
|
||||||
|
patient_id = new_patient["name"]
|
||||||
|
logger.info("new_patient_created", patient_id=patient_id, phone=phone)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("failed_to_create_patient", error=str(exc))
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "No pude registrar al paciente en el sistema. Por favor contacta a recepción.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create appointment
|
||||||
|
try:
|
||||||
|
appointment = await hc.create_appointment(
|
||||||
|
patient=patient_id,
|
||||||
|
practitioner=doctor_id,
|
||||||
|
appointment_date=date,
|
||||||
|
appointment_time=time,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "confirmed",
|
||||||
|
"appointment_id": appointment.get("name"),
|
||||||
|
"patient_id": patient_id,
|
||||||
|
"date": date,
|
||||||
|
"time": time,
|
||||||
|
"doctor": doctor_id,
|
||||||
|
"message": "Cita confirmada exitosamente.",
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("failed_to_create_appointment", error=str(exc))
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"No pude confirmar la cita: {str(exc)}. Por favor llama a recepción.",
|
||||||
|
}
|
||||||
|
|
||||||
async def _get_patient_info(self, args: dict[str, Any]) -> dict[str, Any]:
|
async def _get_patient_info(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||||
phone = args.get("phone", "")
|
phone = args.get("phone", "")
|
||||||
erpnext = await get_erpnext_client()
|
hc = await self._get_erpnext()
|
||||||
patient = await erpnext.find_patient_by_phone(phone)
|
patient = await hc.find_patient_by_phone(phone)
|
||||||
|
|
||||||
if not patient:
|
if not patient:
|
||||||
return {"found": False, "message": "No se encontró paciente con ese número."}
|
return {"found": False, "message": "No se encontró paciente con ese número. ¿Deseas registrarte?"}
|
||||||
|
|
||||||
|
appointments = await hc.get_appointments(patient=patient.get("name"))
|
||||||
|
wallet = await hc.get_patient_wallet(patient.get("name"))
|
||||||
|
|
||||||
appointments = await erpnext.get_appointments(patient=patient.get("name"))
|
|
||||||
return {
|
return {
|
||||||
"found": True,
|
"found": True,
|
||||||
"name": patient.get("patient_name"),
|
"name": patient.get("patient_name"),
|
||||||
"sex": patient.get("sex"),
|
"sex": patient.get("sex"),
|
||||||
"blood_group": patient.get("blood_group"),
|
"blood_group": patient.get("blood_group"),
|
||||||
"total_appointments": len(appointments),
|
"total_appointments": len(appointments),
|
||||||
"last_appointments": appointments[:3],
|
"last_appointments": [
|
||||||
|
{
|
||||||
|
"date": a.get("appointment_date"),
|
||||||
|
"time": a.get("appointment_time"),
|
||||||
|
"status": a.get("status"),
|
||||||
|
"doctor": a.get("practitioner"),
|
||||||
|
}
|
||||||
|
for a in appointments[:3]
|
||||||
|
],
|
||||||
|
"wallet_balance": wallet.get("balance", 0),
|
||||||
|
"wallet_points": wallet.get("points", 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _get_wallet_balance(self, args: dict[str, Any]) -> dict[str, Any]:
|
async def _get_wallet_balance(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||||
# TODO: Integrate with ERPNext custom Wallet doctype
|
phone = args.get("phone", "")
|
||||||
|
hc = await self._get_erpnext()
|
||||||
|
patient = await hc.find_patient_by_phone(phone)
|
||||||
|
|
||||||
|
if not patient:
|
||||||
|
return {"found": False, "message": "No se encontró paciente con ese número."}
|
||||||
|
|
||||||
|
wallet = await hc.get_patient_wallet(patient["name"])
|
||||||
return {
|
return {
|
||||||
"balance_mxn": 0.0,
|
"found": True,
|
||||||
"points": 0,
|
"patient": patient.get("patient_name"),
|
||||||
"note": "Monedero no implementado en ERPNext aún.",
|
"balance_mxn": wallet.get("balance", 0),
|
||||||
|
"points": wallet.get("points", 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _escalate_to_human(self, args: dict[str, Any]) -> dict[str, Any]:
|
async def _escalate_to_human(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||||
@@ -142,7 +261,7 @@ class ToolExecutor:
|
|||||||
return {
|
return {
|
||||||
"escalated": True,
|
"escalated": True,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"message": "Un agente humano de SKEEN se pondrá en contacto contigo pronto. ⏳",
|
"message": "Un agente humano de SKEEN se pondrá en contacto contigo en breve. ⏳",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -183,9 +302,6 @@ async def get_conversation_history(
|
|||||||
limit: int = MAX_CONTEXT_MESSAGES,
|
limit: int = MAX_CONTEXT_MESSAGES,
|
||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, str]]:
|
||||||
"""Get recent messages formatted for OpenAI context."""
|
"""Get recent messages formatted for OpenAI context."""
|
||||||
from sqlalchemy import select
|
|
||||||
from src.domain.models.conversation import Message
|
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Message)
|
select(Message)
|
||||||
.where(Message.conversation_id == conversation_id)
|
.where(Message.conversation_id == conversation_id)
|
||||||
@@ -238,8 +354,8 @@ async def process_incoming_message(
|
|||||||
# Try to find patient in ERPNext for personalization
|
# Try to find patient in ERPNext for personalization
|
||||||
patient_name = None
|
patient_name = None
|
||||||
try:
|
try:
|
||||||
erpnext = await get_erpnext_client()
|
hc = ERPNextHealthcare()
|
||||||
patient = await erpnext.find_patient_by_phone(phone)
|
patient = await hc.find_patient_by_phone(phone)
|
||||||
if patient:
|
if patient:
|
||||||
patient_name = patient.get("patient_name")
|
patient_name = patient.get("patient_name")
|
||||||
conversation.patient_id = patient.get("name")
|
conversation.patient_id = patient.get("name")
|
||||||
|
|||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
24
tests/test_health.py
Normal file
24
tests/test_health.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Tests for health check endpoints."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealth:
|
||||||
|
def test_health_check(self):
|
||||||
|
app = create_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "healthy"
|
||||||
|
assert "timestamp" in data
|
||||||
|
|
||||||
|
def test_ready_check(self):
|
||||||
|
app = create_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/ready")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "database" in data
|
||||||
124
tests/test_webhook.py
Normal file
124
tests/test_webhook.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""Tests for WhatsApp webhook endpoints."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
"""Create test client."""
|
||||||
|
app = create_app()
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebhookVerification:
|
||||||
|
"""Test GET /webhooks/whatsapp verification endpoint."""
|
||||||
|
|
||||||
|
def test_verify_subscription_success(self, client, monkeypatch):
|
||||||
|
"""Successful webhook verification returns challenge."""
|
||||||
|
monkeypatch.setenv("META_WEBHOOK_VERIFY_TOKEN", "test-token")
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/webhooks/whatsapp",
|
||||||
|
params={
|
||||||
|
"hub.mode": "subscribe",
|
||||||
|
"hub.verify_token": "test-token",
|
||||||
|
"hub.challenge": "123456789",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "123456789"
|
||||||
|
|
||||||
|
def test_verify_subscription_invalid_token(self, client, monkeypatch):
|
||||||
|
"""Invalid token returns 403."""
|
||||||
|
monkeypatch.setenv("META_WEBHOOK_VERIFY_TOKEN", "test-token")
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/webhooks/whatsapp",
|
||||||
|
params={
|
||||||
|
"hub.mode": "subscribe",
|
||||||
|
"hub.verify_token": "wrong-token",
|
||||||
|
"hub.challenge": "123456789",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebhookReceive:
|
||||||
|
"""Test POST /webhooks/whatsapp message reception."""
|
||||||
|
|
||||||
|
def test_receive_text_message(self, client):
|
||||||
|
"""Process incoming text message."""
|
||||||
|
payload = {
|
||||||
|
"object": "whatsapp_business_account",
|
||||||
|
"entry": [{
|
||||||
|
"id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
|
||||||
|
"changes": [{
|
||||||
|
"value": {
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"metadata": {
|
||||||
|
"display_phone_number": "16505551111",
|
||||||
|
"phone_number_id": "123456789",
|
||||||
|
},
|
||||||
|
"contacts": [{
|
||||||
|
"profile": {"name": "Test User"},
|
||||||
|
"wa_id": "5216641234567",
|
||||||
|
}],
|
||||||
|
"messages": [{
|
||||||
|
"from": "5216641234567",
|
||||||
|
"id": "wamid.TEST123",
|
||||||
|
"timestamp": "1234567890",
|
||||||
|
"text": {"body": "Hola, quiero agendar una cita"},
|
||||||
|
"type": "text",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
"field": "messages",
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/webhooks/whatsapp", json=payload)
|
||||||
|
# In development, it processes synchronously
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] in ("processed", "no_messages")
|
||||||
|
|
||||||
|
def test_receive_status_update(self, client):
|
||||||
|
"""Acknowledge status update without processing."""
|
||||||
|
payload = {
|
||||||
|
"object": "whatsapp_business_account",
|
||||||
|
"entry": [{
|
||||||
|
"id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
|
||||||
|
"changes": [{
|
||||||
|
"value": {
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"metadata": {
|
||||||
|
"display_phone_number": "16505551111",
|
||||||
|
"phone_number_id": "123456789",
|
||||||
|
},
|
||||||
|
"statuses": [{
|
||||||
|
"id": "wamid.TEST123",
|
||||||
|
"status": "delivered",
|
||||||
|
"timestamp": "1234567890",
|
||||||
|
"recipient_id": "5216641234567",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
"field": "messages",
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/webhooks/whatsapp", json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "acknowledged"
|
||||||
|
|
||||||
|
def test_invalid_payload(self, client):
|
||||||
|
"""Invalid payload should be ignored gracefully."""
|
||||||
|
payload = {"object": "not_whatsapp"}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/webhooks/whatsapp", json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "ignored"
|
||||||
61
tests/test_whatsapp_client.py
Normal file
61
tests/test_whatsapp_client.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Tests for WhatsApp API client."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
from httpx import Response
|
||||||
|
|
||||||
|
from src.infrastructure.whatsapp.client import WhatsAppClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestWhatsAppClient:
|
||||||
|
"""Test Meta WhatsApp Business API client."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self):
|
||||||
|
return WhatsAppClient()
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_send_text_message(self, client):
|
||||||
|
"""Successfully send text message."""
|
||||||
|
route = respx.post(
|
||||||
|
"https://graph.facebook.com/v18.0/123456789012345/messages"
|
||||||
|
).mock(return_value=Response(200, json={
|
||||||
|
"messages": [{"id": "wamid.sent123"}],
|
||||||
|
"contacts": [{"wa_id": "5216641234567"}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
result = await client.send_text_message("5216641234567", "Hola SKEEN")
|
||||||
|
assert result["messages"][0]["id"] == "wamid.sent123"
|
||||||
|
assert route.called
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_send_text_message_too_long(self, client):
|
||||||
|
"""Truncate text over 4096 chars."""
|
||||||
|
respx.post(
|
||||||
|
"https://graph.facebook.com/v18.0/123456789012345/messages"
|
||||||
|
).mock(return_value=Response(200, json={"messages": [{"id": "x"}]}))
|
||||||
|
|
||||||
|
long_text = "A" * 5000
|
||||||
|
await client.send_text_message("5216641234567", long_text)
|
||||||
|
# Should not raise
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_mark_as_read(self, client):
|
||||||
|
"""Mark message as read."""
|
||||||
|
route = respx.post(
|
||||||
|
"https://graph.facebook.com/v18.0/123456789012345/messages"
|
||||||
|
).mock(return_value=Response(200, json={"success": True}))
|
||||||
|
|
||||||
|
result = await client.mark_as_read("wamid.test123")
|
||||||
|
assert result["success"] is True
|
||||||
|
assert route.called
|
||||||
|
|
||||||
|
async def test_button_limit(self, client):
|
||||||
|
"""More than 3 buttons raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Maximum 3 buttons"):
|
||||||
|
await client.send_interactive_buttons(
|
||||||
|
"5216641234567",
|
||||||
|
"Choose:",
|
||||||
|
[{"id": "1", "title": "A"}, {"id": "2", "title": "B"},
|
||||||
|
{"id": "3", "title": "C"}, {"id": "4", "title": "D"}],
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user