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