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:
Claude
2026-02-20 20:17:00 +00:00
parent 4d44d4ee58
commit b82ff27efd
24 changed files with 1728 additions and 0 deletions
View File
View File
+216
View File
@@ -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)
+132
View File
@@ -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 12 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:0011:30 | CHF 130/260/390 per month (1/2/3×/week)
- **Outdoor Forest (Waldspielgruppe)**: Mon only, 09:0014: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.
"""
View File
+289
View File
@@ -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}>"
+72
View File
@@ -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")),
)
View File
+38
View File
@@ -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()
View File
+76
View File
@@ -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
+126
View File
@@ -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
View File
+241
View File
@@ -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)
+34
View File
@@ -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'."
)
+37
View File
@@ -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
+36
View File
@@ -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."""
+37
View File
@@ -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
View File
+143
View File
@@ -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"