Author SHA1 Message Date
Claude 905debb48e Clarify API key docs and language support in README
- Remove ANTHROPIC_API_KEY and OPENAI_API_KEY from the Required
  variables table; they are provider-specific, not universally
  required. Add a note pointing readers to the Switching AI providers
  section instead.
- Replace "Supports German and English; defaults to German" with
  "Responds in any language the parent uses; defaults to German" to
  accurately reflect that the agent is fully language-agnostic.

https://claude.ai/code/session_01F9RoUQYKktPrmsvemSYrPk
2026-02-21 22:13:33 +00:00
Markus GrafandGitHub 2fac05c4ee Merge pull request #2 from gurix/claude/email-agent-multi-model-c3ShZ
Implement Python email agent with multi-model AI support
2026-02-21 23:06:40 +01:00
Claude eba450c5a5 Fix email quoting to include full conversation history
fetch_unread_messages now returns both `body` (stripped, for the LLM)
and `raw_body` (full with nested quotes, for the outgoing reply).
main.py passes raw_body as quoted_text so each reply carries the
complete conversation thread, not just the single last message.

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-21 21:54:35 +00:00
Claude 8217b33f38 Remove hardcoded language list from greeting prompt
Replace the explicit enumeration of German, English, French, Italian,
and Spanish with "any human language" to be inclusive of all parents
(Arabic, Turkish, etc.).

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-21 21:44:38 +00:00
Claude db97a357c9 Restore per-leader routing with configurable email addresses
Previously routing was hardcoded (Andrea for indoor, Barbara for outdoor).
Then it was replaced with a flat ADMIN_EMAILS list which lost the routing.
This commit restores routing via three separate env vars:

  ADMIN_EMAIL_INDOOR  — indoor leader, To when indoor days are booked
  ADMIN_EMAIL_OUTDOOR — outdoor leader, To when outdoor days are booked
  ADMIN_EMAIL_CC      — always Cc'd (comma-separated for multiple)

For testing, set all three to your own address so no real leader gets mail.

Changes:
- Config: replaced admin_emails with admin_email_indoor/outdoor/cc fields
- AdminNotifier: replaced admin_emails param with indoor_email/outdoor_email/
  cc_emails; _recipients_for() restored as an instance method using these
- main.py: wires the three new config fields into AdminNotifier
- .env.example: documents the three new variables with production defaults
- Tests: fixture updated to use new params

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-21 21:31:49 +00:00
Claude 7fb1d1fa0f Make admin notification recipients configurable via ADMIN_EMAILS
Previously the To/Cc addresses were hardcoded in notifier.py (Andrea,
Barbara, Markus). This caused accidental emails to production contacts
during testing.

Changes:
- New ADMIN_EMAILS env var: comma-separated list of addresses.
  First address → To; remaining addresses → Cc.
- AdminNotifier now accepts admin_emails list; warns and skips if empty.
- Removed hardcoded _INDOOR_EMAIL / _OUTDOOR_EMAIL / _ADMIN_CC_EMAIL
  constants and the _recipients_for() routing method.
- Config.from_env() parses ADMIN_EMAILS into a list.
- main.py passes config.admin_emails to AdminNotifier.
- .env.example documents the new variable with production example.
- Tests: fixture updated; TestRecipientsFor removed (routing gone).

For testing: ADMIN_EMAILS=you@example.com
For production: ADMIN_EMAILS=andrea.sigrist@gmx.net,baba.laeubli@gmail.com,spielgruppen@familien-verein.ch

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-21 21:23:51 +00:00
Claude af96c7a310 German admin emails and German-only registration data storage
notifier.py:
- All email body text translated to German (section headers, labels,
  day names, playgroup type names, age format, change diff labels)
- Subject lines changed to German: "Neue Anmeldung:" / "Anmeldung aktualisiert:"
- Channel label localised: "E-Mail" / "Chat"
- Fallback special needs label changed to "Keine"

prompts.py:
- New rule: always store free-text field values (especially specialNeeds)
  in German in `updates`, translating from the parent's language if needed;
  use "Keine" for no special needs

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-21 21:13:19 +00:00
Claude c488a0061e Improve email conversation efficiency: language hint + aggressive info collection
Two prompt changes:

1. Greeting step: explicitly tell parents they can write in any language
   (German, English, French, Italian, Spanish, …) and the agent will reply
   in the same language. Also kick off info collection immediately by asking
   for child name + DOB in the greeting reply.

2. Personality: replace the "1–2 questions at a time" rule with a strategy
   that gathers all relevant questions per step in one message (woven into
   natural sentences, not a form), and explicitly re-asks any unanswered
   questions before advancing — no open question is silently skipped.

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-21 21:01:01 +00:00
Claude 4440b00d91 Prepend email headers to message text passed to LLM
The LLM previously only received the stripped email body, giving it no
way to extract the sender's email address for the parentGuardian.email
field. Prepend Von:/Betreff: headers to every message so the LLM can
read the From address and subject without asking the parent for them.

The quoted_text sent back in the reply still uses only msg["body"] so
the quote block stays clean.

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-21 20:46:20 +00:00
Claude 9c33bafbf3 Prohibit markdown in email reply text
Tell the LLM explicitly that the reply field must be plain text with no
markdown (no bold, italic, headers, bullet points, or backticks). Email
clients display raw text so markdown syntax would appear as literal
characters rather than formatting.

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-21 20:10:41 +00:00
Claude fef0388534 Quote parent's message in email replies
Add standard > -prefixed quote block to outbound replies so parents can
see what they wrote in the previous message, matching natural email client
behaviour. The quote header uses the German "Am <date> schrieb <addr>:"
convention (matching Outlook/Thunderbird).

- email_channel.py: add _build_quoted_block() helper; extend send_reply()
  with optional quoted_text/quoted_from params
- main.py: pass msg["body"] and msg["from"] as quoted_text/quoted_from

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-21 20:05:12 +00:00
Claude 98a5f5b5b1 Resolve PR review comments
- prompts.py: correct age restrictions (indoor ≥2 yrs, outdoor ≥2.5 yrs)
  Previously had indoor ≥2.5 and outdoor ≥3, which was too restrictive
- README.md: add full setup and configuration guide covering prerequisites,
  installation, env var reference, provider switching, cron scheduling,
  running tests, and knowledge base editing

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-21 13:35:35 +00:00
gurix 60f056ece4 Merge branch 'main' into claude/email-agent-multi-model-c3ShZ 2026-02-21 14:24:33 +01:00
Markus GrafandGitHub cfa90174e3 Merge pull request #3 from gurix/claude/update-readme-purpose-MxY60
Update README with project purpose and capabilities
2026-02-21 14:22:10 +01:00
Claude 7f55cdd204 Add pytest test suite (92 tests, all passing)
Covers every module in src/ with unit tests:

- tests/conftest.py        shared fixtures (complete_registration, fresh_state, …)
- tests/test_models.py     RegistrationData.is_complete(), to_dict/from_dict round-trips
- tests/test_storage.py    normalize_email, _diff_registrations, ConversationStore CRUD,
                           registration versioning
- tests/test_llm.py        litellm wrapper — message construction, model passthrough,
                           error propagation
- tests/test_agent.py      EmailAgent — new/existing conversations, registration
                           completion, admin notification, fallback on LLM error,
                           JSON parsing, _apply_updates
- tests/test_notifier.py   AdminNotifier routing, fee calculation, SMTP dispatch
- tests/test_knowledge_base.py  KnowledgeBase loading and reload

All external I/O (litellm, SMTP, filesystem) is mocked. Tests run fast (~6s)
with no network access required.

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-21 08:07:31 +00:00
Claude 431847a8b7 Replace custom provider abstraction with litellm
Drops the src/providers/ package (base class, AnthropicProvider,
OpenAIProvider, factory) in favour of a single src/llm.py that calls
litellm.completion() directly. litellm handles provider routing,
authentication, and SDK differences for 100+ providers without any
code we need to maintain.

Changes:
- Delete src/providers/ entirely
- Add src/llm.py — one complete() function wrapping litellm
- src/agent/core.py: EmailAgent takes model: str instead of LLMProvider
- src/config.py: ai_provider + api key fields → single ai_model string
  in litellm format (e.g. "anthropic/claude-opus-4-6")
- main.py: remove provider factory wiring; pass config.ai_model to agent
- .env.example: simplify AI section, show litellm model string examples
- pyproject.toml: replace anthropic + openai deps with litellm>=1.0.0
- uv.lock: regenerated

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-21 07:35:37 +00:00
Claude 1ba42f9497 docs: document flock + cron approach for email agent scheduling
https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-21 06:41:57 +00:00
Claude 05d4b51e7a Upgrade to Python 3.13
- .python-version: 3.11 → 3.13
- pyproject.toml: requires-python = ">=3.13"
- uv.lock: regenerated against Python 3.13.12

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-20 22:30:43 +00:00
Claude a174023ee5 Switch to uv as dependency manager
- Add pyproject.toml with project metadata and pinned dependency ranges
- Add uv.lock (generated by `uv lock`) for reproducible installs
- Add .python-version pinning Python 3.11
- Remove requirements.txt (superseded by pyproject.toml)

Install: `uv sync`
Run: `uv run python main.py`

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-20 22:29:37 +00:00
Claude 0c3b5a9033 Implement email-address-based conversation matching
Closes the gap where parents sending a new email (instead of replying)
would lose their registration progress. All changes follow the
email-based-conversation-matching OpenSpec change.

