Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eba450c5a5 | ||
|
|
8217b33f38 | ||
|
|
db97a357c9 | ||
|
|
7fb1d1fa0f | ||
|
|
af96c7a310 | ||
|
|
c488a0061e | ||
|
|
4440b00d91 | ||
|
|
9c33bafbf3 | ||
|
|
fef0388534 | ||
|
|
98a5f5b5b1 | ||
|
|
60f056ece4 | ||
|
|
cfa90174e3 | ||
|
|
7f55cdd204 | ||
|
|
431847a8b7 | ||
|
|
1ba42f9497 | ||
|
|
05d4b51e7a | ||
|
|
a174023ee5 | ||
|
|
0c3b5a9033 | ||
|
|
c37862b75e | ||
|
|
b82ff27efd |
@@ -0,0 +1,69 @@
|
||||
# ---------------------------------------------------------------
|
||||
# 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 Model (via litellm — supports any provider)
|
||||
# ---------------------------------------------------------------
|
||||
# Use litellm model strings: "<provider>/<model-name>"
|
||||
# Examples:
|
||||
# anthropic/claude-opus-4-6 (default)
|
||||
# openai/gpt-4o
|
||||
# gemini/gemini-2.0-flash
|
||||
AI_MODEL=anthropic/claude-opus-4-6
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# API Keys — set the one matching your chosen model's provider
|
||||
# ---------------------------------------------------------------
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
# OPENAI_API_KEY=sk-...
|
||||
# GEMINI_API_KEY=...
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Admin notification routing
|
||||
# Each leader receives mail only when a day in their group is booked.
|
||||
# ADMIN_EMAIL_CC is always included as Cc (comma-separated for multiple).
|
||||
# For testing, point all three to your own email address.
|
||||
# ---------------------------------------------------------------
|
||||
ADMIN_EMAIL_INDOOR=andrea.sigrist@gmx.net
|
||||
ADMIN_EMAIL_OUTDOOR=baba.laeubli@gmail.com
|
||||
ADMIN_EMAIL_CC=spielgruppen@familien-verein.ch
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 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 @@
|
||||
3.13
|
||||
@@ -1,6 +1,6 @@
|
||||
# Meister-Eder
|
||||
|
||||
AI-powered conversational registration system for **Spielgruppe Pumuckl**, a playgroup run by Familienverein Fällanden (Fällanden, Switzerland).
|
||||
AI-powered conversational registration agent for **Spielgruppe Pumuckl** (Familienverein Fällanden, Switzerland). Parents register their child and ask questions via email — the agent handles the conversation, validates all required fields, and notifies the playgroup admin on completion.
|
||||
|
||||
Replaces a static Google Forms workflow with an AI agent that guides parents through child registration via natural conversation — over email or a web chat interface.
|
||||
|
||||
@@ -19,6 +19,102 @@ Replaces a static Google Forms workflow with an AI agent that guides parents thr
|
||||
| Web chat | Real-time, session-based |
|
||||
| Email | Async, thread-tracked; reminders on days 3, 10, 25 |
|
||||
|
||||
## Status
|
||||
## Prerequisites
|
||||
|
||||
Greenfield — specification complete, implementation not yet started.
|
||||
- Python 3.13+
|
||||
- [uv](https://docs.astral.sh/uv/) (dependency manager)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/gurix/Meister-Eder.git
|
||||
cd Meister-Eder
|
||||
uv sync
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the example env file and fill in your values:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Required variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `AI_MODEL` | litellm model string, e.g. `anthropic/claude-opus-4-6` or `openai/gpt-4o` |
|
||||
| `ANTHROPIC_API_KEY` | API key for Anthropic models |
|
||||
| `OPENAI_API_KEY` | API key for OpenAI models (if using OpenAI) |
|
||||
| `IMAP_HOST` | IMAP server hostname for receiving parent emails |
|
||||
| `IMAP_USERNAME` | Email account username |
|
||||
| `IMAP_PASSWORD` | Email account password |
|
||||
| `SMTP_HOST` | SMTP server hostname for sending replies |
|
||||
| `REGISTRATION_EMAIL` | Sender address shown to parents |
|
||||
|
||||
### Optional variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `IMAP_PORT` | `993` | IMAP port |
|
||||
| `IMAP_USE_SSL` | `true` | Use SSL for IMAP |
|
||||
| `SMTP_PORT` | `587` | SMTP port |
|
||||
| `SMTP_USE_TLS` | `true` | Use STARTTLS for SMTP |
|
||||
| `DATA_DIR` | `data/` | Directory for conversation state and completed registrations |
|
||||
| `KNOWLEDGE_BASE_DIR` | `openspec/…/knowledge-base` | Path to admin-editable knowledge base markdown files |
|
||||
| `POLL_INTERVAL` | `60` | Seconds between inbox polls (only used when running as a daemon) |
|
||||
|
||||
### Switching AI providers
|
||||
|
||||
`AI_MODEL` uses [litellm](https://docs.litellm.ai/docs/providers) model strings — any supported provider works without code changes:
|
||||
|
||||
```bash
|
||||
# Anthropic (default)
|
||||
AI_MODEL=anthropic/claude-opus-4-6
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# OpenAI
|
||||
AI_MODEL=openai/gpt-4o
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# Google Gemini
|
||||
AI_MODEL=gemini/gemini-2.0-flash
|
||||
GEMINI_API_KEY=...
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
### As a cron job (recommended)
|
||||
|
||||
The agent is a plain script — no long-running daemon needed. Schedule it with cron and use `flock` to prevent overlapping runs:
|
||||
|
||||
```cron
|
||||
*/5 * * * * flock -n /tmp/meister-eder-email.lock uv run python main.py
|
||||
```
|
||||
|
||||
`flock -n` exits immediately if a previous run is still in progress, so the script is always safe to schedule aggressively.
|
||||
|
||||
### Manually
|
||||
|
||||
```bash
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Running tests
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
All tests are unit tests — no network access or API keys required.
|
||||
|
||||
### Knowledge base
|
||||
|
||||
The agent answers parent questions from markdown files in the knowledge base directory. These files are designed to be edited directly by playgroup admins — no code changes needed to update fees, schedules, or policies.
|
||||
|
||||
### Adding a new AI provider
|
||||
|
||||
Set `AI_MODEL` to any [litellm-supported model string](https://docs.litellm.ai/docs/providers) and set the corresponding API key environment variable. No code changes required.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/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_MODEL litellm model string (default: anthropic/claude-opus-4-6)
|
||||
ANTHROPIC_API_KEY Required for Anthropic models
|
||||
OPENAI_API_KEY Required for OpenAI models
|
||||
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.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."""
|
||||
logger.info("AI model: %s", config.ai_model)
|
||||
|
||||
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,
|
||||
indoor_email=config.admin_email_indoor,
|
||||
outdoor_email=config.admin_email_outdoor,
|
||||
cc_emails=[e.strip() for e in config.admin_email_cc.split(",") if e.strip()],
|
||||
)
|
||||
|
||||
agent = EmailAgent(model=config.ai_model, 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", msg["from"])
|
||||
try:
|
||||
# Prepend email headers so the LLM can extract the
|
||||
# sender's address and subject (e.g. to fill in
|
||||
# parentGuardian.email automatically).
|
||||
message_text = (
|
||||
f"Von: {msg['from']}\n"
|
||||
f"Betreff: {msg['subject']}\n\n"
|
||||
f"{msg['body']}"
|
||||
)
|
||||
reply = agent.process_message(
|
||||
parent_email=msg["from"],
|
||||
message_text=message_text,
|
||||
inbound_message_id=msg["message_id"],
|
||||
)
|
||||
if reply:
|
||||
channel.send_reply(
|
||||
to=msg["from"],
|
||||
subject=msg["subject"],
|
||||
body=reply,
|
||||
in_reply_to=msg["message_id"],
|
||||
references=msg["references"],
|
||||
quoted_text=msg["raw_body"],
|
||||
quoted_from=msg["from"],
|
||||
)
|
||||
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,29 @@
|
||||
[project]
|
||||
name = "meister-eder"
|
||||
version = "0.1.0"
|
||||
description = "AI-powered conversational registration agent for Spielgruppe Pumuckl"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
# LLM access — supports any provider (Anthropic, OpenAI, Gemini, …)
|
||||
"litellm>=1.0.0",
|
||||
# Configuration
|
||||
"python-dotenv>=1.0.0",
|
||||
# Registration schema validation
|
||||
"jsonschema>=4.23.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
meister-eder = "main:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src"]
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-mock>=3.14.0",
|
||||
]
|
||||
@@ -0,0 +1,270 @@
|
||||
"""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, RegistrationData
|
||||
from .. import llm
|
||||
from ..knowledge_base.loader import KnowledgeBase
|
||||
from ..storage.json_store import ConversationStore, normalize_email, _diff_registrations
|
||||
from ..notifications.notifier import AdminNotifier
|
||||
from .prompts import build_system_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailAgent:
|
||||
"""Processes one inbound email and returns the agent's reply text.
|
||||
|
||||
Conversations are identified by the sender's normalized email address, so
|
||||
a parent who composes a fresh email (instead of replying) continues their
|
||||
existing conversation seamlessly.
|
||||
|
||||
All business logic lives here; channel I/O is handled by the caller.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
kb: KnowledgeBase,
|
||||
store: ConversationStore,
|
||||
notifier: AdminNotifier,
|
||||
) -> None:
|
||||
self._model = model
|
||||
self._kb = kb
|
||||
self._store = store
|
||||
self._notifier = notifier
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def process_message(
|
||||
self,
|
||||
parent_email: str,
|
||||
message_text: str,
|
||||
inbound_message_id: str = "",
|
||||
) -> str:
|
||||
"""Process one inbound message and return the reply text.
|
||||
|
||||
Args:
|
||||
parent_email: Sender email address — used as conversation key.
|
||||
message_text: Stripped plain-text body of the inbound email.
|
||||
inbound_message_id: Message-ID of the inbound email (stored for
|
||||
reply threading headers; not used for conversation matching).
|
||||
|
||||
Returns:
|
||||
Reply text to send back to the parent.
|
||||
"""
|
||||
email_key = normalize_email(parent_email)
|
||||
|
||||
# Load or create conversation state — keyed by email address
|
||||
state = self._store.load(email_key)
|
||||
if state is None:
|
||||
state = ConversationState(
|
||||
conversation_id=email_key,
|
||||
parent_email=email_key,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
state.last_activity = now
|
||||
if inbound_message_id:
|
||||
state.last_inbound_message_id = inbound_message_id
|
||||
|
||||
# Append the user's message to history
|
||||
state.messages.append(ChatMessage(role="user", content=message_text))
|
||||
|
||||
# Route to the appropriate handler
|
||||
if state.completed:
|
||||
reply_text = self._handle_post_completion(state)
|
||||
else:
|
||||
reply_text = self._handle_registration(state)
|
||||
|
||||
# Record the assistant reply and persist
|
||||
state.messages.append(ChatMessage(role="assistant", content=reply_text))
|
||||
state.updated_at = now
|
||||
self._store.save(state)
|
||||
|
||||
return reply_text
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Registration flow
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_registration(self, state: ConversationState) -> str:
|
||||
"""Drive the in-progress registration conversation."""
|
||||
system = build_system_prompt(self._kb, state)
|
||||
|
||||
try:
|
||||
content = llm.complete(self._model, system, state.messages)
|
||||
parsed = self._parse_llm_response(content)
|
||||
except Exception:
|
||||
logger.exception("LLM call failed for %s", state.conversation_id)
|
||||
return self._fallback_message(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)
|
||||
|
||||
self._apply_updates(state, updates)
|
||||
state.flow_step = next_step
|
||||
state.language = language
|
||||
|
||||
if is_complete and not state.completed:
|
||||
state.completed = True
|
||||
email_key, version = self._store.save_registration(state)
|
||||
try:
|
||||
self._notifier.notify_admin(
|
||||
registration=state.registration,
|
||||
registration_id=email_key,
|
||||
version=version,
|
||||
conversation_id=state.conversation_id,
|
||||
channel="email",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send admin notification for %s", email_key)
|
||||
logger.info("Registration complete for %s", state.conversation_id)
|
||||
|
||||
return reply_text
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Post-completion flow
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_post_completion(self, state: ConversationState) -> str:
|
||||
"""Handle messages received after a registration is already complete."""
|
||||
system = build_system_prompt(self._kb, state)
|
||||
|
||||
try:
|
||||
content = llm.complete(self._model, system, state.messages)
|
||||
parsed = self._parse_llm_response(content)
|
||||
except Exception:
|
||||
logger.exception("LLM call failed (post-completion) for %s", state.conversation_id)
|
||||
return self._fallback_message(state)
|
||||
|
||||
reply_text: str = parsed.get("reply", "")
|
||||
intent: str = parsed.get("intent", "question")
|
||||
updates: dict = parsed.get("updates", {}) or {}
|
||||
language: str = parsed.get("language", state.language)
|
||||
state.language = language
|
||||
|
||||
if intent == "update" and any(v is not None for v in updates.values()):
|
||||
self._handle_registration_update(state, updates)
|
||||
elif intent == "new_child":
|
||||
# Reset registration so a fresh flow begins in the next message
|
||||
state.registration = RegistrationData()
|
||||
state.completed = False
|
||||
state.flow_step = "child_name"
|
||||
logger.info("Starting new child registration for %s", state.conversation_id)
|
||||
|
||||
return reply_text
|
||||
|
||||
def _handle_registration_update(self, state: ConversationState, updates: dict) -> None:
|
||||
"""Apply field updates, version the record, and notify the admin."""
|
||||
old_data = state.registration.to_dict()
|
||||
self._apply_updates(state, updates)
|
||||
new_data = state.registration.to_dict()
|
||||
|
||||
change_summary = _diff_registrations(old_data, new_data)
|
||||
if not change_summary:
|
||||
return # Nothing actually changed
|
||||
|
||||
email_key, version = self._store.save_registration_version(state, change_summary)
|
||||
try:
|
||||
self._notifier.notify_registration_update(
|
||||
registration=state.registration,
|
||||
registration_id=email_key,
|
||||
version=version,
|
||||
change_summary=change_summary,
|
||||
conversation_id=state.conversation_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send update notification for %s", email_key)
|
||||
logger.info("Registration updated to v%d for %s", version, state.conversation_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_llm_response(self, content: str) -> dict:
|
||||
"""Extract the JSON payload from the LLM's raw output."""
|
||||
text = content.strip()
|
||||
|
||||
fence_match = re.match(r"^```(?:json)?\s*\n(.*?)\n```\s*$", text, re.DOTALL)
|
||||
if fence_match:
|
||||
text = fence_match.group(1).strip()
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
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,
|
||||
"intent": "question",
|
||||
"updates": {},
|
||||
"next_step": "greeting",
|
||||
"registration_complete": False,
|
||||
"language": "de",
|
||||
}
|
||||
|
||||
def _fallback_message(self, state: ConversationState) -> str:
|
||||
if state.language == "en":
|
||||
return (
|
||||
"I'm sorry, I'm having a technical issue right now. "
|
||||
"Please try again in a moment or contact us directly."
|
||||
)
|
||||
return (
|
||||
"Entschuldigung, ich habe gerade ein technisches Problem. "
|
||||
"Bitte versuche es gleich nochmal oder kontaktiere uns direkt."
|
||||
)
|
||||
|
||||
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,236 @@
|
||||
"""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 registration flow.
|
||||
# ---------------------------------------------------------------------------
|
||||
STEP_DESCRIPTIONS = {
|
||||
"greeting": (
|
||||
"Greet the parent warmly and detect their intent (registration vs. questions). "
|
||||
"In this first message, explicitly tell them they can write in any human language "
|
||||
"and you will reply in the same language. "
|
||||
"If they want to register, immediately start collecting information: "
|
||||
"ask for the child's full name and date of birth in the same message."
|
||||
),
|
||||
"child_name": "Ask for the child's full name.",
|
||||
"child_dob": (
|
||||
"Ask for the child's date of birth. "
|
||||
"Validate age: indoor requires ≥2 years, outdoor requires ≥2.5 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.",
|
||||
}
|
||||
|
||||
_PERSONALITY = """## 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 to German if unclear
|
||||
- Collect all information for the current step — and any clearly related follow-up steps — in a single message; weave the questions naturally into flowing sentences, never as a form or bullet list
|
||||
- If the parent's reply leaves some of your questions unanswered, explicitly re-ask every unanswered question before moving on — never silently skip an open question
|
||||
- Be patient and understanding; never make parents feel they made a mistake"""
|
||||
|
||||
_CONTACTS = """## 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"""
|
||||
|
||||
_PLAYGROUP_DETAILS = """## 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"""
|
||||
|
||||
_REGISTRATION_RESPONSE_FORMAT = """## 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.
|
||||
- Always store free-text field values (especially `child.specialNeeds`) **in German** in `updates`, translating from the parent's language if necessary. Use "Keine" if the parent indicates no special needs.
|
||||
- The `reply` field must be natural, friendly, conversational text — not JSON and not a list of fields.
|
||||
- The `reply` field must be plain text only. No markdown: no **bold**, no _italic_, no # headers, no bullet points with - or *, no backticks. Use plain sentences and line breaks only."""
|
||||
|
||||
_POST_COMPLETION_RESPONSE_FORMAT = """## 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)",
|
||||
"intent": "question",
|
||||
"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
|
||||
}},
|
||||
"language": "de"
|
||||
}}
|
||||
```
|
||||
|
||||
`intent` values:
|
||||
- `"question"` — parent is asking about fees, schedule, policies, etc. → answer from knowledge base; set `updates` to all nulls.
|
||||
- `"update"` — parent explicitly wants to change their registration data → collect the new values in `updates`, confirm the change in `reply`.
|
||||
- `"new_child"` — parent wants to register an additional child → treat as a new registration; begin from step child_name.
|
||||
|
||||
Rules:
|
||||
- Only set fields in `updates` when intent is `"update"` AND the parent has provided the new value in this message.
|
||||
- Use `null` for all `updates` fields when intent is `"question"` or `"new_child"`.
|
||||
- `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.
|
||||
- The `reply` field must be plain text only. No markdown: no **bold**, no _italic_, no # headers, no bullet points with - or *, no backticks. Use plain sentences and line breaks only.
|
||||
- If you are unsure of the parent's intent, ask a clarifying question and set intent to `"question"`."""
|
||||
|
||||
|
||||
def build_system_prompt(kb: KnowledgeBase, state: ConversationState) -> str:
|
||||
"""Return the system prompt appropriate for the current conversation state."""
|
||||
if state.completed:
|
||||
return _build_post_completion_prompt(kb, state)
|
||||
return _build_registration_prompt(kb, state)
|
||||
|
||||
|
||||
def _build_registration_prompt(kb: KnowledgeBase, state: ConversationState) -> str:
|
||||
"""System prompt for an in-progress registration conversation."""
|
||||
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.
|
||||
|
||||
{_PERSONALITY}
|
||||
|
||||
## 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 yrs, outdoor ≥2.5 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}
|
||||
|
||||
{_CONTACTS}
|
||||
|
||||
---
|
||||
|
||||
{_REGISTRATION_RESPONSE_FORMAT}
|
||||
"""
|
||||
|
||||
|
||||
def _build_post_completion_prompt(kb: KnowledgeBase, state: ConversationState) -> str:
|
||||
"""System prompt for a conversation where registration is already complete."""
|
||||
kb_content = kb.get_all()
|
||||
reg_json = json.dumps(state.registration.to_dict(), ensure_ascii=False, indent=2)
|
||||
child_name = state.registration.child.full_name or "their child"
|
||||
|
||||
return f"""You are the registration assistant for Spielgruppe Pumuckl, run by Familienverein Fällanden in Fällanden, Switzerland.
|
||||
|
||||
{_PERSONALITY}
|
||||
|
||||
## Context: Registration Already Complete
|
||||
This parent has already completed registration for {child_name}. Their current registration data is:
|
||||
|
||||
```json
|
||||
{reg_json}
|
||||
```
|
||||
|
||||
The parent is contacting you again. Your job is to:
|
||||
1. Detect their **intent**: are they asking a question, requesting a change to their registration, or registering another child?
|
||||
2. Respond helpfully and warmly.
|
||||
3. If they want to **update** their registration, confirm exactly what they want to change and include the new values in `updates`.
|
||||
4. If they are asking a **question**, answer from the knowledge base.
|
||||
5. If they want to register a **new child**, let them know you'll start a new registration and guide them from the beginning.
|
||||
|
||||
When handling update requests:
|
||||
- Confirm the change explicitly before reporting it as done ("So you'd like to change X to Y — is that right?").
|
||||
- Once confirmed, include the new value in `updates` so it can be saved.
|
||||
- Let the parent know the playgroup team will be informed of the change.
|
||||
|
||||
## Knowledge Base
|
||||
{kb_content}
|
||||
|
||||
{_PLAYGROUP_DETAILS}
|
||||
|
||||
{_CONTACTS}
|
||||
|
||||
---
|
||||
|
||||
{_POST_COMPLETION_RESPONSE_FORMAT}
|
||||
"""
|
||||
@@ -0,0 +1,307 @@
|
||||
"""IMAP / SMTP email channel adapter.
|
||||
|
||||
Handles:
|
||||
- Polling the inbox for unread messages (IMAP)
|
||||
- Conversation matching by sender email address (NOT by thread headers)
|
||||
- Sending reply emails (SMTP) with proper threading headers for email clients
|
||||
- Stripping quoted reply text so the agent only sees the new content
|
||||
|
||||
Threading headers (Message-ID, In-Reply-To, References) are preserved for
|
||||
outbound replies so messages appear threaded in Gmail/Outlook, but they are
|
||||
NOT used to identify which conversation an incoming message belongs to.
|
||||
Conversation matching is exclusively by normalized sender email address.
|
||||
"""
|
||||
|
||||
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}>"
|
||||
|
||||
|
||||
def _build_quoted_block(original_text: str, from_addr: str) -> str:
|
||||
"""Format original_text as a standard email quote block.
|
||||
|
||||
Produces the classic:
|
||||
|
||||
On <date>, <from> wrote:
|
||||
> line 1
|
||||
> line 2
|
||||
"""
|
||||
date_str = time.strftime("%a, %d %b %Y %H:%M", time.localtime())
|
||||
header = f"Am {date_str} schrieb {from_addr}:"
|
||||
quoted_lines = "\n".join(
|
||||
f"> {line}" for line in original_text.splitlines()
|
||||
)
|
||||
return f"\n\n{header}\n{quoted_lines}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 (use this as conversation key)
|
||||
subject — decoded subject line
|
||||
message_id — Message-ID of this inbound email (for reply threading)
|
||||
in_reply_to — In-Reply-To header (for reply threading, may be empty)
|
||||
references — References header (for reply threading, may be empty)
|
||||
body — stripped plain-text body (quoted text removed)
|
||||
|
||||
Note: ``thread_id`` is no longer returned. Conversation matching is done
|
||||
by ``from`` (sender email address), not by threading headers.
|
||||
"""
|
||||
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()
|
||||
|
||||
raw_body = _extract_text(msg)
|
||||
body = _strip_quoted_text(raw_body)
|
||||
|
||||
if not body.strip():
|
||||
imap.store(num, "+FLAGS", "\\Seen")
|
||||
continue
|
||||
|
||||
messages.append(
|
||||
{
|
||||
"from": from_addr,
|
||||
"subject": subject,
|
||||
"message_id": message_id,
|
||||
"in_reply_to": in_reply_to,
|
||||
"references": references,
|
||||
"body": body,
|
||||
"raw_body": raw_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 = "",
|
||||
quoted_text: str = "",
|
||||
quoted_from: str = "",
|
||||
) -> str:
|
||||
"""Send an email reply.
|
||||
|
||||
If quoted_text is provided it is appended to body as a standard
|
||||
``> ``-prefixed quote block so parents can see what they wrote.
|
||||
|
||||
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)
|
||||
|
||||
# Append quoted original message
|
||||
if quoted_text.strip():
|
||||
body = body + _build_quoted_block(quoted_text, quoted_from or to)
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""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 model — litellm format, e.g. "anthropic/claude-opus-4-6" or "openai/gpt-4o".
|
||||
# The matching API key must be set as an env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
|
||||
ai_model: str = "anthropic/claude-opus-4-6"
|
||||
|
||||
# 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 = ""
|
||||
|
||||
# Admin notification routing.
|
||||
# Each leader receives mail only when a day in their group is booked.
|
||||
# For testing, point all three to your own address.
|
||||
admin_email_indoor: str = "" # Indoor leader (Andrea Sigrist) — To when indoor booked
|
||||
admin_email_outdoor: str = "" # Outdoor leader (Barbara Gross) — To when outdoor booked
|
||||
admin_email_cc: str = "" # Always Cc'd (Markus Graf / admin); comma-separated if multiple
|
||||
|
||||
# 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_model=os.getenv("AI_MODEL", "anthropic/claude-opus-4-6"),
|
||||
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", ""),
|
||||
admin_email_indoor=os.getenv("ADMIN_EMAIL_INDOOR", ""),
|
||||
admin_email_outdoor=os.getenv("ADMIN_EMAIL_OUTDOOR", ""),
|
||||
admin_email_cc=os.getenv("ADMIN_EMAIL_CC", ""),
|
||||
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()
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""LLM completion via litellm — supports any provider with a single call."""
|
||||
|
||||
import litellm
|
||||
|
||||
|
||||
def complete(model: str, system: str, messages: list) -> str:
|
||||
"""Call any LLM and return the response text.
|
||||
|
||||
Args:
|
||||
model: litellm model string, e.g. "anthropic/claude-opus-4-6" or
|
||||
"openai/gpt-4o". The matching API key must be set as an
|
||||
environment variable (ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
|
||||
system: System prompt text.
|
||||
messages: List of objects with .role and .content attributes.
|
||||
|
||||
Returns:
|
||||
The model's reply as a plain string.
|
||||
"""
|
||||
api_messages = [{"role": "system", "content": system}]
|
||||
api_messages += [{"role": m.role, "content": m.content} for m in messages]
|
||||
response = litellm.completion(model=model, messages=api_messages, max_tokens=2048)
|
||||
return response.choices[0].message.content
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Conversation state model — persisted per sender email address."""
|
||||
|
||||
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 # normalized sender email address
|
||||
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
|
||||
# Most recent inbound Message-ID — used for reply threading headers only,
|
||||
# NOT for conversation matching (which is always by email address).
|
||||
last_inbound_message_id: str = ""
|
||||
|
||||
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,
|
||||
"last_inbound_message_id": self.last_inbound_message_id,
|
||||
}
|
||||
|
||||
@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)
|
||||
state.last_inbound_message_id = data.get("last_inbound_message_id", "")
|
||||
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,343 @@
|
||||
"""Admin email notifications — new registrations and registration updates."""
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
class AdminNotifier:
|
||||
"""Sends formatted admin notification emails.
|
||||
|
||||
Handles two notification types:
|
||||
- New registration completed → "New Registration: …"
|
||||
- Existing registration updated → "Registration Updated: …" (with field diff)
|
||||
|
||||
When *smtp_host* is empty the notifier logs and skips sending (dev mode).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
use_tls: bool = True,
|
||||
from_email: str = "",
|
||||
indoor_email: str = "",
|
||||
outdoor_email: str = "",
|
||||
cc_emails: list[str] | None = None,
|
||||
) -> 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
|
||||
self._indoor_email = indoor_email
|
||||
self._outdoor_email = outdoor_email
|
||||
self._cc_emails: list[str] = cc_emails or []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def notify_admin(
|
||||
self,
|
||||
registration: RegistrationData,
|
||||
registration_id: str,
|
||||
version: int,
|
||||
conversation_id: str,
|
||||
channel: str,
|
||||
) -> None:
|
||||
"""Send notification for a newly completed registration (version 1)."""
|
||||
types = registration.booking.playgroup_types
|
||||
to_addresses = self._recipients_for(types)
|
||||
if not to_addresses:
|
||||
logger.warning(
|
||||
"No leader email configured for types %s — new-registration notification skipped.",
|
||||
types,
|
||||
)
|
||||
return
|
||||
|
||||
subject = (
|
||||
f"Neue Anmeldung: {registration.child.full_name} "
|
||||
f"– {self._format_types(types)}"
|
||||
)
|
||||
body = self._build_new_body(registration, registration_id, version, channel)
|
||||
|
||||
self._send(
|
||||
to=to_addresses,
|
||||
cc=self._cc_emails,
|
||||
subject=subject,
|
||||
body=body,
|
||||
reply_to=registration.parent_guardian.email or "",
|
||||
)
|
||||
|
||||
def notify_registration_update(
|
||||
self,
|
||||
registration: RegistrationData,
|
||||
registration_id: str,
|
||||
version: int,
|
||||
change_summary: dict,
|
||||
conversation_id: str,
|
||||
) -> None:
|
||||
"""Send notification when an existing registration is updated."""
|
||||
types = registration.booking.playgroup_types
|
||||
to_addresses = self._recipients_for(types)
|
||||
if not to_addresses:
|
||||
logger.warning(
|
||||
"No leader email configured for types %s — update notification skipped.",
|
||||
types,
|
||||
)
|
||||
return
|
||||
|
||||
subject = f"Anmeldung aktualisiert: {registration.child.full_name}"
|
||||
body = self._build_update_body(registration, registration_id, version, change_summary)
|
||||
|
||||
self._send(
|
||||
to=to_addresses,
|
||||
cc=self._cc_emails,
|
||||
subject=subject,
|
||||
body=body,
|
||||
reply_to=registration.parent_guardian.email or "",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Routing helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _recipients_for(self, types: list[str]) -> list[str]:
|
||||
"""Return To addresses based on which playgroup types are booked."""
|
||||
recipients = []
|
||||
if "indoor" in types and self._indoor_email:
|
||||
recipients.append(self._indoor_email)
|
||||
if "outdoor" in types and self._outdoor_email:
|
||||
recipients.append(self._outdoor_email)
|
||||
return recipients
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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 "Innen- und Waldspielgruppe"
|
||||
if has_indoor:
|
||||
return "Innenspielgruppe"
|
||||
if has_outdoor:
|
||||
return "Waldspielgruppe"
|
||||
return "Spielgruppe"
|
||||
|
||||
@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} Jahre, {months} Monate"
|
||||
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": "Montag", "wednesday": "Mittwoch", "thursday": "Donnerstag"}
|
||||
type_map = {"indoor": "Innenspielgruppe", "outdoor": "Waldspielgruppe"}
|
||||
return ", ".join(
|
||||
f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})"
|
||||
for d in registration.booking.selected_days
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_change_summary(change_summary: dict) -> str:
|
||||
"""Render field changes as a human-readable list."""
|
||||
lines = []
|
||||
for field_path, values in sorted(change_summary.items()):
|
||||
old_val, new_val = values["old"], values["new"]
|
||||
lines.append(f" {field_path}:")
|
||||
lines.append(f" Alt: {old_val}")
|
||||
lines.append(f" Neu: {new_val}")
|
||||
return "\n".join(lines) if lines else " (keine Änderungen erkannt)"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Email body builders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_new_body(
|
||||
self,
|
||||
registration: RegistrationData,
|
||||
registration_id: str,
|
||||
version: int,
|
||||
channel: str,
|
||||
) -> str:
|
||||
now = datetime.utcnow()
|
||||
pg = registration.parent_guardian
|
||||
ec = registration.emergency_contact
|
||||
channel_de = {"email": "E-Mail", "chat": "Chat"}.get(channel.lower(), channel.title())
|
||||
|
||||
return (
|
||||
"===============================================\n"
|
||||
"NEUE SPIELGRUPPEN-ANMELDUNG\n"
|
||||
"===============================================\n"
|
||||
"\n"
|
||||
f"Eingereicht: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n"
|
||||
f"Kanal: {channel_de}\n"
|
||||
f"Anmelde-ID: {registration_id} (Version {version})\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"ANGABEN ZUM KIND\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Name: {registration.child.full_name}\n"
|
||||
f"Geburtsdatum: {self._format_dob(registration.child.date_of_birth or '')} "
|
||||
f"(Alter: {self._calculate_age(registration.child.date_of_birth or '')})\n"
|
||||
f"Bes. Bedürfnisse: {registration.child.special_needs or 'Keine'}\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"SPIELGRUPPEN-AUSWAHL\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Art: {self._format_types(registration.booking.playgroup_types)}\n"
|
||||
f"Tage: {self._format_days(registration)}\n"
|
||||
"\n"
|
||||
f"Monatlicher Beitrag: {self._calculate_monthly_fee(registration)}\n"
|
||||
"(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung)\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"ELTERN / ERZIEHUNGSBERECHTIGTE\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Name: {pg.full_name}\n"
|
||||
f"Adresse: {pg.street_address}\n"
|
||||
f" {pg.postal_code} {pg.city}\n"
|
||||
f"Telefon: {pg.phone}\n"
|
||||
f"E-Mail: {pg.email}\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"NOTFALLKONTAKT\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Name: {ec.full_name}\n"
|
||||
f"Telefon: {ec.phone}\n"
|
||||
"\n"
|
||||
"===============================================\n"
|
||||
"\n"
|
||||
"Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht.\n"
|
||||
)
|
||||
|
||||
def _build_update_body(
|
||||
self,
|
||||
registration: RegistrationData,
|
||||
registration_id: str,
|
||||
version: int,
|
||||
change_summary: dict,
|
||||
) -> str:
|
||||
now = datetime.utcnow()
|
||||
pg = registration.parent_guardian
|
||||
|
||||
return (
|
||||
"===============================================\n"
|
||||
"ANMELDUNGS-AKTUALISIERUNG\n"
|
||||
"===============================================\n"
|
||||
"\n"
|
||||
f"Aktualisiert: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n"
|
||||
f"Anmelde-ID: {registration_id} (Version {version})\n"
|
||||
f"Kind: {registration.child.full_name}\n"
|
||||
f"Eltern-E-Mail: {pg.email}\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"WAS HAT SICH GEÄNDERT\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"{self._format_change_summary(change_summary)}\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"AKTUELLE ANMELDUNG (nach Aktualisierung)\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Spielgruppe: {self._format_types(registration.booking.playgroup_types)}\n"
|
||||
f"Tage: {self._format_days(registration)}\n"
|
||||
f"Monatl. Beitrag: {self._calculate_monthly_fee(registration)}\n"
|
||||
"\n"
|
||||
f"Elternteil: {pg.full_name}\n"
|
||||
f"Adresse: {pg.street_address}, {pg.postal_code} {pg.city}\n"
|
||||
f"Telefon: {pg.phone}\n"
|
||||
"\n"
|
||||
"===============================================\n"
|
||||
"\n"
|
||||
"Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht.\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("Notification sent to %s", all_recipients)
|
||||
except Exception:
|
||||
logger.exception("Failed to send notification to %s", all_recipients)
|
||||
@@ -0,0 +1,270 @@
|
||||
"""File-based JSON storage for conversations and completed registrations.
|
||||
|
||||
Conversations are keyed by the sender's normalized email address so that a
|
||||
parent who sends a new email (instead of replying) continues the same
|
||||
conversation. Completed registrations are stored with versioning so every
|
||||
update produces a new numbered version rather than overwriting the original.
|
||||
|
||||
Directory layout::
|
||||
|
||||
data/
|
||||
conversations/
|
||||
parent_at_example.com.json # one file per unique sender address
|
||||
registrations/
|
||||
parent_at_example.com/
|
||||
v1_2024-09-15T10-30-00Z.json # initial registration
|
||||
v2_2024-10-03T14-22-10Z.json # updated registration
|
||||
current.json # copy of the latest version
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ..models.conversation import ConversationState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
"""Return a canonical email address for matching and storage.
|
||||
|
||||
Lowercases and strips whitespace. ``Maria@Example.com`` → ``maria@example.com``.
|
||||
"""
|
||||
return email.strip().lower()
|
||||
|
||||
|
||||
def _email_to_filename(email: str) -> str:
|
||||
"""Convert a normalized email address to a safe filename stem.
|
||||
|
||||
``parent@example.com`` → ``parent_at_example.com``
|
||||
"""
|
||||
return normalize_email(email).replace("@", "_at_")
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _timestamp_for_filename() -> str:
|
||||
"""Return a filesystem-safe ISO-8601-ish timestamp (no colons)."""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
|
||||
|
||||
|
||||
def _diff_registrations(old: dict, new: dict) -> dict[str, tuple]:
|
||||
"""Return a mapping of field_path → (old_value, new_value) for changed fields."""
|
||||
changes: dict[str, tuple] = {}
|
||||
|
||||
def _flatten(d: dict, prefix: str = "") -> dict:
|
||||
out: dict = {}
|
||||
for k, v in d.items():
|
||||
key = f"{prefix}.{k}" if prefix else k
|
||||
if isinstance(v, dict):
|
||||
out.update(_flatten(v, key))
|
||||
else:
|
||||
out[key] = v
|
||||
return out
|
||||
|
||||
old_flat = _flatten(old)
|
||||
new_flat = _flatten(new)
|
||||
|
||||
all_keys = set(old_flat) | set(new_flat)
|
||||
for key in sorted(all_keys):
|
||||
o = old_flat.get(key)
|
||||
n = new_flat.get(key)
|
||||
if o != n:
|
||||
changes[key] = (o, n)
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConversationStore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ConversationStore:
|
||||
"""Persists ConversationState and registration versions on disk."""
|
||||
|
||||
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 — keyed by normalized email address
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def load(self, email_address: str) -> ConversationState | None:
|
||||
"""Load a conversation by sender email address. Returns None if not found."""
|
||||
path = self._conversation_path(email_address)
|
||||
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 for %s", email_address)
|
||||
return None
|
||||
|
||||
# Alias for clarity in call sites that emphasise the email-lookup semantic
|
||||
find_by_email = load
|
||||
|
||||
def save(self, state: ConversationState) -> None:
|
||||
"""Persist a conversation state to disk."""
|
||||
path = self._conversation_path(state.parent_email or 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 for %s", state.conversation_id)
|
||||
|
||||
def delete(self, email_address: str) -> None:
|
||||
"""Remove a conversation file."""
|
||||
path = self._conversation_path(email_address)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
def list_incomplete(self) -> list[ConversationState]:
|
||||
"""Return all conversations that have not yet been 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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Versioned registration storage
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def save_registration(self, state: ConversationState) -> tuple[str, int]:
|
||||
"""Store the first version of a completed registration.
|
||||
|
||||
Returns:
|
||||
Tuple of (registration_dir_key, version_number).
|
||||
"""
|
||||
email_key = _email_to_filename(state.parent_email or state.conversation_id)
|
||||
reg_dir = self._registrations_dir / email_key
|
||||
reg_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
version = 1
|
||||
record = self._build_record(state.registration.to_dict(), version, state)
|
||||
|
||||
self._write_version(reg_dir, version, record)
|
||||
logger.info("Saved initial registration v%d for %s", version, email_key)
|
||||
return email_key, version
|
||||
|
||||
def save_registration_version(
|
||||
self,
|
||||
state: ConversationState,
|
||||
change_summary: dict[str, tuple],
|
||||
) -> tuple[str, int]:
|
||||
"""Store an updated registration as a new version.
|
||||
|
||||
Args:
|
||||
state: Current conversation state with updated registration data.
|
||||
change_summary: Dict of field_path → (old_value, new_value).
|
||||
|
||||
Returns:
|
||||
Tuple of (registration_dir_key, new_version_number).
|
||||
"""
|
||||
email_key = _email_to_filename(state.parent_email or state.conversation_id)
|
||||
reg_dir = self._registrations_dir / email_key
|
||||
reg_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
history = self.get_registration_history(state.parent_email or state.conversation_id)
|
||||
version = len(history) + 1
|
||||
|
||||
record = self._build_record(state.registration.to_dict(), version, state)
|
||||
record["metadata"]["changeSummary"] = {
|
||||
k: {"old": v[0], "new": v[1]} for k, v in change_summary.items()
|
||||
}
|
||||
|
||||
self._write_version(reg_dir, version, record)
|
||||
logger.info("Saved registration v%d for %s", version, email_key)
|
||||
return email_key, version
|
||||
|
||||
def get_registration_history(self, email_address: str) -> list[dict]:
|
||||
"""Return all registration versions for an email address, oldest first."""
|
||||
email_key = _email_to_filename(email_address)
|
||||
reg_dir = self._registrations_dir / email_key
|
||||
if not reg_dir.exists():
|
||||
return []
|
||||
|
||||
records: list[dict] = []
|
||||
for path in sorted(reg_dir.glob("v*.json")):
|
||||
try:
|
||||
records.append(json.loads(path.read_text(encoding="utf-8")))
|
||||
except Exception:
|
||||
logger.warning("Could not read registration version %s", path)
|
||||
return records
|
||||
|
||||
def get_current_registration(self, email_address: str) -> dict | None:
|
||||
"""Return the latest registration version for an email address."""
|
||||
email_key = _email_to_filename(email_address)
|
||||
current_path = self._registrations_dir / email_key / "current.json"
|
||||
if not current_path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(current_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
logger.exception("Failed to load current registration for %s", email_address)
|
||||
return None
|
||||
|
||||
def list_registrations(self) -> list[dict]:
|
||||
"""Return the current (latest) registration for every known email address."""
|
||||
records: list[dict] = []
|
||||
for email_dir in sorted(self._registrations_dir.iterdir()):
|
||||
if not email_dir.is_dir():
|
||||
continue
|
||||
current = email_dir / "current.json"
|
||||
if current.exists():
|
||||
try:
|
||||
records.append(json.loads(current.read_text(encoding="utf-8")))
|
||||
except Exception:
|
||||
logger.warning("Could not read %s", current)
|
||||
return records
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _conversation_path(self, email_address: str) -> Path:
|
||||
return self._conversations_dir / f"{_email_to_filename(email_address)}.json"
|
||||
|
||||
@staticmethod
|
||||
def _build_record(reg_data: dict, version: int, state: ConversationState) -> dict:
|
||||
record = dict(reg_data)
|
||||
record["metadata"] = {
|
||||
"version": version,
|
||||
"submittedAt": _now(),
|
||||
"channel": "email",
|
||||
"parentEmail": state.parent_email,
|
||||
"conversationId": state.conversation_id,
|
||||
}
|
||||
return record
|
||||
|
||||
@staticmethod
|
||||
def _write_version(reg_dir: Path, version: int, record: dict) -> None:
|
||||
ts = _timestamp_for_filename()
|
||||
version_path = reg_dir / f"v{version}_{ts}.json"
|
||||
version_path.write_text(
|
||||
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
# Keep current.json as a plain copy of the latest version
|
||||
(reg_dir / "current.json").write_text(
|
||||
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Shared pytest fixtures."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models.conversation import ConversationState, ChatMessage
|
||||
from src.models.registration import (
|
||||
RegistrationData,
|
||||
ChildInfo,
|
||||
ParentGuardian,
|
||||
EmergencyContact,
|
||||
Booking,
|
||||
BookingDay,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def complete_registration() -> RegistrationData:
|
||||
"""A fully populated RegistrationData that passes is_complete()."""
|
||||
return RegistrationData(
|
||||
child=ChildInfo(
|
||||
full_name="Lena Muster",
|
||||
date_of_birth="2022-03-15",
|
||||
special_needs="None",
|
||||
),
|
||||
parent_guardian=ParentGuardian(
|
||||
full_name="Anna Muster",
|
||||
street_address="Hauptstrasse 1",
|
||||
postal_code="8117",
|
||||
city="Fällanden",
|
||||
phone="044 123 45 67",
|
||||
email="anna.muster@example.com",
|
||||
),
|
||||
emergency_contact=EmergencyContact(
|
||||
full_name="Hans Muster",
|
||||
phone="079 123 45 67",
|
||||
),
|
||||
booking=Booking(
|
||||
playgroup_types=["indoor"],
|
||||
selected_days=[BookingDay(day="monday", type="indoor")],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_state() -> ConversationState:
|
||||
"""A brand-new ConversationState for a parent email."""
|
||||
return ConversationState(
|
||||
conversation_id="anna.muster@example.com",
|
||||
parent_email="anna.muster@example.com",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_with_messages(fresh_state) -> ConversationState:
|
||||
"""A ConversationState with a couple of chat turns."""
|
||||
fresh_state.messages = [
|
||||
ChatMessage(role="user", content="Hallo, ich möchte mein Kind anmelden."),
|
||||
ChatMessage(role="assistant", content="Hallo! Wie heisst dein Kind?"),
|
||||
ChatMessage(role="user", content="Lena Muster"),
|
||||
]
|
||||
return fresh_state
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Tests for EmailAgent — the conversation orchestrator."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.agent.core import EmailAgent
|
||||
from src.models.conversation import ConversationState
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_LLM_REPLY = json.dumps({
|
||||
"reply": "Wie heisst dein Kind?",
|
||||
"updates": {},
|
||||
"next_step": "child_name",
|
||||
"registration_complete": False,
|
||||
"language": "de",
|
||||
})
|
||||
|
||||
COMPLETION_LLM_REPLY = json.dumps({
|
||||
"reply": "Vielen Dank, dein Kind ist angemeldet!",
|
||||
"updates": {},
|
||||
"next_step": "done",
|
||||
"registration_complete": True,
|
||||
"language": "de",
|
||||
})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_kb():
|
||||
kb = MagicMock()
|
||||
kb.get_all.return_value = "# FAQ\nSome knowledge base content."
|
||||
return kb
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_store():
|
||||
store = MagicMock()
|
||||
store.load.return_value = None # no prior conversation by default
|
||||
store.save_registration.return_value = ("anna.muster@example.com", 1)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_notifier():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent(mock_kb, mock_store, mock_notifier):
|
||||
return EmailAgent(
|
||||
model="anthropic/claude-opus-4-6",
|
||||
kb=mock_kb,
|
||||
store=mock_store,
|
||||
notifier=mock_notifier,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process_message — new conversation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessMessageNewConversation:
|
||||
def test_creates_new_state_when_none_exists(self, agent, mock_store):
|
||||
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||
agent.process_message("anna.muster@example.com", "Hallo")
|
||||
|
||||
saved_state = mock_store.save.call_args[0][0]
|
||||
assert saved_state.conversation_id == "anna.muster@example.com"
|
||||
|
||||
def test_returns_llm_reply_text(self, agent):
|
||||
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||
reply = agent.process_message("anna.muster@example.com", "Hallo")
|
||||
|
||||
assert reply == "Wie heisst dein Kind?"
|
||||
|
||||
def test_user_message_added_to_history(self, agent, mock_store):
|
||||
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||
agent.process_message("anna.muster@example.com", "Hallo, ich möchte anmelden")
|
||||
|
||||
state = mock_store.save.call_args[0][0]
|
||||
assert any(m.role == "user" and "anmelden" in m.content for m in state.messages)
|
||||
|
||||
def test_assistant_reply_added_to_history(self, agent, mock_store):
|
||||
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||
agent.process_message("anna.muster@example.com", "Hallo")
|
||||
|
||||
state = mock_store.save.call_args[0][0]
|
||||
assert any(m.role == "assistant" for m in state.messages)
|
||||
|
||||
def test_normalizes_email_key(self, agent, mock_store):
|
||||
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||
agent.process_message("Anna.Muster@EXAMPLE.COM", "Hallo")
|
||||
|
||||
state = mock_store.save.call_args[0][0]
|
||||
assert state.conversation_id == "anna.muster@example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process_message — existing conversation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessMessageExistingConversation:
|
||||
def test_loads_existing_state(self, agent, mock_store, fresh_state):
|
||||
mock_store.load.return_value = fresh_state
|
||||
|
||||
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||
agent.process_message("anna.muster@example.com", "Lena")
|
||||
|
||||
mock_store.load.assert_called_once()
|
||||
|
||||
def test_flow_step_updated(self, agent, mock_store, fresh_state):
|
||||
mock_store.load.return_value = fresh_state
|
||||
|
||||
reply_with_step = json.dumps({
|
||||
"reply": "Wann ist Lena geboren?",
|
||||
"updates": {"child.fullName": "Lena"},
|
||||
"next_step": "child_dob",
|
||||
"registration_complete": False,
|
||||
"language": "de",
|
||||
})
|
||||
|
||||
with patch("src.llm.complete", return_value=reply_with_step):
|
||||
agent.process_message("anna.muster@example.com", "Lena")
|
||||
|
||||
state = mock_store.save.call_args[0][0]
|
||||
assert state.flow_step == "child_dob"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process_message — registration completion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistrationCompletion:
|
||||
def test_notifier_called_on_completion(self, agent, mock_store, mock_notifier, complete_registration):
|
||||
state = ConversationState(
|
||||
conversation_id="anna.muster@example.com",
|
||||
parent_email="anna.muster@example.com",
|
||||
)
|
||||
state.registration = complete_registration
|
||||
mock_store.load.return_value = state
|
||||
|
||||
with patch("src.llm.complete", return_value=COMPLETION_LLM_REPLY):
|
||||
agent.process_message("anna.muster@example.com", "Ja, alles korrekt")
|
||||
|
||||
mock_notifier.notify_admin.assert_called_once()
|
||||
|
||||
def test_state_marked_completed(self, agent, mock_store, complete_registration):
|
||||
state = ConversationState(
|
||||
conversation_id="anna.muster@example.com",
|
||||
parent_email="anna.muster@example.com",
|
||||
)
|
||||
state.registration = complete_registration
|
||||
mock_store.load.return_value = state
|
||||
|
||||
with patch("src.llm.complete", return_value=COMPLETION_LLM_REPLY):
|
||||
agent.process_message("anna.muster@example.com", "Ja")
|
||||
|
||||
saved = mock_store.save.call_args[0][0]
|
||||
assert saved.completed is True
|
||||
|
||||
def test_notifier_not_called_when_already_completed(self, agent, mock_store, mock_notifier, complete_registration):
|
||||
state = ConversationState(
|
||||
conversation_id="anna.muster@example.com",
|
||||
parent_email="anna.muster@example.com",
|
||||
)
|
||||
state.registration = complete_registration
|
||||
state.completed = True # already done
|
||||
mock_store.load.return_value = state
|
||||
|
||||
with patch("src.llm.complete", return_value=COMPLETION_LLM_REPLY):
|
||||
agent.process_message("anna.muster@example.com", "Noch eine Frage")
|
||||
|
||||
mock_notifier.notify_admin.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback on LLM error
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFallbackOnLLMError:
|
||||
def test_returns_german_fallback_by_default(self, agent):
|
||||
with patch("src.llm.complete", side_effect=RuntimeError("API down")):
|
||||
reply = agent.process_message("anna.muster@example.com", "Hallo")
|
||||
|
||||
assert "technisches Problem" in reply or "Entschuldigung" in reply
|
||||
|
||||
def test_returns_english_fallback_when_language_is_en(self, agent, mock_store, fresh_state):
|
||||
fresh_state.language = "en"
|
||||
mock_store.load.return_value = fresh_state
|
||||
|
||||
with patch("src.llm.complete", side_effect=RuntimeError("API down")):
|
||||
reply = agent.process_message("anna.muster@example.com", "Hello")
|
||||
|
||||
assert "technical issue" in reply.lower() or "sorry" in reply.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_llm_response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseLlmResponse:
|
||||
def test_parses_plain_json(self, agent):
|
||||
payload = '{"reply": "Hi", "updates": {}, "next_step": "greeting", "registration_complete": false, "language": "de"}'
|
||||
result = agent._parse_llm_response(payload)
|
||||
assert result["reply"] == "Hi"
|
||||
|
||||
def test_parses_fenced_json(self, agent):
|
||||
payload = '```json\n{"reply": "Hi", "updates": {}}\n```'
|
||||
result = agent._parse_llm_response(payload)
|
||||
assert result["reply"] == "Hi"
|
||||
|
||||
def test_parses_json_embedded_in_text(self, agent):
|
||||
payload = 'Sure, here is the response: {"reply": "Hi", "updates": {}}'
|
||||
result = agent._parse_llm_response(payload)
|
||||
assert result["reply"] == "Hi"
|
||||
|
||||
def test_falls_back_to_raw_text_when_no_json(self, agent):
|
||||
result = agent._parse_llm_response("Ich bin ein Hilfsroboter")
|
||||
assert result["reply"] == "Ich bin ein Hilfsroboter"
|
||||
|
||||
def test_fallback_has_safe_defaults(self, agent):
|
||||
result = agent._parse_llm_response("plain text")
|
||||
assert result["registration_complete"] is False
|
||||
assert result["updates"] == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _apply_updates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApplyUpdates:
|
||||
def test_sets_child_name(self, agent, fresh_state):
|
||||
agent._apply_updates(fresh_state, {"child.fullName": "Lena Muster"})
|
||||
assert fresh_state.registration.child.full_name == "Lena Muster"
|
||||
|
||||
def test_sets_child_dob(self, agent, fresh_state):
|
||||
agent._apply_updates(fresh_state, {"child.dateOfBirth": "2022-03-15"})
|
||||
assert fresh_state.registration.child.date_of_birth == "2022-03-15"
|
||||
|
||||
def test_sets_parent_email(self, agent, fresh_state):
|
||||
agent._apply_updates(fresh_state, {"parentGuardian.email": "test@example.com"})
|
||||
assert fresh_state.registration.parent_guardian.email == "test@example.com"
|
||||
|
||||
def test_sets_emergency_contact(self, agent, fresh_state):
|
||||
agent._apply_updates(fresh_state, {"emergencyContact.phone": "079 111 22 33"})
|
||||
assert fresh_state.registration.emergency_contact.phone == "079 111 22 33"
|
||||
|
||||
def test_sets_booking_days(self, agent, fresh_state):
|
||||
agent._apply_updates(fresh_state, {
|
||||
"booking.selectedDays": [{"day": "wednesday", "type": "indoor"}]
|
||||
})
|
||||
assert fresh_state.registration.booking.selected_days[0].day == "wednesday"
|
||||
|
||||
def test_ignores_none_values(self, agent, fresh_state):
|
||||
fresh_state.registration.child.full_name = "Lena"
|
||||
agent._apply_updates(fresh_state, {"child.fullName": None})
|
||||
assert fresh_state.registration.child.full_name == "Lena"
|
||||
|
||||
def test_ignores_unknown_keys(self, agent, fresh_state):
|
||||
agent._apply_updates(fresh_state, {"unknown.key": "value"}) # should not raise
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for KnowledgeBase loader."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from src.knowledge_base.loader import KnowledgeBase
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kb_dir(tmp_path) -> Path:
|
||||
"""A temporary knowledge-base directory with a couple of markdown files."""
|
||||
(tmp_path / "faq.md").write_text("# FAQ\nWann beginnt die Spielgruppe?\nIm August.")
|
||||
(tmp_path / "fees.md").write_text("# Fees\nCHF 130 per month.")
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kb(kb_dir) -> KnowledgeBase:
|
||||
return KnowledgeBase(kb_dir)
|
||||
|
||||
|
||||
class TestKnowledgeBaseLoading:
|
||||
def test_get_all_includes_file_content(self, kb):
|
||||
content = kb.get_all()
|
||||
assert "FAQ" in content
|
||||
assert "Fees" in content
|
||||
|
||||
def test_get_all_concatenates_multiple_files(self, kb):
|
||||
content = kb.get_all()
|
||||
assert "CHF 130" in content
|
||||
assert "Spielgruppe" in content
|
||||
|
||||
def test_reload_picks_up_new_file(self, kb, kb_dir):
|
||||
(kb_dir / "schedule.md").write_text("# Schedule\nMonday 9:00")
|
||||
kb.reload()
|
||||
assert "Schedule" in kb.get_all()
|
||||
|
||||
def test_empty_directory_returns_empty_string(self, tmp_path):
|
||||
kb = KnowledgeBase(tmp_path)
|
||||
assert kb.get_all() == "" or isinstance(kb.get_all(), str)
|
||||
|
||||
def test_nonexistent_directory_does_not_raise_on_init(self, tmp_path):
|
||||
# Should either handle gracefully or raise — just must not crash silently
|
||||
missing = tmp_path / "does_not_exist"
|
||||
try:
|
||||
kb = KnowledgeBase(missing)
|
||||
kb.get_all()
|
||||
except (FileNotFoundError, OSError):
|
||||
pass # Acceptable to raise on missing dir
|
||||
|
||||
def test_get_all_returns_string(self, kb):
|
||||
assert isinstance(kb.get_all(), str)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tests for the litellm wrapper in src/llm.py."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src import llm
|
||||
from src.models.conversation import ChatMessage
|
||||
|
||||
|
||||
class TestLlmComplete:
|
||||
def test_returns_model_reply(self, mocker):
|
||||
mock_response = mocker.MagicMock()
|
||||
mock_response.choices[0].message.content = "Hallo! Wie heisst dein Kind?"
|
||||
mocker.patch("litellm.completion", return_value=mock_response)
|
||||
|
||||
result = llm.complete("anthropic/claude-opus-4-6", "system prompt", [])
|
||||
|
||||
assert result == "Hallo! Wie heisst dein Kind?"
|
||||
|
||||
def test_passes_model_to_litellm(self, mocker):
|
||||
mock_response = mocker.MagicMock()
|
||||
mock_response.choices[0].message.content = "ok"
|
||||
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
|
||||
|
||||
llm.complete("openai/gpt-4o", "system", [])
|
||||
|
||||
call_kwargs = mock_completion.call_args.kwargs
|
||||
assert call_kwargs["model"] == "openai/gpt-4o"
|
||||
|
||||
def test_system_prompt_prepended_as_system_message(self, mocker):
|
||||
mock_response = mocker.MagicMock()
|
||||
mock_response.choices[0].message.content = "ok"
|
||||
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
|
||||
|
||||
llm.complete("anthropic/claude-opus-4-6", "You are helpful.", [])
|
||||
|
||||
messages = mock_completion.call_args.kwargs["messages"]
|
||||
assert messages[0] == {"role": "system", "content": "You are helpful."}
|
||||
|
||||
def test_chat_messages_appended_after_system(self, mocker):
|
||||
mock_response = mocker.MagicMock()
|
||||
mock_response.choices[0].message.content = "ok"
|
||||
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
|
||||
|
||||
chat = [
|
||||
ChatMessage(role="user", content="Hallo"),
|
||||
ChatMessage(role="assistant", content="Guten Tag"),
|
||||
]
|
||||
llm.complete("anthropic/claude-opus-4-6", "system", chat)
|
||||
|
||||
messages = mock_completion.call_args.kwargs["messages"]
|
||||
assert messages[1] == {"role": "user", "content": "Hallo"}
|
||||
assert messages[2] == {"role": "assistant", "content": "Guten Tag"}
|
||||
|
||||
def test_max_tokens_passed(self, mocker):
|
||||
mock_response = mocker.MagicMock()
|
||||
mock_response.choices[0].message.content = "ok"
|
||||
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
|
||||
|
||||
llm.complete("anthropic/claude-opus-4-6", "system", [])
|
||||
|
||||
assert mock_completion.call_args.kwargs["max_tokens"] == 2048
|
||||
|
||||
def test_litellm_exception_propagates(self, mocker):
|
||||
mocker.patch("litellm.completion", side_effect=RuntimeError("API error"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="API error"):
|
||||
llm.complete("anthropic/claude-opus-4-6", "system", [])
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests for data models: RegistrationData and ConversationState."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models.registration import (
|
||||
RegistrationData,
|
||||
ChildInfo,
|
||||
ParentGuardian,
|
||||
EmergencyContact,
|
||||
Booking,
|
||||
BookingDay,
|
||||
)
|
||||
from src.models.conversation import ConversationState, ChatMessage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RegistrationData.is_complete()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistrationDataIsComplete:
|
||||
def test_complete_registration_passes(self, complete_registration):
|
||||
assert complete_registration.is_complete() is True
|
||||
|
||||
def test_empty_registration_fails(self):
|
||||
assert RegistrationData().is_complete() is False
|
||||
|
||||
def test_missing_child_name_fails(self, complete_registration):
|
||||
complete_registration.child.full_name = None
|
||||
assert complete_registration.is_complete() is False
|
||||
|
||||
def test_missing_dob_fails(self, complete_registration):
|
||||
complete_registration.child.date_of_birth = None
|
||||
assert complete_registration.is_complete() is False
|
||||
|
||||
def test_missing_special_needs_fails(self, complete_registration):
|
||||
complete_registration.child.special_needs = None
|
||||
assert complete_registration.is_complete() is False
|
||||
|
||||
def test_missing_parent_name_fails(self, complete_registration):
|
||||
complete_registration.parent_guardian.full_name = None
|
||||
assert complete_registration.is_complete() is False
|
||||
|
||||
def test_missing_parent_email_fails(self, complete_registration):
|
||||
complete_registration.parent_guardian.email = None
|
||||
assert complete_registration.is_complete() is False
|
||||
|
||||
def test_missing_emergency_contact_fails(self, complete_registration):
|
||||
complete_registration.emergency_contact.full_name = None
|
||||
assert complete_registration.is_complete() is False
|
||||
|
||||
def test_missing_booking_days_fails(self, complete_registration):
|
||||
complete_registration.booking.selected_days = []
|
||||
assert complete_registration.is_complete() is False
|
||||
|
||||
def test_missing_playgroup_types_fails(self, complete_registration):
|
||||
complete_registration.booking.playgroup_types = []
|
||||
assert complete_registration.is_complete() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RegistrationData serialisation round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistrationDataSerialization:
|
||||
def test_to_dict_contains_expected_keys(self, complete_registration):
|
||||
d = complete_registration.to_dict()
|
||||
assert "child" in d
|
||||
assert "parentGuardian" in d
|
||||
assert "emergencyContact" in d
|
||||
assert "booking" in d
|
||||
|
||||
def test_to_dict_child_fields(self, complete_registration):
|
||||
d = complete_registration.to_dict()
|
||||
assert d["child"]["fullName"] == "Lena Muster"
|
||||
assert d["child"]["dateOfBirth"] == "2022-03-15"
|
||||
assert d["child"]["specialNeeds"] == "None"
|
||||
|
||||
def test_to_dict_parent_fields(self, complete_registration):
|
||||
d = complete_registration.to_dict()
|
||||
assert d["parentGuardian"]["email"] == "anna.muster@example.com"
|
||||
assert d["parentGuardian"]["postalCode"] == "8117"
|
||||
|
||||
def test_to_dict_booking_fields(self, complete_registration):
|
||||
d = complete_registration.to_dict()
|
||||
assert d["booking"]["playgroupTypes"] == ["indoor"]
|
||||
assert d["booking"]["selectedDays"] == [{"day": "monday", "type": "indoor"}]
|
||||
|
||||
def test_from_dict_round_trip(self, complete_registration):
|
||||
d = complete_registration.to_dict()
|
||||
restored = RegistrationData.from_dict(d)
|
||||
assert restored.child.full_name == complete_registration.child.full_name
|
||||
assert restored.parent_guardian.email == complete_registration.parent_guardian.email
|
||||
assert restored.emergency_contact.phone == complete_registration.emergency_contact.phone
|
||||
assert len(restored.booking.selected_days) == len(complete_registration.booking.selected_days)
|
||||
|
||||
def test_from_dict_outdoor_booking(self):
|
||||
data = {
|
||||
"child": {"fullName": "Tim", "dateOfBirth": "2021-01-01", "specialNeeds": "None"},
|
||||
"parentGuardian": {
|
||||
"fullName": "Eva", "streetAddress": "Seeweg 2", "postalCode": "8117",
|
||||
"city": "Fällanden", "phone": "044 000 00 00", "email": "eva@example.com",
|
||||
},
|
||||
"emergencyContact": {"fullName": "Bob", "phone": "079 000 00 00"},
|
||||
"booking": {
|
||||
"playgroupTypes": ["outdoor"],
|
||||
"selectedDays": [{"day": "monday", "type": "outdoor"}],
|
||||
},
|
||||
}
|
||||
reg = RegistrationData.from_dict(data)
|
||||
assert reg.booking.playgroup_types == ["outdoor"]
|
||||
assert reg.booking.selected_days[0].day == "monday"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConversationState serialisation round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConversationStateSerialization:
|
||||
def test_to_dict_contains_expected_keys(self, fresh_state):
|
||||
d = fresh_state.to_dict()
|
||||
assert "conversation_id" in d
|
||||
assert "language" in d
|
||||
assert "flow_step" in d
|
||||
assert "messages" in d
|
||||
assert "completed" in d
|
||||
|
||||
def test_default_language_is_german(self, fresh_state):
|
||||
assert fresh_state.language == "de"
|
||||
|
||||
def test_default_flow_step_is_greeting(self, fresh_state):
|
||||
assert fresh_state.flow_step == "greeting"
|
||||
|
||||
def test_default_completed_is_false(self, fresh_state):
|
||||
assert fresh_state.completed is False
|
||||
|
||||
def test_from_dict_round_trip(self, state_with_messages):
|
||||
state_with_messages.language = "en"
|
||||
state_with_messages.flow_step = "parent_name"
|
||||
d = state_with_messages.to_dict()
|
||||
restored = ConversationState.from_dict(d)
|
||||
assert restored.conversation_id == state_with_messages.conversation_id
|
||||
assert restored.language == "en"
|
||||
assert restored.flow_step == "parent_name"
|
||||
assert len(restored.messages) == len(state_with_messages.messages)
|
||||
|
||||
def test_messages_serialized_with_role_and_content(self, state_with_messages):
|
||||
d = state_with_messages.to_dict()
|
||||
assert d["messages"][0]["role"] == "user"
|
||||
assert "Hallo" in d["messages"][0]["content"]
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for AdminNotifier helper methods."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.notifications.notifier import AdminNotifier
|
||||
from src.models.registration import RegistrationData, Booking, BookingDay
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier():
|
||||
return AdminNotifier(
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_port=587,
|
||||
username="agent@example.com",
|
||||
password="secret",
|
||||
use_tls=True,
|
||||
from_email="agent@example.com",
|
||||
indoor_email="andrea@example.com",
|
||||
outdoor_email="barbara@example.com",
|
||||
cc_emails=["markus@example.com"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _format_types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatTypes:
|
||||
def test_indoor_label(self, notifier):
|
||||
assert "Innen" in notifier._format_types(["indoor"]) or "indoor" in notifier._format_types(["indoor"]).lower()
|
||||
|
||||
def test_outdoor_label(self, notifier):
|
||||
assert "Wald" in notifier._format_types(["outdoor"]) or "outdoor" in notifier._format_types(["outdoor"]).lower()
|
||||
|
||||
def test_both_labels(self, notifier):
|
||||
result = notifier._format_types(["indoor", "outdoor"])
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _calculate_age
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCalculateAge:
|
||||
def test_returns_age_string(self, notifier):
|
||||
result = notifier._calculate_age("2022-01-01")
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_invalid_dob_returns_original_string(self, notifier):
|
||||
result = notifier._calculate_age("not-a-date")
|
||||
assert result == "not-a-date"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _calculate_monthly_fee
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCalculateMonthlyFee:
|
||||
def test_indoor_one_day(self, notifier, complete_registration):
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["indoor"],
|
||||
selected_days=[BookingDay(day="monday", type="indoor")],
|
||||
)
|
||||
fee = notifier._calculate_monthly_fee(complete_registration)
|
||||
assert "130" in fee
|
||||
|
||||
def test_indoor_two_days(self, notifier, complete_registration):
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["indoor"],
|
||||
selected_days=[
|
||||
BookingDay(day="monday", type="indoor"),
|
||||
BookingDay(day="wednesday", type="indoor"),
|
||||
],
|
||||
)
|
||||
fee = notifier._calculate_monthly_fee(complete_registration)
|
||||
assert "260" in fee
|
||||
|
||||
def test_indoor_three_days(self, notifier, complete_registration):
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["indoor"],
|
||||
selected_days=[
|
||||
BookingDay(day="monday", type="indoor"),
|
||||
BookingDay(day="wednesday", type="indoor"),
|
||||
BookingDay(day="thursday", type="indoor"),
|
||||
],
|
||||
)
|
||||
fee = notifier._calculate_monthly_fee(complete_registration)
|
||||
assert "390" in fee
|
||||
|
||||
def test_outdoor_one_day(self, notifier, complete_registration):
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["outdoor"],
|
||||
selected_days=[BookingDay(day="monday", type="outdoor")],
|
||||
)
|
||||
fee = notifier._calculate_monthly_fee(complete_registration)
|
||||
assert "250" in fee
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _send — SMTP interaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSend:
|
||||
def test_send_calls_smtp(self, notifier, mocker):
|
||||
# _send uses smtplib.SMTP directly (not as context manager)
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
mock_server = mock_smtp_cls.return_value
|
||||
|
||||
notifier._send(
|
||||
to=["admin@example.com"],
|
||||
cc=["cc@example.com"],
|
||||
subject="Test",
|
||||
body="Hello",
|
||||
)
|
||||
|
||||
mock_server.sendmail.assert_called_once()
|
||||
|
||||
def test_send_includes_all_recipients(self, notifier, mocker):
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
mock_server = mock_smtp_cls.return_value
|
||||
|
||||
notifier._send(
|
||||
to=["a@example.com"],
|
||||
cc=["b@example.com"],
|
||||
subject="Test",
|
||||
body="Hello",
|
||||
)
|
||||
|
||||
call_args = mock_server.sendmail.call_args
|
||||
recipients = call_args[0][1] # positional arg: to_addrs
|
||||
assert "a@example.com" in recipients
|
||||
assert "b@example.com" in recipients
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for ConversationStore and storage helpers."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.storage.json_store import (
|
||||
ConversationStore,
|
||||
normalize_email,
|
||||
_diff_registrations,
|
||||
)
|
||||
from src.models.conversation import ConversationState
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_email
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalizeEmail:
|
||||
def test_lowercases(self):
|
||||
assert normalize_email("Anna.Muster@Example.COM") == "anna.muster@example.com"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert normalize_email(" user@example.com ") == "user@example.com"
|
||||
|
||||
def test_already_normalized(self):
|
||||
assert normalize_email("user@example.com") == "user@example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _diff_registrations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiffRegistrations:
|
||||
def test_detects_changed_field(self):
|
||||
old = {"child": {"fullName": "Lena"}}
|
||||
new = {"child": {"fullName": "Lena Muster"}}
|
||||
diff = _diff_registrations(old, new)
|
||||
assert "child.fullName" in diff
|
||||
assert diff["child.fullName"] == ("Lena", "Lena Muster")
|
||||
|
||||
def test_unchanged_fields_not_included(self):
|
||||
old = {"child": {"fullName": "Lena", "dateOfBirth": "2022-01-01"}}
|
||||
new = {"child": {"fullName": "Lena", "dateOfBirth": "2022-01-01"}}
|
||||
assert _diff_registrations(old, new) == {}
|
||||
|
||||
def test_nested_change_detected(self):
|
||||
old = {"parentGuardian": {"email": "old@example.com"}}
|
||||
new = {"parentGuardian": {"email": "new@example.com"}}
|
||||
diff = _diff_registrations(old, new)
|
||||
assert "parentGuardian.email" in diff
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConversationStore — CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path) -> ConversationStore:
|
||||
return ConversationStore(tmp_path)
|
||||
|
||||
|
||||
class TestConversationStoreCRUD:
|
||||
def test_load_returns_none_for_unknown_email(self, store):
|
||||
assert store.load("nobody@example.com") is None
|
||||
|
||||
def test_save_and_load_round_trip(self, store, fresh_state):
|
||||
store.save(fresh_state)
|
||||
loaded = store.load(fresh_state.parent_email)
|
||||
assert loaded is not None
|
||||
assert loaded.conversation_id == fresh_state.conversation_id
|
||||
|
||||
def test_save_overwrites_existing(self, store, fresh_state):
|
||||
store.save(fresh_state)
|
||||
fresh_state.language = "en"
|
||||
store.save(fresh_state)
|
||||
loaded = store.load(fresh_state.parent_email)
|
||||
assert loaded.language == "en"
|
||||
|
||||
def test_delete_removes_conversation(self, store, fresh_state):
|
||||
store.save(fresh_state)
|
||||
store.delete(fresh_state.parent_email)
|
||||
assert store.load(fresh_state.parent_email) is None
|
||||
|
||||
def test_delete_nonexistent_is_silent(self, store):
|
||||
store.delete("ghost@example.com") # should not raise
|
||||
|
||||
def test_list_incomplete_returns_non_completed(self, store, fresh_state):
|
||||
store.save(fresh_state)
|
||||
incomplete = store.list_incomplete()
|
||||
assert any(s.conversation_id == fresh_state.conversation_id for s in incomplete)
|
||||
|
||||
def test_list_incomplete_excludes_completed(self, store, fresh_state):
|
||||
fresh_state.completed = True
|
||||
store.save(fresh_state)
|
||||
incomplete = store.list_incomplete()
|
||||
assert all(not s.completed for s in incomplete)
|
||||
|
||||
def test_find_by_email_is_alias_for_load(self, store, fresh_state):
|
||||
store.save(fresh_state)
|
||||
assert store.find_by_email(fresh_state.parent_email) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConversationStore — registration versioning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistrationVersioning:
|
||||
def test_save_registration_creates_version_1(self, store, fresh_state, complete_registration):
|
||||
fresh_state.registration = complete_registration
|
||||
fresh_state.completed = True
|
||||
email_key, version = store.save_registration(fresh_state)
|
||||
assert version == 1
|
||||
# email_key is the filesystem-safe form (@ → _at_)
|
||||
assert email_key == "anna.muster_at_example.com"
|
||||
|
||||
def test_save_registration_writes_current_json(self, store, fresh_state, complete_registration, tmp_path):
|
||||
fresh_state.registration = complete_registration
|
||||
fresh_state.completed = True
|
||||
email_key, _ = store.save_registration(fresh_state)
|
||||
current = tmp_path / "registrations" / email_key / "current.json"
|
||||
assert current.exists()
|
||||
|
||||
def test_save_registration_version_increments(self, store, fresh_state, complete_registration):
|
||||
fresh_state.registration = complete_registration
|
||||
fresh_state.completed = True
|
||||
store.save_registration(fresh_state)
|
||||
_, v2 = store.save_registration_version(
|
||||
fresh_state, {"child.fullName": ("Old", "New")}
|
||||
)
|
||||
assert v2 == 2
|
||||
|
||||
def test_get_current_registration_returns_latest(self, store, fresh_state, complete_registration):
|
||||
fresh_state.registration = complete_registration
|
||||
fresh_state.completed = True
|
||||
store.save_registration(fresh_state)
|
||||
current = store.get_current_registration(fresh_state.parent_email)
|
||||
assert current is not None
|
||||
assert current["metadata"]["version"] == 1
|
||||
|
||||
def test_get_registration_history_returns_all_versions(self, store, fresh_state, complete_registration):
|
||||
fresh_state.registration = complete_registration
|
||||
fresh_state.completed = True
|
||||
store.save_registration(fresh_state)
|
||||
store.save_registration_version(fresh_state, {"child.fullName": ("A", "B")})
|
||||
history = store.get_registration_history(fresh_state.parent_email)
|
||||
assert len(history) == 2
|
||||
|
||||
def test_list_registrations_includes_saved(self, store, fresh_state, complete_registration):
|
||||
fresh_state.registration = complete_registration
|
||||
fresh_state.completed = True
|
||||
store.save_registration(fresh_state)
|
||||
registrations = store.list_registrations()
|
||||
assert len(registrations) == 1
|
||||
Reference in New Issue
Block a user