Implement Python email agent with multi-model AI support
Adds a complete email-based registration agent for Spielgruppe Pumuckl based on the OpenSpec define-project-scope specifications. Architecture - Channel-agnostic EmailAgent core — no email-specific code in business logic - Pluggable AI provider layer: Anthropic (Claude) and OpenAI (GPT) supported via a shared LLMProvider interface; switch with AI_PROVIDER env var - IMAP polling for inbound emails with thread-tracking via email headers (Message-ID / In-Reply-To / References) - SMTP for outbound replies and admin notifications - File-based JSON storage for conversation state and completed registrations - Admin-editable knowledge-base loaded from markdown files at startup Key files src/config.py — env-var configuration src/providers/base.py — abstract LLMProvider src/providers/anthropic_provider.py — Claude backend src/providers/openai_provider.py — OpenAI backend src/agent/core.py — EmailAgent orchestrator src/agent/prompts.py — system prompt builder (KB + registration state) src/models/registration.py — RegistrationData matching the JSON schema src/models/conversation.py — ConversationState persisted per thread src/channels/email_channel.py — IMAP/SMTP I/O + quoted-text stripping src/storage/json_store.py — conversation & registration persistence src/notifications/notifier.py — admin notification routing by playgroup type main.py — polling entry point requirements.txt — anthropic, openai, python-dotenv, jsonschema .env.example — configuration template https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Meister-Eder Email Agent — Configuration Template
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Copy this file to .env and fill in your values.
|
||||||
|
# The .env file must NOT be committed to version control.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# AI Provider
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Choose "anthropic" (Claude) or "openai" (GPT).
|
||||||
|
AI_PROVIDER=anthropic
|
||||||
|
|
||||||
|
# Optional: override the default model for the chosen provider.
|
||||||
|
# Anthropic default: claude-opus-4-6
|
||||||
|
# OpenAI default: gpt-4o
|
||||||
|
# AI_MODEL=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# API Keys — set the one matching your AI_PROVIDER
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
# OPENAI_API_KEY=sk-...
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Email — IMAP (receiving parent messages)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
IMAP_HOST=imap.example.com
|
||||||
|
IMAP_PORT=993
|
||||||
|
IMAP_USERNAME=anmeldung@example.com
|
||||||
|
IMAP_PASSWORD=your-imap-password
|
||||||
|
IMAP_USE_SSL=true
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Email — SMTP (sending replies and notifications)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USE_TLS=true
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Registration email address (displayed as sender to parents)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
REGISTRATION_EMAIL=anmeldung@example.com
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Storage
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Directory for conversation state and completed registrations.
|
||||||
|
DATA_DIR=data
|
||||||
|
|
||||||
|
# Path to the knowledge-base markdown files (admin-editable).
|
||||||
|
KNOWLEDGE_BASE_DIR=openspec/changes/define-project-scope/content/knowledge-base
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Polling
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# How often (in seconds) to check the inbox for new messages.
|
||||||
|
POLL_INTERVAL=60
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Agent data (conversations and registrations stored at runtime)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Meister-Eder — Email Registration Agent for Spielgruppe Pumuckl.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
Copy `.env.example` to `.env`, fill in your credentials, then run:
|
||||||
|
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
The agent polls the configured IMAP inbox every POLL_INTERVAL seconds,
|
||||||
|
processes new messages, and replies via SMTP.
|
||||||
|
|
||||||
|
Environment variables (see .env.example for full list):
|
||||||
|
AI_PROVIDER anthropic | openai (default: anthropic)
|
||||||
|
AI_MODEL Model name override (default: provider default)
|
||||||
|
ANTHROPIC_API_KEY Required if AI_PROVIDER=anthropic
|
||||||
|
OPENAI_API_KEY Required if AI_PROVIDER=openai
|
||||||
|
IMAP_HOST IMAP server hostname
|
||||||
|
IMAP_PORT IMAP port (default: 993)
|
||||||
|
IMAP_USERNAME Email account username
|
||||||
|
IMAP_PASSWORD Email account password
|
||||||
|
SMTP_HOST SMTP server hostname
|
||||||
|
SMTP_PORT SMTP port (default: 587)
|
||||||
|
REGISTRATION_EMAIL Sender address shown to parents
|
||||||
|
DATA_DIR Directory for JSON storage (default: data/)
|
||||||
|
POLL_INTERVAL Seconds between inbox polls (default: 60)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from src.agent.core import EmailAgent
|
||||||
|
from src.channels.email_channel import EmailChannel
|
||||||
|
from src.config import Config
|
||||||
|
from src.knowledge_base.loader import KnowledgeBase
|
||||||
|
from src.notifications.notifier import AdminNotifier
|
||||||
|
from src.providers import create_provider
|
||||||
|
from src.storage.json_store import ConversationStore
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_components(config: Config):
|
||||||
|
"""Instantiate and wire together all agent components."""
|
||||||
|
|
||||||
|
# Resolve API key for the chosen provider
|
||||||
|
if config.ai_provider == "anthropic":
|
||||||
|
if not config.anthropic_api_key:
|
||||||
|
logger.error("ANTHROPIC_API_KEY is required when AI_PROVIDER=anthropic")
|
||||||
|
sys.exit(1)
|
||||||
|
api_key = config.anthropic_api_key
|
||||||
|
elif config.ai_provider == "openai":
|
||||||
|
if not config.openai_api_key:
|
||||||
|
logger.error("OPENAI_API_KEY is required when AI_PROVIDER=openai")
|
||||||
|
sys.exit(1)
|
||||||
|
api_key = config.openai_api_key
|
||||||
|
else:
|
||||||
|
logger.error("Unknown AI_PROVIDER '%s'. Choose 'anthropic' or 'openai'.", config.ai_provider)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
provider = create_provider(config.ai_provider, api_key, config.ai_model)
|
||||||
|
logger.info("AI provider: %s / model: %s", config.ai_provider, provider.model_name)
|
||||||
|
|
||||||
|
kb = KnowledgeBase(config.knowledge_base_dir)
|
||||||
|
store = ConversationStore(config.data_dir)
|
||||||
|
|
||||||
|
notifier = AdminNotifier(
|
||||||
|
smtp_host=config.smtp_host,
|
||||||
|
smtp_port=config.smtp_port,
|
||||||
|
username=config.imap_username,
|
||||||
|
password=config.imap_password,
|
||||||
|
use_tls=config.smtp_use_tls,
|
||||||
|
from_email=config.registration_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = EmailAgent(provider=provider, kb=kb, store=store, notifier=notifier)
|
||||||
|
|
||||||
|
channel = EmailChannel(
|
||||||
|
imap_host=config.imap_host,
|
||||||
|
imap_port=config.imap_port,
|
||||||
|
smtp_host=config.smtp_host,
|
||||||
|
smtp_port=config.smtp_port,
|
||||||
|
username=config.imap_username,
|
||||||
|
password=config.imap_password,
|
||||||
|
use_ssl=config.imap_use_ssl,
|
||||||
|
use_tls=config.smtp_use_tls,
|
||||||
|
registration_email=config.registration_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
return agent, channel
|
||||||
|
|
||||||
|
|
||||||
|
def run_poll_loop(agent: EmailAgent, channel: EmailChannel, poll_interval: int) -> None:
|
||||||
|
"""Main polling loop — never returns unless interrupted."""
|
||||||
|
logger.info("Agent started. Polling every %ds for new messages.", poll_interval)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
messages = channel.fetch_unread_messages()
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
logger.info(
|
||||||
|
"Processing message from %s (thread: %s)",
|
||||||
|
msg["from"],
|
||||||
|
msg["thread_id"],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reply = agent.process_message(
|
||||||
|
conversation_id=msg["thread_id"],
|
||||||
|
parent_email=msg["from"],
|
||||||
|
message_text=msg["body"],
|
||||||
|
)
|
||||||
|
if reply:
|
||||||
|
channel.send_reply(
|
||||||
|
to=msg["from"],
|
||||||
|
subject=msg["subject"],
|
||||||
|
body=reply,
|
||||||
|
in_reply_to=msg["message_id"],
|
||||||
|
references=msg["references"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Unhandled error processing message from %s", msg["from"]
|
||||||
|
)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Shutdown requested — stopping.")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unexpected error in poll loop")
|
||||||
|
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
config = Config.from_env()
|
||||||
|
|
||||||
|
if not config.imap_host:
|
||||||
|
logger.error(
|
||||||
|
"IMAP_HOST is not set. "
|
||||||
|
"Copy .env.example to .env and fill in your email credentials."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
agent, channel = build_components(config)
|
||||||
|
run_poll_loop(agent, channel, config.poll_interval)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Core — at least one AI provider SDK is required
|
||||||
|
anthropic>=0.40.0
|
||||||
|
openai>=1.50.0
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
|
||||||
|
# Registration schema validation
|
||||||
|
jsonschema>=4.23.0
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""EmailAgent — the channel-agnostic conversation orchestrator."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from ..models.conversation import ConversationState, ChatMessage
|
||||||
|
from ..models.registration import BookingDay
|
||||||
|
from ..providers.base import LLMProvider, LLMMessage
|
||||||
|
from ..knowledge_base.loader import KnowledgeBase
|
||||||
|
from ..storage.json_store import ConversationStore
|
||||||
|
from ..notifications.notifier import AdminNotifier
|
||||||
|
from .prompts import build_system_prompt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailAgent:
|
||||||
|
"""Processes one inbound message and returns the agent's reply text.
|
||||||
|
|
||||||
|
All business logic (conversation flow, field extraction, validation,
|
||||||
|
storage, notifications) lives here. Channel-specific I/O is handled
|
||||||
|
by the caller.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: LLMProvider,
|
||||||
|
kb: KnowledgeBase,
|
||||||
|
store: ConversationStore,
|
||||||
|
notifier: AdminNotifier,
|
||||||
|
) -> None:
|
||||||
|
self._provider = provider
|
||||||
|
self._kb = kb
|
||||||
|
self._store = store
|
||||||
|
self._notifier = notifier
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def process_message(
|
||||||
|
self,
|
||||||
|
conversation_id: str,
|
||||||
|
parent_email: str,
|
||||||
|
message_text: str,
|
||||||
|
) -> str:
|
||||||
|
"""Process an inbound message and return the reply to send back.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Unique thread identifier (from email headers).
|
||||||
|
parent_email: Sender's email address.
|
||||||
|
message_text: Stripped plain-text body of the inbound email.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Reply text to send to the parent.
|
||||||
|
"""
|
||||||
|
# Load or create conversation state
|
||||||
|
state = self._store.load(conversation_id)
|
||||||
|
if state is None:
|
||||||
|
state = ConversationState(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
parent_email=parent_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
state.last_activity = now
|
||||||
|
|
||||||
|
# Append the user's message to history
|
||||||
|
state.messages.append(ChatMessage(role="user", content=message_text))
|
||||||
|
|
||||||
|
# Build system prompt and conversation history for the LLM
|
||||||
|
system = build_system_prompt(self._kb, state)
|
||||||
|
llm_messages = [
|
||||||
|
LLMMessage(role=m.role, content=m.content) for m in state.messages
|
||||||
|
]
|
||||||
|
|
||||||
|
# Call the LLM
|
||||||
|
try:
|
||||||
|
response = self._provider.complete(system=system, messages=llm_messages)
|
||||||
|
parsed = self._parse_llm_response(response.content)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("LLM call failed for conversation %s", conversation_id)
|
||||||
|
parsed = self._fallback_response(state)
|
||||||
|
|
||||||
|
reply_text: str = parsed.get("reply", "")
|
||||||
|
updates: dict = parsed.get("updates", {}) or {}
|
||||||
|
next_step: str = parsed.get("next_step", state.flow_step)
|
||||||
|
is_complete: bool = bool(parsed.get("registration_complete", False))
|
||||||
|
language: str = parsed.get("language", state.language)
|
||||||
|
|
||||||
|
# Apply extracted field updates
|
||||||
|
self._apply_updates(state, updates)
|
||||||
|
|
||||||
|
# Update conversation metadata
|
||||||
|
state.flow_step = next_step
|
||||||
|
state.language = language
|
||||||
|
state.updated_at = now
|
||||||
|
|
||||||
|
# Record the assistant reply
|
||||||
|
state.messages.append(ChatMessage(role="assistant", content=reply_text))
|
||||||
|
|
||||||
|
# Handle registration completion
|
||||||
|
if is_complete and not state.completed:
|
||||||
|
state.completed = True
|
||||||
|
registration_id = self._store.save_registration(state)
|
||||||
|
try:
|
||||||
|
self._notifier.notify_admin(
|
||||||
|
registration=state.registration,
|
||||||
|
registration_id=registration_id,
|
||||||
|
conversation_id=state.conversation_id,
|
||||||
|
channel="email",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send admin notification for %s", registration_id)
|
||||||
|
logger.info("Registration complete: %s", registration_id)
|
||||||
|
|
||||||
|
# Persist conversation state
|
||||||
|
self._store.save(state)
|
||||||
|
|
||||||
|
return reply_text
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_llm_response(self, content: str) -> dict:
|
||||||
|
"""Extract the JSON payload from the LLM's output."""
|
||||||
|
text = content.strip()
|
||||||
|
|
||||||
|
# Strip markdown code fences if present
|
||||||
|
fence_pattern = re.compile(r"^```(?:json)?\s*\n(.*?)\n```\s*$", re.DOTALL)
|
||||||
|
match = fence_pattern.match(text)
|
||||||
|
if match:
|
||||||
|
text = match.group(1).strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: find the first {...} block
|
||||||
|
brace_match = re.search(r"\{.*\}", text, re.DOTALL)
|
||||||
|
if brace_match:
|
||||||
|
try:
|
||||||
|
return json.loads(brace_match.group())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.warning("Could not parse LLM response as JSON; using raw text as reply.")
|
||||||
|
return {
|
||||||
|
"reply": content,
|
||||||
|
"updates": {},
|
||||||
|
"next_step": "greeting",
|
||||||
|
"registration_complete": False,
|
||||||
|
"language": "de",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fallback_response(self, state: ConversationState) -> dict:
|
||||||
|
"""Return a safe fallback when the LLM call fails."""
|
||||||
|
if state.language == "en":
|
||||||
|
msg = (
|
||||||
|
"I'm sorry, I'm having a technical issue right now. "
|
||||||
|
"Please try again in a moment or contact us directly."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = (
|
||||||
|
"Entschuldigung, ich habe gerade ein technisches Problem. "
|
||||||
|
"Bitte versuche es gleich nochmal oder kontaktiere uns direkt."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"reply": msg,
|
||||||
|
"updates": {},
|
||||||
|
"next_step": state.flow_step,
|
||||||
|
"registration_complete": False,
|
||||||
|
"language": state.language,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _apply_updates(self, state: ConversationState, updates: dict) -> None:
|
||||||
|
"""Write extracted field values into the RegistrationData object."""
|
||||||
|
reg = state.registration
|
||||||
|
|
||||||
|
field_map = {
|
||||||
|
"child.fullName": lambda v: setattr(reg.child, "full_name", v),
|
||||||
|
"child.dateOfBirth": lambda v: setattr(reg.child, "date_of_birth", v),
|
||||||
|
"child.specialNeeds": lambda v: setattr(reg.child, "special_needs", v),
|
||||||
|
"parentGuardian.fullName": lambda v: (
|
||||||
|
setattr(reg.parent_guardian, "full_name", v),
|
||||||
|
setattr(state, "parent_name", v),
|
||||||
|
),
|
||||||
|
"parentGuardian.streetAddress": lambda v: setattr(reg.parent_guardian, "street_address", v),
|
||||||
|
"parentGuardian.postalCode": lambda v: setattr(reg.parent_guardian, "postal_code", str(v)),
|
||||||
|
"parentGuardian.city": lambda v: setattr(reg.parent_guardian, "city", v),
|
||||||
|
"parentGuardian.phone": lambda v: setattr(reg.parent_guardian, "phone", v),
|
||||||
|
"parentGuardian.email": lambda v: setattr(reg.parent_guardian, "email", v),
|
||||||
|
"emergencyContact.fullName": lambda v: setattr(reg.emergency_contact, "full_name", v),
|
||||||
|
"emergencyContact.phone": lambda v: setattr(reg.emergency_contact, "phone", v),
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value in updates.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if key in field_map:
|
||||||
|
field_map[key](value)
|
||||||
|
elif key == "booking.playgroupTypes" and isinstance(value, list):
|
||||||
|
reg.booking.playgroup_types = value
|
||||||
|
elif key == "booking.selectedDays" and isinstance(value, list):
|
||||||
|
reg.booking.selected_days = [
|
||||||
|
BookingDay(day=d["day"], type=d["type"])
|
||||||
|
for d in value
|
||||||
|
if isinstance(d, dict) and "day" in d and "type" in d
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
logger.debug("Unknown update key ignored: %s", key)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Build the system prompt sent to the LLM on every turn."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ..knowledge_base.loader import KnowledgeBase
|
||||||
|
from ..models.conversation import ConversationState
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step descriptions help the model understand where it is in the flow.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
STEP_DESCRIPTIONS = {
|
||||||
|
"greeting": "Greet the parent and detect their intent (registration vs. questions).",
|
||||||
|
"child_name": "Ask for the child's full name.",
|
||||||
|
"child_dob": (
|
||||||
|
"Ask for the child's date of birth. "
|
||||||
|
"Validate age: indoor requires ≥2.5 years, outdoor requires ≥3 years."
|
||||||
|
),
|
||||||
|
"playgroup_selection": (
|
||||||
|
"Explain both playgroup options and ask which the parent wants "
|
||||||
|
"(indoor / outdoor / both) and which days."
|
||||||
|
),
|
||||||
|
"special_needs": (
|
||||||
|
"Ask whether the child has any special needs, allergies, or medical conditions."
|
||||||
|
),
|
||||||
|
"parent_contact": (
|
||||||
|
"Collect the parent/guardian's full name, street address, postal code (4 digits), "
|
||||||
|
"city, phone number, and email address."
|
||||||
|
),
|
||||||
|
"emergency_contact": (
|
||||||
|
"Ask for an emergency contact (someone other than the parent): full name and phone."
|
||||||
|
),
|
||||||
|
"confirmation": (
|
||||||
|
"Show a summary of all collected information and ask the parent to confirm."
|
||||||
|
),
|
||||||
|
"complete": "Thank the parent, mention fees and next steps. Registration is done.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt(kb: KnowledgeBase, state: ConversationState) -> str:
|
||||||
|
"""Return the full system prompt for the current conversation turn."""
|
||||||
|
|
||||||
|
kb_content = kb.get_all()
|
||||||
|
reg_json = json.dumps(state.registration.to_dict(), ensure_ascii=False, indent=2)
|
||||||
|
step_hint = STEP_DESCRIPTIONS.get(state.flow_step, "Continue the conversation.")
|
||||||
|
|
||||||
|
return f"""You are the registration assistant for Spielgruppe Pumuckl, run by Familienverein Fällanden in Fällanden, Switzerland. You help parents register their children for the playgroup and answer questions about it.
|
||||||
|
|
||||||
|
## Your Personality
|
||||||
|
- Warm, friendly, and helpful — like a caring playgroup staff member
|
||||||
|
- Use informal "du" in German (never the formal "Sie")
|
||||||
|
- Auto-detect the parent's language from their message; respond in the same language
|
||||||
|
- Default language is German if unclear
|
||||||
|
- Ask 1–2 questions at a time — never send an overwhelming form-like list
|
||||||
|
- Be patient and understanding; never make parents feel they made a mistake
|
||||||
|
|
||||||
|
## Registration Flow (8 steps)
|
||||||
|
1. greeting — greet and detect intent
|
||||||
|
2. child_name — ask for child's full name
|
||||||
|
3. child_dob — ask for date of birth; validate age (indoor ≥2.5 yrs, outdoor ≥3 yrs)
|
||||||
|
4. playgroup_selection — present options, collect type(s) and day(s)
|
||||||
|
5. special_needs — ask about special needs / allergies / medical conditions
|
||||||
|
6. parent_contact — name, street address, postal code, city, phone, email
|
||||||
|
7. emergency_contact — emergency contact name and phone
|
||||||
|
8. confirmation — show full summary; ask to confirm; submit on confirmation
|
||||||
|
9. complete — thank parent, mention CHF 80 registration fee, monthly fees, and contacts
|
||||||
|
|
||||||
|
**Current step: {state.flow_step}**
|
||||||
|
**What to do now: {step_hint}**
|
||||||
|
|
||||||
|
At any point the parent may ask a question. Answer it from the knowledge base, then offer to continue the registration.
|
||||||
|
|
||||||
|
## Current Registration Data (so far)
|
||||||
|
```json
|
||||||
|
{reg_json}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Knowledge Base
|
||||||
|
Use the information below to answer parent questions accurately:
|
||||||
|
|
||||||
|
{kb_content}
|
||||||
|
|
||||||
|
## Playgroup Details
|
||||||
|
- **Indoor (Innenspielgruppe)**: Mon / Wed / Thu, 09:00–11:30 | CHF 130/260/390 per month (1/2/3×/week)
|
||||||
|
- **Outdoor Forest (Waldspielgruppe)**: Mon only, 09:00–14:00 (includes snack & lunch) | CHF 250/month
|
||||||
|
- **One-time registration fee**: CHF 80 (first year); CHF 80 craft materials from second year
|
||||||
|
- **Cleaning deposit (indoor only)**: CHF 50 (refundable)
|
||||||
|
- **Sibling discount**: 10% per additional child
|
||||||
|
- **July & August**: fee-free
|
||||||
|
|
||||||
|
## Admin Contacts
|
||||||
|
- Administration: Markus Graf — spielgruppen@familien-verein.ch — 079 261 16 37
|
||||||
|
- Indoor leader: Andrea Sigrist — andrea.sigrist@gmx.net — 079 674 99 92
|
||||||
|
- Outdoor leader: Barbara Gross — baba.laeubli@gmail.com — 078 761 19 64
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL: Response Format
|
||||||
|
|
||||||
|
You MUST respond with **only** a valid JSON object — no markdown, no extra text outside the JSON.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"reply": "Your conversational message to the parent (plain text, NOT JSON)",
|
||||||
|
"updates": {{
|
||||||
|
"child.fullName": "string or null",
|
||||||
|
"child.dateOfBirth": "YYYY-MM-DD or null",
|
||||||
|
"child.specialNeeds": "string or null",
|
||||||
|
"parentGuardian.fullName": "string or null",
|
||||||
|
"parentGuardian.streetAddress": "string or null",
|
||||||
|
"parentGuardian.postalCode": "4-digit string or null",
|
||||||
|
"parentGuardian.city": "string or null",
|
||||||
|
"parentGuardian.phone": "string or null",
|
||||||
|
"parentGuardian.email": "string or null",
|
||||||
|
"emergencyContact.fullName": "string or null",
|
||||||
|
"emergencyContact.phone": "string or null",
|
||||||
|
"booking.playgroupTypes": ["indoor", "outdoor"] or null,
|
||||||
|
"booking.selectedDays": [{{"day": "monday", "type": "indoor"}}] or null
|
||||||
|
}},
|
||||||
|
"next_step": "greeting|child_name|child_dob|playgroup_selection|special_needs|parent_contact|emergency_contact|confirmation|complete",
|
||||||
|
"registration_complete": false,
|
||||||
|
"language": "de"
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Only set fields in `updates` that you actually extracted from the parent's **latest message**. Use `null` for everything else.
|
||||||
|
- Set `registration_complete` to `true` **only** when ALL required fields are filled AND the parent has just confirmed the summary is correct.
|
||||||
|
- Dates must be YYYY-MM-DD. Postal codes must be exactly 4 digits.
|
||||||
|
- Valid days: "monday", "wednesday", "thursday" (indoor) or "monday" (outdoor).
|
||||||
|
- `language` must be "de" or "en" based on the parent's message.
|
||||||
|
- The `reply` field must be natural, friendly, conversational text — not JSON and not a list of fields.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""IMAP / SMTP email channel adapter.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Polling the inbox for unread messages (IMAP)
|
||||||
|
- Thread tracking via Message-ID / In-Reply-To / References headers
|
||||||
|
- Sending reply emails (SMTP) with proper threading headers
|
||||||
|
- Stripping quoted reply text so the agent only sees the new content
|
||||||
|
"""
|
||||||
|
|
||||||
|
import email
|
||||||
|
import email.header
|
||||||
|
import email.utils
|
||||||
|
import imaplib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import smtplib
|
||||||
|
import time
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _decode_header(value: str) -> str:
|
||||||
|
"""Decode an RFC-2047 encoded email header value."""
|
||||||
|
parts = email.header.decode_header(value or "")
|
||||||
|
decoded = []
|
||||||
|
for part, charset in parts:
|
||||||
|
if isinstance(part, bytes):
|
||||||
|
decoded.append(part.decode(charset or "utf-8", errors="replace"))
|
||||||
|
else:
|
||||||
|
decoded.append(part)
|
||||||
|
return "".join(decoded)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text(msg: email.message.Message) -> str:
|
||||||
|
"""Extract the plain-text body from a (potentially multi-part) message."""
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
if (
|
||||||
|
part.get_content_type() == "text/plain"
|
||||||
|
and "attachment" not in str(part.get("Content-Disposition", ""))
|
||||||
|
):
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
return payload.decode(charset, errors="replace")
|
||||||
|
else:
|
||||||
|
charset = msg.get_content_charset() or "utf-8"
|
||||||
|
payload = msg.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
return payload.decode(charset, errors="replace")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_quoted_text(text: str) -> str:
|
||||||
|
"""Remove quoted reply text from the email body.
|
||||||
|
|
||||||
|
Heuristics:
|
||||||
|
- Drop lines starting with ">"
|
||||||
|
- Stop at common reply-separator patterns
|
||||||
|
"""
|
||||||
|
lines = text.splitlines()
|
||||||
|
result: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith(">"):
|
||||||
|
continue
|
||||||
|
# Common separators used by email clients
|
||||||
|
if re.match(r"^-{3,}|^_{3,}|^={3,}", stripped):
|
||||||
|
break
|
||||||
|
if re.match(r"^On .+ wrote:$", stripped):
|
||||||
|
break
|
||||||
|
if re.match(r"^Am .+ schrieb .+:$", stripped): # German Outlook/Thunderbird
|
||||||
|
break
|
||||||
|
if "-----Original Message-----" in stripped:
|
||||||
|
break
|
||||||
|
result.append(line)
|
||||||
|
return "\n".join(result).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_message_id(from_addr: str) -> str:
|
||||||
|
domain = from_addr.split("@")[-1] if "@" in from_addr else "meister-eder.local"
|
||||||
|
return f"<{time.time():.6f}.{id(from_addr)}@{domain}>"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main class
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class EmailChannel:
|
||||||
|
"""Wraps IMAP polling and SMTP sending for the email conversation channel."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
imap_host: str,
|
||||||
|
imap_port: int,
|
||||||
|
smtp_host: str,
|
||||||
|
smtp_port: int,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
use_ssl: bool = True,
|
||||||
|
use_tls: bool = True,
|
||||||
|
registration_email: str = "",
|
||||||
|
) -> None:
|
||||||
|
self._imap_host = imap_host
|
||||||
|
self._imap_port = imap_port
|
||||||
|
self._smtp_host = smtp_host
|
||||||
|
self._smtp_port = smtp_port
|
||||||
|
self._username = username
|
||||||
|
self._password = password
|
||||||
|
self._use_ssl = use_ssl
|
||||||
|
self._use_tls = use_tls
|
||||||
|
self._from_email = registration_email or username
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# IMAP — receive
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def fetch_unread_messages(self) -> list[dict]:
|
||||||
|
"""Poll the inbox and return all unread messages as structured dicts.
|
||||||
|
|
||||||
|
Each dict contains:
|
||||||
|
from — sender email address
|
||||||
|
subject — decoded subject line
|
||||||
|
message_id — Message-ID header of this email
|
||||||
|
in_reply_to — In-Reply-To header (may be empty)
|
||||||
|
references — References header (may be empty)
|
||||||
|
thread_id — canonical ID for the email thread
|
||||||
|
body — stripped plain-text body (quoted text removed)
|
||||||
|
"""
|
||||||
|
messages: list[dict] = []
|
||||||
|
try:
|
||||||
|
imap = self._connect_imap()
|
||||||
|
imap.select("INBOX")
|
||||||
|
|
||||||
|
_, data = imap.search(None, "UNSEEN")
|
||||||
|
msg_nums = data[0].split()
|
||||||
|
|
||||||
|
for num in msg_nums:
|
||||||
|
try:
|
||||||
|
_, raw_data = imap.fetch(num, "(RFC822)")
|
||||||
|
raw = raw_data[0][1]
|
||||||
|
msg = email.message_from_bytes(raw)
|
||||||
|
|
||||||
|
from_addr = email.utils.parseaddr(msg.get("From", ""))[1]
|
||||||
|
subject = _decode_header(msg.get("Subject", "(no subject)"))
|
||||||
|
message_id = msg.get("Message-ID", "").strip()
|
||||||
|
in_reply_to = msg.get("In-Reply-To", "").strip()
|
||||||
|
references = msg.get("References", "").strip()
|
||||||
|
|
||||||
|
body = _extract_text(msg)
|
||||||
|
body = _strip_quoted_text(body)
|
||||||
|
|
||||||
|
if not body.strip():
|
||||||
|
imap.store(num, "+FLAGS", "\\Seen")
|
||||||
|
continue
|
||||||
|
|
||||||
|
thread_id = self._resolve_thread_id(message_id, in_reply_to, references)
|
||||||
|
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"from": from_addr,
|
||||||
|
"subject": subject,
|
||||||
|
"message_id": message_id,
|
||||||
|
"in_reply_to": in_reply_to,
|
||||||
|
"references": references,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
imap.store(num, "+FLAGS", "\\Seen")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error processing IMAP message %s", num)
|
||||||
|
|
||||||
|
imap.logout()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("IMAP connection/fetch error")
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# SMTP — send
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def send_reply(
|
||||||
|
self,
|
||||||
|
to: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
in_reply_to: str = "",
|
||||||
|
references: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Send an email reply.
|
||||||
|
|
||||||
|
Returns the new Message-ID so the caller can track the thread.
|
||||||
|
"""
|
||||||
|
new_message_id = _generate_message_id(self._from_email)
|
||||||
|
|
||||||
|
# Ensure subject starts with "Re:"
|
||||||
|
if not subject.lower().startswith("re:"):
|
||||||
|
subject = f"Re: {subject}"
|
||||||
|
|
||||||
|
# Build References chain
|
||||||
|
ref_parts = [r for r in [references, in_reply_to] if r]
|
||||||
|
new_references = " ".join(ref_parts)
|
||||||
|
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["From"] = self._from_email
|
||||||
|
msg["To"] = to
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["Message-ID"] = new_message_id
|
||||||
|
if in_reply_to:
|
||||||
|
msg["In-Reply-To"] = in_reply_to
|
||||||
|
if new_references:
|
||||||
|
msg["References"] = new_references
|
||||||
|
|
||||||
|
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||||
|
|
||||||
|
if not self._smtp_host:
|
||||||
|
logger.warning("SMTP not configured — reply NOT sent to %s: %s", to, subject)
|
||||||
|
logger.debug("Reply body:\n%s", body)
|
||||||
|
return new_message_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._use_tls:
|
||||||
|
server = smtplib.SMTP(self._smtp_host, self._smtp_port)
|
||||||
|
server.starttls()
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port)
|
||||||
|
|
||||||
|
server.login(self._username, self._password)
|
||||||
|
server.sendmail(self._from_email, [to], msg.as_string())
|
||||||
|
server.quit()
|
||||||
|
logger.info("Reply sent to %s (thread %s)", to, in_reply_to or new_message_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send reply to %s", to)
|
||||||
|
|
||||||
|
return new_message_id
|
||||||
|
|
||||||
|
def send_reminder(
|
||||||
|
self,
|
||||||
|
to: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
in_reply_to: str = "",
|
||||||
|
references: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Send a reminder email for an incomplete registration."""
|
||||||
|
self.send_reply(
|
||||||
|
to=to,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
in_reply_to=in_reply_to,
|
||||||
|
references=references,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _connect_imap(self) -> imaplib.IMAP4:
|
||||||
|
if self._use_ssl:
|
||||||
|
conn = imaplib.IMAP4_SSL(self._imap_host, self._imap_port)
|
||||||
|
else:
|
||||||
|
conn = imaplib.IMAP4(self._imap_host, self._imap_port)
|
||||||
|
conn.login(self._username, self._password)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_thread_id(message_id: str, in_reply_to: str, references: str) -> str:
|
||||||
|
"""Determine the canonical thread ID from email threading headers.
|
||||||
|
|
||||||
|
The root of the thread is the first message ever sent — its ID is the
|
||||||
|
first token in the References header (oldest first convention).
|
||||||
|
"""
|
||||||
|
if references:
|
||||||
|
first_ref = references.strip().split()[0]
|
||||||
|
return first_ref
|
||||||
|
if in_reply_to:
|
||||||
|
return in_reply_to.strip()
|
||||||
|
return message_id.strip() or f"<unknown-{time.time():.0f}>"
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Configuration loaded from environment variables."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except ImportError:
|
||||||
|
pass # python-dotenv is optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
# AI Provider
|
||||||
|
ai_provider: str = "anthropic" # "anthropic" or "openai"
|
||||||
|
ai_model: str = ""
|
||||||
|
anthropic_api_key: str = ""
|
||||||
|
openai_api_key: str = ""
|
||||||
|
|
||||||
|
# Email — IMAP (receiving)
|
||||||
|
imap_host: str = ""
|
||||||
|
imap_port: int = 993
|
||||||
|
imap_username: str = ""
|
||||||
|
imap_password: str = ""
|
||||||
|
imap_use_ssl: bool = True
|
||||||
|
|
||||||
|
# Email — SMTP (sending)
|
||||||
|
smtp_host: str = ""
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_use_tls: bool = True
|
||||||
|
|
||||||
|
# Registration email address shown to parents
|
||||||
|
registration_email: str = ""
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
data_dir: Path = field(default_factory=lambda: Path("data"))
|
||||||
|
knowledge_base_dir: Path = field(
|
||||||
|
default_factory=lambda: Path(
|
||||||
|
"openspec/changes/define-project-scope/content/knowledge-base"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Polling interval in seconds
|
||||||
|
poll_interval: int = 60
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "Config":
|
||||||
|
return cls(
|
||||||
|
ai_provider=os.getenv("AI_PROVIDER", "anthropic"),
|
||||||
|
ai_model=os.getenv("AI_MODEL", ""),
|
||||||
|
anthropic_api_key=os.getenv("ANTHROPIC_API_KEY", ""),
|
||||||
|
openai_api_key=os.getenv("OPENAI_API_KEY", ""),
|
||||||
|
imap_host=os.getenv("IMAP_HOST", ""),
|
||||||
|
imap_port=int(os.getenv("IMAP_PORT", "993")),
|
||||||
|
imap_username=os.getenv("IMAP_USERNAME", ""),
|
||||||
|
imap_password=os.getenv("IMAP_PASSWORD", ""),
|
||||||
|
imap_use_ssl=os.getenv("IMAP_USE_SSL", "true").lower() == "true",
|
||||||
|
smtp_host=os.getenv("SMTP_HOST", ""),
|
||||||
|
smtp_port=int(os.getenv("SMTP_PORT", "587")),
|
||||||
|
smtp_use_tls=os.getenv("SMTP_USE_TLS", "true").lower() == "true",
|
||||||
|
registration_email=os.getenv("REGISTRATION_EMAIL", ""),
|
||||||
|
data_dir=Path(os.getenv("DATA_DIR", "data")),
|
||||||
|
knowledge_base_dir=Path(
|
||||||
|
os.getenv(
|
||||||
|
"KNOWLEDGE_BASE_DIR",
|
||||||
|
"openspec/changes/define-project-scope/content/knowledge-base",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
poll_interval=int(os.getenv("POLL_INTERVAL", "60")),
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Load admin-editable knowledge-base markdown files into memory."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBase:
|
||||||
|
"""Reads markdown files from *kb_dir* and exposes them as a single string."""
|
||||||
|
|
||||||
|
def __init__(self, kb_dir: Path) -> None:
|
||||||
|
self._dir = kb_dir
|
||||||
|
self._content: dict[str, str] = {}
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
if not self._dir.exists():
|
||||||
|
logger.warning("Knowledge-base directory not found: %s", self._dir)
|
||||||
|
return
|
||||||
|
for path in sorted(self._dir.glob("*.md")):
|
||||||
|
self._content[path.stem] = path.read_text(encoding="utf-8")
|
||||||
|
logger.info("Loaded %d knowledge-base file(s) from %s", len(self._content), self._dir)
|
||||||
|
|
||||||
|
def get_all(self) -> str:
|
||||||
|
"""Return every KB file concatenated with section headers."""
|
||||||
|
if not self._content:
|
||||||
|
return "(No knowledge-base content available.)"
|
||||||
|
sections = [
|
||||||
|
f"### {name.upper().replace('-', ' ')}\n\n{content}"
|
||||||
|
for name, content in self._content.items()
|
||||||
|
]
|
||||||
|
return "\n\n---\n\n".join(sections)
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
"""Re-read all files from disk (useful when admins update content)."""
|
||||||
|
self._content = {}
|
||||||
|
self._load()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Conversation state model — persisted per email thread."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .registration import RegistrationData
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChatMessage:
|
||||||
|
role: str # "user" or "assistant"
|
||||||
|
content: str
|
||||||
|
timestamp: str = field(default_factory=_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConversationState:
|
||||||
|
conversation_id: str
|
||||||
|
language: str = "de" # "de" or "en"
|
||||||
|
flow_step: str = "greeting" # current step in registration flow
|
||||||
|
registration: RegistrationData = field(default_factory=RegistrationData)
|
||||||
|
messages: list = field(default_factory=list) # list[ChatMessage]
|
||||||
|
parent_email: str = ""
|
||||||
|
parent_name: Optional[str] = None
|
||||||
|
created_at: str = field(default_factory=_now)
|
||||||
|
updated_at: str = field(default_factory=_now)
|
||||||
|
last_activity: str = field(default_factory=_now)
|
||||||
|
completed: bool = False
|
||||||
|
reminder_count: int = 0
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"conversation_id": self.conversation_id,
|
||||||
|
"language": self.language,
|
||||||
|
"flow_step": self.flow_step,
|
||||||
|
"registration": self.registration.to_dict(),
|
||||||
|
"messages": [
|
||||||
|
{"role": m.role, "content": m.content, "timestamp": m.timestamp}
|
||||||
|
for m in self.messages
|
||||||
|
],
|
||||||
|
"parent_email": self.parent_email,
|
||||||
|
"parent_name": self.parent_name,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
"last_activity": self.last_activity,
|
||||||
|
"completed": self.completed,
|
||||||
|
"reminder_count": self.reminder_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ConversationState":
|
||||||
|
state = cls(conversation_id=data["conversation_id"])
|
||||||
|
state.language = data.get("language", "de")
|
||||||
|
state.flow_step = data.get("flow_step", "greeting")
|
||||||
|
state.registration = RegistrationData.from_dict(data.get("registration", {}))
|
||||||
|
state.messages = [
|
||||||
|
ChatMessage(
|
||||||
|
role=m["role"],
|
||||||
|
content=m["content"],
|
||||||
|
timestamp=m.get("timestamp", ""),
|
||||||
|
)
|
||||||
|
for m in data.get("messages", [])
|
||||||
|
]
|
||||||
|
state.parent_email = data.get("parent_email", "")
|
||||||
|
state.parent_name = data.get("parent_name")
|
||||||
|
state.created_at = data.get("created_at", "")
|
||||||
|
state.updated_at = data.get("updated_at", "")
|
||||||
|
state.last_activity = data.get("last_activity", "")
|
||||||
|
state.completed = data.get("completed", False)
|
||||||
|
state.reminder_count = data.get("reminder_count", 0)
|
||||||
|
return state
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Registration data models matching the JSON schema in registration-schema.json."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BookingDay:
|
||||||
|
day: str # "monday", "wednesday", "thursday"
|
||||||
|
type: str # "indoor", "outdoor"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Booking:
|
||||||
|
playgroup_types: list = field(default_factory=list) # ["indoor", "outdoor"]
|
||||||
|
selected_days: list = field(default_factory=list) # list[BookingDay]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChildInfo:
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
date_of_birth: Optional[str] = None # YYYY-MM-DD
|
||||||
|
special_needs: Optional[str] = None # text or "None"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParentGuardian:
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
street_address: Optional[str] = None
|
||||||
|
postal_code: Optional[str] = None # 4-digit Swiss code
|
||||||
|
city: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EmergencyContact:
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RegistrationData:
|
||||||
|
child: ChildInfo = field(default_factory=ChildInfo)
|
||||||
|
parent_guardian: ParentGuardian = field(default_factory=ParentGuardian)
|
||||||
|
emergency_contact: EmergencyContact = field(default_factory=EmergencyContact)
|
||||||
|
booking: Booking = field(default_factory=Booking)
|
||||||
|
|
||||||
|
def is_complete(self) -> bool:
|
||||||
|
"""Return True when all required schema fields are present."""
|
||||||
|
return (
|
||||||
|
bool(self.child.full_name)
|
||||||
|
and bool(self.child.date_of_birth)
|
||||||
|
and self.child.special_needs is not None
|
||||||
|
and bool(self.parent_guardian.full_name)
|
||||||
|
and bool(self.parent_guardian.street_address)
|
||||||
|
and bool(self.parent_guardian.postal_code)
|
||||||
|
and bool(self.parent_guardian.city)
|
||||||
|
and bool(self.parent_guardian.phone)
|
||||||
|
and bool(self.parent_guardian.email)
|
||||||
|
and bool(self.emergency_contact.full_name)
|
||||||
|
and bool(self.emergency_contact.phone)
|
||||||
|
and len(self.booking.playgroup_types) > 0
|
||||||
|
and len(self.booking.selected_days) > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"child": {
|
||||||
|
"fullName": self.child.full_name,
|
||||||
|
"dateOfBirth": self.child.date_of_birth,
|
||||||
|
"specialNeeds": self.child.special_needs,
|
||||||
|
},
|
||||||
|
"parentGuardian": {
|
||||||
|
"fullName": self.parent_guardian.full_name,
|
||||||
|
"streetAddress": self.parent_guardian.street_address,
|
||||||
|
"postalCode": self.parent_guardian.postal_code,
|
||||||
|
"city": self.parent_guardian.city,
|
||||||
|
"phone": self.parent_guardian.phone,
|
||||||
|
"email": self.parent_guardian.email,
|
||||||
|
},
|
||||||
|
"emergencyContact": {
|
||||||
|
"fullName": self.emergency_contact.full_name,
|
||||||
|
"phone": self.emergency_contact.phone,
|
||||||
|
},
|
||||||
|
"booking": {
|
||||||
|
"playgroupTypes": self.booking.playgroup_types,
|
||||||
|
"selectedDays": [
|
||||||
|
{"day": d.day, "type": d.type}
|
||||||
|
for d in self.booking.selected_days
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "RegistrationData":
|
||||||
|
reg = cls()
|
||||||
|
if child := data.get("child", {}):
|
||||||
|
reg.child = ChildInfo(
|
||||||
|
full_name=child.get("fullName"),
|
||||||
|
date_of_birth=child.get("dateOfBirth"),
|
||||||
|
special_needs=child.get("specialNeeds"),
|
||||||
|
)
|
||||||
|
if parent := data.get("parentGuardian", {}):
|
||||||
|
reg.parent_guardian = ParentGuardian(
|
||||||
|
full_name=parent.get("fullName"),
|
||||||
|
street_address=parent.get("streetAddress"),
|
||||||
|
postal_code=parent.get("postalCode"),
|
||||||
|
city=parent.get("city"),
|
||||||
|
phone=parent.get("phone"),
|
||||||
|
email=parent.get("email"),
|
||||||
|
)
|
||||||
|
if emergency := data.get("emergencyContact", {}):
|
||||||
|
reg.emergency_contact = EmergencyContact(
|
||||||
|
full_name=emergency.get("fullName"),
|
||||||
|
phone=emergency.get("phone"),
|
||||||
|
)
|
||||||
|
if booking := data.get("booking", {}):
|
||||||
|
reg.booking = Booking(
|
||||||
|
playgroup_types=booking.get("playgroupTypes", []),
|
||||||
|
selected_days=[
|
||||||
|
BookingDay(day=d["day"], type=d["type"])
|
||||||
|
for d in booking.get("selectedDays", [])
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return reg
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""Admin email notifications sent when a registration is completed."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from datetime import date, datetime
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
from ..models.registration import RegistrationData
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Notification routing per spec
|
||||||
|
_INDOOR_EMAIL = "andrea.sigrist@gmx.net"
|
||||||
|
_OUTDOOR_EMAIL = "baba.laeubli@gmail.com"
|
||||||
|
_ADMIN_CC_EMAIL = "spielgruppen@familien-verein.ch"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminNotifier:
|
||||||
|
"""Sends formatted admin notification emails upon registration completion.
|
||||||
|
|
||||||
|
The SMTP credentials are re-used from the agent's outbound email config.
|
||||||
|
When *smtp_host* is empty the notifier logs the notification and skips
|
||||||
|
sending (useful for local development / testing).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
smtp_host: str,
|
||||||
|
smtp_port: int,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
use_tls: bool = True,
|
||||||
|
from_email: str = "",
|
||||||
|
) -> None:
|
||||||
|
self._smtp_host = smtp_host
|
||||||
|
self._smtp_port = smtp_port
|
||||||
|
self._username = username
|
||||||
|
self._password = password
|
||||||
|
self._use_tls = use_tls
|
||||||
|
self._from_email = from_email or username
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def notify_admin(
|
||||||
|
self,
|
||||||
|
registration: RegistrationData,
|
||||||
|
registration_id: str,
|
||||||
|
conversation_id: str,
|
||||||
|
channel: str,
|
||||||
|
) -> None:
|
||||||
|
"""Send notification email(s) for a completed registration."""
|
||||||
|
types = registration.booking.playgroup_types
|
||||||
|
|
||||||
|
to_addresses: list[str] = []
|
||||||
|
if "indoor" in types:
|
||||||
|
to_addresses.append(_INDOOR_EMAIL)
|
||||||
|
if "outdoor" in types:
|
||||||
|
to_addresses.append(_OUTDOOR_EMAIL)
|
||||||
|
|
||||||
|
subject = (
|
||||||
|
f"New Registration: {registration.child.full_name} "
|
||||||
|
f"for {self._format_types(types)}"
|
||||||
|
)
|
||||||
|
body = self._build_body(registration, registration_id, channel)
|
||||||
|
|
||||||
|
self._send(
|
||||||
|
to=to_addresses,
|
||||||
|
cc=[_ADMIN_CC_EMAIL],
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
reply_to=registration.parent_guardian.email or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Formatting helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_types(types: list[str]) -> str:
|
||||||
|
has_indoor = "indoor" in types
|
||||||
|
has_outdoor = "outdoor" in types
|
||||||
|
if has_indoor and has_outdoor:
|
||||||
|
return "Indoor + Outdoor Playgroup"
|
||||||
|
if has_indoor:
|
||||||
|
return "Indoor Playgroup"
|
||||||
|
if has_outdoor:
|
||||||
|
return "Outdoor Playgroup"
|
||||||
|
return "Playgroup"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_age(dob_str: str) -> str:
|
||||||
|
try:
|
||||||
|
dob = datetime.strptime(dob_str, "%Y-%m-%d").date()
|
||||||
|
today = date.today()
|
||||||
|
years = today.year - dob.year - (
|
||||||
|
(today.month, today.day) < (dob.month, dob.day)
|
||||||
|
)
|
||||||
|
months = (today.month - dob.month) % 12
|
||||||
|
return f"{years} years, {months} months"
|
||||||
|
except Exception:
|
||||||
|
return dob_str
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_dob(dob_str: str) -> str:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y")
|
||||||
|
except Exception:
|
||||||
|
return dob_str or ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_monthly_fee(registration: RegistrationData) -> str:
|
||||||
|
indoor_days = sum(1 for d in registration.booking.selected_days if d.type == "indoor")
|
||||||
|
outdoor_days = sum(1 for d in registration.booking.selected_days if d.type == "outdoor")
|
||||||
|
fee = 0
|
||||||
|
if indoor_days == 1:
|
||||||
|
fee += 130
|
||||||
|
elif indoor_days == 2:
|
||||||
|
fee += 260
|
||||||
|
elif indoor_days >= 3:
|
||||||
|
fee += 390
|
||||||
|
if outdoor_days >= 1:
|
||||||
|
fee += 250
|
||||||
|
return f"CHF {fee}.-"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_days(registration: RegistrationData) -> str:
|
||||||
|
day_map = {
|
||||||
|
"monday": "Monday",
|
||||||
|
"wednesday": "Wednesday",
|
||||||
|
"thursday": "Thursday",
|
||||||
|
}
|
||||||
|
return ", ".join(
|
||||||
|
f"{day_map.get(d.day, d.day.capitalize())} ({d.type})"
|
||||||
|
for d in registration.booking.selected_days
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_body(
|
||||||
|
self,
|
||||||
|
registration: RegistrationData,
|
||||||
|
registration_id: str,
|
||||||
|
channel: str,
|
||||||
|
) -> str:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
pg = registration.parent_guardian
|
||||||
|
ec = registration.emergency_contact
|
||||||
|
|
||||||
|
return (
|
||||||
|
"===============================================\n"
|
||||||
|
"NEW PLAYGROUP REGISTRATION\n"
|
||||||
|
"===============================================\n"
|
||||||
|
"\n"
|
||||||
|
f"Submitted: {now.strftime('%d.%m.%Y')} at {now.strftime('%H:%M')} UTC\n"
|
||||||
|
f"Channel: {channel.title()}\n"
|
||||||
|
f"Registration ID: {registration_id}\n"
|
||||||
|
"\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
"CHILD INFORMATION\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
f"Name: {registration.child.full_name}\n"
|
||||||
|
f"Date of Birth: {self._format_dob(registration.child.date_of_birth or '')} "
|
||||||
|
f"(Age: {self._calculate_age(registration.child.date_of_birth or '')})\n"
|
||||||
|
f"Special Needs: {registration.child.special_needs or 'None'}\n"
|
||||||
|
"\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
"PLAYGROUP SELECTION\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
f"Type: {self._format_types(registration.booking.playgroup_types)}\n"
|
||||||
|
f"Days: {self._format_days(registration)}\n"
|
||||||
|
"\n"
|
||||||
|
f"Monthly Fee: {self._calculate_monthly_fee(registration)}\n"
|
||||||
|
"(Plus CHF 80 registration fee if first enrolment)\n"
|
||||||
|
"\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
"PARENT / GUARDIAN\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
f"Name: {pg.full_name}\n"
|
||||||
|
f"Address: {pg.street_address}\n"
|
||||||
|
f" {pg.postal_code} {pg.city}\n"
|
||||||
|
f"Phone: {pg.phone}\n"
|
||||||
|
f"Email: {pg.email}\n"
|
||||||
|
"\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
"EMERGENCY CONTACT\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
f"Name: {ec.full_name}\n"
|
||||||
|
f"Phone: {ec.phone}\n"
|
||||||
|
"\n"
|
||||||
|
"===============================================\n"
|
||||||
|
"\n"
|
||||||
|
"This registration was submitted via the automated registration assistant.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# SMTP dispatch
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _send(
|
||||||
|
self,
|
||||||
|
to: list[str],
|
||||||
|
cc: list[str],
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
reply_to: str = "",
|
||||||
|
) -> None:
|
||||||
|
if not self._smtp_host:
|
||||||
|
logger.warning(
|
||||||
|
"SMTP not configured — notification NOT sent. Would have emailed %s (CC: %s): %s",
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
subject,
|
||||||
|
)
|
||||||
|
logger.debug("Notification body:\n%s", body)
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["From"] = self._from_email
|
||||||
|
msg["To"] = ", ".join(to)
|
||||||
|
msg["CC"] = ", ".join(cc)
|
||||||
|
msg["Subject"] = subject
|
||||||
|
if reply_to:
|
||||||
|
msg["Reply-To"] = reply_to
|
||||||
|
|
||||||
|
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||||
|
all_recipients = to + cc
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._use_tls:
|
||||||
|
server = smtplib.SMTP(self._smtp_host, self._smtp_port)
|
||||||
|
server.starttls()
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port)
|
||||||
|
|
||||||
|
server.login(self._username, self._password)
|
||||||
|
server.sendmail(self._from_email, all_recipients, msg.as_string())
|
||||||
|
server.quit()
|
||||||
|
logger.info("Admin notification sent to %s", all_recipients)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send admin notification to %s", all_recipients)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""LLM provider registry."""
|
||||||
|
|
||||||
|
from .base import LLMProvider, LLMMessage, LLMResponse
|
||||||
|
from .anthropic_provider import AnthropicProvider
|
||||||
|
from .openai_provider import OpenAIProvider
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LLMProvider",
|
||||||
|
"LLMMessage",
|
||||||
|
"LLMResponse",
|
||||||
|
"AnthropicProvider",
|
||||||
|
"OpenAIProvider",
|
||||||
|
"create_provider",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def create_provider(provider: str, api_key: str, model: str = "") -> LLMProvider:
|
||||||
|
"""Instantiate the correct LLMProvider by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: "anthropic" or "openai"
|
||||||
|
api_key: API key for the chosen provider.
|
||||||
|
model: Optional model name override.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured LLMProvider instance.
|
||||||
|
"""
|
||||||
|
if provider == "anthropic":
|
||||||
|
return AnthropicProvider(api_key=api_key, model=model)
|
||||||
|
if provider == "openai":
|
||||||
|
return OpenAIProvider(api_key=api_key, model=model)
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown AI provider: '{provider}'. Supported values: 'anthropic', 'openai'."
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Anthropic (Claude) LLM provider."""
|
||||||
|
|
||||||
|
from .base import LLMProvider, LLMMessage, LLMResponse
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicProvider(LLMProvider):
|
||||||
|
DEFAULT_MODEL = "claude-opus-4-6"
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, model: str = "") -> None:
|
||||||
|
try:
|
||||||
|
import anthropic
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Install the 'anthropic' package to use the Anthropic provider: "
|
||||||
|
"pip install anthropic"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
self._client = anthropic.Anthropic(api_key=api_key)
|
||||||
|
self._model = model or self.DEFAULT_MODEL
|
||||||
|
|
||||||
|
def complete(self, system: str, messages: list) -> LLMResponse:
|
||||||
|
api_messages = [
|
||||||
|
{"role": m.role, "content": m.content}
|
||||||
|
for m in messages
|
||||||
|
if m.role in ("user", "assistant")
|
||||||
|
]
|
||||||
|
response = self._client.messages.create(
|
||||||
|
model=self._model,
|
||||||
|
max_tokens=2048,
|
||||||
|
system=system,
|
||||||
|
messages=api_messages,
|
||||||
|
)
|
||||||
|
return LLMResponse(content=response.content[0].text)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_name(self) -> str:
|
||||||
|
return self._model
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Abstract base class for LLM providers."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LLMMessage:
|
||||||
|
role: str # "user" or "assistant"
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LLMResponse:
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProvider(ABC):
|
||||||
|
"""Uniform interface for any LLM backend."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def complete(self, system: str, messages: list) -> LLMResponse:
|
||||||
|
"""Generate a completion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
system: System prompt text.
|
||||||
|
messages: List of LLMMessage objects (user/assistant turns).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LLMResponse with the model's text output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def model_name(self) -> str:
|
||||||
|
"""Human-readable model identifier."""
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""OpenAI (GPT) LLM provider."""
|
||||||
|
|
||||||
|
from .base import LLMProvider, LLMMessage, LLMResponse
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIProvider(LLMProvider):
|
||||||
|
DEFAULT_MODEL = "gpt-4o"
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, model: str = "") -> None:
|
||||||
|
try:
|
||||||
|
from openai import OpenAI
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Install the 'openai' package to use the OpenAI provider: "
|
||||||
|
"pip install openai"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
self._client = OpenAI(api_key=api_key)
|
||||||
|
self._model = model or self.DEFAULT_MODEL
|
||||||
|
|
||||||
|
def complete(self, system: str, messages: list) -> LLMResponse:
|
||||||
|
api_messages = [{"role": "system", "content": system}]
|
||||||
|
api_messages.extend(
|
||||||
|
{"role": m.role, "content": m.content}
|
||||||
|
for m in messages
|
||||||
|
if m.role in ("user", "assistant")
|
||||||
|
)
|
||||||
|
response = self._client.chat.completions.create(
|
||||||
|
model=self._model,
|
||||||
|
messages=api_messages,
|
||||||
|
max_tokens=2048,
|
||||||
|
)
|
||||||
|
return LLMResponse(content=response.choices[0].message.content)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_name(self) -> str:
|
||||||
|
return self._model
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""File-based JSON storage for conversations and completed registrations."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..models.conversation import ConversationState
|
||||||
|
from ..models.registration import RegistrationData
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationStore:
|
||||||
|
"""Persists ConversationState objects as JSON files on disk.
|
||||||
|
|
||||||
|
Directory layout::
|
||||||
|
|
||||||
|
data/
|
||||||
|
conversations/ # one file per email thread
|
||||||
|
registrations/ # one file per completed registration
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data_dir: Path) -> None:
|
||||||
|
self._conversations_dir = data_dir / "conversations"
|
||||||
|
self._registrations_dir = data_dir / "registrations"
|
||||||
|
self._conversations_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._registrations_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Conversation CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load(self, conversation_id: str) -> ConversationState | None:
|
||||||
|
"""Load a conversation by ID. Returns None if not found."""
|
||||||
|
path = self._conversation_path(conversation_id)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return ConversationState.from_dict(data)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load conversation %s", conversation_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save(self, state: ConversationState) -> None:
|
||||||
|
"""Persist a conversation state to disk."""
|
||||||
|
path = self._conversation_path(state.conversation_id)
|
||||||
|
try:
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(state.to_dict(), ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to save conversation %s", state.conversation_id)
|
||||||
|
|
||||||
|
def delete(self, conversation_id: str) -> None:
|
||||||
|
"""Remove a conversation file (e.g., after retention period expires)."""
|
||||||
|
path = self._conversation_path(conversation_id)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def list_incomplete(self) -> list[ConversationState]:
|
||||||
|
"""Return all conversations that are not yet completed."""
|
||||||
|
states: list[ConversationState] = []
|
||||||
|
for path in self._conversations_dir.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
state = ConversationState.from_dict(data)
|
||||||
|
if not state.completed:
|
||||||
|
states.append(state)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not read conversation file %s", path)
|
||||||
|
return states
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Registration storage
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def save_registration(self, state: ConversationState) -> str:
|
||||||
|
"""Write the completed registration to disk and return its ID."""
|
||||||
|
registration_id = str(uuid.uuid4())
|
||||||
|
record = state.registration.to_dict()
|
||||||
|
record["metadata"] = {
|
||||||
|
"registrationId": registration_id,
|
||||||
|
"submittedAt": _now(),
|
||||||
|
"channel": "email",
|
||||||
|
"conversationId": state.conversation_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
path = self._registrations_dir / f"{registration_id}.json"
|
||||||
|
try:
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(record, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
logger.info("Saved registration %s", registration_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to save registration %s", registration_id)
|
||||||
|
|
||||||
|
return registration_id
|
||||||
|
|
||||||
|
def load_registration(self, registration_id: str) -> dict | None:
|
||||||
|
"""Load a completed registration record by ID."""
|
||||||
|
path = self._registrations_dir / f"{registration_id}.json"
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load registration %s", registration_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_registrations(self) -> list[dict]:
|
||||||
|
"""Return all completed registration records."""
|
||||||
|
records: list[dict] = []
|
||||||
|
for path in sorted(self._registrations_dir.glob("*.json")):
|
||||||
|
try:
|
||||||
|
records.append(json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not read registration file %s", path)
|
||||||
|
return records
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _conversation_path(self, conversation_id: str) -> Path:
|
||||||
|
"""Sanitise the conversation ID to produce a safe filename."""
|
||||||
|
safe = (
|
||||||
|
conversation_id
|
||||||
|
.replace("/", "_")
|
||||||
|
.replace("\\", "_")
|
||||||
|
.replace("<", "")
|
||||||
|
.replace(">", "")
|
||||||
|
.replace("@", "_at_")
|
||||||
|
)[:200]
|
||||||
|
return self._conversations_dir / f"{safe}.json"
|
||||||
Reference in New Issue
Block a user