Key changes
-----------
storage/json_store.py
  - normalize_email() helper (lowercase + trim)
  - Conversations now keyed by sender email address, not thread ID
  - Versioned registration storage: data/registrations/<email>/v<N>_<ts>.json
  - current.json always reflects the latest version
  - save_registration() returns (email_key, version) tuple
  - save_registration_version() for updates with change_summary
  - get_registration_history() returns all versions in order

models/conversation.py
  - Added last_inbound_message_id field for reply threading (not matching)

channels/email_channel.py
  - fetch_unread_messages() no longer exposes thread_id
  - Conversation matching removed from channel layer (now in agent)
  - Removed _resolve_thread_id() — threading headers kept for SMTP only

agent/core.py
  - process_message() takes parent_email + inbound_message_id (no thread ID)
  - Looks up conversation by normalized email address
  - Post-completion handler: detects intent (question / update / new_child)
  - Registration updates: diffs old vs new, versions storage, notifies admin

agent/prompts.py
  - build_system_prompt() dispatches to registration or post-completion prompt
  - Post-completion prompt guides LLM to return intent field
  - Reminder language updated: no expiration threats

notifications/notifier.py
  - notify_admin() accepts version parameter
  - notify_registration_update() sends "Registration Updated" emails with diff
  - _build_update_body() includes field-level old→new change summary

main.py
  - Poll loop passes parent_email + inbound_message_id to agent (no thread_id)

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-20 22:15:49 +00:00
Claude 968475804c Update README with project purpose and capabilities
Replaces the placeholder heading with a concise description of what
Meister-Eder does, its channels, and current status.

https://claude.ai/code/session_01XinJbQWij74daNnyZ9grSw
2026-02-20 22:14:45 +00:00
gurix c37862b75e Merge branch 'main' into claude/email-agent-multi-model-c3ShZ 2026-02-20 22:30:15 +01:00
gurixandClaude Opus 4.5 b10ff7a4fe Add change spec: email-based conversation matching
Replace thread-ID-based conversation matching with email-address-based
matching for more reliable conversation continuity. Key changes:

- One conversation per email address (simpler model)
- No data expiration (conversations persist indefinitely)
- Post-completion support (questions and registration updates)
- Versioned storage for registration updates (audit trail)
- Admin notifications for registration changes

This addresses the gap where parents sending new emails (instead of
replying) would lose their registration progress.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 22:00:21 +01:00
Claude b82ff27efd Implement Python email agent with multi-model AI support
Adds a complete email-based registration agent for Spielgruppe Pumuckl
based on the OpenSpec define-project-scope specifications.

Architecture
- Channel-agnostic EmailAgent core — no email-specific code in business logic
- Pluggable AI provider layer: Anthropic (Claude) and OpenAI (GPT) supported
  via a shared LLMProvider interface; switch with AI_PROVIDER env var
- IMAP polling for inbound emails with thread-tracking via email headers
  (Message-ID / In-Reply-To / References)
- SMTP for outbound replies and admin notifications
- File-based JSON storage for conversation state and completed registrations
- Admin-editable knowledge-base loaded from markdown files at startup

