Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6866d126cb | ||
|
|
52901dd7c4 | ||
|
|
8275286647 | ||
|
|
75691879aa | ||
|
|
602b8eddc3 | ||
|
|
14fde88c89 | ||
|
|
f1d0ad045c | ||
|
|
54d88d1d64 | ||
|
|
b2e04fa07b | ||
|
|
1632433b6a | ||
|
|
9981a9121e | ||
|
|
42a01a011e | ||
|
|
e8cf170007 | ||
|
|
c6fd7497aa | ||
|
|
43ec87e151 | ||
|
|
1bd11e6acd | ||
|
|
13cb35111d | ||
|
|
72189d2b7b | ||
|
|
981948c106 | ||
|
|
808be0185f | ||
|
|
e4b4cdd41e | ||
|
|
e204e29b09 | ||
|
|
9fdbe341be | ||
|
|
08bee013f9 | ||
|
|
da0e6823f0 | ||
|
|
b0ebd40302 | ||
|
|
cb78b90332 | ||
|
|
eb2b69557e | ||
|
|
3b32652d3e | ||
|
|
bab0e5fe5e | ||
|
|
905debb48e | ||
|
|
2fac05c4ee | ||
|
|
eba450c5a5 | ||
|
|
8217b33f38 | ||
|
|
db97a357c9 | ||
|
|
7fb1d1fa0f | ||
|
|
af96c7a310 | ||
|
|
c488a0061e | ||
|
|
4440b00d91 | ||
|
|
9c33bafbf3 | ||
|
|
fef0388534 | ||
|
|
98a5f5b5b1 | ||
|
|
60f056ece4 | ||
|
|
cfa90174e3 | ||
|
|
7f55cdd204 | ||
|
|
431847a8b7 | ||
|
|
1ba42f9497 | ||
|
|
05d4b51e7a | ||
|
|
a174023ee5 | ||
|
|
0c3b5a9033 | ||
|
|
968475804c | ||
|
|
c37862b75e | ||
|
|
b10ff7a4fe | ||
|
|
b82ff27efd | ||
|
|
4d44d4ee58 |
@@ -0,0 +1,22 @@
|
||||
# Secrets — never bake into the image
|
||||
.env
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Python build artefacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
|
||||
# Tests — not needed at runtime
|
||||
tests/
|
||||
|
||||
# Persistent data — mounted as a volume at runtime
|
||||
data/
|
||||
|
||||
# Tool configs — not needed in the container
|
||||
.claude/
|
||||
.gemini/
|
||||
@@ -0,0 +1,88 @@
|
||||
# ---------------------------------------------------------------
|
||||
# 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 Models (via litellm — supports any provider)
|
||||
# ---------------------------------------------------------------
|
||||
# Use litellm model strings: "<provider>/<model-name>"
|
||||
#
|
||||
# AI_MODEL — primary model for conversation with parents (complex reasoning).
|
||||
# SIMPLE_MODEL — lightweight model for simple tasks, e.g. email-label translation.
|
||||
# Can be from a different provider than AI_MODEL.
|
||||
# If unset, falls back to AI_MODEL with a warning in the logs.
|
||||
#
|
||||
# Examples — mixing providers:
|
||||
# AI_MODEL=gemini/gemini-3-pro-preview + SIMPLE_MODEL=gemini/gemini-3-flash-preview
|
||||
# AI_MODEL=anthropic/claude-opus-4-6 + SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
# AI_MODEL=gemini/gemini-3-pro-preview + SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
AI_MODEL=anthropic/claude-opus-4-6
|
||||
SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
|
||||
# Extended thinking — Anthropic models only (leave unset to disable).
|
||||
# Enables a reasoning phase before the model's reply, which improves
|
||||
# accuracy on age calculations, logic-heavy questions, and edge cases.
|
||||
# Recommended value: 8000 (tokens). Must be less than max_tokens.
|
||||
# THINKING_BUDGET=8000
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Web chat server
|
||||
# ---------------------------------------------------------------
|
||||
# Listen on all interfaces so the app is reachable from outside localhost.
|
||||
CHAINLIT_HOST=0.0.0.0
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# 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/
|
||||
|
||||
# Chainlit runtime (auto-generated; not authored)
|
||||
.chainlit/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies — cached independently of application code
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
# Copy application source
|
||||
COPY src/ ./src/
|
||||
COPY chat_app.py main.py chainlit.toml ./
|
||||
COPY public/ ./public/
|
||||
|
||||
# Make the venv's binaries (chainlit, python, etc.) available directly
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# data/ and openspec/ are expected to be mounted at runtime
|
||||
@@ -1 +1,168 @@
|
||||
# Meister-Eder
|
||||
# Meister-Eder
|
||||
|
||||
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.
|
||||
|
||||
## What it does
|
||||
|
||||
- Guides parents through registration one question at a time, adapting to their responses
|
||||
- Answers questions about fees, schedule, and policies from a curated knowledge base
|
||||
- Validates and stores completed registrations as structured data
|
||||
- Notifies playgroup administrators on completion, routed by playgroup type
|
||||
- Responds in any language the parent uses; defaults to German
|
||||
|
||||
## Channels
|
||||
|
||||
| Channel | Description |
|
||||
|---------|-------------|
|
||||
| Web chat | Real-time, session-based |
|
||||
| Email | Async, thread-tracked; reminders on days 3, 10, 25 |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 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` |
|
||||
| `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 |
|
||||
|
||||
The API key variable depends on your chosen provider — see [Switching AI providers](#switching-ai-providers) below.
|
||||
|
||||
### 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
|
||||
|
||||
### Web chat
|
||||
|
||||
Start the web chat interface:
|
||||
|
||||
```bash
|
||||
uv run chainlit run chat_app.py
|
||||
```
|
||||
|
||||
The chat opens at **http://localhost:8000** by default.
|
||||
|
||||
To listen on a different port or host:
|
||||
|
||||
```bash
|
||||
uv run chainlit run chat_app.py --port 8080 --host 0.0.0.0
|
||||
```
|
||||
|
||||
**Minimum required env vars for the web chat:**
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `AI_MODEL` | litellm model string, e.g. `anthropic/claude-opus-4-6` |
|
||||
| `ANTHROPIC_API_KEY` | (or the key for your chosen provider) |
|
||||
| `SMTP_HOST` / `SMTP_PORT` | For admin notification emails on registration completion |
|
||||
| `IMAP_USERNAME` / `IMAP_PASSWORD` | Used as SMTP credentials |
|
||||
| `ADMIN_EMAIL_INDOOR` | Andrea Sigrist — notified when indoor group is booked |
|
||||
| `ADMIN_EMAIL_OUTDOOR` | Barbara Gross — notified when outdoor group is booked |
|
||||
| `ADMIN_EMAIL_CC` | Markus Graf — always CC'd on notifications |
|
||||
|
||||
IMAP variables (`IMAP_HOST`, etc.) are not required for the web chat — only for the email channel.
|
||||
|
||||
### Email channel
|
||||
|
||||
The email agent polls an IMAP inbox and replies via SMTP. No web server required.
|
||||
|
||||
**As a cron job (recommended)**
|
||||
|
||||
Schedule 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
|
||||
```
|
||||
|
||||
### Running both channels together
|
||||
|
||||
The web chat and email agent are independent processes — run them side by side:
|
||||
|
||||
```bash
|
||||
# Terminal 1 — web chat
|
||||
uv run chainlit run chat_app.py
|
||||
|
||||
# Terminal 2 — email polling
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
Completed registrations from both channels are stored in the same `DATA_DIR` (default: `data/`) and share the same admin notification configuration.
|
||||
|
||||
## 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.
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Spielgruppe Pumuckl
|
||||
|
||||
Willkommen beim Anmeldeassistenten der **Spielgruppe Pumuckl** (Familienverein Fällanden).
|
||||
|
||||
Ich helfe dir, dein Kind für die Spielgruppe anzumelden — schnell und unkompliziert per Chat.
|
||||
|
||||
## Was ich tun kann
|
||||
|
||||
- **Anmeldung**: Ich führe dich Schritt für Schritt durch die Anmeldung
|
||||
- **Fragen beantworten**: Preise, Zeiten, Reglement — frag einfach
|
||||
- **Deutsch oder Englisch**: Schreib in der Sprache, die dir lieber ist
|
||||
|
||||
## Spielgruppen
|
||||
|
||||
| | Innenspielgruppe | Waldspielgruppe |
|
||||
|---|---|---|
|
||||
| **Tage** | Mo / Mi / Do | Mo |
|
||||
| **Zeit** | 09:00–11:30 | 09:00–14:00 |
|
||||
| **Alter** | ab 2.5 Jahren | ab 3 Jahren |
|
||||
|
||||
## Kontakt
|
||||
|
||||
Bei Fragen zum Chat oder zur Anmeldung: **spielgruppen@familien-verein.ch**
|
||||
|
||||
---
|
||||
|
||||
*Für längere Pausen empfehlen wir die Anmeldung per E-Mail, da der Chat-Verlauf nur für die aktuelle Browser-Sitzung gespeichert wird.*
|
||||
@@ -0,0 +1,18 @@
|
||||
[project]
|
||||
# Project name shown in browser title / header
|
||||
name = "Spielgruppe Pumuckl"
|
||||
# Never send usage data to Chainlit cloud
|
||||
enable_telemetry = false
|
||||
|
||||
[UI]
|
||||
name = "Spielgruppe Pumuckl"
|
||||
# German is the default language; the agent switches automatically to English
|
||||
# if the parent writes in English
|
||||
default_language = "de"
|
||||
# Accessibility overrides on top of the default Chainlit theme
|
||||
custom_css = "/public/custom.css"
|
||||
# Accessibility JS: ARIA live region, focus management, skip-to-content link
|
||||
custom_js = "/public/accessibility.js"
|
||||
|
||||
[meta]
|
||||
generated_by = "2.x"
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Meister-Eder — Web Chat Interface for Spielgruppe Pumuckl.
|
||||
|
||||
Usage
|
||||
-----
|
||||
Copy `.env.example` to `.env`, fill in your credentials, then run:
|
||||
|
||||
chainlit run chat_app.py
|
||||
|
||||
The app serves a web chat interface at http://localhost:8000.
|
||||
Parents can register their child or ask questions in real time.
|
||||
|
||||
Environment variables (see .env.example):
|
||||
AI_MODEL litellm model string (default: anthropic/claude-opus-4-6)
|
||||
ANTHROPIC_API_KEY Required for Anthropic models
|
||||
SMTP_HOST / SMTP_PORT For admin notifications (optional in dev)
|
||||
ADMIN_EMAIL_INDOOR / ADMIN_EMAIL_OUTDOOR / ADMIN_EMAIL_CC Notification routing
|
||||
DATA_DIR Directory for completed registration JSON (default: data/)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import chainlit as cl
|
||||
|
||||
from src import llm
|
||||
from src.agent.prompts import build_system_prompt
|
||||
from src.agent.response_parser import apply_updates, fallback_message, parse_llm_response
|
||||
from src.config import Config
|
||||
from src.knowledge_base.loader import KnowledgeBase
|
||||
from src.models.conversation import ChatMessage, ConversationState
|
||||
from src.models.registration import RegistrationData
|
||||
from src.notifications.notifier import AdminNotifier
|
||||
from src.storage.json_store import ConversationStore, _diff_registrations
|
||||
|
||||
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__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared components — initialised once when the server starts.
|
||||
# These are read-only after startup and safe to share across sessions.
|
||||
# ---------------------------------------------------------------------------
|
||||
_config = Config.from_env()
|
||||
_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()],
|
||||
model=_config.simple_model,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Welcome message (German default, per spec)
|
||||
# ---------------------------------------------------------------------------
|
||||
_WELCOME_DE = (
|
||||
"Hallo! Ich bin der Anmeldeassistent der Spielgruppe Pumuckl. "
|
||||
"Ich kann dir helfen, dein Kind anzumelden, oder deine Fragen zur Spielgruppe beantworten.\n\n"
|
||||
"Du kannst mir auf Deutsch oder Englisch schreiben — ich antworte in derselben Sprache.\n\n"
|
||||
"Womit kann ich dir helfen?"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chainlit lifecycle handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cl.on_chat_start
|
||||
async def on_chat_start() -> None:
|
||||
"""Initialise a fresh conversation state and greet the parent.
|
||||
|
||||
If cl.user_session already holds state (WebSocket reconnect after a
|
||||
network drop), replay the existing message history so the parent sees
|
||||
the full conversation rather than a blank screen.
|
||||
"""
|
||||
existing = cl.user_session.get("state")
|
||||
if existing:
|
||||
# Reconnected — restore visual history from our stored state
|
||||
state = ConversationState.from_dict(existing)
|
||||
logger.info(
|
||||
"Session reconnected: %s (%d messages)",
|
||||
state.conversation_id,
|
||||
len(state.messages),
|
||||
)
|
||||
for msg in state.messages:
|
||||
author = "Spielgruppe Pumuckl" if msg.role == "assistant" else "Du"
|
||||
await cl.Message(content=msg.content, author=author).send()
|
||||
return
|
||||
|
||||
# Brand new session
|
||||
session_id = str(uuid.uuid4())
|
||||
state = ConversationState(conversation_id=session_id)
|
||||
# Store the welcome in history so it's replayed if the session reconnects.
|
||||
state.messages.append(ChatMessage(role="assistant", content=_WELCOME_DE))
|
||||
cl.user_session.set("state", state.to_dict())
|
||||
logger.info("Chat session started: %s", session_id)
|
||||
await cl.Message(content=_WELCOME_DE).send()
|
||||
|
||||
|
||||
@cl.on_message
|
||||
async def on_message(message: cl.Message) -> None:
|
||||
"""Process one parent message and stream the agent's reply."""
|
||||
# --- Restore state from session ---
|
||||
state = ConversationState.from_dict(cl.user_session.get("state"))
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
state.last_activity = now
|
||||
|
||||
# Append parent's message to history and persist immediately so that any
|
||||
# WebSocket reconnect during the LLM call can replay the full conversation.
|
||||
state.messages.append(ChatMessage(role="user", content=message.content))
|
||||
cl.user_session.set("state", state.to_dict())
|
||||
|
||||
# --- Build system prompt ---
|
||||
system = build_system_prompt(_kb, state)
|
||||
|
||||
# --- Call LLM natively async (supports extended thinking; no event-loop blocking) ---
|
||||
try:
|
||||
full_content = await llm.acomplete(
|
||||
_config.ai_model, system, state.messages, _config.thinking_budget
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("LLM call failed for session %s", state.conversation_id)
|
||||
error_text = fallback_message(state.language)
|
||||
await cl.Message(content=error_text).send()
|
||||
return
|
||||
|
||||
# --- Parse and apply LLM response ---
|
||||
parsed = parse_llm_response(full_content)
|
||||
|
||||
reply_text: str = parsed.get("reply", full_content)
|
||||
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)
|
||||
intent: str = parsed.get("intent", "")
|
||||
|
||||
apply_updates(state, updates)
|
||||
state.flow_step = next_step
|
||||
state.language = language
|
||||
state.updated_at = now
|
||||
|
||||
# Send the reply text to the parent (only the human-readable reply, not the JSON wrapper)
|
||||
await cl.Message(content=reply_text).send()
|
||||
|
||||
# Append assistant reply to history
|
||||
state.messages.append(ChatMessage(role="assistant", content=reply_text))
|
||||
|
||||
# --- Handle registration completion ---
|
||||
if is_complete and not state.completed:
|
||||
state.completed = True
|
||||
try:
|
||||
email_key, version = _store.save_registration(state)
|
||||
_notifier.notify_admin(
|
||||
registration=state.registration,
|
||||
registration_id=email_key,
|
||||
version=version,
|
||||
conversation_id=state.conversation_id,
|
||||
channel="chat",
|
||||
)
|
||||
logger.info("Registration complete for session %s", state.conversation_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to save/notify for session %s", state.conversation_id
|
||||
)
|
||||
try:
|
||||
_notifier.notify_parent(
|
||||
registration=state.registration,
|
||||
language=state.language,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to send parent confirmation for session %s", state.conversation_id
|
||||
)
|
||||
|
||||
# --- Handle post-completion update intent ---
|
||||
if state.completed and intent == "update" and any(v is not None for v in updates.values()):
|
||||
_handle_registration_update(state)
|
||||
|
||||
# --- Handle new-child reset ---
|
||||
if state.completed and intent == "new_child":
|
||||
state.registration = RegistrationData()
|
||||
state.completed = False
|
||||
state.flow_step = "child_name"
|
||||
logger.info("New child registration started for session %s", state.conversation_id)
|
||||
|
||||
# --- Persist updated state ---
|
||||
cl.user_session.set("state", state.to_dict())
|
||||
|
||||
|
||||
@cl.on_chat_end
|
||||
async def on_chat_end() -> None:
|
||||
"""Log session end. Hook for future email-reminder integration."""
|
||||
state_dict = cl.user_session.get("state")
|
||||
if state_dict:
|
||||
conversation_id = state_dict.get("conversation_id", "unknown")
|
||||
completed = state_dict.get("completed", False)
|
||||
logger.info(
|
||||
"Chat session ended: %s (completed=%s)", conversation_id, completed
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _handle_registration_update(state: ConversationState) -> None:
|
||||
"""Version the registration record and notify admin of changes."""
|
||||
# Re-apply and diff from the stored current version
|
||||
current = _store.get_current_registration(state.conversation_id)
|
||||
if current is None:
|
||||
return
|
||||
|
||||
change_summary = _diff_registrations(current, state.registration.to_dict())
|
||||
if not change_summary:
|
||||
return
|
||||
|
||||
try:
|
||||
email_key, version = _store.save_registration_version(state, change_summary)
|
||||
_notifier.notify_registration_update(
|
||||
registration=state.registration,
|
||||
registration_id=email_key,
|
||||
version=version,
|
||||
change_summary=change_summary,
|
||||
conversation_id=state.conversation_id,
|
||||
)
|
||||
logger.info(
|
||||
"Registration updated to v%d for session %s", version, state.conversation_id
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to save update for session %s", state.conversation_id
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
|
||||
# Web chat interface — Chainlit served at http://localhost:8000
|
||||
web:
|
||||
build: .
|
||||
command: chainlit run chat_app.py --host 0.0.0.0 --port 8000
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file: .env
|
||||
volumes:
|
||||
# Persistent storage for conversations and completed registrations
|
||||
- ./data:/app/data
|
||||
# Knowledge-base markdown files — edit on the host, no rebuild needed
|
||||
- ./openspec:/app/openspec:ro
|
||||
restart: unless-stopped
|
||||
|
||||
# Email polling agent — checks the inbox every POLL_INTERVAL seconds
|
||||
email-worker:
|
||||
build: .
|
||||
command: python main.py
|
||||
env_file: .env
|
||||
volumes:
|
||||
# Shares the same data directory as the web service
|
||||
- ./data:/app/data
|
||||
- ./openspec:/app/openspec:ro
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/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()],
|
||||
model=config.simple_model,
|
||||
)
|
||||
|
||||
agent = EmailAgent(
|
||||
model=config.ai_model,
|
||||
kb=kb,
|
||||
store=store,
|
||||
notifier=notifier,
|
||||
thinking_budget=config.thinking_budget,
|
||||
)
|
||||
|
||||
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,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-20
|
||||
@@ -0,0 +1,104 @@
|
||||
## Context
|
||||
|
||||
The current implementation on branch `claude/email-agent-multi-model-c3ShZ` uses email threading headers to identify conversations. This is fragile—parents often send new emails instead of replying, breaking the thread association.
|
||||
|
||||
**Current behavior:**
|
||||
```
|
||||
Email 1 (new): "I want to register" → Thread ID: <abc@gmail.com> → New conversation
|
||||
Email 2 (new): "Her name is Emma" → Thread ID: <xyz@gmail.com> → NEW conversation (context lost!)
|
||||
```
|
||||
|
||||
**Desired behavior:**
|
||||
```
|
||||
Email 1: parent@example.com → Conversation for parent@example.com (new)
|
||||
Email 2: parent@example.com → Conversation for parent@example.com (continue)
|
||||
```
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Reliable conversation continuity regardless of email threading behavior
|
||||
- Simple mental model: one email address = one conversation
|
||||
- Support post-completion interactions (questions and updates)
|
||||
- Audit trail for registration changes
|
||||
|
||||
**Non-Goals:**
|
||||
- Supporting multiple registrations per email address (one parent, multiple children handled in single conversation)
|
||||
- Anonymous/guest conversations (email address is the identity)
|
||||
- Complex merge logic for duplicate conversations
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Conversation Key: Email Address
|
||||
|
||||
**Decision**: Use normalized sender email address as the conversation key.
|
||||
|
||||
**Rationale**: Email address is the only reliable identifier across email threads. Parents may use different devices, email clients, or simply compose new messages.
|
||||
|
||||
**Normalization**: Lowercase, trim whitespace. Consider: `maria@Example.com` = `maria@example.com`
|
||||
|
||||
**Trade-off**: A parent using multiple email addresses would have multiple conversations. This is acceptable—different address = different identity from the system's perspective.
|
||||
|
||||
### 2. Thread ID Usage
|
||||
|
||||
**Decision**: Store thread IDs for reply headers only, not for conversation matching.
|
||||
|
||||
**Rationale**: Thread IDs (`Message-ID`, `In-Reply-To`, `References`) are still needed for proper email client threading (so replies appear in the same thread in Gmail/Outlook). But matching uses email address.
|
||||
|
||||
**Implementation**: When sending a reply, use the most recent inbound message's ID for `In-Reply-To`.
|
||||
|
||||
### 3. No Data Expiration
|
||||
|
||||
**Decision**: Remove the 30-day retention limit for email conversations.
|
||||
|
||||
**Rationale**: With email-address-based matching, the conversation is a permanent record. There's no reason to delete it—if the parent returns in 6 months, their data should still be there.
|
||||
|
||||
**Privacy consideration**: If GDPR deletion is requested, admin can manually remove the conversation file.
|
||||
|
||||
### 4. Post-Completion Intent Detection
|
||||
|
||||
**Decision**: When a completed registration receives a new message, use the LLM to detect intent.
|
||||
|
||||
**Intent categories:**
|
||||
- **Question**: Parent asking about fees, schedule, policies → Answer from knowledge base
|
||||
- **Update request**: Parent wants to change registration data → Collect updates, version storage, notify admin
|
||||
- **New registration**: Parent wants to register another child → Continue in same conversation, add to booking
|
||||
|
||||
**Implementation**: Add prompt guidance for post-completion state; LLM returns `intent` field.
|
||||
|
||||
### 5. Versioned Registration Storage
|
||||
|
||||
**Decision**: Store registration updates as versions, not overwrites.
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
data/registrations/
|
||||
parent_at_example.com/
|
||||
v1_2024-09-15.json # Original registration
|
||||
v2_2024-10-03.json # Updated (changed phone number)
|
||||
current.json # Symlink or copy of latest
|
||||
```
|
||||
|
||||
**Rationale**: Admin needs audit trail to see what changed and when. Original data preserved for compliance.
|
||||
|
||||
### 6. Admin Update Notifications
|
||||
|
||||
**Decision**: Send notification when registration is updated, including diff.
|
||||
|
||||
**Email subject**: "Registration Updated: [Child Name]"
|
||||
**Body includes**: What changed (old → new), when, conversation excerpt
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**Multiple children per family** → Single conversation handles this; booking can include multiple children. If needed later, extend the data model.
|
||||
|
||||
**Parent changes email address** → Creates new conversation. Admin would need to manually merge if needed. Acceptable for MVP.
|
||||
|
||||
**Storage growth** → Without expiration, conversations accumulate. Monitor disk usage; consider archival strategy later.
|
||||
|
||||
**LLM intent detection accuracy** → May misclassify. Err on the side of asking for clarification rather than making assumptions.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should the system support explicit "delete my data" requests via email? (GDPR)
|
||||
- Should reminders stop after a certain count, or continue indefinitely for incomplete registrations?
|
||||
@@ -0,0 +1,42 @@
|
||||
## Why
|
||||
|
||||
The current email agent implementation uses email thread IDs (from `Message-ID`, `In-Reply-To`, `References` headers) to match conversations. This breaks when a parent sends a new email instead of replying to the existing thread—they start a fresh conversation and lose all previously collected registration data.
|
||||
|
||||
Parents don't always use "Reply"—they may compose a new email, use a different device, or their email client may not preserve threading headers. The system should recognize them by their email address, not by email client threading behavior.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Match conversations by sender email address** instead of thread ID
|
||||
- **One conversation per email address** — simple, permanent association
|
||||
- **Remove data expiration** — no 30-day retention limit; conversations persist indefinitely
|
||||
- **Handle post-completion interactions** — if registration is complete, detect whether the parent is asking a question or requesting updates to their registration
|
||||
- **Version registration updates** — store changes alongside original data for admin audit trail
|
||||
- **Notify admin of updates** — when a completed registration is modified, notify admin with change details
|
||||
|
||||
### Removed Features
|
||||
- ~~1-month data retention for email conversations~~
|
||||
- ~~Day 30 data clearing~~
|
||||
- ~~"Your registration will expire" warning~~
|
||||
|
||||
### Retained Features
|
||||
- Email reminders for incomplete registrations (Day 3, 10, 25) — still useful to nudge parents
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `email-channel`: Change conversation matching from thread ID to sender email address; remove data expiration
|
||||
- `registration-data-store`: Add versioned storage for registration updates; key conversations by email address
|
||||
- `registration-notifications`: Add notification type for registration updates (not just new registrations)
|
||||
|
||||
### New Capabilities
|
||||
|
||||
*None — this modifies existing capabilities*
|
||||
|
||||
## Impact
|
||||
|
||||
- **Email channel**: Simpler matching logic; more reliable conversation continuity
|
||||
- **Storage**: Conversations keyed by email address instead of thread ID; registration updates stored as versions
|
||||
- **Admin workflow**: Admin sees change history when registrations are updated
|
||||
- **Data retention**: No automatic deletion; conversations persist until manually removed
|
||||
- **Spec updates**: `conversation-flow.md` timeout/retention section needs updating
|
||||
@@ -0,0 +1,42 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: System identifies conversations by sender email address
|
||||
The system SHALL identify conversations by the sender's email address, not by email threading headers. Each unique email address corresponds to exactly one conversation.
|
||||
|
||||
#### Scenario: New email from unknown address
|
||||
- **WHEN** an email arrives from an address with no existing conversation
|
||||
- **THEN** the system SHALL create a new conversation keyed by that email address
|
||||
|
||||
#### Scenario: New email from known address (any thread)
|
||||
- **WHEN** an email arrives from an address with an existing conversation
|
||||
- **THEN** the system SHALL continue that existing conversation regardless of email threading headers
|
||||
|
||||
#### Scenario: Email address normalization
|
||||
- **WHEN** comparing email addresses for matching
|
||||
- **THEN** the system SHALL normalize addresses (lowercase, trim whitespace) so that `Maria@Example.com` matches `maria@example.com`
|
||||
|
||||
### Requirement: Thread headers used for reply threading only
|
||||
The system SHALL use email threading headers (`In-Reply-To`, `References`) for outbound replies to maintain proper email client threading, but SHALL NOT use them for conversation matching.
|
||||
|
||||
#### Scenario: Reply includes threading headers
|
||||
- **WHEN** the agent sends a reply email
|
||||
- **THEN** the reply SHALL include `In-Reply-To` referencing the most recent inbound message ID
|
||||
- **AND** the reply SHALL include `References` header for the email thread chain
|
||||
|
||||
#### Scenario: Threading headers ignored for matching
|
||||
- **WHEN** an inbound email has threading headers pointing to a different conversation
|
||||
- **THEN** the system SHALL ignore those headers and match by sender email address only
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Email data retention and expiration
|
||||
**Reason**: With email-address-based matching, conversations are permanent records. No automatic expiration needed.
|
||||
**Migration**: Remove any scheduled cleanup jobs; existing conversations remain accessible indefinitely.
|
||||
|
||||
### Requirement: Day 30 data clearing
|
||||
**Reason**: No longer applicable; data persists indefinitely.
|
||||
**Migration**: None required.
|
||||
|
||||
### Requirement: "Registration will expire" warning
|
||||
**Reason**: No expiration means no warning needed.
|
||||
**Migration**: Remove from reminder sequence.
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Conversations keyed by email address
|
||||
The system SHALL store conversations using the sender's normalized email address as the unique key, replacing thread-ID-based storage.
|
||||
|
||||
#### Scenario: Conversation file naming
|
||||
- **WHEN** storing a conversation for `parent@example.com`
|
||||
- **THEN** the system SHALL use a filename derived from the email address (e.g., `parent_at_example.com.json`)
|
||||
|
||||
#### Scenario: Conversation lookup
|
||||
- **WHEN** loading a conversation for an incoming email
|
||||
- **THEN** the system SHALL lookup by normalized sender email address
|
||||
|
||||
### Requirement: Registration updates stored as versions
|
||||
The system SHALL store registration updates as separate versions, preserving the original and all subsequent changes for audit purposes.
|
||||
|
||||
#### Scenario: Initial registration stored
|
||||
- **WHEN** a registration is completed for the first time
|
||||
- **THEN** the system SHALL store it as version 1 with timestamp
|
||||
|
||||
#### Scenario: Registration update creates new version
|
||||
- **WHEN** a parent requests changes to a completed registration
|
||||
- **THEN** the system SHALL store the updated data as a new version
|
||||
- **AND** the system SHALL preserve all previous versions
|
||||
|
||||
#### Scenario: Version metadata
|
||||
- **WHEN** storing a registration version
|
||||
- **THEN** the version SHALL include: version number, timestamp, and change summary (which fields changed)
|
||||
|
||||
### Requirement: Current registration accessible
|
||||
The system SHALL provide easy access to the current (latest) registration data while preserving version history.
|
||||
|
||||
#### Scenario: Retrieve current registration
|
||||
- **WHEN** the admin or system requests the current registration for an email address
|
||||
- **THEN** the system SHALL return the most recent version
|
||||
|
||||
#### Scenario: Retrieve version history
|
||||
- **WHEN** the admin requests registration history for an email address
|
||||
- **THEN** the system SHALL return all versions in chronological order
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Post-completion conversation state
|
||||
The system SHALL support a "completed" conversation state that allows continued interaction for questions and updates.
|
||||
|
||||
#### Scenario: Conversation continues after completion
|
||||
- **WHEN** a parent sends an email after their registration is complete
|
||||
- **THEN** the system SHALL load the existing conversation and process the message
|
||||
|
||||
#### Scenario: Intent detection for post-completion messages
|
||||
- **WHEN** processing a message in a completed conversation
|
||||
- **THEN** the system SHALL detect intent: question, update request, or new child registration
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Notify admin on registration updates
|
||||
The system SHALL send an email notification to the admin when an existing registration is updated, including details of what changed.
|
||||
|
||||
#### Scenario: Update notification sent
|
||||
- **WHEN** a parent updates their completed registration
|
||||
- **THEN** the admin SHALL receive an email notification
|
||||
|
||||
#### Scenario: Update notification content
|
||||
- **WHEN** sending an update notification
|
||||
- **THEN** the notification SHALL include:
|
||||
- Child name and registration ID
|
||||
- What changed (field name, old value → new value)
|
||||
- When the change was made
|
||||
- Version number (e.g., "Version 2 of 2")
|
||||
|
||||
#### Scenario: Update notification routing
|
||||
- **WHEN** sending an update notification
|
||||
- **THEN** the notification SHALL be routed to the same recipients as the original registration (based on playgroup type)
|
||||
|
||||
### Requirement: Distinguish new vs update notifications
|
||||
The system SHALL clearly distinguish between new registration notifications and update notifications in the email subject and content.
|
||||
|
||||
#### Scenario: New registration subject
|
||||
- **WHEN** sending a notification for a new registration
|
||||
- **THEN** the subject SHALL be "New Registration: [Child Name] for [Playgroup Type]"
|
||||
|
||||
#### Scenario: Update notification subject
|
||||
- **WHEN** sending a notification for a registration update
|
||||
- **THEN** the subject SHALL be "Registration Updated: [Child Name]"
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Email reminders for incomplete registrations
|
||||
The system SHALL send reminder emails for incomplete registrations, but SHALL NOT threaten data deletion since data no longer expires.
|
||||
|
||||
#### Scenario: Reminder content without expiration warning
|
||||
- **WHEN** sending a reminder for an incomplete registration
|
||||
- **THEN** the reminder SHALL encourage completion but SHALL NOT mention data expiration or deletion
|
||||
|
||||
#### Scenario: Reminder schedule unchanged
|
||||
- **WHEN** an incomplete registration exists
|
||||
- **THEN** reminders SHALL be sent at Day 3, Day 10, and Day 25 after last activity
|
||||
|
||||
#### Scenario: Reminders stop after completion
|
||||
- **WHEN** a registration is completed
|
||||
- **THEN** no further reminders SHALL be sent for that conversation
|
||||
@@ -0,0 +1,56 @@
|
||||
## 1. Update Conversation Storage
|
||||
|
||||
- [ ] 1.1 Modify `ConversationStore` to key conversations by normalized email address
|
||||
- [ ] 1.2 Add `normalize_email()` helper function (lowercase, trim)
|
||||
- [ ] 1.3 Update `_conversation_path()` to use email-based filename
|
||||
- [ ] 1.4 Add `find_by_email()` method to replace thread-ID-based lookup
|
||||
|
||||
## 2. Update Email Channel
|
||||
|
||||
- [ ] 2.1 Remove `_resolve_thread_id()` from conversation matching logic
|
||||
- [ ] 2.2 Pass sender email to agent instead of thread ID for conversation lookup
|
||||
- [ ] 2.3 Keep thread ID handling for outbound reply headers (`In-Reply-To`, `References`)
|
||||
- [ ] 2.4 Store most recent inbound message ID for reply threading
|
||||
|
||||
## 3. Update Agent Core
|
||||
|
||||
- [ ] 3.1 Modify `process_message()` to lookup conversation by email address
|
||||
- [ ] 3.2 Add post-completion intent detection (question vs. update vs. new child)
|
||||
- [ ] 3.3 Handle registration updates in completed conversations
|
||||
- [ ] 3.4 Update prompts to guide LLM for post-completion states
|
||||
|
||||
## 4. Implement Versioned Registration Storage
|
||||
|
||||
- [ ] 4.1 Create versioned storage structure for registrations
|
||||
- [ ] 4.2 Implement `save_registration_version()` method
|
||||
- [ ] 4.3 Implement `get_registration_history()` method
|
||||
- [ ] 4.4 Track change summary (which fields changed) between versions
|
||||
- [ ] 4.5 Update `save_registration()` to use versioning for updates
|
||||
|
||||
## 5. Update Admin Notifications
|
||||
|
||||
- [ ] 5.1 Add `notify_registration_update()` method to `AdminNotifier`
|
||||
- [ ] 5.2 Create email template for update notifications (include diff)
|
||||
- [ ] 5.3 Distinguish "New Registration" vs "Registration Updated" subjects
|
||||
- [ ] 5.4 Include version number in update notifications
|
||||
|
||||
## 6. Update Reminders
|
||||
|
||||
- [ ] 6.1 Remove expiration warnings from reminder templates
|
||||
- [ ] 6.2 Update reminder messages to encourage completion without deletion threat
|
||||
- [ ] 6.3 Remove any scheduled data cleanup jobs (if present)
|
||||
|
||||
## 7. Update Specs and Documentation
|
||||
|
||||
- [ ] 7.1 Update `conversation-flow.md` to remove expiration language
|
||||
- [ ] 7.2 Update `channel-config.md` state management section
|
||||
- [ ] 7.3 Update sample responses to remove expiration references
|
||||
- [ ] 7.4 Update CLAUDE.md with new conversation matching behavior
|
||||
|
||||
## 8. Testing
|
||||
|
||||
- [ ] 8.1 Test: New email creates new conversation
|
||||
- [ ] 8.2 Test: Follow-up email (same address, different thread) continues conversation
|
||||
- [ ] 8.3 Test: Post-completion question is answered correctly
|
||||
- [ ] 8.4 Test: Post-completion update creates new version and notifies admin
|
||||
- [ ] 8.5 Test: Email address normalization works correctly
|
||||
@@ -0,0 +1,9 @@
|
||||
name: implement-web-chat
|
||||
schema: spec-driven
|
||||
status: in-progress
|
||||
created: 2026-02-22
|
||||
artifacts:
|
||||
proposal: pending
|
||||
design: pending
|
||||
specs: pending
|
||||
tasks: pending
|
||||
@@ -0,0 +1,132 @@
|
||||
## Context
|
||||
|
||||
The `chat-interface` capability is specified but not yet built. This change implements it. The core agent (LLM conversation logic) is defined separately; this design covers the web frontend, its real-time transport, session management, and how the chat layer connects to the agent core.
|
||||
|
||||
The tech stack for the entire project is decided here as part of this first implementation change, since the chat interface is the most visible component and its runtime shapes the whole backend.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Deliver a working, accessible web chat interface parents can use to register
|
||||
- Choose a library that provides WCAG 2.1 AA compliance out of the box or close to it
|
||||
- Keep the frontend thin: no business logic, just message in / message out
|
||||
- Establish the Python tech stack and project layout for all subsequent changes
|
||||
|
||||
**Non-Goals:**
|
||||
- Custom chat UI built from scratch (we use a library)
|
||||
- Authentication / login before chatting
|
||||
- Persistent chat history across separate browser sessions (session-scoped only)
|
||||
- Admin-facing UI (out of scope for this project entirely)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Chat UI Library: Chainlit
|
||||
|
||||
**Decision**: Use [Chainlit](https://github.com/Chainlit/chainlit) as the chat interface framework.
|
||||
|
||||
**Rationale**:
|
||||
Chainlit is purpose-built for AI assistant chat interfaces. It handles everything the spec requires without building it from scratch:
|
||||
- Real-time streaming responses (SSE / WebSocket)
|
||||
- Typing indicators while the agent generates
|
||||
- Session management (server-side, survives page refresh within the same session)
|
||||
- Message history display with clear agent / user attribution
|
||||
- Built-in mobile-responsive layout
|
||||
- Python-native: integrates directly with the Anthropic SDK with no bridging layer
|
||||
|
||||
**Accessibility baseline**: Chainlit's React frontend uses semantic HTML and has basic ARIA support. Gaps (see Risk section) are filled with CSS overrides and custom header components.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
| Option | Why rejected |
|
||||
|--------|-------------|
|
||||
| React + `@chatscope/chat-ui-kit-react` | Requires a separate Node.js build pipeline and a backend bridge; more moving parts for a small project |
|
||||
| Gradio | Data-science oriented; poor accessibility; limited chat customisation |
|
||||
| FastAPI + HTMX (server-rendered) | Accessible by default but no real-time streaming without complex SSE setup; typing indicators are awkward |
|
||||
| Custom React app | Reimplements what Chainlit provides; no accessibility gains justify the cost |
|
||||
|
||||
### 2. Language and Runtime: Python
|
||||
|
||||
**Decision**: Python as the sole backend language.
|
||||
|
||||
**Rationale**: The Anthropic SDK is first-class in Python. Email processing (IMAP/SMTP), file I/O for the knowledge base, and JSON storage are all well-supported. Chainlit is Python-native. Using one language for the entire stack minimises operational complexity for a small project.
|
||||
|
||||
### 3. Real-Time Transport: Chainlit's built-in WebSocket / SSE
|
||||
|
||||
**Decision**: Rely on Chainlit's managed real-time layer; do not implement a separate WebSocket server.
|
||||
|
||||
**Rationale**: Chainlit handles connection lifecycle, reconnection, and streaming out of the box. The agent core is invoked inside Chainlit's `@cl.on_message` handler and streams tokens back with `cl.Message.stream_token()`. There is no need for a separate transport layer.
|
||||
|
||||
### 4. Session State: Chainlit User Session
|
||||
|
||||
**Decision**: Store in-progress registration state in `cl.user_session` (server-side, keyed by Chainlit's session ID).
|
||||
|
||||
**Rationale**: Chainlit provides a per-connection server-side dict (`cl.user_session`) that persists across page refreshes within the same browser session. This satisfies the spec requirement that conversation history and state survive a refresh. No external state store (Redis, database) is needed for MVP.
|
||||
|
||||
**Trade-off**: State is lost when the server restarts. For a small playgroup this is acceptable; parents are encouraged to use email if they need to resume days later.
|
||||
|
||||
### 5. Accessibility Gaps and Mitigations
|
||||
|
||||
Chainlit covers most WCAG 2.1 AA requirements but has known gaps:
|
||||
|
||||
| Gap | Mitigation |
|
||||
|----|-----------|
|
||||
| ARIA live region for incoming messages | Add `aria-live="polite"` via Chainlit's custom CSS / element override on the message list container |
|
||||
| Focus management after agent reply | Inject a small JS snippet via Chainlit's `head` config to move focus to the latest message |
|
||||
| Colour contrast of default theme | Override with a high-contrast custom CSS theme (≥ 4.5:1 for all text) |
|
||||
| Animated typing dots | Wrap in `@media (prefers-reduced-motion: reduce)` to hide or swap for a static indicator |
|
||||
| Skip-to-content link | Add via Chainlit's custom `header` HTML config |
|
||||
|
||||
### 6. Project Layout
|
||||
|
||||
```
|
||||
meister-eder/
|
||||
├── app/
|
||||
│ ├── chat.py # Chainlit entry point (@cl.on_chat_start, @cl.on_message)
|
||||
│ ├── agent/
|
||||
│ │ ├── core.py # Channel-agnostic agent logic (shared with email)
|
||||
│ │ └── tools.py # Agent tool definitions (knowledge base lookup, etc.)
|
||||
│ ├── knowledge_base/ # Markdown files (symlink or copy of content/knowledge-base/)
|
||||
│ └── storage/
|
||||
│ └── registrations/ # JSON registration records
|
||||
├── public/
|
||||
│ └── custom.css # Accessibility overrides for Chainlit
|
||||
├── chainlit.md # Welcome message shown in chat (German default)
|
||||
├── chainlit.toml # Chainlit configuration (theme, title, etc.)
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
### 7. Chainlit Configuration
|
||||
|
||||
Key settings in `chainlit.toml`:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "Spielgruppe Pumuckl"
|
||||
enable_telemetry = false
|
||||
|
||||
[UI]
|
||||
name = "Spielgruppe Pumuckl"
|
||||
default_language = "de"
|
||||
# Custom CSS applied on top of default theme
|
||||
custom_css = "/public/custom.css"
|
||||
|
||||
[meta]
|
||||
generated_by = "1.x"
|
||||
```
|
||||
|
||||
Welcome message (`chainlit.md`) is written in German with an English fallback comment, matching the agent personality spec.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**Chainlit version stability**: Chainlit's API has changed between major versions. Pin to a specific minor version in `requirements.txt` and document upgrade steps.
|
||||
|
||||
**Accessibility completeness**: Chainlit's built-in accessibility is not fully audited. The mitigations in Decision 5 address known gaps; a manual screen-reader test (NVDA/VoiceOver) should be part of the implementation verification.
|
||||
|
||||
**Session loss on server restart**: Acceptable for MVP. Documented in the chat UI welcome message ("for longer registrations, consider email").
|
||||
|
||||
**Mobile keyboard overlap**: On small screens, the browser's virtual keyboard can obscure the chat input. Chainlit's layout is mobile-responsive but may need a CSS viewport-height fix (`dvh` units) for iOS Safari.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should the Chainlit app be the same process as future admin/export endpoints, or run separately? (Likely separate; Chainlit's web server is not designed to host arbitrary REST APIs.)
|
||||
- What domain/subdomain will the chat be served from? (Affects CORS config if the agent API is separate.)
|
||||
@@ -0,0 +1,38 @@
|
||||
## Why
|
||||
|
||||
The `chat-interface` capability was defined in the scoping phase as a must-have for the initial release, but no implementation exists yet. This change delivers it.
|
||||
|
||||
Parents need a zero-friction way to start a registration or ask questions without setting up email threads. A web chat interface lowers that barrier: they navigate to a URL, start typing, and they're immediately talking to the agent. The interface must work equally well on a mobile phone held in one hand while watching a toddler.
|
||||
|
||||
Accessibility is a first-class concern. Parents may rely on screen readers, keyboard navigation, or high-contrast displays. An inaccessible registration interface excludes families with disabilities—contrary to the playgroup's values.
|
||||
|
||||
Using a mature, accessibility-tested chat UI library rather than building from scratch means we inherit ARIA compliance, keyboard navigation, focus management, and screen reader support without reimplementing them.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **ADDED `chat-interface` implementation**: A deployable web application that renders the chat UI, connects to the core agent, and manages browser-session state. Built on top of an existing accessible chat UI library.
|
||||
|
||||
### What this does NOT include
|
||||
|
||||
- The core conversational agent (separate concern, shared with email channel)
|
||||
- Email channel implementation
|
||||
- Backend API for the agent (defined separately; this change specifies only the frontend and its integration contract)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `chat-interface`: Moves from specified-but-unbuilt to implemented. Requirements remain as defined in the existing spec, with accessibility requirements added:
|
||||
- WCAG 2.1 AA compliance
|
||||
- Full keyboard navigation (no mouse required)
|
||||
- Screen reader compatibility (ARIA live regions for incoming messages)
|
||||
- Sufficient colour contrast (≥ 4.5:1 for normal text)
|
||||
- Focus management (focus moves to new agent messages; skip-to-content link)
|
||||
- Respects `prefers-reduced-motion` (no animated typing dots if disabled)
|
||||
|
||||
## Impact
|
||||
|
||||
- **Parents**: Can access the registration agent from any browser without email setup; works on mobile phones; usable by parents with accessibility needs
|
||||
- **Infrastructure**: Adds a web server serving the chat frontend; WebSocket or SSE connection to the agent backend
|
||||
- **Dependencies**: One new runtime dependency — a well-maintained, accessible chat UI library (to be decided in design); the existing agent core (no changes required to the core)
|
||||
- **Admin**: No impact; admin does not interact with the chat interface
|
||||
@@ -0,0 +1,173 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
> Extends the `chat-interface` spec from `define-project-scope`. All previously
|
||||
> defined requirements remain in force. This spec adds accessibility requirements
|
||||
> and implementation constraints introduced by this change.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: WCAG 2.1 AA compliance
|
||||
The chat interface SHALL conform to WCAG 2.1 Level AA in all user-facing interactions.
|
||||
|
||||
#### Scenario: Automated accessibility check passes
|
||||
- **WHEN** an automated accessibility audit (e.g. axe-core) is run against the chat page
|
||||
- **THEN** it SHALL report zero Level A and Level AA violations
|
||||
|
||||
#### Scenario: Manual screen reader test passes
|
||||
- **WHEN** a user navigates the chat using NVDA (Windows) or VoiceOver (macOS/iOS)
|
||||
- **THEN** they SHALL be able to read all messages and send a new message without using a mouse
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Full keyboard navigation
|
||||
The chat interface SHALL be fully operable using only a keyboard.
|
||||
|
||||
#### Scenario: Sending a message by keyboard
|
||||
- **WHEN** a parent focuses the text input and types a message
|
||||
- **THEN** they SHALL be able to submit it with the Enter key without pressing a mouse button
|
||||
|
||||
#### Scenario: Tab order is logical
|
||||
- **WHEN** a parent presses Tab repeatedly from the top of the page
|
||||
- **THEN** focus SHALL move through interactive elements in a logical reading order (skip link → message list → text input → send button)
|
||||
|
||||
#### Scenario: No keyboard trap
|
||||
- **WHEN** focus enters any component (e.g. the text input)
|
||||
- **THEN** the parent SHALL be able to move focus out again using only the keyboard
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Skip-to-content link
|
||||
The chat interface SHALL provide a skip navigation link as the first focusable element.
|
||||
|
||||
#### Scenario: Skip link is visible on focus
|
||||
- **WHEN** a keyboard user presses Tab on the chat page for the first time
|
||||
- **THEN** a "Skip to chat" link SHALL become visible and, when activated, move focus directly to the message input
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Screen reader announcements for new messages
|
||||
Incoming agent messages SHALL be announced to screen readers without requiring focus change.
|
||||
|
||||
#### Scenario: Agent reply announced
|
||||
- **WHEN** the agent sends a new message
|
||||
- **THEN** a screen reader SHALL announce the message content via an ARIA live region (`aria-live="polite"`)
|
||||
|
||||
#### Scenario: In-progress stream not announced mid-token
|
||||
- **WHEN** the agent is streaming a response token by token
|
||||
- **THEN** the live region SHALL NOT announce each individual token; only the completed message SHALL be announced
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Focus moves to agent reply on completion
|
||||
After the agent finishes a response, keyboard focus SHALL be placed near the new message.
|
||||
|
||||
#### Scenario: Focus after reply
|
||||
- **WHEN** the agent finishes generating a response
|
||||
- **THEN** focus SHALL move to the new agent message element (or a wrapper with `tabindex="-1"`) so the parent can read it immediately with a screen reader
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Sufficient colour contrast
|
||||
All text in the chat interface SHALL meet WCAG 2.1 SC 1.4.3 contrast requirements.
|
||||
|
||||
#### Scenario: Normal text contrast
|
||||
- **WHEN** any text of normal size is rendered
|
||||
- **THEN** its contrast ratio against the background SHALL be ≥ 4.5:1
|
||||
|
||||
#### Scenario: Large text contrast
|
||||
- **WHEN** any text at 18pt (or 14pt bold) or larger is rendered
|
||||
- **THEN** its contrast ratio against the background SHALL be ≥ 3:1
|
||||
|
||||
#### Scenario: Input placeholder contrast
|
||||
- **WHEN** the text input displays placeholder text
|
||||
- **THEN** the placeholder contrast ratio SHALL be ≥ 4.5:1
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Typing indicator respects reduced-motion preference
|
||||
The agent-processing indicator SHALL not use animation when the user has requested reduced motion.
|
||||
|
||||
#### Scenario: Reduced motion active
|
||||
- **WHEN** the OS or browser has `prefers-reduced-motion: reduce` set
|
||||
- **AND** the agent is generating a response
|
||||
- **THEN** the typing indicator SHALL display as a static element (e.g. "…" text) with no animation
|
||||
|
||||
#### Scenario: Reduced motion not active
|
||||
- **WHEN** `prefers-reduced-motion` is not set or is set to `no-preference`
|
||||
- **THEN** the typing indicator MAY display an animated element (e.g. bouncing dots)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Mobile viewport input visibility
|
||||
The text input SHALL remain visible when the virtual keyboard is open on mobile devices.
|
||||
|
||||
#### Scenario: iOS Safari virtual keyboard
|
||||
- **WHEN** a parent taps the text input on an iOS device and the virtual keyboard appears
|
||||
- **THEN** the input field SHALL remain in view and not be obscured by the keyboard
|
||||
|
||||
#### Scenario: Android Chrome virtual keyboard
|
||||
- **WHEN** a parent taps the text input on an Android device and the virtual keyboard appears
|
||||
- **THEN** the input field SHALL remain in view and the message list SHALL scroll to show the latest message above the keyboard
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Session state survives page refresh
|
||||
Conversation history and registration state SHALL be preserved when the parent reloads the page within the same browser session.
|
||||
|
||||
#### Scenario: Page refresh mid-conversation
|
||||
- **WHEN** a parent refreshes the browser tab during an active conversation
|
||||
- **THEN** the full conversation history SHALL be displayed and the registration state SHALL be intact on reconnect
|
||||
|
||||
#### Scenario: Server restart loses state (acceptable degradation)
|
||||
- **WHEN** the server restarts while a session is active
|
||||
- **THEN** the parent SHALL see a notification that the session ended and be invited to start a new conversation
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Session-loss notification on disconnect
|
||||
If the connection to the server is lost and cannot be recovered, the interface SHALL inform the parent.
|
||||
|
||||
#### Scenario: Unrecoverable disconnect
|
||||
- **WHEN** the WebSocket / SSE connection is lost and reconnection fails after a reasonable timeout
|
||||
- **THEN** the interface SHALL display a message explaining the session ended and suggest using email for longer registration processes
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Welcome message in the parent's language
|
||||
The chat interface SHALL display a welcome message in German by default, with automatic language adaptation.
|
||||
|
||||
#### Scenario: Default welcome message
|
||||
- **WHEN** a parent opens the chat interface
|
||||
- **THEN** the welcome message SHALL be displayed in German
|
||||
|
||||
#### Scenario: Language adaptation
|
||||
- **WHEN** a parent sends their first message in English
|
||||
- **THEN** the agent SHALL respond in English for the remainder of the session
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Chainlit-based implementation
|
||||
The chat interface SHALL be implemented using the Chainlit framework.
|
||||
|
||||
#### Scenario: Application entry point
|
||||
- **WHEN** the server starts
|
||||
- **THEN** it SHALL run a Chainlit application with `@cl.on_chat_start` and `@cl.on_message` handlers
|
||||
|
||||
#### Scenario: Telemetry disabled
|
||||
- **WHEN** the application runs
|
||||
- **THEN** Chainlit telemetry SHALL be disabled (`enable_telemetry = false` in `chainlit.toml`)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Custom accessibility CSS applied
|
||||
The Chainlit default theme SHALL be extended with a custom CSS file that addresses accessibility gaps.
|
||||
|
||||
#### Scenario: Custom CSS loaded
|
||||
- **WHEN** the chat page is served
|
||||
- **THEN** the custom CSS file (`/public/custom.css`) SHALL be loaded and applied on top of the default Chainlit theme
|
||||
|
||||
#### Scenario: Custom CSS addresses contrast and motion
|
||||
- **WHEN** the page renders
|
||||
- **THEN** the custom CSS SHALL include contrast overrides achieving ≥ 4.5:1 for all normal text AND a `prefers-reduced-motion` block that suppresses typing-dot animation
|
||||
@@ -0,0 +1,71 @@
|
||||
## 1. Add Chainlit Dependency
|
||||
|
||||
- [x] 1.1 Run `uv add chainlit` to add Chainlit to `pyproject.toml` (matches existing `uv`-based workflow; do not create `requirements.txt`)
|
||||
- [x] 1.2 Create `chainlit.toml` at the project root with: `name = "Spielgruppe Pumuckl"`, `enable_telemetry = false`, `custom_css = "/public/custom.css"`, `default_language = "de"`
|
||||
|
||||
## 2. Add Streaming Support to src/llm.py
|
||||
|
||||
- [x] 2.1 Add a `stream_complete(model: str, system: str, messages: list)` generator function to `src/llm.py` that calls `litellm.completion(..., stream=True)` and yields text chunks (parallels the existing `complete()` function; both share the same `api_messages` build logic)
|
||||
- [x] 2.2 Add tests for `stream_complete` in `tests/test_llm.py` — verify chunks are yielded, empty deltas are skipped, and the model/system/messages args are forwarded correctly
|
||||
|
||||
## 3. Create Chainlit Entry Point
|
||||
|
||||
- [x] 3.1 Create `chat_app.py` at the project root with a `@cl.on_chat_start` handler that:
|
||||
- Loads `Config.from_env()` from `src/config.py`
|
||||
- Initialises `KnowledgeBase`, `ConversationStore`, `AdminNotifier` from existing `src/` modules
|
||||
- Creates a fresh `ConversationState` (use Chainlit's session ID as `conversation_id`; no email address required at this stage)
|
||||
- Stores state dict in `cl.user_session["state"]`
|
||||
- Sends the German welcome message (drawn from `content/sample-responses.md`)
|
||||
- [x] 3.2 Add a `@cl.on_message` handler in `chat_app.py` that:
|
||||
- Deserialises `ConversationState` from `cl.user_session["state"]`
|
||||
- Appends the parent's message to `state.messages` as a `ChatMessage(role="user", ...)`
|
||||
- Builds the system prompt via `build_system_prompt()` from `src/agent/prompts.py`
|
||||
- Creates a `cl.Message(content="")` and streams chunks from `stream_complete()` using `msg.stream_token(chunk)`, then calls `msg.send()`
|
||||
- Parses the completed response text for JSON (reuse `_parse_llm_response` logic from `src/agent/core.py` — extract into `src/agent/response_parser.py` if needed)
|
||||
- Applies field updates to `ConversationState.registration`; updates `state.flow_step` and `state.language`
|
||||
- Appends the assistant reply to `state.messages`
|
||||
- Serialises state back to `cl.user_session["state"]`
|
||||
- When `registration_complete` is true: saves registration via `ConversationStore`, sends admin notification via `AdminNotifier`, sets `state.completed = True`
|
||||
- [x] 3.3 Create `chainlit.md` at the project root with the German welcome/intro text shown in the Chainlit sidebar (plain markdown; drawn from `content/agent-personality.md`)
|
||||
|
||||
## 4. Accessibility CSS
|
||||
|
||||
- [x] 4.1 Create `public/` directory at the project root
|
||||
- [x] 4.2 Create `public/custom.css` with colour contrast overrides: all normal text ≥ 4.5:1, large text ≥ 3:1, and placeholder text ≥ 4.5:1 — measure Chainlit's default colours with a contrast checker and override as needed
|
||||
- [x] 4.3 Add a `@media (prefers-reduced-motion: reduce)` block to `public/custom.css` that hides the animated typing-dot element and replaces it with a static `"…"` pseudo-element
|
||||
- [x] 4.4 Add CSS in `public/custom.css` for the skip link: hidden by default, visible and highlighted on `:focus`, and targeting `#chat-input`
|
||||
- [x] 4.5 Add `min-height: 100dvh` (dynamic viewport height) to the chat container selector in `public/custom.css` so the input is not obscured by iOS Safari's virtual keyboard
|
||||
|
||||
## 5. ARIA and Focus Management
|
||||
|
||||
- [x] 5.1 Inspect the Chainlit message list container selector in a running browser (dev tools) and add `aria-live="polite"` and `aria-atomic="false"` to it via a `MutationObserver` JS snippet injected through `chainlit.toml`'s `[UI] custom_js` or as `public/accessibility.js`
|
||||
- [x] 5.2 Extend the JS snippet so that after each completed agent message (after `msg.send()`, not during streaming) focus moves to the new message element via `element.setAttribute("tabindex", "-1"); element.focus()`
|
||||
- [x] 5.3 Add the skip link HTML element to the page via the same JS snippet or Chainlit's `[UI] custom_header` config so it is the first focusable element
|
||||
|
||||
## 6. Session Management
|
||||
|
||||
- [ ] 6.1 Test manually: start a conversation in the browser, refresh the page, verify that `cl.user_session["state"]` is restored and conversation history is displayed
|
||||
- [x] 6.2 Add a `@cl.on_chat_end` handler in `chat_app.py` that logs the session end (no action needed for MVP, but provides a hook for future email-reminder integration)
|
||||
- [x] 6.3 Add a disconnect/reconnect message in Chainlit configuration: "Deine Sitzung ist abgelaufen. Starte ein neues Gespräch oder nutze E-Mail für eine längere Pause." (and English equivalent)
|
||||
|
||||
## 7. Mobile Testing
|
||||
|
||||
- [ ] 7.1 Test on iOS Safari (device or BrowserStack): tap the text input while virtual keyboard is open — confirm input is not obscured
|
||||
- [ ] 7.2 Test on Android Chrome: tap input, confirm message list scrolls to keep the latest message visible above the keyboard
|
||||
- [ ] 7.3 Test landscape orientation on a mobile screen: confirm no horizontal scroll and layout is usable
|
||||
|
||||
## 8. Accessibility Verification
|
||||
|
||||
- [ ] 8.1 Run the axe-core browser extension against the running chat page and fix all reported Level A and Level AA violations
|
||||
- [ ] 8.2 Verify keyboard-only flow: Tab → skip link becomes visible → activate → focus moves to message input → type message → Enter → agent replies → focus moves to new message
|
||||
- [ ] 8.3 Test with VoiceOver (macOS or iOS): navigate to input, send a message, confirm agent reply is announced via the live region without manual focus movement
|
||||
- [ ] 8.4 Set OS `prefers-reduced-motion: reduce` and confirm the typing indicator shows as static text (not animated)
|
||||
- [ ] 8.5 Spot-check contrast of all rendered text elements; document which selectors required overrides in `public/custom.css`
|
||||
|
||||
## 9. Final Integration Check
|
||||
|
||||
- [ ] 9.1 Confirm `chainlit run chat_app.py` starts cleanly with no warnings or import errors
|
||||
- [ ] 9.2 Complete an end-to-end registration through the chat interface: verify the registration JSON is saved to `data/registrations/` and the admin notification email is sent
|
||||
- [ ] 9.3 Confirm Chainlit telemetry is disabled: open the network tab and verify no requests go to Chainlit analytics endpoints
|
||||
- [ ] 9.4 Confirm `ANTHROPIC_API_KEY` and other secrets are not printed in logs or error output
|
||||
- [x] 9.5 Update the `context` field in `openspec/config.yaml` with the finalised tech stack: Python 3.13, Chainlit, LiteLLM, uv, file-based JSON storage
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-22
|
||||
@@ -0,0 +1,101 @@
|
||||
## Context
|
||||
|
||||
When a registration is completed, `AdminNotifier.notify_admin()` sends an email to the relevant playgroup leaders. The parent receives nothing — no acknowledgement, no summary, no payment details.
|
||||
|
||||
The existing `AdminNotifier` in `src/notifications/notifier.py` handles both new-registration and update notifications to admins. It uses `MIMEMultipart("alternative")` and sends via the configured SMTP server. All the SMTP plumbing already works.
|
||||
|
||||
This change adds a parent-facing confirmation email triggered at the same point as the admin notification.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Parent receives an HTML confirmation email immediately after registration is stored
|
||||
- Email contains full registration summary (all 13 fields)
|
||||
- Email contains payment instructions for the CHF 80 registration fee
|
||||
- Email contains a Swiss QR-bill (QR code image) embedded inline so the parent can pay via banking app or print
|
||||
|
||||
**Non-Goals:**
|
||||
- Translating the QR-bill slip labels themselves (the SIX Group standard mandates German/French/Italian for the payment slip fields — surrounding email text is translated, but the slip is not)
|
||||
- Sending a reminder if the parent hasn't paid (payment tracking is out of scope)
|
||||
- Generating a full PDF invoice (QR code embedded in HTML email is sufficient)
|
||||
- Sibling discount handling in the QR-bill amount (CHF 80 is always fixed for the registration fee)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Extend `AdminNotifier` vs. New Class
|
||||
|
||||
**Decision**: Add `notify_parent()` to the existing `AdminNotifier` class (renamed conceptually; kept in the same file for now).
|
||||
|
||||
**Rationale**: The SMTP plumbing (`_send`, `_smtp_host`, credentials) is already there. A `notify_parent` method reuses all of it. Splitting into a separate class would require duplicating constructor parameters and SMTP setup for no structural benefit at this stage.
|
||||
|
||||
**Trade-off**: `AdminNotifier` becomes slightly misnamed. Acceptable — the class handles all outbound notification emails. Rename in a future refactor if needed.
|
||||
|
||||
### 2. HTML Email Format
|
||||
|
||||
**Decision**: Send `multipart/alternative` with both plain-text and HTML parts. The HTML part is the primary view; plain-text is fallback.
|
||||
|
||||
**Rationale**: Matches the existing `_send` method's `MIMEMultipart("alternative")` pattern. HTML is needed to embed the QR-bill image inline.
|
||||
|
||||
**QR image embedding**: Use `multipart/related` wrapping the HTML part, with the QR PNG attached as `Content-ID` (`cid:qrbill`). This is the standard approach for inline images that don't appear as attachments.
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
multipart/mixed
|
||||
└── multipart/alternative
|
||||
├── text/plain (fallback)
|
||||
└── multipart/related
|
||||
├── text/html (references cid:qrbill)
|
||||
└── image/png (Content-ID: qrbill, inline)
|
||||
```
|
||||
|
||||
### 3. Swiss QR-Bill Generation
|
||||
|
||||
**Decision**: Use the `qrbill` Python library to generate the QR code image.
|
||||
|
||||
**Rationale**: `qrbill` implements the Swiss QR-bill standard (SIX Group spec) directly. It accepts IBAN, payee address, amount, and currency, and outputs an SVG or PNG. No external services required.
|
||||
|
||||
**Fixed payment data** (hardcoded in the notifier, not in config — this is stable bank data):
|
||||
- IBAN: `CH14 0900 0000 4930 8018 8`
|
||||
- Payee: Familienverein Fällanden Spielgruppen, c/o Markus Graf, Huebwisstrase 5, 8117 Fällanden
|
||||
- Amount: CHF 80.00
|
||||
- Currency: CHF
|
||||
- Reference type: NON (no structured reference)
|
||||
|
||||
**Output**: PNG bytes, embedded as inline image in HTML email.
|
||||
|
||||
**Dependency**: Add `qrbill` to `pyproject.toml` dependencies.
|
||||
|
||||
### 4. Language
|
||||
|
||||
**Decision**: Add a `language` field to `RegistrationData` (default `"de"`). The agent sets it when it detects the parent's language during conversation. The confirmation email body is rendered in the stored language. The QR-bill slip labels are fixed German/French/Italian per the SIX Group standard and are not translated.
|
||||
|
||||
**Supported values**: `"de"` (German, default) and `"en"` (English). Other values fall back to `"de"`.
|
||||
|
||||
**Where it lives in the model**: A new `metadata` field on `RegistrationData` (a `Metadata` dataclass) with fields `submitted_at`, `channel`, `conversation_id`, and `language`. This also aligns with the JSON schema in `registration-schema.json` which already defines a `metadata` object with those keys. The `language` field is added to both the Python model and the JSON schema.
|
||||
|
||||
**Template strategy**: Two string-template dicts (one per language) for all user-visible strings in the confirmation email. The notifier selects the dict based on `registration.metadata.language`. Admin notifications remain German-only (admins are Swiss German speakers).
|
||||
|
||||
**Rationale**: Parents who conversed in English reasonably expect an English confirmation. Storing language in the model (rather than passing it as a parameter) means it's persisted with the registration and available for future use (e.g. update notifications, reminders).
|
||||
|
||||
### 5. Trigger Point
|
||||
|
||||
**Decision**: Call `notify_parent()` immediately after `notify_admin()` at the same trigger site — wherever `notify_admin` is currently called (in `src/agent/core.py` or equivalent).
|
||||
|
||||
**Rationale**: The parent notification is a direct consequence of the same event (registration completed). No separate trigger or queue needed.
|
||||
|
||||
**Error isolation**: If the parent email fails, log the error but do not fail the registration or block the admin notification. Both notifications are best-effort.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**`qrbill` library maturity**: Actively maintained, used in production Swiss applications. Risk is low. If the library is unavailable, the QR code can be omitted and the plain IBAN text still enables payment.
|
||||
|
||||
**Inline image rendering**: Some email clients block inline images by default (Outlook, some mobile clients). The plain-text fallback and the raw IBAN text in the HTML body ensure the payment info is always readable even if the QR image is blocked.
|
||||
|
||||
**SMTP failure for parent email**: Parent notification is non-critical (the registration is already stored). Failure is logged as a warning, not an exception.
|
||||
|
||||
**Language detection accuracy**: The agent infers language from conversation content. Misdetection is possible but low-risk — a parent who receives a German email when they expected English can still understand the registration summary. The QR-bill is universally recognisable regardless of surrounding language.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should the confirmation email also include the monthly subscription fee (in addition to the CHF 80 registration fee), or only the registration fee QR-bill? The CHF 80 one-time fee is the immediate action required; the monthly fee is recurring and not yet payable. **Proposed answer: include both as informational text, but the QR-bill is for CHF 80 only.**
|
||||
- Should the parent's email be CC'd on the admin notification, or kept as a separate send? **Proposed answer: separate send — keeps admin and parent content distinct.**
|
||||
@@ -0,0 +1,31 @@
|
||||
## Why
|
||||
|
||||
When a parent completes registration, they currently receive no confirmation. They have no record of what they submitted, no clarity on next steps, and no way to pay the registration fee without separately asking for bank details. This creates uncertainty for parents and additional back-and-forth for admins.
|
||||
|
||||
A confirmation email closes this gap: the parent gets a clear summary of their registration, knows exactly what they agreed to, and can pay immediately using the included Swiss QR-bill.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Send HTML confirmation email to the parent** immediately when a registration is completed and stored
|
||||
- **Include full registration summary** — all fields the parent filled out, formatted clearly
|
||||
- **Include payment instructions** (German text) for the CHF 80 registration fee with IBAN and payee details
|
||||
- **Include a Swiss QR-bill** (payment QR code) so the parent can pay directly from their banking app or print-to-pay
|
||||
- The confirmation is sent in the **same language** the parent used during the conversation (German or English), but the QR-bill and payment block are always in German (banking standard)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `registration-notifications`: Currently only notifies admins. Extended to also send a confirmation to the parent's email address upon completion.
|
||||
|
||||
### New Capabilities
|
||||
|
||||
*None — this extends an existing capability*
|
||||
|
||||
## Impact
|
||||
|
||||
- **Parents**: Receive immediate, clear confirmation with everything they need — what was registered and how to pay. No need to ask for bank details.
|
||||
- **Admins**: Fewer follow-up inquiries about "did my registration go through?" and "where do I pay?". Payment is initiated earlier.
|
||||
- **Email deliverability**: System must send to parent email, not just admin addresses. Parent email is already a required field in the registration schema.
|
||||
- **Swiss QR-bill generation**: Requires a library to generate the QR code image from the payment data (IBAN, amount, payee address). The QR code is embedded inline in the HTML email.
|
||||
- **Bilingual**: Confirmation body adapts to the parent's language. The payment section uses German regardless (Swiss QR-bill standard).
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Notify parent on completed registration
|
||||
The system SHALL send an HTML confirmation email to the parent immediately after a registration is completed and stored.
|
||||
|
||||
#### Scenario: Confirmation sent to parent email
|
||||
- **WHEN** a registration is completed
|
||||
- **THEN** the system SHALL send a confirmation email to the address in `parentGuardian.email`
|
||||
|
||||
#### Scenario: Confirmation sent before or alongside admin notification
|
||||
- **WHEN** a registration is completed
|
||||
- **THEN** both the admin notification and the parent confirmation SHALL be dispatched in the same completion event; failure of either SHALL be logged but SHALL NOT block the other or fail the registration
|
||||
|
||||
#### Scenario: No confirmation for incomplete registration
|
||||
- **WHEN** a registration is not yet complete (any required field missing)
|
||||
- **THEN** no confirmation email SHALL be sent to the parent
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Confirmation email contains full registration summary
|
||||
The confirmation email SHALL include a summary of all registration data the parent submitted.
|
||||
|
||||
#### Scenario: All required fields present in confirmation
|
||||
- **WHEN** the confirmation email is sent
|
||||
- **THEN** it SHALL include child name, date of birth, special needs, selected playgroup type(s), selected days, parent/guardian contact details, and emergency contact
|
||||
|
||||
#### Scenario: Monthly fee shown as informational text
|
||||
- **WHEN** the confirmation email is sent
|
||||
- **THEN** it SHALL display the calculated monthly subscription fee as informational text (not a payment request)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Confirmation email contains payment instructions for registration fee
|
||||
The confirmation email SHALL include instructions for paying the one-time CHF 80 registration fee.
|
||||
|
||||
#### Scenario: IBAN and payee shown as text
|
||||
- **WHEN** the confirmation email is sent
|
||||
- **THEN** it SHALL display the payee name, IBAN, and amount in plain text so the parent can pay manually if the QR code is not rendered
|
||||
|
||||
#### Scenario: Swiss QR-bill embedded inline
|
||||
- **WHEN** the confirmation email is sent
|
||||
- **THEN** it SHALL include a Swiss QR-bill image (per SIX Group standard) embedded inline as a `Content-ID` referenced image within the HTML part
|
||||
- **AND** the QR-bill SHALL encode: IBAN `CH14 0900 0000 4930 8018 8`, payee Familienverein Fällanden Spielgruppen (Huebwisstrase 5, 8117 Fällanden), amount CHF 80.00, currency CHF, reference type NON
|
||||
|
||||
#### Scenario: QR-bill fallback for non-HTML clients
|
||||
- **WHEN** a parent's email client does not render HTML
|
||||
- **THEN** the plain-text part SHALL include the IBAN and payee details in full so payment is still possible without the QR code
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Confirmation email language matches parent's detected language
|
||||
The confirmation email body SHALL be rendered in the language detected during the conversation.
|
||||
|
||||
#### Scenario: German parent receives German confirmation
|
||||
- **WHEN** the conversation language is `"de"`
|
||||
- **THEN** the confirmation email body SHALL be in German
|
||||
|
||||
#### Scenario: English-speaking parent receives English confirmation
|
||||
- **WHEN** the conversation language is `"en"`
|
||||
- **THEN** the confirmation email body SHALL be in English
|
||||
|
||||
#### Scenario: Unknown language falls back to German
|
||||
- **WHEN** the stored language value is not `"de"` or `"en"`
|
||||
- **THEN** the confirmation email SHALL be sent in German
|
||||
|
||||
#### Scenario: QR-bill slip labels are not translated
|
||||
- **WHEN** the confirmation email is rendered in any language
|
||||
- **THEN** the Swiss QR-bill payment slip labels SHALL remain in German (per SIX Group standard; the slip is internationally recognisable without translation)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Parent's conversation language is persisted in the registration record
|
||||
The language detected during the parent's conversation SHALL be stored in the completed registration record.
|
||||
|
||||
#### Scenario: Language written to registration record
|
||||
- **WHEN** a registration is stored
|
||||
- **THEN** the JSON record SHALL include a `metadata.language` field containing the detected language code (`"de"` or `"en"`)
|
||||
|
||||
#### Scenario: Language defaults to German when not detected
|
||||
- **WHEN** no language was explicitly detected during the conversation
|
||||
- **THEN** `metadata.language` SHALL be `"de"`
|
||||
@@ -0,0 +1,52 @@
|
||||
## 1. Add `qrbill` Dependency
|
||||
|
||||
- [x] 1.1 Add `qrbill` to `[project.dependencies]` in `pyproject.toml`
|
||||
- [x] 1.2 Run `uv lock` to update the lockfile
|
||||
- [x] 1.3 Verify `qrbill` imports successfully in a smoke test or REPL
|
||||
|
||||
## 2. Persist Language in Registration Record
|
||||
|
||||
- [x] 2.1 Update `ConversationStore._build_record()` in `src/storage/json_store.py` to include `language` from `state.language` in the `metadata` dict
|
||||
- [x] 2.2 Update `ConversationStore.save_registration()` and `save_registration_version()` signatures to accept/forward `state` (already does — confirm `_build_record` receives the full state)
|
||||
- [x] 2.3 Add a test in `tests/test_storage.py` asserting that the saved record's `metadata.language` matches `state.language`
|
||||
|
||||
## 3. Add `notify_parent()` to `AdminNotifier`
|
||||
|
||||
- [x] 3.1 Add a `_generate_qr_bill_png()` static/class method to `AdminNotifier` using `qrbill` with fixed payment data:
|
||||
- IBAN: `CH14 0900 0000 4930 8018 8`
|
||||
- Payee: Familienverein Fällanden Spielgruppen, Huebwisstrase 5, 8117 Fällanden
|
||||
- Amount: `80.00`, Currency: `CHF`, Reference type: NON
|
||||
- Returns raw PNG `bytes`
|
||||
- [x] 3.2 Add bilingual string template dicts `_STRINGS_DE` and `_STRINGS_EN` (module-level constants) covering all user-visible strings in the confirmation email (subject, section headers, fee labels, payment instructions text, closing)
|
||||
- [x] 3.3 Add `_build_parent_html()` method: renders full HTML confirmation email body using the appropriate string dict, embedding the QR image via `cid:qrbill`; includes registration summary and both monthly fee (informational) and CHF 80 registration fee (with IBAN text + QR reference)
|
||||
- [x] 3.4 Add `_build_parent_text()` method: renders the plain-text fallback, including all summary fields and IBAN/payee details in plain text (no image)
|
||||
- [x] 3.5 Add `notify_parent()` public method:
|
||||
- Parameters: `registration: RegistrationData`, `language: str = "de"`
|
||||
- Select string dict based on `language`; fall back to `"de"` for unknown values
|
||||
- Call `_generate_qr_bill_png()` to get PNG bytes
|
||||
- Build MIME structure: `multipart/mixed` > `multipart/alternative` > plain text part + `multipart/related` > HTML part + inline PNG (`Content-Disposition: inline`, `Content-ID: <qrbill>`)
|
||||
- Call `_send()` with `to=[registration.parent_guardian.email]`, empty `cc`, localised subject, the assembled MIME message
|
||||
- If `_smtp_host` is empty (dev mode), log and skip as with `notify_admin`
|
||||
|
||||
## 4. Wire `notify_parent()` into Completion Events
|
||||
|
||||
- [x] 4.1 In `src/agent/core.py` `_handle_registration()`: after the existing `notify_admin()` try/except block, add a parallel try/except block calling `self._notifier.notify_parent(registration=state.registration, language=state.language)`
|
||||
- [x] 4.2 In `chat_app.py` `on_message()`: after the existing `notify_admin()` call inside the completion block, add a parallel try/except block calling `_notifier.notify_parent(registration=state.registration, language=state.language)`
|
||||
- [x] 4.3 Verify both call sites log a warning (not an exception) on failure, and the registration completion path continues normally
|
||||
|
||||
## 5. Tests
|
||||
|
||||
- [x] 5.1 Add `tests/test_notifier.py` tests for `notify_parent()`:
|
||||
- `test_notify_parent_calls_send`: mock `_send` and assert it is called with `to=[parent_email]`
|
||||
- `test_notify_parent_german_subject`: assert subject contains German text when `language="de"`
|
||||
- `test_notify_parent_english_subject`: assert subject contains English text when `language="en"`
|
||||
- `test_notify_parent_unknown_language_falls_back_to_de`: assert `language="fr"` produces German subject
|
||||
- `test_notify_parent_no_smtp_skips_send`: when `smtp_host=""`, `_send` is NOT called
|
||||
- [x] 5.2 Add a test asserting that the plain-text body contains the IBAN string `CH14` when `smtp_host` is empty (inspecting log or body build directly)
|
||||
- [x] 5.3 Add a test for `_generate_qr_bill_png()` asserting it returns `bytes` with non-zero length (requires `qrbill` installed)
|
||||
|
||||
## 6. Manual Smoke Test
|
||||
|
||||
- [ ] 6.1 Run `chainlit run chat_app.py` locally (or the email poller), complete a registration end-to-end, and verify the parent confirmation email arrives with the inline QR image rendered correctly
|
||||
- [ ] 6.2 Verify the admin notification still arrives unchanged alongside the parent confirmation
|
||||
- [ ] 6.3 Verify the saved `current.json` for the registration includes `metadata.language`
|
||||
+26
-5
@@ -3,11 +3,32 @@ schema: spec-driven
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
context: |
|
||||
Tech stack:
|
||||
- Language: Python 3.13+
|
||||
- Package manager: uv (pyproject.toml — never requirements.txt)
|
||||
- Chat interface: Chainlit 2.x (entry point: chat_app.py)
|
||||
- LLM access: LiteLLM (provider-agnostic; src/llm.py wraps litellm.completion)
|
||||
- Email channel: IMAP/SMTP via stdlib (imaplib, smtplib); entry point: main.py
|
||||
- Storage: file-based JSON (no database); data/ directory
|
||||
- Knowledge base: admin-editable Markdown files in openspec/.../knowledge-base/
|
||||
- Tests: pytest + pytest-mock (tests/ directory)
|
||||
|
||||
Key architectural decisions:
|
||||
- Channel-agnostic agent core: src/agent/ (prompts, response_parser) is shared
|
||||
between email (src/agent/core.py / EmailAgent) and chat (chat_app.py)
|
||||
- LLM streaming: src/llm.stream_complete() yields chunks via litellm stream=True
|
||||
- Session state for chat: cl.user_session (Chainlit server-side, survives page refresh)
|
||||
- Accessibility: public/custom.css (contrast, dvh, reduced-motion) +
|
||||
public/accessibility.js (ARIA live region, focus management, skip link)
|
||||
- Admin notifications routed by playgroup type (indoor→Andrea, outdoor→Barbara, CC Markus)
|
||||
|
||||
Conventions:
|
||||
- German is the default language; agent auto-detects and switches to English
|
||||
- Use informal "du" in German agent responses
|
||||
- All knowledge base content stays in Markdown (non-technical admins can edit it)
|
||||
- Schema validation required before saving any completed registration
|
||||
- Never hardcode fee amounts or contact details in application code
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Spielgruppe Pumuckl — Accessibility enhancements for Chainlit
|
||||
*
|
||||
* This script runs after the page loads and makes three changes:
|
||||
*
|
||||
* 1. Injects a "Skip to chat" link as the first focusable element so keyboard
|
||||
* users can bypass the header and jump straight to the message input.
|
||||
*
|
||||
* 2. Adds aria-live="polite" to the message list container so screen readers
|
||||
* announce new agent replies without requiring a focus change.
|
||||
*
|
||||
* 3. Observes the message list for newly completed agent messages and moves
|
||||
* keyboard focus to the latest one so screen reader users can read it
|
||||
* immediately after it appears.
|
||||
*
|
||||
* NOTE: Chainlit renders a React app, so the DOM is not fully available on
|
||||
* DOMContentLoaded. We use a MutationObserver to wait for the message
|
||||
* container to appear before attaching further observers.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// ── 1. Skip-to-content link ─────────────────────────────────────────────
|
||||
|
||||
function injectSkipLink() {
|
||||
if (document.getElementById("skip-to-chat")) return; // already injected
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.id = "skip-to-chat";
|
||||
link.href = "#chat-input";
|
||||
link.textContent = "Zum Chat springen / Skip to chat";
|
||||
|
||||
// Clicking moves focus to the textarea inside #chat-input
|
||||
link.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
const target =
|
||||
document.querySelector("#chat-input textarea") ||
|
||||
document.querySelector("[data-testid='chat-input'] textarea") ||
|
||||
document.querySelector("textarea");
|
||||
if (target) {
|
||||
target.focus();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.insertBefore(link, document.body.firstChild);
|
||||
}
|
||||
|
||||
// ── 2. ARIA live region on the message list ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Selectors to try for the message list container.
|
||||
* Chainlit's class names may change between versions; list several candidates.
|
||||
*/
|
||||
const MESSAGE_LIST_SELECTORS = [
|
||||
"[data-testid='message-list']",
|
||||
".message-list",
|
||||
"[class*='MessageList']",
|
||||
"[class*='messages']",
|
||||
".cl-message-list",
|
||||
];
|
||||
|
||||
function findMessageList() {
|
||||
for (const sel of MESSAGE_LIST_SELECTORS) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyLiveRegion(container) {
|
||||
if (container.dataset.liveRegionApplied) return;
|
||||
container.setAttribute("aria-live", "polite");
|
||||
container.setAttribute("aria-atomic", "false");
|
||||
container.setAttribute("aria-relevant", "additions");
|
||||
container.dataset.liveRegionApplied = "true";
|
||||
}
|
||||
|
||||
// ── 3. Focus management after agent replies ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Selectors for individual assistant message elements.
|
||||
* We look for the last one after a new addition.
|
||||
*/
|
||||
const ASSISTANT_MESSAGE_SELECTORS = [
|
||||
"[data-testid='assistant-message']",
|
||||
"[data-author='assistant']",
|
||||
"[class*='assistant']",
|
||||
".cl-message[data-role='assistant']",
|
||||
];
|
||||
|
||||
let _lastFocusedMessage = null;
|
||||
|
||||
function focusLatestAssistantMessage(container) {
|
||||
let latest = null;
|
||||
for (const sel of ASSISTANT_MESSAGE_SELECTORS) {
|
||||
const all = container.querySelectorAll(sel);
|
||||
if (all.length > 0) {
|
||||
latest = all[all.length - 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: grab the last direct child of the message list
|
||||
if (!latest) {
|
||||
const children = container.children;
|
||||
latest = children[children.length - 1] || null;
|
||||
}
|
||||
|
||||
if (!latest || latest === _lastFocusedMessage) return;
|
||||
|
||||
_lastFocusedMessage = latest;
|
||||
// tabindex="-1" lets us focus() without adding the element to tab order
|
||||
latest.setAttribute("tabindex", "-1");
|
||||
latest.focus({ preventScroll: false });
|
||||
}
|
||||
|
||||
// ── Bootstrap: wait for Chainlit to render, then attach everything ───────
|
||||
|
||||
let _messageListObserver = null;
|
||||
|
||||
function onMessageListFound(messageList) {
|
||||
applyLiveRegion(messageList);
|
||||
|
||||
// Watch for new messages being added
|
||||
_messageListObserver = new MutationObserver(function (mutations) {
|
||||
const hasAdditions = mutations.some(function (m) {
|
||||
return m.addedNodes.length > 0;
|
||||
});
|
||||
if (hasAdditions) {
|
||||
// Small delay lets Chainlit finish rendering the new message element
|
||||
setTimeout(function () {
|
||||
focusLatestAssistantMessage(messageList);
|
||||
}, 150);
|
||||
}
|
||||
});
|
||||
|
||||
_messageListObserver.observe(messageList, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
// Watch the body for the message list to appear (Chainlit is a SPA)
|
||||
const _rootObserver = new MutationObserver(function () {
|
||||
injectSkipLink();
|
||||
|
||||
const messageList = findMessageList();
|
||||
if (messageList) {
|
||||
onMessageListFound(messageList);
|
||||
// No need to keep watching once we found the container
|
||||
_rootObserver.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
_rootObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Also try immediately in case the app rendered synchronously
|
||||
injectSkipLink();
|
||||
const messageList = findMessageList();
|
||||
if (messageList) {
|
||||
onMessageListFound(messageList);
|
||||
_rootObserver.disconnect();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Spielgruppe Pumuckl — Accessibility overrides for Chainlit
|
||||
*
|
||||
* Goals:
|
||||
* - WCAG 2.1 AA colour contrast (≥ 4.5:1 for normal text, ≥ 3:1 for large text)
|
||||
* - prefers-reduced-motion: replace animated typing dots with static indicator
|
||||
* - Skip-to-content link visible on keyboard focus
|
||||
* - iOS Safari virtual keyboard: use dynamic viewport height so input stays visible
|
||||
*/
|
||||
|
||||
/* ─── Skip-to-content link ──────────────────────────────────────────────── */
|
||||
|
||||
#skip-to-chat {
|
||||
position: absolute;
|
||||
top: -9999px;
|
||||
left: 8px;
|
||||
z-index: 9999;
|
||||
padding: 8px 16px;
|
||||
background: #1a56db; /* WCAG AA on white: contrast ≈ 5.9:1 */
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#skip-to-chat:focus {
|
||||
top: 8px;
|
||||
outline: 3px solid #f97316;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ─── Viewport height fix for iOS Safari virtual keyboard ───────────────── */
|
||||
/*
|
||||
* 100dvh (dynamic viewport height) shrinks when the virtual keyboard opens,
|
||||
* keeping the message input visible. Falls back to 100vh on older browsers.
|
||||
*/
|
||||
|
||||
#root,
|
||||
.cl-app,
|
||||
[data-testid="layout"],
|
||||
.main-container {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* ─── Colour contrast overrides ─────────────────────────────────────────── */
|
||||
/*
|
||||
* Chainlit's default light theme uses mid-grey text (#6b7280) on white,
|
||||
* which gives ≈ 4.0:1 — below the 4.5:1 AA threshold for normal text.
|
||||
* Override to #595f6b which measures ≈ 4.6:1 on #ffffff.
|
||||
*
|
||||
* Chainlit's dark theme text is #d1d5db on #1c1c1e (≈ 10:1) — already passes.
|
||||
* We only override the light theme muted/secondary colour.
|
||||
*/
|
||||
|
||||
/* Muted/secondary text — bump from #6b7280 (4.0:1) to #595f6b (≈ 4.6:1) */
|
||||
.text-gray-500,
|
||||
[class*="text-muted"],
|
||||
[class*="secondary"] {
|
||||
color: #595f6b !important;
|
||||
}
|
||||
|
||||
/* Input placeholder — same issue; force dark enough value */
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #595f6b !important;
|
||||
opacity: 1; /* Firefox reduces opacity by default */
|
||||
}
|
||||
|
||||
/* ─── Reduced-motion: replace animated typing dots with static "…" ──────── */
|
||||
/*
|
||||
* Chainlit shows a bouncing-dots animation while the agent is generating.
|
||||
* When prefers-reduced-motion is set, hide the animation and show "…" instead.
|
||||
*/
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Hide animated dot elements (Chainlit uses span.dot or similar) */
|
||||
.typing-indicator span,
|
||||
.loader span,
|
||||
[class*="typing"] span,
|
||||
[class*="loader"] span,
|
||||
[class*="bounce"] {
|
||||
animation: none !important;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Show a static ellipsis as the parent container's content */
|
||||
.typing-indicator::after,
|
||||
.loader::after,
|
||||
[class*="typing"]::after,
|
||||
[class*="loader"]::after {
|
||||
content: "…";
|
||||
visibility: visible;
|
||||
display: inline-block;
|
||||
color: inherit;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Suppress all other CSS transitions and animations */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
[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",
|
||||
"chainlit>=2.9.6",
|
||||
"qrbill>=1.2.0",
|
||||
"pillow>=12.1.1",
|
||||
"jinja2>=3.0.0",
|
||||
"pyyaml>=6.0.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-asyncio>=1.3.0",
|
||||
"pytest-mock>=3.14.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
@@ -0,0 +1,209 @@
|
||||
"""EmailAgent — the channel-agnostic conversation orchestrator."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..models.conversation import ConversationState, ChatMessage
|
||||
from ..models.registration import 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
|
||||
from .response_parser import apply_updates, fallback_message, parse_llm_response
|
||||
|
||||
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,
|
||||
thinking_budget: int | None = None,
|
||||
) -> None:
|
||||
self._model = model
|
||||
self._thinking_budget = thinking_budget
|
||||
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, self._thinking_budget)
|
||||
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)
|
||||
try:
|
||||
self._notifier.notify_parent(
|
||||
registration=state.registration,
|
||||
language=state.language,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send parent confirmation 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, self._thinking_budget)
|
||||
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:
|
||||
return parse_llm_response(content)
|
||||
|
||||
def _fallback_message(self, state: ConversationState) -> str:
|
||||
return fallback_message(state.language)
|
||||
|
||||
def _apply_updates(self, state: ConversationState, updates: dict) -> None:
|
||||
apply_updates(state, updates)
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Build the system prompt sent to the LLM on every turn."""
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
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.")
|
||||
|
||||
today = date.today().isoformat()
|
||||
|
||||
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.
|
||||
|
||||
**Today's date is {today}.** Use this exact date when calculating a child's age from their date of birth.
|
||||
|
||||
{_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"
|
||||
|
||||
today = date.today().isoformat()
|
||||
|
||||
return f"""You are the registration assistant for Spielgruppe Pumuckl, run by Familienverein Fällanden in Fällanden, Switzerland.
|
||||
|
||||
**Today's date is {today}.**
|
||||
|
||||
{_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,100 @@
|
||||
"""Parse and apply LLM JSON responses — shared between email and chat channels."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from ..models.conversation import ConversationState
|
||||
from ..models.registration import BookingDay
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_llm_response(content: str) -> dict:
|
||||
"""Extract the JSON payload from the LLM's raw output.
|
||||
|
||||
Tries three strategies in order:
|
||||
1. Entire content is a fenced code block (```json ... ```)
|
||||
2. Entire content is a bare JSON object
|
||||
3. JSON object embedded somewhere in the text
|
||||
|
||||
Falls back to wrapping raw text as a ``reply`` if nothing parses.
|
||||
"""
|
||||
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 apply_updates(state: ConversationState, updates: dict) -> None:
|
||||
"""Write extracted field values into the RegistrationData on *state*."""
|
||||
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)
|
||||
|
||||
|
||||
def fallback_message(language: str) -> str:
|
||||
"""Return a safe error message in the parent's detected language."""
|
||||
if 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."
|
||||
)
|
||||
@@ -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
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
"""Configuration loaded from environment variables."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass # python-dotenv is optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
# Primary model for conversation with parents — litellm format.
|
||||
# The matching API key must be set as an env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
|
||||
# Example: "anthropic/claude-opus-4-6", "google/gemini-2.0-flash", "openai/gpt-4o"
|
||||
ai_model: str = "anthropic/claude-opus-4-6"
|
||||
|
||||
# Lightweight model for simple tasks such as email-label translation.
|
||||
# Can be from a different provider than ai_model.
|
||||
# If not configured (SIMPLE_MODEL env var unset), falls back to ai_model with a warning.
|
||||
# Example: "anthropic/claude-haiku-4-5-20251001", "openai/gpt-4o-mini"
|
||||
simple_model: str = ""
|
||||
|
||||
# Email — IMAP (receiving)
|
||||
imap_host: str = ""
|
||||
imap_port: int = 993
|
||||
imap_username: str = ""
|
||||
imap_password: str = ""
|
||||
imap_use_ssl: bool = True
|
||||
|
||||
# Email — SMTP (sending)
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_use_tls: bool = True
|
||||
|
||||
# Registration email address shown to parents
|
||||
registration_email: str = ""
|
||||
|
||||
# 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
|
||||
|
||||
# Extended thinking — Anthropic models only.
|
||||
# When set, enables the thinking phase before the LLM replies.
|
||||
# Recommended value: 8000 (tokens). Set to None/unset to disable.
|
||||
thinking_budget: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Config":
|
||||
ai_model = os.getenv("AI_MODEL", "anthropic/claude-opus-4-6")
|
||||
simple_model = os.getenv("SIMPLE_MODEL", "")
|
||||
if not simple_model:
|
||||
logger.warning(
|
||||
"SIMPLE_MODEL not configured — falling back to AI_MODEL (%s) for simple tasks "
|
||||
"(set SIMPLE_MODEL to a cheaper model, e.g. anthropic/claude-haiku-4-5-20251001)",
|
||||
ai_model,
|
||||
)
|
||||
simple_model = ai_model
|
||||
return cls(
|
||||
ai_model=ai_model,
|
||||
simple_model=simple_model,
|
||||
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")),
|
||||
thinking_budget=(
|
||||
int(os.getenv("THINKING_BUDGET"))
|
||||
if os.getenv("THINKING_BUDGET")
|
||||
else None
|
||||
),
|
||||
)
|
||||
@@ -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()
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
"""LLM completion via litellm — supports any provider with a single call."""
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import litellm
|
||||
|
||||
|
||||
async def acomplete(
|
||||
model: str,
|
||||
system: str,
|
||||
messages: list,
|
||||
thinking_budget: int | None = None,
|
||||
) -> str:
|
||||
"""Call any LLM asynchronously and return the response text.
|
||||
|
||||
This is the async equivalent of ``complete()`` — use this from async
|
||||
handlers (e.g. Chainlit's ``@cl.on_message``) to avoid blocking the
|
||||
event loop and losing framework context variables.
|
||||
|
||||
Args:
|
||||
model: litellm model string, e.g. "anthropic/claude-opus-4-6".
|
||||
system: System prompt text.
|
||||
messages: List of objects with .role and .content attributes.
|
||||
thinking_budget: When set, enables extended thinking (Anthropic models
|
||||
only). See ``complete()`` for details.
|
||||
|
||||
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]
|
||||
|
||||
kwargs: dict = {"model": model, "messages": api_messages, "max_tokens": 2048}
|
||||
if thinking_budget is not None:
|
||||
kwargs["thinking"] = {"type": "enabled", "budget_tokens": thinking_budget}
|
||||
kwargs["max_tokens"] = thinking_budget + 4096
|
||||
|
||||
response = await litellm.acompletion(**kwargs)
|
||||
return response.choices[0].message.content
|
||||
|
||||
|
||||
def complete(
|
||||
model: str,
|
||||
system: str,
|
||||
messages: list,
|
||||
thinking_budget: int | None = None,
|
||||
) -> 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.
|
||||
thinking_budget: When set, enables extended thinking (Anthropic models
|
||||
only). The value is the token budget for the thinking phase; the
|
||||
final ``max_tokens`` is set to ``thinking_budget + 4096`` so the
|
||||
model has enough room to both think and reply.
|
||||
|
||||
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]
|
||||
|
||||
kwargs: dict = {"model": model, "messages": api_messages, "max_tokens": 2048}
|
||||
if thinking_budget is not None:
|
||||
kwargs["thinking"] = {"type": "enabled", "budget_tokens": thinking_budget}
|
||||
# max_tokens must exceed budget_tokens or the API returns an error.
|
||||
kwargs["max_tokens"] = thinking_budget + 4096
|
||||
|
||||
response = litellm.completion(**kwargs)
|
||||
return response.choices[0].message.content
|
||||
|
||||
|
||||
def stream_complete(
|
||||
model: str, system: str, messages: list
|
||||
) -> Generator[str, None, None]:
|
||||
"""Call any LLM with streaming and yield text chunks as they arrive.
|
||||
|
||||
Args:
|
||||
model: litellm model string (same format as ``complete``).
|
||||
system: System prompt text.
|
||||
messages: List of objects with .role and .content attributes.
|
||||
|
||||
Yields:
|
||||
Non-empty text chunks from the model's streamed response.
|
||||
"""
|
||||
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, stream=True
|
||||
)
|
||||
for chunk in response:
|
||||
delta = chunk.choices[0].delta.content
|
||||
if delta:
|
||||
yield delta
|
||||
@@ -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,204 @@
|
||||
"""Pure functions for building email context dicts from registration data."""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from ..models.registration import RegistrationData
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Swiss QR-bill payment constants (stable bank details — not in config)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
QR_IBAN = "CH14 0900 0000 4930 8018 8"
|
||||
QR_PAYEE = "Familienverein Fällanden Spielgruppen"
|
||||
QR_STREET = "Huebwisstrase 5"
|
||||
QR_PCODE = "8117"
|
||||
QR_CITY = "Fällanden"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatting helpers (pure functions, no side-effects)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def format_dob(dob_str: str) -> str:
|
||||
"""Return DD.MM.YYYY from a YYYY-MM-DD string, or the original on error."""
|
||||
try:
|
||||
return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y")
|
||||
except Exception:
|
||||
return dob_str or ""
|
||||
|
||||
|
||||
def calculate_age(dob_str: str) -> str:
|
||||
"""Return 'X Jahre, Y Monate' from a YYYY-MM-DD string."""
|
||||
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
|
||||
|
||||
|
||||
def format_types(types: list[str]) -> str:
|
||||
"""German label for a list of playgroup type keys (admin emails)."""
|
||||
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"
|
||||
|
||||
|
||||
def format_types_i18n(types: list[str], strings: dict) -> str:
|
||||
"""Localised label for playgroup type keys using the supplied string table."""
|
||||
type_map: dict = strings["types"]
|
||||
labels = [type_map.get(t, t) for t in types]
|
||||
return ", ".join(labels) if labels else ""
|
||||
|
||||
|
||||
def format_days(registration: RegistrationData) -> str:
|
||||
"""German day + type labels for admin emails."""
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
def format_days_i18n(registration: RegistrationData, strings: dict) -> str:
|
||||
"""Localised day + type labels using the supplied string table."""
|
||||
day_map: dict = strings["days"]
|
||||
type_map: dict = strings["types"]
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
def calculate_monthly_fee(registration: RegistrationData) -> str:
|
||||
"""Compute the monthly fee string from the booking selection."""
|
||||
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 = indoor_days * 130 + outdoor_days * 250
|
||||
return f"CHF {fee}.-"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_admin_new_context(
|
||||
registration: RegistrationData,
|
||||
registration_id: str,
|
||||
version: int,
|
||||
channel: str,
|
||||
) -> dict:
|
||||
"""Build the template context for the admin new-registration email."""
|
||||
now = datetime.utcnow()
|
||||
pg = registration.parent_guardian
|
||||
ec = registration.emergency_contact
|
||||
ch = registration.child
|
||||
channel_de = {"email": "E-Mail", "chat": "Chat"}.get(channel.lower(), channel.title())
|
||||
|
||||
return {
|
||||
"submitted_date": now.strftime("%d.%m.%Y"),
|
||||
"submitted_time": now.strftime("%H:%M"),
|
||||
"channel": channel_de,
|
||||
"registration_id": registration_id,
|
||||
"version": version,
|
||||
"child_name": ch.full_name or "",
|
||||
"child_dob": format_dob(ch.date_of_birth or ""),
|
||||
"child_age": calculate_age(ch.date_of_birth or ""),
|
||||
"child_needs": ch.special_needs or "Keine",
|
||||
"playgroup_types": format_types(registration.booking.playgroup_types),
|
||||
"days": format_days(registration),
|
||||
"monthly_fee": calculate_monthly_fee(registration),
|
||||
"parent_name": pg.full_name or "",
|
||||
"parent_street": pg.street_address or "",
|
||||
"parent_postal_code": pg.postal_code or "",
|
||||
"parent_city": pg.city or "",
|
||||
"parent_phone": pg.phone or "",
|
||||
"parent_email": pg.email or "",
|
||||
"emergency_name": ec.full_name or "",
|
||||
"emergency_phone": ec.phone or "",
|
||||
}
|
||||
|
||||
|
||||
def build_admin_update_context(
|
||||
registration: RegistrationData,
|
||||
registration_id: str,
|
||||
version: int,
|
||||
change_summary: dict,
|
||||
) -> dict:
|
||||
"""Build the template context for the admin registration-update email."""
|
||||
now = datetime.utcnow()
|
||||
pg = registration.parent_guardian
|
||||
|
||||
changes = [
|
||||
{"field": field_path, "old": values["old"], "new": values["new"]}
|
||||
for field_path, values in sorted(change_summary.items())
|
||||
]
|
||||
|
||||
return {
|
||||
"updated_date": now.strftime("%d.%m.%Y"),
|
||||
"updated_time": now.strftime("%H:%M"),
|
||||
"registration_id": registration_id,
|
||||
"version": version,
|
||||
"child_name": registration.child.full_name or "",
|
||||
"parent_email": pg.email or "",
|
||||
"changes": changes,
|
||||
"playgroup_types": format_types(registration.booking.playgroup_types),
|
||||
"days": format_days(registration),
|
||||
"monthly_fee": calculate_monthly_fee(registration),
|
||||
"parent_name": pg.full_name or "",
|
||||
"parent_street": pg.street_address or "",
|
||||
"parent_postal_code": pg.postal_code or "",
|
||||
"parent_city": pg.city or "",
|
||||
"parent_phone": pg.phone or "",
|
||||
}
|
||||
|
||||
|
||||
def build_parent_context(
|
||||
registration: RegistrationData,
|
||||
strings: dict,
|
||||
has_qr: bool = True,
|
||||
) -> dict:
|
||||
"""Build the template context for the parent confirmation email."""
|
||||
pg = registration.parent_guardian
|
||||
ec = registration.emergency_contact
|
||||
ch = registration.child
|
||||
parent_name = pg.full_name or pg.email or ""
|
||||
|
||||
return {
|
||||
"lang": "de" if strings.get("none") == "Keine" else "en",
|
||||
"strings": strings,
|
||||
"greeting": strings["greeting"].format(name=parent_name),
|
||||
"child_name": ch.full_name or "",
|
||||
"child_dob": format_dob(ch.date_of_birth or ""),
|
||||
"child_needs": ch.special_needs or strings["none"],
|
||||
"playgroup_types": format_types_i18n(registration.booking.playgroup_types, strings),
|
||||
"days": format_days_i18n(registration, strings),
|
||||
"monthly_fee": calculate_monthly_fee(registration),
|
||||
"has_indoor": "indoor" in registration.booking.playgroup_types,
|
||||
"has_qr": has_qr,
|
||||
"parent_name": pg.full_name or "",
|
||||
"parent_address": pg.street_address or "",
|
||||
"parent_postal_code": pg.postal_code or "",
|
||||
"parent_city": pg.city or "",
|
||||
"parent_phone": pg.phone or "",
|
||||
"parent_email": pg.email or "",
|
||||
"emergency_name": ec.full_name or "",
|
||||
"emergency_phone": ec.phone or "",
|
||||
"iban": QR_IBAN,
|
||||
"payee": QR_PAYEE,
|
||||
"payee_street": QR_STREET,
|
||||
"payee_postal_code": QR_PCODE,
|
||||
"payee_city": QR_CITY,
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"""i18n support for parent confirmation emails.
|
||||
|
||||
German is the canonical source language (de.yaml). For any other language
|
||||
the German labels are translated on-demand via an LLM call and cached
|
||||
in-process for the lifetime of the server — no static translation files to
|
||||
maintain, any language the parent writes in is supported automatically.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import litellm
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_I18N_DIR = Path(__file__).parent / "i18n"
|
||||
|
||||
# In-memory translation cache keyed by language code.
|
||||
_cache: dict[str, dict] = {}
|
||||
|
||||
# Pure data values that must never be sent to the LLM for translation.
|
||||
_PASSTHROUGH_KEYS = {"reg_fee_amount", "deposit_amount"}
|
||||
|
||||
_SYSTEM_PROMPT = """\
|
||||
You are a translation assistant for a Swiss playgroup registration system.
|
||||
Translate the following JSON label strings from German into {language}.
|
||||
|
||||
Rules:
|
||||
- Return ONLY a valid JSON object with the exact same keys and structure.
|
||||
- Preserve all {{placeholder}} variables exactly as-is (e.g. {{name}}).
|
||||
- Preserve all HTML tags and entities exactly (e.g. <strong>, ).
|
||||
- Keep proper nouns untranslated: "Spielgruppe Pumuckl", "Familienverein Fällanden".
|
||||
- Do not include any explanation or text outside the JSON."""
|
||||
|
||||
|
||||
def get_strings(language: str, model: str) -> dict:
|
||||
"""Return the label string table for *language*.
|
||||
|
||||
For German, loads directly from de.yaml (no LLM call).
|
||||
For all other languages, translates the German labels via LLM and caches
|
||||
the result in memory. Falls back to German if the LLM call fails.
|
||||
"""
|
||||
if language == "de":
|
||||
return _load_german()
|
||||
|
||||
if language in _cache:
|
||||
return _cache[language]
|
||||
|
||||
german = _load_german()
|
||||
translated = _translate(german, language, model)
|
||||
_cache[language] = translated
|
||||
return translated
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Evict all cached translations (intended for use in tests)."""
|
||||
_cache.clear()
|
||||
|
||||
|
||||
def _load_german() -> dict:
|
||||
with (_I18N_DIR / "de.yaml").open(encoding="utf-8") as fh:
|
||||
return yaml.safe_load(fh)
|
||||
|
||||
|
||||
def _translate(german: dict, language: str, model: str) -> dict:
|
||||
"""Translate the German label dict into *language* via LLM.
|
||||
|
||||
Returns the German dict unchanged if the LLM call fails or returns
|
||||
malformed JSON.
|
||||
"""
|
||||
passthrough = {k: german[k] for k in _PASSTHROUGH_KEYS if k in german}
|
||||
to_translate = {k: v for k, v in german.items() if k not in _PASSTHROUGH_KEYS}
|
||||
|
||||
system = _SYSTEM_PROMPT.format(language=language)
|
||||
payload = json.dumps(to_translate, ensure_ascii=False, indent=2)
|
||||
|
||||
try:
|
||||
response = litellm.completion(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": payload},
|
||||
],
|
||||
max_tokens=2048,
|
||||
)
|
||||
raw = response.choices[0].message.content.strip()
|
||||
|
||||
# Strip markdown code fences that some models add
|
||||
if raw.startswith("```"):
|
||||
raw = raw[raw.index("\n") + 1 :]
|
||||
raw = raw[: raw.rfind("```")]
|
||||
|
||||
translated: dict = json.loads(raw)
|
||||
translated.update(passthrough)
|
||||
return translated
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to translate email labels into %s — falling back to German", language
|
||||
)
|
||||
return german
|
||||
@@ -0,0 +1,57 @@
|
||||
subject: "Anmeldebestätigung – Spielgruppe Pumuckl"
|
||||
greeting: "Guten Tag {name}"
|
||||
intro: >-
|
||||
Deine Anmeldung für die Spielgruppe Pumuckl ist bei uns eingegangen.
|
||||
Hier ist eine Zusammenfassung:
|
||||
|
||||
child_section: "Angaben zum Kind"
|
||||
child_name: "Name"
|
||||
child_dob: "Geburtsdatum"
|
||||
child_needs: "Besondere Bedürfnisse"
|
||||
|
||||
booking_section: "Spielgruppen-Buchung"
|
||||
booking_type: "Art"
|
||||
booking_days: "Tage"
|
||||
|
||||
fees_section: "Kosten"
|
||||
monthly_fee: "Monatlicher Beitrag"
|
||||
reg_fee: "Anmeldegebühr (einmalig, erstes Jahr)"
|
||||
reg_fee_amount: "CHF 80.00"
|
||||
deposit: "Reinigungsdepot Innen (rückerstattbar)"
|
||||
deposit_amount: "CHF 50.00"
|
||||
|
||||
parent_section: "Deine Kontaktdaten"
|
||||
parent_name: "Name"
|
||||
parent_address: "Adresse"
|
||||
parent_phone: "Telefon"
|
||||
parent_email: "E-Mail"
|
||||
|
||||
emergency_section: "Notfallkontakt"
|
||||
emergency_name: "Name"
|
||||
emergency_phone: "Telefon"
|
||||
|
||||
payment_section: "Zahlungsinformationen"
|
||||
payment_intro: >-
|
||||
Bitte überweise die Anmeldegebühr von <strong>CHF 80.00</strong> auf folgendes Konto.
|
||||
Du kannst den QR-Code mit deiner Banking-App scannen:
|
||||
payment_intro_text: "Bitte überweise die Anmeldegebühr von CHF 80.00 auf folgendes Konto:"
|
||||
iban_label: "IBAN"
|
||||
payee_label: "Empfänger"
|
||||
amount_label: "Betrag"
|
||||
|
||||
closing: |
|
||||
Bei Fragen stehen wir dir gerne zur Verfügung. Wir freuen uns auf dein Kind!
|
||||
|
||||
Herzliche Grüsse
|
||||
Spielgruppe Pumuckl
|
||||
|
||||
none: "Keine"
|
||||
|
||||
days:
|
||||
monday: "Montag"
|
||||
wednesday: "Mittwoch"
|
||||
thursday: "Donnerstag"
|
||||
|
||||
types:
|
||||
indoor: "Innenspielgruppe"
|
||||
outdoor: "Waldspielgruppe"
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Admin email notifications — new registrations, updates, and parent confirmations."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.image import MIMEImage
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import qrcode
|
||||
import qrcode.constants
|
||||
from PIL import Image, ImageDraw
|
||||
from qrbill import QRBill
|
||||
|
||||
from ..models.registration import RegistrationData
|
||||
from .context import (
|
||||
QR_CITY,
|
||||
QR_IBAN,
|
||||
QR_PAYEE,
|
||||
QR_PCODE,
|
||||
QR_STREET,
|
||||
build_admin_new_context,
|
||||
build_admin_update_context,
|
||||
build_parent_context,
|
||||
format_types,
|
||||
)
|
||||
from .i18n import get_strings
|
||||
from .renderer import render_template
|
||||
|
||||
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,
|
||||
model: str = "anthropic/claude-haiku-4-5-20251001",
|
||||
) -> 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 []
|
||||
self._model = model
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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"– {format_types(types)}"
|
||||
)
|
||||
ctx = build_admin_new_context(registration, registration_id, version, channel)
|
||||
body = render_template("admin_new.txt.j2", ctx)
|
||||
|
||||
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}"
|
||||
ctx = build_admin_update_context(registration, registration_id, version, change_summary)
|
||||
body = render_template("admin_update.txt.j2", ctx)
|
||||
|
||||
self._send(
|
||||
to=to_addresses,
|
||||
cc=self._cc_emails,
|
||||
subject=subject,
|
||||
body=body,
|
||||
reply_to=registration.parent_guardian.email or "",
|
||||
)
|
||||
|
||||
def notify_parent(
|
||||
self,
|
||||
registration: RegistrationData,
|
||||
language: str = "de",
|
||||
) -> None:
|
||||
"""Send an HTML confirmation email to the parent with registration summary and QR-bill."""
|
||||
parent_email = registration.parent_guardian.email
|
||||
if not parent_email:
|
||||
logger.warning("No parent email in registration — confirmation not sent.")
|
||||
return
|
||||
|
||||
strings = get_strings(language, self._model)
|
||||
|
||||
try:
|
||||
qr_png = self._generate_qr_bill_png()
|
||||
except Exception:
|
||||
logger.exception("Failed to generate QR-bill PNG — omitting image from confirmation")
|
||||
qr_png = None
|
||||
|
||||
ctx = build_parent_context(registration, strings, has_qr=qr_png is not None)
|
||||
html_body = render_template("parent_confirmation.html.j2", ctx)
|
||||
text_body = render_template("parent_confirmation.txt.j2", ctx)
|
||||
subject = strings["subject"]
|
||||
|
||||
if not self._smtp_host:
|
||||
logger.warning(
|
||||
"SMTP not configured — parent confirmation NOT sent. Would have emailed %s: %s",
|
||||
parent_email,
|
||||
subject,
|
||||
)
|
||||
logger.debug("Parent confirmation body:\n%s", text_body)
|
||||
return
|
||||
|
||||
# MIME structure:
|
||||
# multipart/mixed
|
||||
# └── multipart/alternative
|
||||
# ├── text/plain (fallback)
|
||||
# └── multipart/related
|
||||
# ├── text/html (references cid:qrbill)
|
||||
# └── image/png (Content-ID: qrbill, inline)
|
||||
msg_outer = MIMEMultipart("mixed")
|
||||
msg_outer["From"] = self._from_email
|
||||
msg_outer["To"] = parent_email
|
||||
msg_outer["Subject"] = subject
|
||||
|
||||
msg_alt = MIMEMultipart("alternative")
|
||||
msg_alt.attach(MIMEText(text_body, "plain", "utf-8"))
|
||||
|
||||
if qr_png is not None:
|
||||
msg_related = MIMEMultipart("related")
|
||||
msg_related.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
img_part = MIMEImage(qr_png, "png")
|
||||
img_part.add_header("Content-ID", "<qrbill>")
|
||||
img_part.add_header("Content-Disposition", "inline", filename="qrbill.png")
|
||||
msg_related.attach(img_part)
|
||||
msg_alt.attach(msg_related)
|
||||
else:
|
||||
msg_alt.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
msg_outer.attach(msg_alt)
|
||||
|
||||
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, [parent_email], msg_outer.as_string())
|
||||
server.quit()
|
||||
logger.info("Parent confirmation sent to %s", parent_email)
|
||||
except Exception:
|
||||
logger.exception("Failed to send parent confirmation to %s", parent_email)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# QR-bill generation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _generate_qr_bill_png() -> bytes:
|
||||
"""Generate a Swiss QR-bill payment QR code as a PNG image.
|
||||
|
||||
Uses the fixed registration fee payment data (CHF 80.00).
|
||||
The QR code includes the Swiss cross overlay as required by the SIX Group standard.
|
||||
|
||||
Returns:
|
||||
PNG image bytes of the QR code.
|
||||
"""
|
||||
bill = QRBill(
|
||||
account=QR_IBAN,
|
||||
creditor={
|
||||
"name": QR_PAYEE,
|
||||
"street": QR_STREET,
|
||||
"pcode": QR_PCODE,
|
||||
"city": QR_CITY,
|
||||
"country": "CH",
|
||||
},
|
||||
amount="80.00",
|
||||
currency="CHF",
|
||||
)
|
||||
payload = bill.qr_data()
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
version=None,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=8,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(payload)
|
||||
qr.make(fit=True)
|
||||
pil_img: Image.Image = qr.make_image(fill_color="black", back_color="white").get_image()
|
||||
pil_img = pil_img.convert("RGB")
|
||||
|
||||
# Overlay Swiss cross in center (SIX Group standard)
|
||||
w, h = pil_img.size
|
||||
cross_size = max(int(w * 0.15), 20)
|
||||
cx, cy = w // 2, h // 2
|
||||
half = cross_size // 2
|
||||
bar = cross_size // 5
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
draw.rectangle([cx - half, cy - half, cx + half, cy + half], fill="white")
|
||||
draw.rectangle([cx - bar // 2, cy - half, cx + bar // 2, cy + half], fill="#FF0000")
|
||||
draw.rectangle([cx - half, cy - bar // 2, cx + half, cy + bar // 2], fill="#FF0000")
|
||||
|
||||
buf = io.BytesIO()
|
||||
pil_img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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,18 @@
|
||||
"""Jinja2 template renderer for email notifications."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
_env = Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
||||
autoescape=select_autoescape(enabled_extensions=["html.j2"]),
|
||||
keep_trailing_newline=True,
|
||||
)
|
||||
|
||||
|
||||
def render_template(name: str, context: dict) -> str:
|
||||
"""Render *name* (relative to the templates directory) with *context*."""
|
||||
return _env.get_template(name).render(**context)
|
||||
@@ -0,0 +1,42 @@
|
||||
===============================================
|
||||
NEUE SPIELGRUPPEN-ANMELDUNG
|
||||
===============================================
|
||||
|
||||
Eingereicht: {{ submitted_date }} um {{ submitted_time }} Uhr (UTC)
|
||||
Kanal: {{ channel }}
|
||||
Anmelde-ID: {{ registration_id }} (Version {{ version }})
|
||||
|
||||
-----------------------------------------------
|
||||
ANGABEN ZUM KIND
|
||||
-----------------------------------------------
|
||||
Name: {{ child_name }}
|
||||
Geburtsdatum: {{ child_dob }} (Alter: {{ child_age }})
|
||||
Bes. Bedürfnisse: {{ child_needs }}
|
||||
|
||||
-----------------------------------------------
|
||||
SPIELGRUPPEN-AUSWAHL
|
||||
-----------------------------------------------
|
||||
Art: {{ playgroup_types }}
|
||||
Tage: {{ days }}
|
||||
|
||||
Monatlicher Beitrag: {{ monthly_fee }}
|
||||
(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung)
|
||||
|
||||
-----------------------------------------------
|
||||
ELTERN / ERZIEHUNGSBERECHTIGTE
|
||||
-----------------------------------------------
|
||||
Name: {{ parent_name }}
|
||||
Adresse: {{ parent_street }}
|
||||
{{ parent_postal_code }} {{ parent_city }}
|
||||
Telefon: {{ parent_phone }}
|
||||
E-Mail: {{ parent_email }}
|
||||
|
||||
-----------------------------------------------
|
||||
NOTFALLKONTAKT
|
||||
-----------------------------------------------
|
||||
Name: {{ emergency_name }}
|
||||
Telefon: {{ emergency_phone }}
|
||||
|
||||
===============================================
|
||||
|
||||
Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht.
|
||||
@@ -0,0 +1,33 @@
|
||||
===============================================
|
||||
ANMELDUNGS-AKTUALISIERUNG
|
||||
===============================================
|
||||
|
||||
Aktualisiert: {{ updated_date }} um {{ updated_time }} Uhr (UTC)
|
||||
Anmelde-ID: {{ registration_id }} (Version {{ version }})
|
||||
Kind: {{ child_name }}
|
||||
Eltern-E-Mail: {{ parent_email }}
|
||||
|
||||
-----------------------------------------------
|
||||
WAS HAT SICH GEÄNDERT
|
||||
-----------------------------------------------
|
||||
{% for change in changes %}
|
||||
{{ change.field }}:
|
||||
Alt: {{ change.old }}
|
||||
Neu: {{ change.new }}
|
||||
{% else %}
|
||||
(keine Änderungen erkannt)
|
||||
{% endfor %}
|
||||
-----------------------------------------------
|
||||
AKTUELLE ANMELDUNG (nach Aktualisierung)
|
||||
-----------------------------------------------
|
||||
Spielgruppe: {{ playgroup_types }}
|
||||
Tage: {{ days }}
|
||||
Monatl. Beitrag: {{ monthly_fee }}
|
||||
|
||||
Elternteil: {{ parent_name }}
|
||||
Adresse: {{ parent_street }}, {{ parent_postal_code }} {{ parent_city }}
|
||||
Telefon: {{ parent_phone }}
|
||||
|
||||
===============================================
|
||||
|
||||
Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht.
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ lang }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
</head>
|
||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:0 auto;padding:20px;">
|
||||
|
||||
<div style="background:#2e7d32;color:white;padding:20px;border-radius:8px 8px 0 0;text-align:center;">
|
||||
<h1 style="margin:0;font-size:22px;">Spielgruppe Pumuckl</h1>
|
||||
<p style="margin:4px 0 0;font-size:13px;opacity:.85;">Familienverein Fällanden</p>
|
||||
</div>
|
||||
|
||||
<div style="background:white;border:1px solid #e0e0e0;border-top:none;padding:24px;border-radius:0 0 8px 8px;">
|
||||
|
||||
<p>{{ greeting }},</p>
|
||||
<p>{{ strings.intro }}</p>
|
||||
|
||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.child_section }}</h2>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.child_name }}</td><td>{{ child_name }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.child_dob }}</td><td>{{ child_dob }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.child_needs }}</td><td>{{ child_needs }}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.booking_section }}</h2>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.booking_type }}</td><td>{{ playgroup_types }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.booking_days }}</td><td>{{ days }}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.fees_section }}</h2>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.monthly_fee }}</td><td>{{ monthly_fee }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.reg_fee }}</td><td>{{ strings.reg_fee_amount }}</td></tr>
|
||||
{% if has_indoor %}
|
||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.deposit }}</td><td>{{ strings.deposit_amount }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.payment_section }}</h2>
|
||||
<p>{{ strings.payment_intro | safe }}</p>
|
||||
<table style="width:100%;border-collapse:collapse;background:#f8f8f8;border-radius:4px;">
|
||||
<tr>
|
||||
<td style="padding:8px;color:#666;width:40%;border-bottom:1px solid #e0e0e0;">{{ strings.iban_label }}</td>
|
||||
<td style="padding:8px;font-family:monospace;font-weight:bold;border-bottom:1px solid #e0e0e0;">{{ iban }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;color:#666;border-bottom:1px solid #e0e0e0;">{{ strings.payee_label }}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #e0e0e0;">{{ payee }}<br>{{ payee_street }}, {{ payee_postal_code }} {{ payee_city }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;color:#666;">{{ strings.amount_label }}</td>
|
||||
<td style="padding:8px;font-weight:bold;">CHF 80.00</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% if has_qr %}
|
||||
<div style="text-align:center;margin:20px 0;">
|
||||
<img src="cid:qrbill" alt="Swiss QR-Bill" style="max-width:380px;width:100%;border:1px solid #e0e0e0;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.parent_section }}</h2>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.parent_name }}</td><td>{{ parent_name }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_address }}</td><td>{{ parent_address }}, {{ parent_postal_code }} {{ parent_city }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_phone }}</td><td>{{ parent_phone }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_email }}</td><td>{{ parent_email }}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.emergency_section }}</h2>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.emergency_name }}</td><td>{{ emergency_name }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.emergency_phone }}</td><td>{{ emergency_phone }}</td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:24px;white-space:pre-line;">{{ strings.closing }}</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
{{ greeting }},
|
||||
|
||||
{{ strings.intro }}
|
||||
|
||||
===============================================
|
||||
{{ strings.child_section | upper }}
|
||||
===============================================
|
||||
{{ strings.child_name }}: {{ child_name }}
|
||||
{{ strings.child_dob }}: {{ child_dob }}
|
||||
{{ strings.child_needs }}: {{ child_needs }}
|
||||
|
||||
===============================================
|
||||
{{ strings.booking_section | upper }}
|
||||
===============================================
|
||||
{{ strings.booking_type }}: {{ playgroup_types }}
|
||||
{{ strings.booking_days }}: {{ days }}
|
||||
|
||||
===============================================
|
||||
{{ strings.fees_section | upper }}
|
||||
===============================================
|
||||
{{ strings.monthly_fee }}: {{ monthly_fee }}
|
||||
{{ strings.reg_fee }}: {{ strings.reg_fee_amount }}
|
||||
{% if has_indoor %}{{ strings.deposit }}: {{ strings.deposit_amount }}
|
||||
{% endif %}
|
||||
===============================================
|
||||
{{ strings.payment_section | upper }}
|
||||
===============================================
|
||||
{{ strings.payment_intro_text }}
|
||||
|
||||
{{ strings.iban_label }}: {{ iban }}
|
||||
{{ strings.payee_label }}: {{ payee }}
|
||||
{{ payee_street }}, {{ payee_postal_code }} {{ payee_city }}
|
||||
{{ strings.amount_label }}: CHF 80.00
|
||||
|
||||
===============================================
|
||||
{{ strings.parent_section | upper }}
|
||||
===============================================
|
||||
{{ strings.parent_name }}: {{ parent_name }}
|
||||
{{ strings.parent_address }}: {{ parent_address }}, {{ parent_postal_code }} {{ parent_city }}
|
||||
{{ strings.parent_phone }}: {{ parent_phone }}
|
||||
{{ strings.parent_email }}: {{ parent_email }}
|
||||
|
||||
===============================================
|
||||
{{ strings.emergency_section | upper }}
|
||||
===============================================
|
||||
{{ strings.emergency_name }}: {{ emergency_name }}
|
||||
{{ strings.emergency_phone }}: {{ emergency_phone }}
|
||||
|
||||
-----------------------------------------------
|
||||
|
||||
{{ strings.closing }}
|
||||
@@ -0,0 +1,271 @@
|
||||
"""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,
|
||||
"language": state.language,
|
||||
}
|
||||
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,195 @@
|
||||
"""Tests for the litellm wrapper in src/llm.py."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src import llm
|
||||
from src.models.conversation import ChatMessage
|
||||
|
||||
|
||||
def _make_async_mock(mocker, content: str):
|
||||
"""Return an awaitable mock that resolves to a response with the given content."""
|
||||
mock_response = mocker.MagicMock()
|
||||
mock_response.choices[0].message.content = content
|
||||
|
||||
async def _coro(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
return mocker.patch("litellm.acompletion", side_effect=_coro), mock_response
|
||||
|
||||
|
||||
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", [])
|
||||
|
||||
|
||||
class TestLlmStreamComplete:
|
||||
def _make_chunk(self, content):
|
||||
chunk = type("Chunk", (), {})()
|
||||
choice = type("Choice", (), {})()
|
||||
delta = type("Delta", (), {"content": content})()
|
||||
choice.delta = delta
|
||||
chunk.choices = [choice]
|
||||
return chunk
|
||||
|
||||
def test_yields_chunks(self, mocker):
|
||||
chunks = [self._make_chunk("Hal"), self._make_chunk("lo!")]
|
||||
mocker.patch("litellm.completion", return_value=iter(chunks))
|
||||
|
||||
result = list(llm.stream_complete("anthropic/claude-opus-4-6", "system", []))
|
||||
|
||||
assert result == ["Hal", "lo!"]
|
||||
|
||||
def test_skips_empty_deltas(self, mocker):
|
||||
chunks = [self._make_chunk("Hello"), self._make_chunk(None), self._make_chunk("!")]
|
||||
mocker.patch("litellm.completion", return_value=iter(chunks))
|
||||
|
||||
result = list(llm.stream_complete("anthropic/claude-opus-4-6", "system", []))
|
||||
|
||||
assert result == ["Hello", "!"]
|
||||
|
||||
def test_passes_stream_true(self, mocker):
|
||||
mock_completion = mocker.patch("litellm.completion", return_value=iter([]))
|
||||
|
||||
list(llm.stream_complete("anthropic/claude-opus-4-6", "system", []))
|
||||
|
||||
assert mock_completion.call_args.kwargs["stream"] is True
|
||||
|
||||
def test_passes_model(self, mocker):
|
||||
mock_completion = mocker.patch("litellm.completion", return_value=iter([]))
|
||||
|
||||
list(llm.stream_complete("openai/gpt-4o", "system", []))
|
||||
|
||||
assert mock_completion.call_args.kwargs["model"] == "openai/gpt-4o"
|
||||
|
||||
def test_system_prompt_prepended(self, mocker):
|
||||
mock_completion = mocker.patch("litellm.completion", return_value=iter([]))
|
||||
|
||||
list(llm.stream_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(self, mocker):
|
||||
mock_completion = mocker.patch("litellm.completion", return_value=iter([]))
|
||||
|
||||
chat = [ChatMessage(role="user", content="Hallo")]
|
||||
list(llm.stream_complete("anthropic/claude-opus-4-6", "system", chat))
|
||||
|
||||
messages = mock_completion.call_args.kwargs["messages"]
|
||||
assert messages[1] == {"role": "user", "content": "Hallo"}
|
||||
|
||||
|
||||
class TestLlmAComplete:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_model_reply(self, mocker):
|
||||
mock_acompletion, _ = _make_async_mock(mocker, "Hallo! Wie heisst dein Kind?")
|
||||
|
||||
result = await llm.acomplete("anthropic/claude-opus-4-6", "system prompt", [])
|
||||
|
||||
assert result == "Hallo! Wie heisst dein Kind?"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_model_to_litellm(self, mocker):
|
||||
mock_acompletion, _ = _make_async_mock(mocker, "ok")
|
||||
|
||||
await llm.acomplete("openai/gpt-4o", "system", [])
|
||||
|
||||
call_kwargs = mock_acompletion.call_args.kwargs
|
||||
assert call_kwargs["model"] == "openai/gpt-4o"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prompt_prepended_as_system_message(self, mocker):
|
||||
mock_acompletion, _ = _make_async_mock(mocker, "ok")
|
||||
|
||||
await llm.acomplete("anthropic/claude-opus-4-6", "You are helpful.", [])
|
||||
|
||||
messages = mock_acompletion.call_args.kwargs["messages"]
|
||||
assert messages[0] == {"role": "system", "content": "You are helpful."}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_messages_appended_after_system(self, mocker):
|
||||
mock_acompletion, _ = _make_async_mock(mocker, "ok")
|
||||
|
||||
chat = [
|
||||
ChatMessage(role="user", content="Hallo"),
|
||||
ChatMessage(role="assistant", content="Guten Tag"),
|
||||
]
|
||||
await llm.acomplete("anthropic/claude-opus-4-6", "system", chat)
|
||||
|
||||
messages = mock_acompletion.call_args.kwargs["messages"]
|
||||
assert messages[1] == {"role": "user", "content": "Hallo"}
|
||||
assert messages[2] == {"role": "assistant", "content": "Guten Tag"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_tokens_passed(self, mocker):
|
||||
mock_acompletion, _ = _make_async_mock(mocker, "ok")
|
||||
|
||||
await llm.acomplete("anthropic/claude-opus-4-6", "system", [])
|
||||
|
||||
assert mock_acompletion.call_args.kwargs["max_tokens"] == 2048
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_propagates(self, mocker):
|
||||
async def _raise(*args, **kwargs):
|
||||
raise RuntimeError("API error")
|
||||
|
||||
mocker.patch("litellm.acompletion", side_effect=_raise)
|
||||
|
||||
with pytest.raises(RuntimeError, match="API error"):
|
||||
await llm.acomplete("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,347 @@
|
||||
"""Tests for AdminNotifier and notification helper functions."""
|
||||
|
||||
import email
|
||||
import json
|
||||
from email.header import decode_header
|
||||
|
||||
import pytest
|
||||
|
||||
from src.notifications.notifier import AdminNotifier
|
||||
from src.notifications.context import (
|
||||
calculate_age,
|
||||
calculate_monthly_fee,
|
||||
format_types,
|
||||
build_parent_context,
|
||||
)
|
||||
from src.notifications.i18n import get_strings, clear_cache
|
||||
from src.notifications.renderer import render_template
|
||||
from src.models.registration import RegistrationData, Booking, BookingDay
|
||||
|
||||
|
||||
def _decoded_subject(msg_str: str) -> str:
|
||||
"""Parse a raw MIME message string and return the decoded Subject header."""
|
||||
msg = email.message_from_string(msg_str)
|
||||
raw_subject = msg.get("Subject", "")
|
||||
parts = decode_header(raw_subject)
|
||||
return "".join(
|
||||
chunk.decode(enc or "utf-8") if isinstance(chunk, bytes) else chunk
|
||||
for chunk, enc in parts
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_translation_cache():
|
||||
"""Clear the in-memory translation cache before every test."""
|
||||
clear_cache()
|
||||
yield
|
||||
clear_cache()
|
||||
|
||||
|
||||
@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"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier_no_smtp():
|
||||
"""Notifier in dev mode (no SMTP host)."""
|
||||
return AdminNotifier(
|
||||
smtp_host="",
|
||||
smtp_port=587,
|
||||
username="",
|
||||
password="",
|
||||
from_email="agent@example.com",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatTypes:
|
||||
def test_indoor_label(self):
|
||||
result = format_types(["indoor"])
|
||||
assert "Innen" in result or "indoor" in result.lower()
|
||||
|
||||
def test_outdoor_label(self):
|
||||
result = format_types(["outdoor"])
|
||||
assert "Wald" in result or "outdoor" in result.lower()
|
||||
|
||||
def test_both_labels(self):
|
||||
result = format_types(["indoor", "outdoor"])
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_age
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCalculateAge:
|
||||
def test_returns_age_string(self):
|
||||
result = calculate_age("2022-01-01")
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_invalid_dob_returns_original_string(self):
|
||||
result = calculate_age("not-a-date")
|
||||
assert result == "not-a-date"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_monthly_fee
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCalculateMonthlyFee:
|
||||
def test_indoor_one_day(self, complete_registration):
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["indoor"],
|
||||
selected_days=[BookingDay(day="monday", type="indoor")],
|
||||
)
|
||||
fee = calculate_monthly_fee(complete_registration)
|
||||
assert "130" in fee
|
||||
|
||||
def test_indoor_two_days(self, complete_registration):
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["indoor"],
|
||||
selected_days=[
|
||||
BookingDay(day="monday", type="indoor"),
|
||||
BookingDay(day="wednesday", type="indoor"),
|
||||
],
|
||||
)
|
||||
fee = calculate_monthly_fee(complete_registration)
|
||||
assert "260" in fee
|
||||
|
||||
def test_indoor_three_days(self, 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 = calculate_monthly_fee(complete_registration)
|
||||
assert "390" in fee
|
||||
|
||||
def test_outdoor_one_day(self, complete_registration):
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["outdoor"],
|
||||
selected_days=[BookingDay(day="monday", type="outdoor")],
|
||||
)
|
||||
fee = calculate_monthly_fee(complete_registration)
|
||||
assert "250" in fee
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _send — SMTP interaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSend:
|
||||
def test_send_calls_smtp(self, notifier, mocker):
|
||||
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]
|
||||
assert "a@example.com" in recipients
|
||||
assert "b@example.com" in recipients
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_strings — i18n / LLM translation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetStrings:
|
||||
def test_german_loads_from_yaml_without_llm(self, mocker):
|
||||
"""German must never trigger an LLM call."""
|
||||
mock_litellm = mocker.patch("litellm.completion")
|
||||
strings = get_strings("de", "some-model")
|
||||
mock_litellm.assert_not_called()
|
||||
assert strings["subject"] == "Anmeldebestätigung – Spielgruppe Pumuckl"
|
||||
|
||||
def test_other_language_calls_llm(self, mocker):
|
||||
"""Non-German languages should call litellm.completion."""
|
||||
german = get_strings("de", "some-model")
|
||||
translated = {**german, "subject": "Registration Confirmation – Spielgruppe Pumuckl"}
|
||||
mock_litellm = mocker.patch("litellm.completion")
|
||||
mock_litellm.return_value.choices[0].message.content = json.dumps(translated)
|
||||
|
||||
result = get_strings("en", "some-model")
|
||||
|
||||
mock_litellm.assert_called_once()
|
||||
assert result["subject"] == "Registration Confirmation – Spielgruppe Pumuckl"
|
||||
|
||||
def test_result_is_cached(self, mocker):
|
||||
"""The LLM is only called once per language per process lifetime."""
|
||||
german = get_strings("de", "some-model")
|
||||
mock_litellm = mocker.patch("litellm.completion")
|
||||
mock_litellm.return_value.choices[0].message.content = json.dumps(german)
|
||||
|
||||
get_strings("fr", "some-model")
|
||||
get_strings("fr", "some-model")
|
||||
|
||||
assert mock_litellm.call_count == 1
|
||||
|
||||
def test_llm_failure_falls_back_to_german(self, mocker):
|
||||
"""If the LLM raises, the German strings are returned silently."""
|
||||
mocker.patch("litellm.completion", side_effect=RuntimeError("network error"))
|
||||
|
||||
result = get_strings("it", "some-model")
|
||||
|
||||
assert result["subject"] == "Anmeldebestätigung – Spielgruppe Pumuckl"
|
||||
|
||||
def test_passthrough_keys_not_altered(self, mocker):
|
||||
"""reg_fee_amount and deposit_amount must survive translation unchanged."""
|
||||
german = get_strings("de", "some-model")
|
||||
# Return translation that omits passthrough keys (as the LLM would)
|
||||
without_passthrough = {k: v for k, v in german.items()
|
||||
if k not in {"reg_fee_amount", "deposit_amount"}}
|
||||
mocker.patch("litellm.completion").return_value.choices[0].message.content = (
|
||||
json.dumps(without_passthrough)
|
||||
)
|
||||
|
||||
result = get_strings("en", "some-model")
|
||||
|
||||
assert result["reg_fee_amount"] == "CHF 80.00"
|
||||
assert result["deposit_amount"] == "CHF 50.00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# notify_parent — parent confirmation email
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNotifyParent:
|
||||
def test_notify_parent_calls_send(self, notifier, complete_registration, mocker):
|
||||
"""notify_parent dispatches an email to the parent address."""
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
mock_server = mock_smtp_cls.return_value
|
||||
|
||||
notifier.notify_parent(complete_registration, language="de")
|
||||
|
||||
mock_server.sendmail.assert_called_once()
|
||||
call_args = mock_server.sendmail.call_args
|
||||
recipients = call_args[0][1]
|
||||
assert "anna.muster@example.com" in recipients
|
||||
|
||||
def test_notify_parent_german_subject(self, notifier, complete_registration, mocker):
|
||||
"""German language produces a German subject line without any LLM call."""
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
captured = {}
|
||||
|
||||
def fake_sendmail(from_, to_, msg_str):
|
||||
captured["msg"] = msg_str
|
||||
|
||||
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
|
||||
|
||||
notifier.notify_parent(complete_registration, language="de")
|
||||
|
||||
assert "Anmeldebestätigung" in _decoded_subject(captured["msg"])
|
||||
|
||||
def test_notify_parent_english_subject(self, notifier, complete_registration, mocker):
|
||||
"""English language produces an English subject line via LLM translation."""
|
||||
german = get_strings("de", "some-model")
|
||||
english = {**german, "subject": "Registration Confirmation – Spielgruppe Pumuckl"}
|
||||
mocker.patch("litellm.completion").return_value.choices[0].message.content = (
|
||||
json.dumps(english)
|
||||
)
|
||||
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
captured = {}
|
||||
|
||||
def fake_sendmail(from_, to_, msg_str):
|
||||
captured["msg"] = msg_str
|
||||
|
||||
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
|
||||
|
||||
notifier.notify_parent(complete_registration, language="en")
|
||||
|
||||
assert "Registration Confirmation" in _decoded_subject(captured["msg"])
|
||||
|
||||
def test_notify_parent_unknown_language_falls_back_to_de(
|
||||
self, notifier, complete_registration, mocker
|
||||
):
|
||||
"""When the LLM call fails, the email is sent in German."""
|
||||
mocker.patch("litellm.completion", side_effect=RuntimeError("timeout"))
|
||||
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
captured = {}
|
||||
|
||||
def fake_sendmail(from_, to_, msg_str):
|
||||
captured["msg"] = msg_str
|
||||
|
||||
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
|
||||
|
||||
notifier.notify_parent(complete_registration, language="fr")
|
||||
|
||||
assert "Anmeldebestätigung" in _decoded_subject(captured["msg"])
|
||||
|
||||
def test_notify_parent_no_smtp_skips_send(
|
||||
self, notifier_no_smtp, complete_registration, mocker
|
||||
):
|
||||
"""When SMTP host is empty, no sendmail call is made."""
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
|
||||
notifier_no_smtp.notify_parent(complete_registration, language="de")
|
||||
|
||||
mock_smtp_cls.assert_not_called()
|
||||
|
||||
def test_text_body_contains_iban(self, complete_registration):
|
||||
"""Rendered plain-text body includes the IBAN regardless of language."""
|
||||
strings = get_strings("de", "some-model")
|
||||
ctx = build_parent_context(complete_registration, strings, has_qr=False)
|
||||
text = render_template("parent_confirmation.txt.j2", ctx)
|
||||
assert "CH14" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _generate_qr_bill_png
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenerateQrBillPng:
|
||||
def test_returns_nonempty_bytes(self, notifier):
|
||||
"""_generate_qr_bill_png returns a non-empty bytes object (PNG)."""
|
||||
png = notifier._generate_qr_bill_png()
|
||||
assert isinstance(png, bytes)
|
||||
assert len(png) > 0
|
||||
|
||||
def test_returns_png_signature(self, notifier):
|
||||
"""Output starts with the PNG magic bytes."""
|
||||
png = notifier._generate_qr_bill_png()
|
||||
assert png[:4] == b"\x89PNG"
|
||||
@@ -0,0 +1,176 @@
|
||||
"""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
|
||||
|
||||
def test_save_registration_persists_language(self, store, fresh_state, complete_registration):
|
||||
fresh_state.registration = complete_registration
|
||||
fresh_state.completed = True
|
||||
fresh_state.language = "en"
|
||||
store.save_registration(fresh_state)
|
||||
current = store.get_current_registration(fresh_state.parent_email)
|
||||
assert current is not None
|
||||
assert current["metadata"]["language"] == "en"
|
||||
|
||||
def test_save_registration_defaults_language_to_de(self, store, fresh_state, complete_registration):
|
||||
fresh_state.registration = complete_registration
|
||||
fresh_state.completed = True
|
||||
# language defaults to "de" in ConversationState
|
||||
store.save_registration(fresh_state)
|
||||
current = store.get_current_registration(fresh_state.parent_email)
|
||||
assert current["metadata"]["language"] == "de"
|
||||
Reference in New Issue
Block a user