Key files
  src/config.py                  — env-var configuration
  src/providers/base.py          — abstract LLMProvider
  src/providers/anthropic_provider.py — Claude backend
  src/providers/openai_provider.py    — OpenAI backend
  src/agent/core.py              — EmailAgent orchestrator
  src/agent/prompts.py           — system prompt builder (KB + registration state)
  src/models/registration.py     — RegistrationData matching the JSON schema
  src/models/conversation.py     — ConversationState persisted per thread
  src/channels/email_channel.py  — IMAP/SMTP I/O + quoted-text stripping
  src/storage/json_store.py      — conversation & registration persistence
  src/notifications/notifier.py  — admin notification routing by playgroup type
  main.py                        — polling entry point
  requirements.txt               — anthropic, openai, python-dotenv, jsonschema
  .env.example                   — configuration template

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
2026-02-20 20:17:00 +00:00
Markus GrafandGitHub 4d44d4ee58 Merge pull request #1 from gurix/claude/claude-md-mlv4qouppkw29jdy-GvDt5
Add CLAUDE.md with comprehensive codebase documentation
2026-02-20 18:00:08 +01:00
39 changed files with 4827 additions and 1 deletions
+69
View File
@@ -0,0 +1,69 @@
# ---------------------------------------------------------------
# Meister-Eder Email Agent — Configuration Template
# ---------------------------------------------------------------
# Copy this file to .env and fill in your values.
# The .env file must NOT be committed to version control.
# ---------------------------------------------------------------
# ---------------------------------------------------------------
# AI Model (via litellm — supports any provider)
# ---------------------------------------------------------------
# Use litellm model strings: "<provider>/<model-name>"
# Examples:
# anthropic/claude-opus-4-6 (default)
# openai/gpt-4o
# gemini/gemini-2.0-flash
AI_MODEL=anthropic/claude-opus-4-6
# ---------------------------------------------------------------
# API Keys — set the one matching your chosen model's provider
# ---------------------------------------------------------------
ANTHROPIC_API_KEY=sk-ant-...
# OPENAI_API_KEY=sk-...
# GEMINI_API_KEY=...
# ---------------------------------------------------------------
# Email — IMAP (receiving parent messages)
# ---------------------------------------------------------------
IMAP_HOST=imap.example.com
IMAP_PORT=993
IMAP_USERNAME=anmeldung@example.com
IMAP_PASSWORD=your-imap-password
IMAP_USE_SSL=true
# ---------------------------------------------------------------
# Email — SMTP (sending replies and notifications)
# ---------------------------------------------------------------
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USE_TLS=true
# ---------------------------------------------------------------
# Registration email address (displayed as sender to parents)
# ---------------------------------------------------------------
REGISTRATION_EMAIL=anmeldung@example.com
# ---------------------------------------------------------------
# Admin notification routing
# Each leader receives mail only when a day in their group is booked.
# ADMIN_EMAIL_CC is always included as Cc (comma-separated for multiple).
# For testing, point all three to your own email address.
# ---------------------------------------------------------------
ADMIN_EMAIL_INDOOR=andrea.sigrist@gmx.net
ADMIN_EMAIL_OUTDOOR=baba.laeubli@gmail.com
ADMIN_EMAIL_CC=spielgruppen@familien-verein.ch
# ---------------------------------------------------------------
# Storage
# ---------------------------------------------------------------
# Directory for conversation state and completed registrations.
DATA_DIR=data
# Path to the knowledge-base markdown files (admin-editable).
KNOWLEDGE_BASE_DIR=openspec/changes/define-project-scope/content/knowledge-base
# ---------------------------------------------------------------
# Polling
# ---------------------------------------------------------------
# How often (in seconds) to check the inbox for new messages.
POLL_INTERVAL=60
+27
View File
@@ -0,0 +1,27 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.eggs/
# Virtual environments
.venv/
venv/
env/
# Environment / secrets
.env
# Agent data (conversations and registrations stored at runtime)
data/
# IDE
.idea/
.vscode/
*.swp
*.swo
+1
View File
@@ -0,0 +1 @@
3.13
+120 -1
View File
@@ -1 +1,120 @@
# 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
### As a cron job (recommended)
The agent is a plain script — no long-running daemon needed. Schedule it with cron and use `flock` to prevent overlapping runs:
```cron
*/5 * * * * flock -n /tmp/meister-eder-email.lock uv run python main.py
```
`flock -n` exits immediately if a previous run is still in progress, so the script is always safe to schedule aggressively.
### Manually
```bash
uv run python main.py
```
## Development
### Running tests
```bash
uv run pytest
```
All tests are unit tests — no network access or API keys required.
### Knowledge base
The agent answers parent questions from markdown files in the knowledge base directory. These files are designed to be edited directly by playgroup admins — no code changes needed to update fees, schedules, or policies.
### Adding a new AI provider
Set `AI_MODEL` to any [litellm-supported model string](https://docs.litellm.ai/docs/providers) and set the corresponding API key environment variable. No code changes required.
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Meister-Eder — Email Registration Agent for Spielgruppe Pumuckl.
Usage
-----
Copy `.env.example` to `.env`, fill in your credentials, then run:
python main.py
The agent polls the configured IMAP inbox every POLL_INTERVAL seconds,
processes new messages, and replies via SMTP.
Environment variables (see .env.example for full list):
AI_MODEL litellm model string (default: anthropic/claude-opus-4-6)
ANTHROPIC_API_KEY Required for Anthropic models
OPENAI_API_KEY Required for OpenAI models
IMAP_HOST IMAP server hostname
IMAP_PORT IMAP port (default: 993)
IMAP_USERNAME Email account username
IMAP_PASSWORD Email account password
SMTP_HOST SMTP server hostname
SMTP_PORT SMTP port (default: 587)
REGISTRATION_EMAIL Sender address shown to parents
DATA_DIR Directory for JSON storage (default: data/)
POLL_INTERVAL Seconds between inbox polls (default: 60)
"""
import logging
import sys
import time
from src.agent.core import EmailAgent
from src.channels.email_channel import EmailChannel
from src.config import Config
from src.knowledge_base.loader import KnowledgeBase
from src.notifications.notifier import AdminNotifier
from src.storage.json_store import ConversationStore
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
logger = logging.getLogger(__name__)
def build_components(config: Config):
"""Instantiate and wire together all agent components."""
logger.info("AI model: %s", config.ai_model)
kb = KnowledgeBase(config.knowledge_base_dir)
store = ConversationStore(config.data_dir)
notifier = AdminNotifier(
smtp_host=config.smtp_host,
smtp_port=config.smtp_port,
username=config.imap_username,
password=config.imap_password,
use_tls=config.smtp_use_tls,
from_email=config.registration_email,
indoor_email=config.admin_email_indoor,
outdoor_email=config.admin_email_outdoor,
cc_emails=[e.strip() for e in config.admin_email_cc.split(",") if e.strip()],
)
agent = EmailAgent(model=config.ai_model, kb=kb, store=store, notifier=notifier)
channel = EmailChannel(
imap_host=config.imap_host,
imap_port=config.imap_port,
smtp_host=config.smtp_host,
smtp_port=config.smtp_port,
username=config.imap_username,
password=config.imap_password,
use_ssl=config.imap_use_ssl,
use_tls=config.smtp_use_tls,
registration_email=config.registration_email,
)
return agent, channel
def run_poll_loop(agent: EmailAgent, channel: EmailChannel, poll_interval: int) -> None:
"""Main polling loop — never returns unless interrupted."""
logger.info("Agent started. Polling every %ds for new messages.", poll_interval)
while True:
try:
messages = channel.fetch_unread_messages()
for msg in messages:
logger.info("Processing message from %s", msg["from"])
try:
# Prepend email headers so the LLM can extract the
# sender's address and subject (e.g. to fill in
# parentGuardian.email automatically).
message_text = (
f"Von: {msg['from']}\n"
f"Betreff: {msg['subject']}\n\n"
f"{msg['body']}"
)
reply = agent.process_message(
parent_email=msg["from"],
message_text=message_text,
inbound_message_id=msg["message_id"],
)
if reply:
channel.send_reply(
to=msg["from"],
subject=msg["subject"],
body=reply,
in_reply_to=msg["message_id"],
references=msg["references"],
quoted_text=msg["raw_body"],
quoted_from=msg["from"],
)
except Exception:
logger.exception(
"Unhandled error processing message from %s", msg["from"]
)
except KeyboardInterrupt:
logger.info("Shutdown requested — stopping.")
break
except Exception:
logger.exception("Unexpected error in poll loop")
time.sleep(poll_interval)
def main() -> None:
config = Config.from_env()
if not config.imap_host:
logger.error(
"IMAP_HOST is not set. "
"Copy .env.example to .env and fill in your email credentials."
)
sys.exit(1)
agent, channel = build_components(config)
run_poll_loop(agent, channel, config.poll_interval)
if __name__ == "__main__":
main()
@@ -0,0 +1,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.
@@ -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
@@ -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
+29
View File
@@ -0,0 +1,29 @@
[project]
name = "meister-eder"
version = "0.1.0"
description = "AI-powered conversational registration agent for Spielgruppe Pumuckl"
requires-python = ">=3.13"
dependencies = [
# LLM access — supports any provider (Anthropic, OpenAI, Gemini, …)
"litellm>=1.0.0",
# Configuration
"python-dotenv>=1.0.0",
# Registration schema validation
"jsonschema>=4.23.0",
]
[project.scripts]
meister-eder = "main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src"]
[tool.uv]
dev-dependencies = [
"pytest>=8.0.0",
"pytest-mock>=3.14.0",
]
View File
View File
+270
View File
@@ -0,0 +1,270 @@
"""EmailAgent — the channel-agnostic conversation orchestrator."""
import json
import logging
import re
from datetime import datetime, timezone
from ..models.conversation import ConversationState, ChatMessage
from ..models.registration import BookingDay, RegistrationData
from .. import llm
from ..knowledge_base.loader import KnowledgeBase
from ..storage.json_store import ConversationStore, normalize_email, _diff_registrations
from ..notifications.notifier import AdminNotifier
from .prompts import build_system_prompt
logger = logging.getLogger(__name__)
class EmailAgent:
"""Processes one inbound email and returns the agent's reply text.
Conversations are identified by the sender's normalized email address, so
a parent who composes a fresh email (instead of replying) continues their
existing conversation seamlessly.
All business logic lives here; channel I/O is handled by the caller.
"""
def __init__(
self,
model: str,
kb: KnowledgeBase,
store: ConversationStore,
notifier: AdminNotifier,
) -> None:
self._model = model
self._kb = kb
self._store = store
self._notifier = notifier
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def process_message(
self,
parent_email: str,
message_text: str,
inbound_message_id: str = "",
) -> str:
"""Process one inbound message and return the reply text.
Args:
parent_email: Sender email address — used as conversation key.
message_text: Stripped plain-text body of the inbound email.
inbound_message_id: Message-ID of the inbound email (stored for
reply threading headers; not used for conversation matching).
Returns:
Reply text to send back to the parent.
"""
email_key = normalize_email(parent_email)
# Load or create conversation state — keyed by email address
state = self._store.load(email_key)
if state is None:
state = ConversationState(
conversation_id=email_key,
parent_email=email_key,
)
now = datetime.now(timezone.utc).isoformat()
state.last_activity = now
if inbound_message_id:
state.last_inbound_message_id = inbound_message_id
# Append the user's message to history
state.messages.append(ChatMessage(role="user", content=message_text))
# Route to the appropriate handler
if state.completed:
reply_text = self._handle_post_completion(state)
else:
reply_text = self._handle_registration(state)
# Record the assistant reply and persist
state.messages.append(ChatMessage(role="assistant", content=reply_text))
state.updated_at = now
self._store.save(state)
return reply_text
# ------------------------------------------------------------------
# Registration flow
# ------------------------------------------------------------------
def _handle_registration(self, state: ConversationState) -> str:
"""Drive the in-progress registration conversation."""
system = build_system_prompt(self._kb, state)
try:
content = llm.complete(self._model, system, state.messages)
parsed = self._parse_llm_response(content)
except Exception:
logger.exception("LLM call failed for %s", state.conversation_id)
return self._fallback_message(state)
reply_text: str = parsed.get("reply", "")
updates: dict = parsed.get("updates", {}) or {}
next_step: str = parsed.get("next_step", state.flow_step)
is_complete: bool = bool(parsed.get("registration_complete", False))
language: str = parsed.get("language", state.language)
self._apply_updates(state, updates)
state.flow_step = next_step
state.language = language
if is_complete and not state.completed:
state.completed = True
email_key, version = self._store.save_registration(state)
try:
self._notifier.notify_admin(
registration=state.registration,
registration_id=email_key,
version=version,
conversation_id=state.conversation_id,
channel="email",
)
except Exception:
logger.exception("Failed to send admin notification for %s", email_key)
logger.info("Registration complete for %s", state.conversation_id)
return reply_text
# ------------------------------------------------------------------
# Post-completion flow
# ------------------------------------------------------------------
def _handle_post_completion(self, state: ConversationState) -> str:
"""Handle messages received after a registration is already complete."""
system = build_system_prompt(self._kb, state)
try:
content = llm.complete(self._model, system, state.messages)
parsed = self._parse_llm_response(content)
except Exception:
logger.exception("LLM call failed (post-completion) for %s", state.conversation_id)
return self._fallback_message(state)
reply_text: str = parsed.get("reply", "")
intent: str = parsed.get("intent", "question")
updates: dict = parsed.get("updates", {}) or {}
language: str = parsed.get("language", state.language)
state.language = language
if intent == "update" and any(v is not None for v in updates.values()):
self._handle_registration_update(state, updates)
elif intent == "new_child":
# Reset registration so a fresh flow begins in the next message
state.registration = RegistrationData()
state.completed = False
state.flow_step = "child_name"
logger.info("Starting new child registration for %s", state.conversation_id)
return reply_text
def _handle_registration_update(self, state: ConversationState, updates: dict) -> None:
"""Apply field updates, version the record, and notify the admin."""
old_data = state.registration.to_dict()
self._apply_updates(state, updates)
new_data = state.registration.to_dict()
change_summary = _diff_registrations(old_data, new_data)
if not change_summary:
return # Nothing actually changed
email_key, version = self._store.save_registration_version(state, change_summary)
try:
self._notifier.notify_registration_update(
registration=state.registration,
registration_id=email_key,
version=version,
change_summary=change_summary,
conversation_id=state.conversation_id,
)
except Exception:
logger.exception("Failed to send update notification for %s", email_key)
logger.info("Registration updated to v%d for %s", version, state.conversation_id)
# ------------------------------------------------------------------
# Shared helpers
# ------------------------------------------------------------------
def _parse_llm_response(self, content: str) -> dict:
"""Extract the JSON payload from the LLM's raw output."""
text = content.strip()
fence_match = re.match(r"^```(?:json)?\s*\n(.*?)\n```\s*$", text, re.DOTALL)
if fence_match:
text = fence_match.group(1).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
brace_match = re.search(r"\{.*\}", text, re.DOTALL)
if brace_match:
try:
return json.loads(brace_match.group())
except json.JSONDecodeError:
pass
logger.warning("Could not parse LLM response as JSON — using raw text as reply.")
return {
"reply": content,
"intent": "question",
"updates": {},
"next_step": "greeting",
"registration_complete": False,
"language": "de",
}
def _fallback_message(self, state: ConversationState) -> str:
if state.language == "en":
return (
"I'm sorry, I'm having a technical issue right now. "
"Please try again in a moment or contact us directly."
)
return (
"Entschuldigung, ich habe gerade ein technisches Problem. "
"Bitte versuche es gleich nochmal oder kontaktiere uns direkt."
)
def _apply_updates(self, state: ConversationState, updates: dict) -> None:
"""Write extracted field values into the RegistrationData object."""
reg = state.registration
field_map = {
"child.fullName": lambda v: setattr(reg.child, "full_name", v),
"child.dateOfBirth": lambda v: setattr(reg.child, "date_of_birth", v),
"child.specialNeeds": lambda v: setattr(reg.child, "special_needs", v),
"parentGuardian.fullName": lambda v: (
setattr(reg.parent_guardian, "full_name", v),
setattr(state, "parent_name", v),
),
"parentGuardian.streetAddress": lambda v: setattr(reg.parent_guardian, "street_address", v),
"parentGuardian.postalCode": lambda v: setattr(reg.parent_guardian, "postal_code", str(v)),
"parentGuardian.city": lambda v: setattr(reg.parent_guardian, "city", v),
"parentGuardian.phone": lambda v: setattr(reg.parent_guardian, "phone", v),
"parentGuardian.email": lambda v: setattr(reg.parent_guardian, "email", v),
"emergencyContact.fullName": lambda v: setattr(reg.emergency_contact, "full_name", v),
"emergencyContact.phone": lambda v: setattr(reg.emergency_contact, "phone", v),
}
for key, value in updates.items():
if value is None:
continue
if key in field_map:
field_map[key](value)
elif key == "booking.playgroupTypes" and isinstance(value, list):
reg.booking.playgroup_types = value
elif key == "booking.selectedDays" and isinstance(value, list):
reg.booking.selected_days = [
BookingDay(day=d["day"], type=d["type"])
for d in value
if isinstance(d, dict) and "day" in d and "type" in d
]
else:
logger.debug("Unknown update key ignored: %s", key)
+236
View File
@@ -0,0 +1,236 @@
"""Build the system prompt sent to the LLM on every turn."""
import json
from ..knowledge_base.loader import KnowledgeBase
from ..models.conversation import ConversationState
# ---------------------------------------------------------------------------
# Step descriptions help the model understand where it is in the registration flow.
# ---------------------------------------------------------------------------
STEP_DESCRIPTIONS = {
"greeting": (
"Greet the parent warmly and detect their intent (registration vs. questions). "
"In this first message, explicitly tell them they can write in any human language "
"and you will reply in the same language. "
"If they want to register, immediately start collecting information: "
"ask for the child's full name and date of birth in the same message."
),
"child_name": "Ask for the child's full name.",
"child_dob": (
"Ask for the child's date of birth. "
"Validate age: indoor requires ≥2 years, outdoor requires ≥2.5 years."
),
"playgroup_selection": (
"Explain both playgroup options and ask which the parent wants "
"(indoor / outdoor / both) and which days."
),
"special_needs": (
"Ask whether the child has any special needs, allergies, or medical conditions."
),
"parent_contact": (
"Collect the parent/guardian's full name, street address, postal code (4 digits), "
"city, phone number, and email address."
),
"emergency_contact": (
"Ask for an emergency contact (someone other than the parent): full name and phone."
),
"confirmation": (
"Show a summary of all collected information and ask the parent to confirm."
),
"complete": "Thank the parent, mention fees and next steps. Registration is done.",
}
_PERSONALITY = """## Your Personality
- Warm, friendly, and helpful — like a caring playgroup staff member
- Use informal "du" in German (never the formal "Sie")
- Auto-detect the parent's language from their message; respond in the same language; default to German if unclear
- Collect all information for the current step — and any clearly related follow-up steps — in a single message; weave the questions naturally into flowing sentences, never as a form or bullet list
- If the parent's reply leaves some of your questions unanswered, explicitly re-ask every unanswered question before moving on — never silently skip an open question
- Be patient and understanding; never make parents feel they made a mistake"""
_CONTACTS = """## Admin Contacts
- Administration: Markus Graf — spielgruppen@familien-verein.ch — 079 261 16 37
- Indoor leader: Andrea Sigrist — andrea.sigrist@gmx.net — 079 674 99 92
- Outdoor leader: Barbara Gross — baba.laeubli@gmail.com — 078 761 19 64"""
_PLAYGROUP_DETAILS = """## Playgroup Details
- **Indoor (Innenspielgruppe)**: Mon / Wed / Thu, 09:0011:30 | CHF 130/260/390 per month (1/2/3×/week)
- **Outdoor Forest (Waldspielgruppe)**: Mon only, 09:0014:00 (includes snack & lunch) | CHF 250/month
- **One-time registration fee**: CHF 80 (first year); CHF 80 craft materials from second year
- **Cleaning deposit (indoor only)**: CHF 50 (refundable)
- **Sibling discount**: 10% per additional child
- **July & August**: fee-free"""
_REGISTRATION_RESPONSE_FORMAT = """## CRITICAL: Response Format
You MUST respond with **only** a valid JSON object — no markdown, no extra text outside the JSON.
```json
{{
"reply": "Your conversational message to the parent (plain text, NOT JSON)",
"updates": {{
"child.fullName": "string or null",
"child.dateOfBirth": "YYYY-MM-DD or null",
"child.specialNeeds": "string or null",
"parentGuardian.fullName": "string or null",
"parentGuardian.streetAddress": "string or null",
"parentGuardian.postalCode": "4-digit string or null",
"parentGuardian.city": "string or null",
"parentGuardian.phone": "string or null",
"parentGuardian.email": "string or null",
"emergencyContact.fullName": "string or null",
"emergencyContact.phone": "string or null",
"booking.playgroupTypes": ["indoor", "outdoor"] or null,
"booking.selectedDays": [{{"day": "monday", "type": "indoor"}}] or null
}},
"next_step": "greeting|child_name|child_dob|playgroup_selection|special_needs|parent_contact|emergency_contact|confirmation|complete",
"registration_complete": false,
"language": "de"
}}
```
Rules:
- Only set fields in `updates` that you actually extracted from the parent's **latest message**. Use `null` for everything else.
- Set `registration_complete` to `true` **only** when ALL required fields are filled AND the parent has just confirmed the summary is correct.
- Dates must be YYYY-MM-DD. Postal codes must be exactly 4 digits.
- Valid days: "monday", "wednesday", "thursday" (indoor) or "monday" (outdoor).
- `language` must be "de" or "en" based on the parent's message.
- Always store free-text field values (especially `child.specialNeeds`) **in German** in `updates`, translating from the parent's language if necessary. Use "Keine" if the parent indicates no special needs.
- The `reply` field must be natural, friendly, conversational text — not JSON and not a list of fields.
- The `reply` field must be plain text only. No markdown: no **bold**, no _italic_, no # headers, no bullet points with - or *, no backticks. Use plain sentences and line breaks only."""
_POST_COMPLETION_RESPONSE_FORMAT = """## CRITICAL: Response Format
You MUST respond with **only** a valid JSON object — no markdown, no extra text outside the JSON.
```json
{{
"reply": "Your conversational message to the parent (plain text, NOT JSON)",
"intent": "question",
"updates": {{
"child.fullName": "string or null",
"child.dateOfBirth": "YYYY-MM-DD or null",
"child.specialNeeds": "string or null",
"parentGuardian.fullName": "string or null",
"parentGuardian.streetAddress": "string or null",
"parentGuardian.postalCode": "4-digit string or null",
"parentGuardian.city": "string or null",
"parentGuardian.phone": "string or null",
"parentGuardian.email": "string or null",
"emergencyContact.fullName": "string or null",
"emergencyContact.phone": "string or null",
"booking.playgroupTypes": ["indoor", "outdoor"] or null,
"booking.selectedDays": [{{"day": "monday", "type": "indoor"}}] or null
}},
"language": "de"
}}
```
`intent` values:
- `"question"` — parent is asking about fees, schedule, policies, etc. → answer from knowledge base; set `updates` to all nulls.
- `"update"` — parent explicitly wants to change their registration data → collect the new values in `updates`, confirm the change in `reply`.
- `"new_child"` — parent wants to register an additional child → treat as a new registration; begin from step child_name.
Rules:
- Only set fields in `updates` when intent is `"update"` AND the parent has provided the new value in this message.
- Use `null` for all `updates` fields when intent is `"question"` or `"new_child"`.
- `language` must be "de" or "en" based on the parent's message.
- The `reply` field must be natural, friendly, conversational text — not JSON and not a list of fields.
- The `reply` field must be plain text only. No markdown: no **bold**, no _italic_, no # headers, no bullet points with - or *, no backticks. Use plain sentences and line breaks only.
- If you are unsure of the parent's intent, ask a clarifying question and set intent to `"question"`."""
def build_system_prompt(kb: KnowledgeBase, state: ConversationState) -> str:
"""Return the system prompt appropriate for the current conversation state."""
if state.completed:
return _build_post_completion_prompt(kb, state)
return _build_registration_prompt(kb, state)
def _build_registration_prompt(kb: KnowledgeBase, state: ConversationState) -> str:
"""System prompt for an in-progress registration conversation."""
kb_content = kb.get_all()
reg_json = json.dumps(state.registration.to_dict(), ensure_ascii=False, indent=2)
step_hint = STEP_DESCRIPTIONS.get(state.flow_step, "Continue the conversation.")
return f"""You are the registration assistant for Spielgruppe Pumuckl, run by Familienverein Fällanden in Fällanden, Switzerland. You help parents register their children for the playgroup and answer questions about it.
{_PERSONALITY}
## Registration Flow (8 steps)
1. greeting — greet and detect intent
2. child_name — ask for child's full name
3. child_dob — ask for date of birth; validate age (indoor ≥2 yrs, outdoor ≥2.5 yrs)
4. playgroup_selection — present options, collect type(s) and day(s)
5. special_needs — ask about special needs / allergies / medical conditions
6. parent_contact — name, street address, postal code, city, phone, email
7. emergency_contact — emergency contact name and phone
8. confirmation — show full summary; ask to confirm; submit on confirmation
9. complete — thank parent, mention CHF 80 registration fee, monthly fees, and contacts
**Current step: {state.flow_step}**
**What to do now: {step_hint}**
At any point the parent may ask a question. Answer it from the knowledge base, then offer to continue the registration.
## Current Registration Data (so far)
```json
{reg_json}
```
## Knowledge Base
Use the information below to answer parent questions accurately:
{kb_content}
{_PLAYGROUP_DETAILS}
{_CONTACTS}
---
{_REGISTRATION_RESPONSE_FORMAT}
"""
def _build_post_completion_prompt(kb: KnowledgeBase, state: ConversationState) -> str:
"""System prompt for a conversation where registration is already complete."""
kb_content = kb.get_all()
reg_json = json.dumps(state.registration.to_dict(), ensure_ascii=False, indent=2)
child_name = state.registration.child.full_name or "their child"
return f"""You are the registration assistant for Spielgruppe Pumuckl, run by Familienverein Fällanden in Fällanden, Switzerland.
{_PERSONALITY}
## Context: Registration Already Complete
This parent has already completed registration for {child_name}. Their current registration data is:
```json
{reg_json}
```
The parent is contacting you again. Your job is to:
1. Detect their **intent**: are they asking a question, requesting a change to their registration, or registering another child?
2. Respond helpfully and warmly.
3. If they want to **update** their registration, confirm exactly what they want to change and include the new values in `updates`.
4. If they are asking a **question**, answer from the knowledge base.
5. If they want to register a **new child**, let them know you'll start a new registration and guide them from the beginning.
When handling update requests:
- Confirm the change explicitly before reporting it as done ("So you'd like to change X to Y — is that right?").
- Once confirmed, include the new value in `updates` so it can be saved.
- Let the parent know the playgroup team will be informed of the change.
## Knowledge Base
{kb_content}
{_PLAYGROUP_DETAILS}
{_CONTACTS}
---
{_POST_COMPLETION_RESPONSE_FORMAT}
"""
View File
+307
View File
@@ -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
+77
View File
@@ -0,0 +1,77 @@
"""Configuration loaded from environment variables."""
import os
from dataclasses import dataclass, field
from pathlib import Path
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass # python-dotenv is optional
@dataclass
class Config:
# AI model — litellm format, e.g. "anthropic/claude-opus-4-6" or "openai/gpt-4o".
# The matching API key must be set as an env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
ai_model: str = "anthropic/claude-opus-4-6"
# Email — IMAP (receiving)
imap_host: str = ""
imap_port: int = 993
imap_username: str = ""
imap_password: str = ""
imap_use_ssl: bool = True
# Email — SMTP (sending)
smtp_host: str = ""
smtp_port: int = 587
smtp_use_tls: bool = True
# Registration email address shown to parents
registration_email: str = ""
# Admin notification routing.
# Each leader receives mail only when a day in their group is booked.
# For testing, point all three to your own address.
admin_email_indoor: str = "" # Indoor leader (Andrea Sigrist) — To when indoor booked
admin_email_outdoor: str = "" # Outdoor leader (Barbara Gross) — To when outdoor booked
admin_email_cc: str = "" # Always Cc'd (Markus Graf / admin); comma-separated if multiple
# Storage
data_dir: Path = field(default_factory=lambda: Path("data"))
knowledge_base_dir: Path = field(
default_factory=lambda: Path(
"openspec/changes/define-project-scope/content/knowledge-base"
)
)
# Polling interval in seconds
poll_interval: int = 60
@classmethod
def from_env(cls) -> "Config":
return cls(
ai_model=os.getenv("AI_MODEL", "anthropic/claude-opus-4-6"),
imap_host=os.getenv("IMAP_HOST", ""),
imap_port=int(os.getenv("IMAP_PORT", "993")),
imap_username=os.getenv("IMAP_USERNAME", ""),
imap_password=os.getenv("IMAP_PASSWORD", ""),
imap_use_ssl=os.getenv("IMAP_USE_SSL", "true").lower() == "true",
smtp_host=os.getenv("SMTP_HOST", ""),
smtp_port=int(os.getenv("SMTP_PORT", "587")),
smtp_use_tls=os.getenv("SMTP_USE_TLS", "true").lower() == "true",
registration_email=os.getenv("REGISTRATION_EMAIL", ""),
admin_email_indoor=os.getenv("ADMIN_EMAIL_INDOOR", ""),
admin_email_outdoor=os.getenv("ADMIN_EMAIL_OUTDOOR", ""),
admin_email_cc=os.getenv("ADMIN_EMAIL_CC", ""),
data_dir=Path(os.getenv("DATA_DIR", "data")),
knowledge_base_dir=Path(
os.getenv(
"KNOWLEDGE_BASE_DIR",
"openspec/changes/define-project-scope/content/knowledge-base",
)
),
poll_interval=int(os.getenv("POLL_INTERVAL", "60")),
)
View File
+38
View File
@@ -0,0 +1,38 @@
"""Load admin-editable knowledge-base markdown files into memory."""
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
class KnowledgeBase:
"""Reads markdown files from *kb_dir* and exposes them as a single string."""
def __init__(self, kb_dir: Path) -> None:
self._dir = kb_dir
self._content: dict[str, str] = {}
self._load()
def _load(self) -> None:
if not self._dir.exists():
logger.warning("Knowledge-base directory not found: %s", self._dir)
return
for path in sorted(self._dir.glob("*.md")):
self._content[path.stem] = path.read_text(encoding="utf-8")
logger.info("Loaded %d knowledge-base file(s) from %s", len(self._content), self._dir)
def get_all(self) -> str:
"""Return every KB file concatenated with section headers."""
if not self._content:
return "(No knowledge-base content available.)"
sections = [
f"### {name.upper().replace('-', ' ')}\n\n{content}"
for name, content in self._content.items()
]
return "\n\n---\n\n".join(sections)
def reload(self) -> None:
"""Re-read all files from disk (useful when admins update content)."""
self._content = {}
self._load()
+22
View File
@@ -0,0 +1,22 @@
"""LLM completion via litellm — supports any provider with a single call."""
import litellm
def complete(model: str, system: str, messages: list) -> str:
"""Call any LLM and return the response text.
Args:
model: litellm model string, e.g. "anthropic/claude-opus-4-6" or
"openai/gpt-4o". The matching API key must be set as an
environment variable (ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
system: System prompt text.
messages: List of objects with .role and .content attributes.
Returns:
The model's reply as a plain string.
"""
api_messages = [{"role": "system", "content": system}]
api_messages += [{"role": m.role, "content": m.content} for m in messages]
response = litellm.completion(model=model, messages=api_messages, max_tokens=2048)
return response.choices[0].message.content
View File
+81
View File
@@ -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
+126
View File
@@ -0,0 +1,126 @@
"""Registration data models matching the JSON schema in registration-schema.json."""
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class BookingDay:
day: str # "monday", "wednesday", "thursday"
type: str # "indoor", "outdoor"
@dataclass
class Booking:
playgroup_types: list = field(default_factory=list) # ["indoor", "outdoor"]
selected_days: list = field(default_factory=list) # list[BookingDay]
@dataclass
class ChildInfo:
full_name: Optional[str] = None
date_of_birth: Optional[str] = None # YYYY-MM-DD
special_needs: Optional[str] = None # text or "None"
@dataclass
class ParentGuardian:
full_name: Optional[str] = None
street_address: Optional[str] = None
postal_code: Optional[str] = None # 4-digit Swiss code
city: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
@dataclass
class EmergencyContact:
full_name: Optional[str] = None
phone: Optional[str] = None
@dataclass
class RegistrationData:
child: ChildInfo = field(default_factory=ChildInfo)
parent_guardian: ParentGuardian = field(default_factory=ParentGuardian)
emergency_contact: EmergencyContact = field(default_factory=EmergencyContact)
booking: Booking = field(default_factory=Booking)
def is_complete(self) -> bool:
"""Return True when all required schema fields are present."""
return (
bool(self.child.full_name)
and bool(self.child.date_of_birth)
and self.child.special_needs is not None
and bool(self.parent_guardian.full_name)
and bool(self.parent_guardian.street_address)
and bool(self.parent_guardian.postal_code)
and bool(self.parent_guardian.city)
and bool(self.parent_guardian.phone)
and bool(self.parent_guardian.email)
and bool(self.emergency_contact.full_name)
and bool(self.emergency_contact.phone)
and len(self.booking.playgroup_types) > 0
and len(self.booking.selected_days) > 0
)
def to_dict(self) -> dict:
return {
"child": {
"fullName": self.child.full_name,
"dateOfBirth": self.child.date_of_birth,
"specialNeeds": self.child.special_needs,
},
"parentGuardian": {
"fullName": self.parent_guardian.full_name,
"streetAddress": self.parent_guardian.street_address,
"postalCode": self.parent_guardian.postal_code,
"city": self.parent_guardian.city,
"phone": self.parent_guardian.phone,
"email": self.parent_guardian.email,
},
"emergencyContact": {
"fullName": self.emergency_contact.full_name,
"phone": self.emergency_contact.phone,
},
"booking": {
"playgroupTypes": self.booking.playgroup_types,
"selectedDays": [
{"day": d.day, "type": d.type}
for d in self.booking.selected_days
],
},
}
@classmethod
def from_dict(cls, data: dict) -> "RegistrationData":
reg = cls()
if child := data.get("child", {}):
reg.child = ChildInfo(
full_name=child.get("fullName"),
date_of_birth=child.get("dateOfBirth"),
special_needs=child.get("specialNeeds"),
)
if parent := data.get("parentGuardian", {}):
reg.parent_guardian = ParentGuardian(
full_name=parent.get("fullName"),
street_address=parent.get("streetAddress"),
postal_code=parent.get("postalCode"),
city=parent.get("city"),
phone=parent.get("phone"),
email=parent.get("email"),
)
if emergency := data.get("emergencyContact", {}):
reg.emergency_contact = EmergencyContact(
full_name=emergency.get("fullName"),
phone=emergency.get("phone"),
)
if booking := data.get("booking", {}):
reg.booking = Booking(
playgroup_types=booking.get("playgroupTypes", []),
selected_days=[
BookingDay(day=d["day"], type=d["type"])
for d in booking.get("selectedDays", [])
],
)
return reg
View File
+343
View File
@@ -0,0 +1,343 @@
"""Admin email notifications — new registrations and registration updates."""
import logging
import smtplib
from datetime import date, datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from ..models.registration import RegistrationData
logger = logging.getLogger(__name__)
class AdminNotifier:
"""Sends formatted admin notification emails.
Handles two notification types:
- New registration completed → "New Registration: …"
- Existing registration updated → "Registration Updated: …" (with field diff)
When *smtp_host* is empty the notifier logs and skips sending (dev mode).
"""
def __init__(
self,
smtp_host: str,
smtp_port: int,
username: str,
password: str,
use_tls: bool = True,
from_email: str = "",
indoor_email: str = "",
outdoor_email: str = "",
cc_emails: list[str] | None = None,
) -> None:
self._smtp_host = smtp_host
self._smtp_port = smtp_port
self._username = username
self._password = password
self._use_tls = use_tls
self._from_email = from_email or username
self._indoor_email = indoor_email
self._outdoor_email = outdoor_email
self._cc_emails: list[str] = cc_emails or []
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def notify_admin(
self,
registration: RegistrationData,
registration_id: str,
version: int,
conversation_id: str,
channel: str,
) -> None:
"""Send notification for a newly completed registration (version 1)."""
types = registration.booking.playgroup_types
to_addresses = self._recipients_for(types)
if not to_addresses:
logger.warning(
"No leader email configured for types %s — new-registration notification skipped.",
types,
)
return
subject = (
f"Neue Anmeldung: {registration.child.full_name} "
f" {self._format_types(types)}"
)
body = self._build_new_body(registration, registration_id, version, channel)
self._send(
to=to_addresses,
cc=self._cc_emails,
subject=subject,
body=body,
reply_to=registration.parent_guardian.email or "",
)
def notify_registration_update(
self,
registration: RegistrationData,
registration_id: str,
version: int,
change_summary: dict,
conversation_id: str,
) -> None:
"""Send notification when an existing registration is updated."""
types = registration.booking.playgroup_types
to_addresses = self._recipients_for(types)
if not to_addresses:
logger.warning(
"No leader email configured for types %s — update notification skipped.",
types,
)
return
subject = f"Anmeldung aktualisiert: {registration.child.full_name}"
body = self._build_update_body(registration, registration_id, version, change_summary)
self._send(
to=to_addresses,
cc=self._cc_emails,
subject=subject,
body=body,
reply_to=registration.parent_guardian.email or "",
)
# ------------------------------------------------------------------
# Routing helpers
# ------------------------------------------------------------------
def _recipients_for(self, types: list[str]) -> list[str]:
"""Return To addresses based on which playgroup types are booked."""
recipients = []
if "indoor" in types and self._indoor_email:
recipients.append(self._indoor_email)
if "outdoor" in types and self._outdoor_email:
recipients.append(self._outdoor_email)
return recipients
# ------------------------------------------------------------------
# Formatting helpers
# ------------------------------------------------------------------
@staticmethod
def _format_types(types: list[str]) -> str:
has_indoor = "indoor" in types
has_outdoor = "outdoor" in types
if has_indoor and has_outdoor:
return "Innen- und Waldspielgruppe"
if has_indoor:
return "Innenspielgruppe"
if has_outdoor:
return "Waldspielgruppe"
return "Spielgruppe"
@staticmethod
def _calculate_age(dob_str: str) -> str:
try:
dob = datetime.strptime(dob_str, "%Y-%m-%d").date()
today = date.today()
years = today.year - dob.year - (
(today.month, today.day) < (dob.month, dob.day)
)
months = (today.month - dob.month) % 12
return f"{years} Jahre, {months} Monate"
except Exception:
return dob_str
@staticmethod
def _format_dob(dob_str: str) -> str:
try:
return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
return dob_str or ""
@staticmethod
def _calculate_monthly_fee(registration: RegistrationData) -> str:
indoor_days = sum(1 for d in registration.booking.selected_days if d.type == "indoor")
outdoor_days = sum(1 for d in registration.booking.selected_days if d.type == "outdoor")
fee = 0
if indoor_days == 1:
fee += 130
elif indoor_days == 2:
fee += 260
elif indoor_days >= 3:
fee += 390
if outdoor_days >= 1:
fee += 250
return f"CHF {fee}.-"
@staticmethod
def _format_days(registration: RegistrationData) -> str:
day_map = {"monday": "Montag", "wednesday": "Mittwoch", "thursday": "Donnerstag"}
type_map = {"indoor": "Innenspielgruppe", "outdoor": "Waldspielgruppe"}
return ", ".join(
f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})"
for d in registration.booking.selected_days
)
@staticmethod
def _format_change_summary(change_summary: dict) -> str:
"""Render field changes as a human-readable list."""
lines = []
for field_path, values in sorted(change_summary.items()):
old_val, new_val = values["old"], values["new"]
lines.append(f" {field_path}:")
lines.append(f" Alt: {old_val}")
lines.append(f" Neu: {new_val}")
return "\n".join(lines) if lines else " (keine Änderungen erkannt)"
# ------------------------------------------------------------------
# Email body builders
# ------------------------------------------------------------------
def _build_new_body(
self,
registration: RegistrationData,
registration_id: str,
version: int,
channel: str,
) -> str:
now = datetime.utcnow()
pg = registration.parent_guardian
ec = registration.emergency_contact
channel_de = {"email": "E-Mail", "chat": "Chat"}.get(channel.lower(), channel.title())
return (
"===============================================\n"
"NEUE SPIELGRUPPEN-ANMELDUNG\n"
"===============================================\n"
"\n"
f"Eingereicht: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n"
f"Kanal: {channel_de}\n"
f"Anmelde-ID: {registration_id} (Version {version})\n"
"\n"
"-----------------------------------------------\n"
"ANGABEN ZUM KIND\n"
"-----------------------------------------------\n"
f"Name: {registration.child.full_name}\n"
f"Geburtsdatum: {self._format_dob(registration.child.date_of_birth or '')} "
f"(Alter: {self._calculate_age(registration.child.date_of_birth or '')})\n"
f"Bes. Bedürfnisse: {registration.child.special_needs or 'Keine'}\n"
"\n"
"-----------------------------------------------\n"
"SPIELGRUPPEN-AUSWAHL\n"
"-----------------------------------------------\n"
f"Art: {self._format_types(registration.booking.playgroup_types)}\n"
f"Tage: {self._format_days(registration)}\n"
"\n"
f"Monatlicher Beitrag: {self._calculate_monthly_fee(registration)}\n"
"(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung)\n"
"\n"
"-----------------------------------------------\n"
"ELTERN / ERZIEHUNGSBERECHTIGTE\n"
"-----------------------------------------------\n"
f"Name: {pg.full_name}\n"
f"Adresse: {pg.street_address}\n"
f" {pg.postal_code} {pg.city}\n"
f"Telefon: {pg.phone}\n"
f"E-Mail: {pg.email}\n"
"\n"
"-----------------------------------------------\n"
"NOTFALLKONTAKT\n"
"-----------------------------------------------\n"
f"Name: {ec.full_name}\n"
f"Telefon: {ec.phone}\n"
"\n"
"===============================================\n"
"\n"
"Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht.\n"
)
def _build_update_body(
self,
registration: RegistrationData,
registration_id: str,
version: int,
change_summary: dict,
) -> str:
now = datetime.utcnow()
pg = registration.parent_guardian
return (
"===============================================\n"
"ANMELDUNGS-AKTUALISIERUNG\n"
"===============================================\n"
"\n"
f"Aktualisiert: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n"
f"Anmelde-ID: {registration_id} (Version {version})\n"
f"Kind: {registration.child.full_name}\n"
f"Eltern-E-Mail: {pg.email}\n"
"\n"
"-----------------------------------------------\n"
"WAS HAT SICH GEÄNDERT\n"
"-----------------------------------------------\n"
f"{self._format_change_summary(change_summary)}\n"
"\n"
"-----------------------------------------------\n"
"AKTUELLE ANMELDUNG (nach Aktualisierung)\n"
"-----------------------------------------------\n"
f"Spielgruppe: {self._format_types(registration.booking.playgroup_types)}\n"
f"Tage: {self._format_days(registration)}\n"
f"Monatl. Beitrag: {self._calculate_monthly_fee(registration)}\n"
"\n"
f"Elternteil: {pg.full_name}\n"
f"Adresse: {pg.street_address}, {pg.postal_code} {pg.city}\n"
f"Telefon: {pg.phone}\n"
"\n"
"===============================================\n"
"\n"
"Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht.\n"
)
# ------------------------------------------------------------------
# SMTP dispatch
# ------------------------------------------------------------------
def _send(
self,
to: list[str],
cc: list[str],
subject: str,
body: str,
reply_to: str = "",
) -> None:
if not self._smtp_host:
logger.warning(
"SMTP not configured — notification NOT sent. Would have emailed %s (CC: %s): %s",
to,
cc,
subject,
)
logger.debug("Notification body:\n%s", body)
return
msg = MIMEMultipart("alternative")
msg["From"] = self._from_email
msg["To"] = ", ".join(to)
msg["CC"] = ", ".join(cc)
msg["Subject"] = subject
if reply_to:
msg["Reply-To"] = reply_to
msg.attach(MIMEText(body, "plain", "utf-8"))
all_recipients = to + cc
try:
if self._use_tls:
server = smtplib.SMTP(self._smtp_host, self._smtp_port)
server.starttls()
else:
server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port)
server.login(self._username, self._password)
server.sendmail(self._from_email, all_recipients, msg.as_string())
server.quit()
logger.info("Notification sent to %s", all_recipients)
except Exception:
logger.exception("Failed to send notification to %s", all_recipients)
View File
+270
View File
@@ -0,0 +1,270 @@
"""File-based JSON storage for conversations and completed registrations.
Conversations are keyed by the sender's normalized email address so that a
parent who sends a new email (instead of replying) continues the same
conversation. Completed registrations are stored with versioning so every
update produces a new numbered version rather than overwriting the original.
Directory layout::
data/
conversations/
parent_at_example.com.json # one file per unique sender address
registrations/
parent_at_example.com/
v1_2024-09-15T10-30-00Z.json # initial registration
v2_2024-10-03T14-22-10Z.json # updated registration
current.json # copy of the latest version
"""
import json
import logging
import re
from datetime import datetime, timezone
from pathlib import Path
from ..models.conversation import ConversationState
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def normalize_email(email: str) -> str:
"""Return a canonical email address for matching and storage.
Lowercases and strips whitespace. ``Maria@Example.com`` → ``maria@example.com``.
"""
return email.strip().lower()
def _email_to_filename(email: str) -> str:
"""Convert a normalized email address to a safe filename stem.
``parent@example.com`` → ``parent_at_example.com``
"""
return normalize_email(email).replace("@", "_at_")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _timestamp_for_filename() -> str:
"""Return a filesystem-safe ISO-8601-ish timestamp (no colons)."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
def _diff_registrations(old: dict, new: dict) -> dict[str, tuple]:
"""Return a mapping of field_path → (old_value, new_value) for changed fields."""
changes: dict[str, tuple] = {}
def _flatten(d: dict, prefix: str = "") -> dict:
out: dict = {}
for k, v in d.items():
key = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
out.update(_flatten(v, key))
else:
out[key] = v
return out
old_flat = _flatten(old)
new_flat = _flatten(new)
all_keys = set(old_flat) | set(new_flat)
for key in sorted(all_keys):
o = old_flat.get(key)
n = new_flat.get(key)
if o != n:
changes[key] = (o, n)
return changes
# ---------------------------------------------------------------------------
# ConversationStore
# ---------------------------------------------------------------------------
class ConversationStore:
"""Persists ConversationState and registration versions on disk."""
def __init__(self, data_dir: Path) -> None:
self._conversations_dir = data_dir / "conversations"
self._registrations_dir = data_dir / "registrations"
self._conversations_dir.mkdir(parents=True, exist_ok=True)
self._registrations_dir.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------------
# Conversation CRUD — keyed by normalized email address
# ------------------------------------------------------------------
def load(self, email_address: str) -> ConversationState | None:
"""Load a conversation by sender email address. Returns None if not found."""
path = self._conversation_path(email_address)
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
return ConversationState.from_dict(data)
except Exception:
logger.exception("Failed to load conversation for %s", email_address)
return None
# Alias for clarity in call sites that emphasise the email-lookup semantic
find_by_email = load
def save(self, state: ConversationState) -> None:
"""Persist a conversation state to disk."""
path = self._conversation_path(state.parent_email or state.conversation_id)
try:
path.write_text(
json.dumps(state.to_dict(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
logger.exception("Failed to save conversation for %s", state.conversation_id)
def delete(self, email_address: str) -> None:
"""Remove a conversation file."""
path = self._conversation_path(email_address)
if path.exists():
path.unlink()
def list_incomplete(self) -> list[ConversationState]:
"""Return all conversations that have not yet been completed."""
states: list[ConversationState] = []
for path in self._conversations_dir.glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
state = ConversationState.from_dict(data)
if not state.completed:
states.append(state)
except Exception:
logger.warning("Could not read conversation file %s", path)
return states
# ------------------------------------------------------------------
# Versioned registration storage
# ------------------------------------------------------------------
def save_registration(self, state: ConversationState) -> tuple[str, int]:
"""Store the first version of a completed registration.
Returns:
Tuple of (registration_dir_key, version_number).
"""
email_key = _email_to_filename(state.parent_email or state.conversation_id)
reg_dir = self._registrations_dir / email_key
reg_dir.mkdir(parents=True, exist_ok=True)
version = 1
record = self._build_record(state.registration.to_dict(), version, state)
self._write_version(reg_dir, version, record)
logger.info("Saved initial registration v%d for %s", version, email_key)
return email_key, version
def save_registration_version(
self,
state: ConversationState,
change_summary: dict[str, tuple],
) -> tuple[str, int]:
"""Store an updated registration as a new version.
Args:
state: Current conversation state with updated registration data.
change_summary: Dict of field_path → (old_value, new_value).
Returns:
Tuple of (registration_dir_key, new_version_number).
"""
email_key = _email_to_filename(state.parent_email or state.conversation_id)
reg_dir = self._registrations_dir / email_key
reg_dir.mkdir(parents=True, exist_ok=True)
history = self.get_registration_history(state.parent_email or state.conversation_id)
version = len(history) + 1
record = self._build_record(state.registration.to_dict(), version, state)
record["metadata"]["changeSummary"] = {
k: {"old": v[0], "new": v[1]} for k, v in change_summary.items()
}
self._write_version(reg_dir, version, record)
logger.info("Saved registration v%d for %s", version, email_key)
return email_key, version
def get_registration_history(self, email_address: str) -> list[dict]:
"""Return all registration versions for an email address, oldest first."""
email_key = _email_to_filename(email_address)
reg_dir = self._registrations_dir / email_key
if not reg_dir.exists():
return []
records: list[dict] = []
for path in sorted(reg_dir.glob("v*.json")):
try:
records.append(json.loads(path.read_text(encoding="utf-8")))
except Exception:
logger.warning("Could not read registration version %s", path)
return records
def get_current_registration(self, email_address: str) -> dict | None:
"""Return the latest registration version for an email address."""
email_key = _email_to_filename(email_address)
current_path = self._registrations_dir / email_key / "current.json"
if not current_path.exists():
return None
try:
return json.loads(current_path.read_text(encoding="utf-8"))
except Exception:
logger.exception("Failed to load current registration for %s", email_address)
return None
def list_registrations(self) -> list[dict]:
"""Return the current (latest) registration for every known email address."""
records: list[dict] = []
for email_dir in sorted(self._registrations_dir.iterdir()):
if not email_dir.is_dir():
continue
current = email_dir / "current.json"
if current.exists():
try:
records.append(json.loads(current.read_text(encoding="utf-8")))
except Exception:
logger.warning("Could not read %s", current)
return records
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _conversation_path(self, email_address: str) -> Path:
return self._conversations_dir / f"{_email_to_filename(email_address)}.json"
@staticmethod
def _build_record(reg_data: dict, version: int, state: ConversationState) -> dict:
record = dict(reg_data)
record["metadata"] = {
"version": version,
"submittedAt": _now(),
"channel": "email",
"parentEmail": state.parent_email,
"conversationId": state.conversation_id,
}
return record
@staticmethod
def _write_version(reg_dir: Path, version: int, record: dict) -> None:
ts = _timestamp_for_filename()
version_path = reg_dir / f"v{version}_{ts}.json"
version_path.write_text(
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
)
# Keep current.json as a plain copy of the latest version
(reg_dir / "current.json").write_text(
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
)
View File
+61
View File
@@ -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
+271
View File
@@ -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
+52
View File
@@ -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)
+67
View File
@@ -0,0 +1,67 @@
"""Tests for the litellm wrapper in src/llm.py."""
import pytest
from src import llm
from src.models.conversation import ChatMessage
class TestLlmComplete:
def test_returns_model_reply(self, mocker):
mock_response = mocker.MagicMock()
mock_response.choices[0].message.content = "Hallo! Wie heisst dein Kind?"
mocker.patch("litellm.completion", return_value=mock_response)
result = llm.complete("anthropic/claude-opus-4-6", "system prompt", [])
assert result == "Hallo! Wie heisst dein Kind?"
def test_passes_model_to_litellm(self, mocker):
mock_response = mocker.MagicMock()
mock_response.choices[0].message.content = "ok"
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
llm.complete("openai/gpt-4o", "system", [])
call_kwargs = mock_completion.call_args.kwargs
assert call_kwargs["model"] == "openai/gpt-4o"
def test_system_prompt_prepended_as_system_message(self, mocker):
mock_response = mocker.MagicMock()
mock_response.choices[0].message.content = "ok"
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
llm.complete("anthropic/claude-opus-4-6", "You are helpful.", [])
messages = mock_completion.call_args.kwargs["messages"]
assert messages[0] == {"role": "system", "content": "You are helpful."}
def test_chat_messages_appended_after_system(self, mocker):
mock_response = mocker.MagicMock()
mock_response.choices[0].message.content = "ok"
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
chat = [
ChatMessage(role="user", content="Hallo"),
ChatMessage(role="assistant", content="Guten Tag"),
]
llm.complete("anthropic/claude-opus-4-6", "system", chat)
messages = mock_completion.call_args.kwargs["messages"]
assert messages[1] == {"role": "user", "content": "Hallo"}
assert messages[2] == {"role": "assistant", "content": "Guten Tag"}
def test_max_tokens_passed(self, mocker):
mock_response = mocker.MagicMock()
mock_response.choices[0].message.content = "ok"
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
llm.complete("anthropic/claude-opus-4-6", "system", [])
assert mock_completion.call_args.kwargs["max_tokens"] == 2048
def test_litellm_exception_propagates(self, mocker):
mocker.patch("litellm.completion", side_effect=RuntimeError("API error"))
with pytest.raises(RuntimeError, match="API error"):
llm.complete("anthropic/claude-opus-4-6", "system", [])
+152
View File
@@ -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"]
+137
View File
@@ -0,0 +1,137 @@
"""Tests for AdminNotifier helper methods."""
import pytest
from src.notifications.notifier import AdminNotifier
from src.models.registration import RegistrationData, Booking, BookingDay
@pytest.fixture
def notifier():
return AdminNotifier(
smtp_host="smtp.example.com",
smtp_port=587,
username="agent@example.com",
password="secret",
use_tls=True,
from_email="agent@example.com",
indoor_email="andrea@example.com",
outdoor_email="barbara@example.com",
cc_emails=["markus@example.com"],
)
# ---------------------------------------------------------------------------
# _format_types
# ---------------------------------------------------------------------------
class TestFormatTypes:
def test_indoor_label(self, notifier):
assert "Innen" in notifier._format_types(["indoor"]) or "indoor" in notifier._format_types(["indoor"]).lower()
def test_outdoor_label(self, notifier):
assert "Wald" in notifier._format_types(["outdoor"]) or "outdoor" in notifier._format_types(["outdoor"]).lower()
def test_both_labels(self, notifier):
result = notifier._format_types(["indoor", "outdoor"])
assert len(result) > 0
# ---------------------------------------------------------------------------
# _calculate_age
# ---------------------------------------------------------------------------
class TestCalculateAge:
def test_returns_age_string(self, notifier):
result = notifier._calculate_age("2022-01-01")
assert isinstance(result, str)
assert len(result) > 0
def test_invalid_dob_returns_original_string(self, notifier):
result = notifier._calculate_age("not-a-date")
assert result == "not-a-date"
# ---------------------------------------------------------------------------
# _calculate_monthly_fee
# ---------------------------------------------------------------------------
class TestCalculateMonthlyFee:
def test_indoor_one_day(self, notifier, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["indoor"],
selected_days=[BookingDay(day="monday", type="indoor")],
)
fee = notifier._calculate_monthly_fee(complete_registration)
assert "130" in fee
def test_indoor_two_days(self, notifier, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["indoor"],
selected_days=[
BookingDay(day="monday", type="indoor"),
BookingDay(day="wednesday", type="indoor"),
],
)
fee = notifier._calculate_monthly_fee(complete_registration)
assert "260" in fee
def test_indoor_three_days(self, notifier, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["indoor"],
selected_days=[
BookingDay(day="monday", type="indoor"),
BookingDay(day="wednesday", type="indoor"),
BookingDay(day="thursday", type="indoor"),
],
)
fee = notifier._calculate_monthly_fee(complete_registration)
assert "390" in fee
def test_outdoor_one_day(self, notifier, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["outdoor"],
selected_days=[BookingDay(day="monday", type="outdoor")],
)
fee = notifier._calculate_monthly_fee(complete_registration)
assert "250" in fee
# ---------------------------------------------------------------------------
# _send — SMTP interaction
# ---------------------------------------------------------------------------
class TestSend:
def test_send_calls_smtp(self, notifier, mocker):
# _send uses smtplib.SMTP directly (not as context manager)
mock_smtp_cls = mocker.patch("smtplib.SMTP")
mock_server = mock_smtp_cls.return_value
notifier._send(
to=["admin@example.com"],
cc=["cc@example.com"],
subject="Test",
body="Hello",
)
mock_server.sendmail.assert_called_once()
def test_send_includes_all_recipients(self, notifier, mocker):
mock_smtp_cls = mocker.patch("smtplib.SMTP")
mock_server = mock_smtp_cls.return_value
notifier._send(
to=["a@example.com"],
cc=["b@example.com"],
subject="Test",
body="Hello",
)
call_args = mock_server.sendmail.call_args
recipients = call_args[0][1] # positional arg: to_addrs
assert "a@example.com" in recipients
assert "b@example.com" in recipients
+159
View File
@@ -0,0 +1,159 @@
"""Tests for ConversationStore and storage helpers."""
import json
from pathlib import Path
import pytest
from src.storage.json_store import (
ConversationStore,
normalize_email,
_diff_registrations,
)
from src.models.conversation import ConversationState
# ---------------------------------------------------------------------------
# normalize_email
# ---------------------------------------------------------------------------
class TestNormalizeEmail:
def test_lowercases(self):
assert normalize_email("Anna.Muster@Example.COM") == "anna.muster@example.com"
def test_strips_whitespace(self):
assert normalize_email(" user@example.com ") == "user@example.com"
def test_already_normalized(self):
assert normalize_email("user@example.com") == "user@example.com"
# ---------------------------------------------------------------------------
# _diff_registrations
# ---------------------------------------------------------------------------
class TestDiffRegistrations:
def test_detects_changed_field(self):
old = {"child": {"fullName": "Lena"}}
new = {"child": {"fullName": "Lena Muster"}}
diff = _diff_registrations(old, new)
assert "child.fullName" in diff
assert diff["child.fullName"] == ("Lena", "Lena Muster")
def test_unchanged_fields_not_included(self):
old = {"child": {"fullName": "Lena", "dateOfBirth": "2022-01-01"}}
new = {"child": {"fullName": "Lena", "dateOfBirth": "2022-01-01"}}
assert _diff_registrations(old, new) == {}
def test_nested_change_detected(self):
old = {"parentGuardian": {"email": "old@example.com"}}
new = {"parentGuardian": {"email": "new@example.com"}}
diff = _diff_registrations(old, new)
assert "parentGuardian.email" in diff
# ---------------------------------------------------------------------------
# ConversationStore — CRUD
# ---------------------------------------------------------------------------
@pytest.fixture
def store(tmp_path) -> ConversationStore:
return ConversationStore(tmp_path)
class TestConversationStoreCRUD:
def test_load_returns_none_for_unknown_email(self, store):
assert store.load("nobody@example.com") is None
def test_save_and_load_round_trip(self, store, fresh_state):
store.save(fresh_state)
loaded = store.load(fresh_state.parent_email)
assert loaded is not None
assert loaded.conversation_id == fresh_state.conversation_id
def test_save_overwrites_existing(self, store, fresh_state):
store.save(fresh_state)
fresh_state.language = "en"
store.save(fresh_state)
loaded = store.load(fresh_state.parent_email)
assert loaded.language == "en"
def test_delete_removes_conversation(self, store, fresh_state):
store.save(fresh_state)
store.delete(fresh_state.parent_email)
assert store.load(fresh_state.parent_email) is None
def test_delete_nonexistent_is_silent(self, store):
store.delete("ghost@example.com") # should not raise
def test_list_incomplete_returns_non_completed(self, store, fresh_state):
store.save(fresh_state)
incomplete = store.list_incomplete()
assert any(s.conversation_id == fresh_state.conversation_id for s in incomplete)
def test_list_incomplete_excludes_completed(self, store, fresh_state):
fresh_state.completed = True
store.save(fresh_state)
incomplete = store.list_incomplete()
assert all(not s.completed for s in incomplete)
def test_find_by_email_is_alias_for_load(self, store, fresh_state):
store.save(fresh_state)
assert store.find_by_email(fresh_state.parent_email) is not None
# ---------------------------------------------------------------------------
# ConversationStore — registration versioning
# ---------------------------------------------------------------------------
class TestRegistrationVersioning:
def test_save_registration_creates_version_1(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
email_key, version = store.save_registration(fresh_state)
assert version == 1
# email_key is the filesystem-safe form (@ → _at_)
assert email_key == "anna.muster_at_example.com"
def test_save_registration_writes_current_json(self, store, fresh_state, complete_registration, tmp_path):
fresh_state.registration = complete_registration
fresh_state.completed = True
email_key, _ = store.save_registration(fresh_state)
current = tmp_path / "registrations" / email_key / "current.json"
assert current.exists()
def test_save_registration_version_increments(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
store.save_registration(fresh_state)
_, v2 = store.save_registration_version(
fresh_state, {"child.fullName": ("Old", "New")}
)
assert v2 == 2
def test_get_current_registration_returns_latest(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
store.save_registration(fresh_state)
current = store.get_current_registration(fresh_state.parent_email)
assert current is not None
assert current["metadata"]["version"] == 1
def test_get_registration_history_returns_all_versions(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
store.save_registration(fresh_state)
store.save_registration_version(fresh_state, {"child.fullName": ("A", "B")})
history = store.get_registration_history(fresh_state.parent_email)
assert len(history) == 2
def test_list_registrations_includes_saved(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
store.save_registration(fresh_state)
registrations = store.list_registrations()
assert len(registrations) == 1
Generated
+1420
View File
File diff suppressed because it is too large Load Diff