Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
905debb48e | ||
|
|
2fac05c4ee | ||
|
|
eba450c5a5 | ||
|
|
8217b33f38 | ||
|
|
db97a357c9 | ||
|
|
7fb1d1fa0f | ||
|
|
af96c7a310 | ||
|
|
c488a0061e | ||
|
|
4440b00d91 | ||
|
|
9c33bafbf3 | ||
|
|
fef0388534 | ||
|
|
98a5f5b5b1 | ||
|
|
60f056ece4 | ||
|
|
cfa90174e3 | ||
|
|
7f55cdd204 | ||
|
|
431847a8b7 | ||
|
|
1ba42f9497 | ||
|
|
05d4b51e7a | ||
|
|
a174023ee5 | ||
|
|
0c3b5a9033 | ||
|
|
968475804c | ||
|
|
c37862b75e | ||
|
|
b10ff7a4fe | ||
|
|
b82ff27efd | ||
|
|
4d44d4ee58 |
@@ -0,0 +1,69 @@
|
|||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Meister-Eder Email Agent — Configuration Template
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Copy this file to .env and fill in your values.
|
||||||
|
# The .env file must NOT be committed to version control.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# AI Model (via litellm — supports any provider)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Use litellm model strings: "<provider>/<model-name>"
|
||||||
|
# Examples:
|
||||||
|
# anthropic/claude-opus-4-6 (default)
|
||||||
|
# openai/gpt-4o
|
||||||
|
# gemini/gemini-2.0-flash
|
||||||
|
AI_MODEL=anthropic/claude-opus-4-6
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# API Keys — set the one matching your chosen model's provider
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
# OPENAI_API_KEY=sk-...
|
||||||
|
# GEMINI_API_KEY=...
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Email — IMAP (receiving parent messages)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
IMAP_HOST=imap.example.com
|
||||||
|
IMAP_PORT=993
|
||||||
|
IMAP_USERNAME=anmeldung@example.com
|
||||||
|
IMAP_PASSWORD=your-imap-password
|
||||||
|
IMAP_USE_SSL=true
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Email — SMTP (sending replies and notifications)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USE_TLS=true
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Registration email address (displayed as sender to parents)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
REGISTRATION_EMAIL=anmeldung@example.com
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Admin notification routing
|
||||||
|
# Each leader receives mail only when a day in their group is booked.
|
||||||
|
# ADMIN_EMAIL_CC is always included as Cc (comma-separated for multiple).
|
||||||
|
# For testing, point all three to your own email address.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
ADMIN_EMAIL_INDOOR=andrea.sigrist@gmx.net
|
||||||
|
ADMIN_EMAIL_OUTDOOR=baba.laeubli@gmail.com
|
||||||
|
ADMIN_EMAIL_CC=spielgruppen@familien-verein.ch
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Storage
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Directory for conversation state and completed registrations.
|
||||||
|
DATA_DIR=data
|
||||||
|
|
||||||
|
# Path to the knowledge-base markdown files (admin-editable).
|
||||||
|
KNOWLEDGE_BASE_DIR=openspec/changes/define-project-scope/content/knowledge-base
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Polling
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# How often (in seconds) to check the inbox for new messages.
|
||||||
|
POLL_INTERVAL=60
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Agent data (conversations and registrations stored at runtime)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
@@ -1 +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.
|
||||||
|
|||||||
@@ -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.
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Conversations keyed by email address
|
||||||
|
The system SHALL store conversations using the sender's normalized email address as the unique key, replacing thread-ID-based storage.
|
||||||
|
|
||||||
|
#### Scenario: Conversation file naming
|
||||||
|
- **WHEN** storing a conversation for `parent@example.com`
|
||||||
|
- **THEN** the system SHALL use a filename derived from the email address (e.g., `parent_at_example.com.json`)
|
||||||
|
|
||||||
|
#### Scenario: Conversation lookup
|
||||||
|
- **WHEN** loading a conversation for an incoming email
|
||||||
|
- **THEN** the system SHALL lookup by normalized sender email address
|
||||||
|
|
||||||
|
### Requirement: Registration updates stored as versions
|
||||||
|
The system SHALL store registration updates as separate versions, preserving the original and all subsequent changes for audit purposes.
|
||||||
|
|
||||||
|
#### Scenario: Initial registration stored
|
||||||
|
- **WHEN** a registration is completed for the first time
|
||||||
|
- **THEN** the system SHALL store it as version 1 with timestamp
|
||||||
|
|
||||||
|
#### Scenario: Registration update creates new version
|
||||||
|
- **WHEN** a parent requests changes to a completed registration
|
||||||
|
- **THEN** the system SHALL store the updated data as a new version
|
||||||
|
- **AND** the system SHALL preserve all previous versions
|
||||||
|
|
||||||
|
#### Scenario: Version metadata
|
||||||
|
- **WHEN** storing a registration version
|
||||||
|
- **THEN** the version SHALL include: version number, timestamp, and change summary (which fields changed)
|
||||||
|
|
||||||
|
### Requirement: Current registration accessible
|
||||||
|
The system SHALL provide easy access to the current (latest) registration data while preserving version history.
|
||||||
|
|
||||||
|
#### Scenario: Retrieve current registration
|
||||||
|
- **WHEN** the admin or system requests the current registration for an email address
|
||||||
|
- **THEN** the system SHALL return the most recent version
|
||||||
|
|
||||||
|
#### Scenario: Retrieve version history
|
||||||
|
- **WHEN** the admin requests registration history for an email address
|
||||||
|
- **THEN** the system SHALL return all versions in chronological order
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Post-completion conversation state
|
||||||
|
The system SHALL support a "completed" conversation state that allows continued interaction for questions and updates.
|
||||||
|
|
||||||
|
#### Scenario: Conversation continues after completion
|
||||||
|
- **WHEN** a parent sends an email after their registration is complete
|
||||||
|
- **THEN** the system SHALL load the existing conversation and process the message
|
||||||
|
|
||||||
|
#### Scenario: Intent detection for post-completion messages
|
||||||
|
- **WHEN** processing a message in a completed conversation
|
||||||
|
- **THEN** the system SHALL detect intent: question, update request, or new child registration
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Notify admin on registration updates
|
||||||
|
The system SHALL send an email notification to the admin when an existing registration is updated, including details of what changed.
|
||||||
|
|
||||||
|
#### Scenario: Update notification sent
|
||||||
|
- **WHEN** a parent updates their completed registration
|
||||||
|
- **THEN** the admin SHALL receive an email notification
|
||||||
|
|
||||||
|
#### Scenario: Update notification content
|
||||||
|
- **WHEN** sending an update notification
|
||||||
|
- **THEN** the notification SHALL include:
|
||||||
|
- Child name and registration ID
|
||||||
|
- What changed (field name, old value → new value)
|
||||||
|
- When the change was made
|
||||||
|
- Version number (e.g., "Version 2 of 2")
|
||||||
|
|
||||||
|
#### Scenario: Update notification routing
|
||||||
|
- **WHEN** sending an update notification
|
||||||
|
- **THEN** the notification SHALL be routed to the same recipients as the original registration (based on playgroup type)
|
||||||
|
|
||||||
|
### Requirement: Distinguish new vs update notifications
|
||||||
|
The system SHALL clearly distinguish between new registration notifications and update notifications in the email subject and content.
|
||||||
|
|
||||||
|
#### Scenario: New registration subject
|
||||||
|
- **WHEN** sending a notification for a new registration
|
||||||
|
- **THEN** the subject SHALL be "New Registration: [Child Name] for [Playgroup Type]"
|
||||||
|
|
||||||
|
#### Scenario: Update notification subject
|
||||||
|
- **WHEN** sending a notification for a registration update
|
||||||
|
- **THEN** the subject SHALL be "Registration Updated: [Child Name]"
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Email reminders for incomplete registrations
|
||||||
|
The system SHALL send reminder emails for incomplete registrations, but SHALL NOT threaten data deletion since data no longer expires.
|
||||||
|
|
||||||
|
#### Scenario: Reminder content without expiration warning
|
||||||
|
- **WHEN** sending a reminder for an incomplete registration
|
||||||
|
- **THEN** the reminder SHALL encourage completion but SHALL NOT mention data expiration or deletion
|
||||||
|
|
||||||
|
#### Scenario: Reminder schedule unchanged
|
||||||
|
- **WHEN** an incomplete registration exists
|
||||||
|
- **THEN** reminders SHALL be sent at Day 3, Day 10, and Day 25 after last activity
|
||||||
|
|
||||||
|
#### Scenario: Reminders stop after completion
|
||||||
|
- **WHEN** a registration is completed
|
||||||
|
- **THEN** no further reminders SHALL be sent for that conversation
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## 1. Update Conversation Storage
|
||||||
|
|
||||||
|
- [ ] 1.1 Modify `ConversationStore` to key conversations by normalized email address
|
||||||
|
- [ ] 1.2 Add `normalize_email()` helper function (lowercase, trim)
|
||||||
|
- [ ] 1.3 Update `_conversation_path()` to use email-based filename
|
||||||
|
- [ ] 1.4 Add `find_by_email()` method to replace thread-ID-based lookup
|
||||||
|
|
||||||
|
## 2. Update Email Channel
|
||||||
|
|
||||||
|
- [ ] 2.1 Remove `_resolve_thread_id()` from conversation matching logic
|
||||||
|
- [ ] 2.2 Pass sender email to agent instead of thread ID for conversation lookup
|
||||||
|
- [ ] 2.3 Keep thread ID handling for outbound reply headers (`In-Reply-To`, `References`)
|
||||||
|
- [ ] 2.4 Store most recent inbound message ID for reply threading
|
||||||
|
|
||||||
|
## 3. Update Agent Core
|
||||||
|
|
||||||
|
- [ ] 3.1 Modify `process_message()` to lookup conversation by email address
|
||||||
|
- [ ] 3.2 Add post-completion intent detection (question vs. update vs. new child)
|
||||||
|
- [ ] 3.3 Handle registration updates in completed conversations
|
||||||
|
- [ ] 3.4 Update prompts to guide LLM for post-completion states
|
||||||
|
|
||||||
|
## 4. Implement Versioned Registration Storage
|
||||||
|
|
||||||
|
- [ ] 4.1 Create versioned storage structure for registrations
|
||||||
|
- [ ] 4.2 Implement `save_registration_version()` method
|
||||||
|
- [ ] 4.3 Implement `get_registration_history()` method
|
||||||
|
- [ ] 4.4 Track change summary (which fields changed) between versions
|
||||||
|
- [ ] 4.5 Update `save_registration()` to use versioning for updates
|
||||||
|
|
||||||
|
## 5. Update Admin Notifications
|
||||||
|
|
||||||
|
- [ ] 5.1 Add `notify_registration_update()` method to `AdminNotifier`
|
||||||
|
- [ ] 5.2 Create email template for update notifications (include diff)
|
||||||
|
- [ ] 5.3 Distinguish "New Registration" vs "Registration Updated" subjects
|
||||||
|
- [ ] 5.4 Include version number in update notifications
|
||||||
|
|
||||||
|
## 6. Update Reminders
|
||||||
|
|
||||||
|
- [ ] 6.1 Remove expiration warnings from reminder templates
|
||||||
|
- [ ] 6.2 Update reminder messages to encourage completion without deletion threat
|
||||||
|
- [ ] 6.3 Remove any scheduled data cleanup jobs (if present)
|
||||||
|
|
||||||
|
## 7. Update Specs and Documentation
|
||||||
|
|
||||||
|
- [ ] 7.1 Update `conversation-flow.md` to remove expiration language
|
||||||
|
- [ ] 7.2 Update `channel-config.md` state management section
|
||||||
|
- [ ] 7.3 Update sample responses to remove expiration references
|
||||||
|
- [ ] 7.4 Update CLAUDE.md with new conversation matching behavior
|
||||||
|
|
||||||
|
## 8. Testing
|
||||||
|
|
||||||
|
- [ ] 8.1 Test: New email creates new conversation
|
||||||
|
- [ ] 8.2 Test: Follow-up email (same address, different thread) continues conversation
|
||||||
|
- [ ] 8.3 Test: Post-completion question is answered correctly
|
||||||
|
- [ ] 8.4 Test: Post-completion update creates new version and notifies admin
|
||||||
|
- [ ] 8.5 Test: Email address normalization works correctly
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[project]
|
||||||
|
name = "meister-eder"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "AI-powered conversational registration agent for Spielgruppe Pumuckl"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
# LLM access — supports any provider (Anthropic, OpenAI, Gemini, …)
|
||||||
|
"litellm>=1.0.0",
|
||||||
|
# Configuration
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
# Registration schema validation
|
||||||
|
"jsonschema>=4.23.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
meister-eder = "main:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src"]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
dev-dependencies = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-mock>=3.14.0",
|
||||||
|
]
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""EmailAgent — the channel-agnostic conversation orchestrator."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from ..models.conversation import ConversationState, ChatMessage
|
||||||
|
from ..models.registration import BookingDay, RegistrationData
|
||||||
|
from .. import llm
|
||||||
|
from ..knowledge_base.loader import KnowledgeBase
|
||||||
|
from ..storage.json_store import ConversationStore, normalize_email, _diff_registrations
|
||||||
|
from ..notifications.notifier import AdminNotifier
|
||||||
|
from .prompts import build_system_prompt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailAgent:
|
||||||
|
"""Processes one inbound email and returns the agent's reply text.
|
||||||
|
|
||||||
|
Conversations are identified by the sender's normalized email address, so
|
||||||
|
a parent who composes a fresh email (instead of replying) continues their
|
||||||
|
existing conversation seamlessly.
|
||||||
|
|
||||||
|
All business logic lives here; channel I/O is handled by the caller.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: str,
|
||||||
|
kb: KnowledgeBase,
|
||||||
|
store: ConversationStore,
|
||||||
|
notifier: AdminNotifier,
|
||||||
|
) -> None:
|
||||||
|
self._model = model
|
||||||
|
self._kb = kb
|
||||||
|
self._store = store
|
||||||
|
self._notifier = notifier
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def process_message(
|
||||||
|
self,
|
||||||
|
parent_email: str,
|
||||||
|
message_text: str,
|
||||||
|
inbound_message_id: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Process one inbound message and return the reply text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parent_email: Sender email address — used as conversation key.
|
||||||
|
message_text: Stripped plain-text body of the inbound email.
|
||||||
|
inbound_message_id: Message-ID of the inbound email (stored for
|
||||||
|
reply threading headers; not used for conversation matching).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Reply text to send back to the parent.
|
||||||
|
"""
|
||||||
|
email_key = normalize_email(parent_email)
|
||||||
|
|
||||||
|
# Load or create conversation state — keyed by email address
|
||||||
|
state = self._store.load(email_key)
|
||||||
|
if state is None:
|
||||||
|
state = ConversationState(
|
||||||
|
conversation_id=email_key,
|
||||||
|
parent_email=email_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
state.last_activity = now
|
||||||
|
if inbound_message_id:
|
||||||
|
state.last_inbound_message_id = inbound_message_id
|
||||||
|
|
||||||
|
# Append the user's message to history
|
||||||
|
state.messages.append(ChatMessage(role="user", content=message_text))
|
||||||
|
|
||||||
|
# Route to the appropriate handler
|
||||||
|
if state.completed:
|
||||||
|
reply_text = self._handle_post_completion(state)
|
||||||
|
else:
|
||||||
|
reply_text = self._handle_registration(state)
|
||||||
|
|
||||||
|
# Record the assistant reply and persist
|
||||||
|
state.messages.append(ChatMessage(role="assistant", content=reply_text))
|
||||||
|
state.updated_at = now
|
||||||
|
self._store.save(state)
|
||||||
|
|
||||||
|
return reply_text
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Registration flow
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _handle_registration(self, state: ConversationState) -> str:
|
||||||
|
"""Drive the in-progress registration conversation."""
|
||||||
|
system = build_system_prompt(self._kb, state)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = llm.complete(self._model, system, state.messages)
|
||||||
|
parsed = self._parse_llm_response(content)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("LLM call failed for %s", state.conversation_id)
|
||||||
|
return self._fallback_message(state)
|
||||||
|
|
||||||
|
reply_text: str = parsed.get("reply", "")
|
||||||
|
updates: dict = parsed.get("updates", {}) or {}
|
||||||
|
next_step: str = parsed.get("next_step", state.flow_step)
|
||||||
|
is_complete: bool = bool(parsed.get("registration_complete", False))
|
||||||
|
language: str = parsed.get("language", state.language)
|
||||||
|
|
||||||
|
self._apply_updates(state, updates)
|
||||||
|
state.flow_step = next_step
|
||||||
|
state.language = language
|
||||||
|
|
||||||
|
if is_complete and not state.completed:
|
||||||
|
state.completed = True
|
||||||
|
email_key, version = self._store.save_registration(state)
|
||||||
|
try:
|
||||||
|
self._notifier.notify_admin(
|
||||||
|
registration=state.registration,
|
||||||
|
registration_id=email_key,
|
||||||
|
version=version,
|
||||||
|
conversation_id=state.conversation_id,
|
||||||
|
channel="email",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send admin notification for %s", email_key)
|
||||||
|
logger.info("Registration complete for %s", state.conversation_id)
|
||||||
|
|
||||||
|
return reply_text
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Post-completion flow
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _handle_post_completion(self, state: ConversationState) -> str:
|
||||||
|
"""Handle messages received after a registration is already complete."""
|
||||||
|
system = build_system_prompt(self._kb, state)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = llm.complete(self._model, system, state.messages)
|
||||||
|
parsed = self._parse_llm_response(content)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("LLM call failed (post-completion) for %s", state.conversation_id)
|
||||||
|
return self._fallback_message(state)
|
||||||
|
|
||||||
|
reply_text: str = parsed.get("reply", "")
|
||||||
|
intent: str = parsed.get("intent", "question")
|
||||||
|
updates: dict = parsed.get("updates", {}) or {}
|
||||||
|
language: str = parsed.get("language", state.language)
|
||||||
|
state.language = language
|
||||||
|
|
||||||
|
if intent == "update" and any(v is not None for v in updates.values()):
|
||||||
|
self._handle_registration_update(state, updates)
|
||||||
|
elif intent == "new_child":
|
||||||
|
# Reset registration so a fresh flow begins in the next message
|
||||||
|
state.registration = RegistrationData()
|
||||||
|
state.completed = False
|
||||||
|
state.flow_step = "child_name"
|
||||||
|
logger.info("Starting new child registration for %s", state.conversation_id)
|
||||||
|
|
||||||
|
return reply_text
|
||||||
|
|
||||||
|
def _handle_registration_update(self, state: ConversationState, updates: dict) -> None:
|
||||||
|
"""Apply field updates, version the record, and notify the admin."""
|
||||||
|
old_data = state.registration.to_dict()
|
||||||
|
self._apply_updates(state, updates)
|
||||||
|
new_data = state.registration.to_dict()
|
||||||
|
|
||||||
|
change_summary = _diff_registrations(old_data, new_data)
|
||||||
|
if not change_summary:
|
||||||
|
return # Nothing actually changed
|
||||||
|
|
||||||
|
email_key, version = self._store.save_registration_version(state, change_summary)
|
||||||
|
try:
|
||||||
|
self._notifier.notify_registration_update(
|
||||||
|
registration=state.registration,
|
||||||
|
registration_id=email_key,
|
||||||
|
version=version,
|
||||||
|
change_summary=change_summary,
|
||||||
|
conversation_id=state.conversation_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send update notification for %s", email_key)
|
||||||
|
logger.info("Registration updated to v%d for %s", version, state.conversation_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Shared helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_llm_response(self, content: str) -> dict:
|
||||||
|
"""Extract the JSON payload from the LLM's raw output."""
|
||||||
|
text = content.strip()
|
||||||
|
|
||||||
|
fence_match = re.match(r"^```(?:json)?\s*\n(.*?)\n```\s*$", text, re.DOTALL)
|
||||||
|
if fence_match:
|
||||||
|
text = fence_match.group(1).strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
brace_match = re.search(r"\{.*\}", text, re.DOTALL)
|
||||||
|
if brace_match:
|
||||||
|
try:
|
||||||
|
return json.loads(brace_match.group())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.warning("Could not parse LLM response as JSON — using raw text as reply.")
|
||||||
|
return {
|
||||||
|
"reply": content,
|
||||||
|
"intent": "question",
|
||||||
|
"updates": {},
|
||||||
|
"next_step": "greeting",
|
||||||
|
"registration_complete": False,
|
||||||
|
"language": "de",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fallback_message(self, state: ConversationState) -> str:
|
||||||
|
if state.language == "en":
|
||||||
|
return (
|
||||||
|
"I'm sorry, I'm having a technical issue right now. "
|
||||||
|
"Please try again in a moment or contact us directly."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"Entschuldigung, ich habe gerade ein technisches Problem. "
|
||||||
|
"Bitte versuche es gleich nochmal oder kontaktiere uns direkt."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_updates(self, state: ConversationState, updates: dict) -> None:
|
||||||
|
"""Write extracted field values into the RegistrationData object."""
|
||||||
|
reg = state.registration
|
||||||
|
|
||||||
|
field_map = {
|
||||||
|
"child.fullName": lambda v: setattr(reg.child, "full_name", v),
|
||||||
|
"child.dateOfBirth": lambda v: setattr(reg.child, "date_of_birth", v),
|
||||||
|
"child.specialNeeds": lambda v: setattr(reg.child, "special_needs", v),
|
||||||
|
"parentGuardian.fullName": lambda v: (
|
||||||
|
setattr(reg.parent_guardian, "full_name", v),
|
||||||
|
setattr(state, "parent_name", v),
|
||||||
|
),
|
||||||
|
"parentGuardian.streetAddress": lambda v: setattr(reg.parent_guardian, "street_address", v),
|
||||||
|
"parentGuardian.postalCode": lambda v: setattr(reg.parent_guardian, "postal_code", str(v)),
|
||||||
|
"parentGuardian.city": lambda v: setattr(reg.parent_guardian, "city", v),
|
||||||
|
"parentGuardian.phone": lambda v: setattr(reg.parent_guardian, "phone", v),
|
||||||
|
"parentGuardian.email": lambda v: setattr(reg.parent_guardian, "email", v),
|
||||||
|
"emergencyContact.fullName": lambda v: setattr(reg.emergency_contact, "full_name", v),
|
||||||
|
"emergencyContact.phone": lambda v: setattr(reg.emergency_contact, "phone", v),
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value in updates.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if key in field_map:
|
||||||
|
field_map[key](value)
|
||||||
|
elif key == "booking.playgroupTypes" and isinstance(value, list):
|
||||||
|
reg.booking.playgroup_types = value
|
||||||
|
elif key == "booking.selectedDays" and isinstance(value, list):
|
||||||
|
reg.booking.selected_days = [
|
||||||
|
BookingDay(day=d["day"], type=d["type"])
|
||||||
|
for d in value
|
||||||
|
if isinstance(d, dict) and "day" in d and "type" in d
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
logger.debug("Unknown update key ignored: %s", key)
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Build the system prompt sent to the LLM on every turn."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ..knowledge_base.loader import KnowledgeBase
|
||||||
|
from ..models.conversation import ConversationState
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step descriptions help the model understand where it is in the registration flow.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
STEP_DESCRIPTIONS = {
|
||||||
|
"greeting": (
|
||||||
|
"Greet the parent warmly and detect their intent (registration vs. questions). "
|
||||||
|
"In this first message, explicitly tell them they can write in any human language "
|
||||||
|
"and you will reply in the same language. "
|
||||||
|
"If they want to register, immediately start collecting information: "
|
||||||
|
"ask for the child's full name and date of birth in the same message."
|
||||||
|
),
|
||||||
|
"child_name": "Ask for the child's full name.",
|
||||||
|
"child_dob": (
|
||||||
|
"Ask for the child's date of birth. "
|
||||||
|
"Validate age: indoor requires ≥2 years, outdoor requires ≥2.5 years."
|
||||||
|
),
|
||||||
|
"playgroup_selection": (
|
||||||
|
"Explain both playgroup options and ask which the parent wants "
|
||||||
|
"(indoor / outdoor / both) and which days."
|
||||||
|
),
|
||||||
|
"special_needs": (
|
||||||
|
"Ask whether the child has any special needs, allergies, or medical conditions."
|
||||||
|
),
|
||||||
|
"parent_contact": (
|
||||||
|
"Collect the parent/guardian's full name, street address, postal code (4 digits), "
|
||||||
|
"city, phone number, and email address."
|
||||||
|
),
|
||||||
|
"emergency_contact": (
|
||||||
|
"Ask for an emergency contact (someone other than the parent): full name and phone."
|
||||||
|
),
|
||||||
|
"confirmation": (
|
||||||
|
"Show a summary of all collected information and ask the parent to confirm."
|
||||||
|
),
|
||||||
|
"complete": "Thank the parent, mention fees and next steps. Registration is done.",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PERSONALITY = """## Your Personality
|
||||||
|
- Warm, friendly, and helpful — like a caring playgroup staff member
|
||||||
|
- Use informal "du" in German (never the formal "Sie")
|
||||||
|
- Auto-detect the parent's language from their message; respond in the same language; default to German if unclear
|
||||||
|
- Collect all information for the current step — and any clearly related follow-up steps — in a single message; weave the questions naturally into flowing sentences, never as a form or bullet list
|
||||||
|
- If the parent's reply leaves some of your questions unanswered, explicitly re-ask every unanswered question before moving on — never silently skip an open question
|
||||||
|
- Be patient and understanding; never make parents feel they made a mistake"""
|
||||||
|
|
||||||
|
_CONTACTS = """## Admin Contacts
|
||||||
|
- Administration: Markus Graf — spielgruppen@familien-verein.ch — 079 261 16 37
|
||||||
|
- Indoor leader: Andrea Sigrist — andrea.sigrist@gmx.net — 079 674 99 92
|
||||||
|
- Outdoor leader: Barbara Gross — baba.laeubli@gmail.com — 078 761 19 64"""
|
||||||
|
|
||||||
|
_PLAYGROUP_DETAILS = """## Playgroup Details
|
||||||
|
- **Indoor (Innenspielgruppe)**: Mon / Wed / Thu, 09:00–11:30 | CHF 130/260/390 per month (1/2/3×/week)
|
||||||
|
- **Outdoor Forest (Waldspielgruppe)**: Mon only, 09:00–14:00 (includes snack & lunch) | CHF 250/month
|
||||||
|
- **One-time registration fee**: CHF 80 (first year); CHF 80 craft materials from second year
|
||||||
|
- **Cleaning deposit (indoor only)**: CHF 50 (refundable)
|
||||||
|
- **Sibling discount**: 10% per additional child
|
||||||
|
- **July & August**: fee-free"""
|
||||||
|
|
||||||
|
_REGISTRATION_RESPONSE_FORMAT = """## CRITICAL: Response Format
|
||||||
|
|
||||||
|
You MUST respond with **only** a valid JSON object — no markdown, no extra text outside the JSON.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"reply": "Your conversational message to the parent (plain text, NOT JSON)",
|
||||||
|
"updates": {{
|
||||||
|
"child.fullName": "string or null",
|
||||||
|
"child.dateOfBirth": "YYYY-MM-DD or null",
|
||||||
|
"child.specialNeeds": "string or null",
|
||||||
|
"parentGuardian.fullName": "string or null",
|
||||||
|
"parentGuardian.streetAddress": "string or null",
|
||||||
|
"parentGuardian.postalCode": "4-digit string or null",
|
||||||
|
"parentGuardian.city": "string or null",
|
||||||
|
"parentGuardian.phone": "string or null",
|
||||||
|
"parentGuardian.email": "string or null",
|
||||||
|
"emergencyContact.fullName": "string or null",
|
||||||
|
"emergencyContact.phone": "string or null",
|
||||||
|
"booking.playgroupTypes": ["indoor", "outdoor"] or null,
|
||||||
|
"booking.selectedDays": [{{"day": "monday", "type": "indoor"}}] or null
|
||||||
|
}},
|
||||||
|
"next_step": "greeting|child_name|child_dob|playgroup_selection|special_needs|parent_contact|emergency_contact|confirmation|complete",
|
||||||
|
"registration_complete": false,
|
||||||
|
"language": "de"
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Only set fields in `updates` that you actually extracted from the parent's **latest message**. Use `null` for everything else.
|
||||||
|
- Set `registration_complete` to `true` **only** when ALL required fields are filled AND the parent has just confirmed the summary is correct.
|
||||||
|
- Dates must be YYYY-MM-DD. Postal codes must be exactly 4 digits.
|
||||||
|
- Valid days: "monday", "wednesday", "thursday" (indoor) or "monday" (outdoor).
|
||||||
|
- `language` must be "de" or "en" based on the parent's message.
|
||||||
|
- Always store free-text field values (especially `child.specialNeeds`) **in German** in `updates`, translating from the parent's language if necessary. Use "Keine" if the parent indicates no special needs.
|
||||||
|
- The `reply` field must be natural, friendly, conversational text — not JSON and not a list of fields.
|
||||||
|
- The `reply` field must be plain text only. No markdown: no **bold**, no _italic_, no # headers, no bullet points with - or *, no backticks. Use plain sentences and line breaks only."""
|
||||||
|
|
||||||
|
_POST_COMPLETION_RESPONSE_FORMAT = """## CRITICAL: Response Format
|
||||||
|
|
||||||
|
You MUST respond with **only** a valid JSON object — no markdown, no extra text outside the JSON.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"reply": "Your conversational message to the parent (plain text, NOT JSON)",
|
||||||
|
"intent": "question",
|
||||||
|
"updates": {{
|
||||||
|
"child.fullName": "string or null",
|
||||||
|
"child.dateOfBirth": "YYYY-MM-DD or null",
|
||||||
|
"child.specialNeeds": "string or null",
|
||||||
|
"parentGuardian.fullName": "string or null",
|
||||||
|
"parentGuardian.streetAddress": "string or null",
|
||||||
|
"parentGuardian.postalCode": "4-digit string or null",
|
||||||
|
"parentGuardian.city": "string or null",
|
||||||
|
"parentGuardian.phone": "string or null",
|
||||||
|
"parentGuardian.email": "string or null",
|
||||||
|
"emergencyContact.fullName": "string or null",
|
||||||
|
"emergencyContact.phone": "string or null",
|
||||||
|
"booking.playgroupTypes": ["indoor", "outdoor"] or null,
|
||||||
|
"booking.selectedDays": [{{"day": "monday", "type": "indoor"}}] or null
|
||||||
|
}},
|
||||||
|
"language": "de"
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`intent` values:
|
||||||
|
- `"question"` — parent is asking about fees, schedule, policies, etc. → answer from knowledge base; set `updates` to all nulls.
|
||||||
|
- `"update"` — parent explicitly wants to change their registration data → collect the new values in `updates`, confirm the change in `reply`.
|
||||||
|
- `"new_child"` — parent wants to register an additional child → treat as a new registration; begin from step child_name.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Only set fields in `updates` when intent is `"update"` AND the parent has provided the new value in this message.
|
||||||
|
- Use `null` for all `updates` fields when intent is `"question"` or `"new_child"`.
|
||||||
|
- `language` must be "de" or "en" based on the parent's message.
|
||||||
|
- The `reply` field must be natural, friendly, conversational text — not JSON and not a list of fields.
|
||||||
|
- The `reply` field must be plain text only. No markdown: no **bold**, no _italic_, no # headers, no bullet points with - or *, no backticks. Use plain sentences and line breaks only.
|
||||||
|
- If you are unsure of the parent's intent, ask a clarifying question and set intent to `"question"`."""
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt(kb: KnowledgeBase, state: ConversationState) -> str:
|
||||||
|
"""Return the system prompt appropriate for the current conversation state."""
|
||||||
|
if state.completed:
|
||||||
|
return _build_post_completion_prompt(kb, state)
|
||||||
|
return _build_registration_prompt(kb, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_registration_prompt(kb: KnowledgeBase, state: ConversationState) -> str:
|
||||||
|
"""System prompt for an in-progress registration conversation."""
|
||||||
|
kb_content = kb.get_all()
|
||||||
|
reg_json = json.dumps(state.registration.to_dict(), ensure_ascii=False, indent=2)
|
||||||
|
step_hint = STEP_DESCRIPTIONS.get(state.flow_step, "Continue the conversation.")
|
||||||
|
|
||||||
|
return f"""You are the registration assistant for Spielgruppe Pumuckl, run by Familienverein Fällanden in Fällanden, Switzerland. You help parents register their children for the playgroup and answer questions about it.
|
||||||
|
|
||||||
|
{_PERSONALITY}
|
||||||
|
|
||||||
|
## Registration Flow (8 steps)
|
||||||
|
1. greeting — greet and detect intent
|
||||||
|
2. child_name — ask for child's full name
|
||||||
|
3. child_dob — ask for date of birth; validate age (indoor ≥2 yrs, outdoor ≥2.5 yrs)
|
||||||
|
4. playgroup_selection — present options, collect type(s) and day(s)
|
||||||
|
5. special_needs — ask about special needs / allergies / medical conditions
|
||||||
|
6. parent_contact — name, street address, postal code, city, phone, email
|
||||||
|
7. emergency_contact — emergency contact name and phone
|
||||||
|
8. confirmation — show full summary; ask to confirm; submit on confirmation
|
||||||
|
9. complete — thank parent, mention CHF 80 registration fee, monthly fees, and contacts
|
||||||
|
|
||||||
|
**Current step: {state.flow_step}**
|
||||||
|
**What to do now: {step_hint}**
|
||||||
|
|
||||||
|
At any point the parent may ask a question. Answer it from the knowledge base, then offer to continue the registration.
|
||||||
|
|
||||||
|
## Current Registration Data (so far)
|
||||||
|
```json
|
||||||
|
{reg_json}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Knowledge Base
|
||||||
|
Use the information below to answer parent questions accurately:
|
||||||
|
|
||||||
|
{kb_content}
|
||||||
|
|
||||||
|
{_PLAYGROUP_DETAILS}
|
||||||
|
|
||||||
|
{_CONTACTS}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{_REGISTRATION_RESPONSE_FORMAT}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_post_completion_prompt(kb: KnowledgeBase, state: ConversationState) -> str:
|
||||||
|
"""System prompt for a conversation where registration is already complete."""
|
||||||
|
kb_content = kb.get_all()
|
||||||
|
reg_json = json.dumps(state.registration.to_dict(), ensure_ascii=False, indent=2)
|
||||||
|
child_name = state.registration.child.full_name or "their child"
|
||||||
|
|
||||||
|
return f"""You are the registration assistant for Spielgruppe Pumuckl, run by Familienverein Fällanden in Fällanden, Switzerland.
|
||||||
|
|
||||||
|
{_PERSONALITY}
|
||||||
|
|
||||||
|
## Context: Registration Already Complete
|
||||||
|
This parent has already completed registration for {child_name}. Their current registration data is:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{reg_json}
|
||||||
|
```
|
||||||
|
|
||||||
|
The parent is contacting you again. Your job is to:
|
||||||
|
1. Detect their **intent**: are they asking a question, requesting a change to their registration, or registering another child?
|
||||||
|
2. Respond helpfully and warmly.
|
||||||
|
3. If they want to **update** their registration, confirm exactly what they want to change and include the new values in `updates`.
|
||||||
|
4. If they are asking a **question**, answer from the knowledge base.
|
||||||
|
5. If they want to register a **new child**, let them know you'll start a new registration and guide them from the beginning.
|
||||||
|
|
||||||
|
When handling update requests:
|
||||||
|
- Confirm the change explicitly before reporting it as done ("So you'd like to change X to Y — is that right?").
|
||||||
|
- Once confirmed, include the new value in `updates` so it can be saved.
|
||||||
|
- Let the parent know the playgroup team will be informed of the change.
|
||||||
|
|
||||||
|
## Knowledge Base
|
||||||
|
{kb_content}
|
||||||
|
|
||||||
|
{_PLAYGROUP_DETAILS}
|
||||||
|
|
||||||
|
{_CONTACTS}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{_POST_COMPLETION_RESPONSE_FORMAT}
|
||||||
|
"""
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""IMAP / SMTP email channel adapter.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Polling the inbox for unread messages (IMAP)
|
||||||
|
- Conversation matching by sender email address (NOT by thread headers)
|
||||||
|
- Sending reply emails (SMTP) with proper threading headers for email clients
|
||||||
|
- Stripping quoted reply text so the agent only sees the new content
|
||||||
|
|
||||||
|
Threading headers (Message-ID, In-Reply-To, References) are preserved for
|
||||||
|
outbound replies so messages appear threaded in Gmail/Outlook, but they are
|
||||||
|
NOT used to identify which conversation an incoming message belongs to.
|
||||||
|
Conversation matching is exclusively by normalized sender email address.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import email
|
||||||
|
import email.header
|
||||||
|
import email.utils
|
||||||
|
import imaplib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import smtplib
|
||||||
|
import time
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _decode_header(value: str) -> str:
|
||||||
|
"""Decode an RFC-2047 encoded email header value."""
|
||||||
|
parts = email.header.decode_header(value or "")
|
||||||
|
decoded = []
|
||||||
|
for part, charset in parts:
|
||||||
|
if isinstance(part, bytes):
|
||||||
|
decoded.append(part.decode(charset or "utf-8", errors="replace"))
|
||||||
|
else:
|
||||||
|
decoded.append(part)
|
||||||
|
return "".join(decoded)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text(msg: email.message.Message) -> str:
|
||||||
|
"""Extract the plain-text body from a (potentially multi-part) message."""
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
if (
|
||||||
|
part.get_content_type() == "text/plain"
|
||||||
|
and "attachment" not in str(part.get("Content-Disposition", ""))
|
||||||
|
):
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
return payload.decode(charset, errors="replace")
|
||||||
|
else:
|
||||||
|
charset = msg.get_content_charset() or "utf-8"
|
||||||
|
payload = msg.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
return payload.decode(charset, errors="replace")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_quoted_text(text: str) -> str:
|
||||||
|
"""Remove quoted reply text from the email body.
|
||||||
|
|
||||||
|
Heuristics:
|
||||||
|
- Drop lines starting with ">"
|
||||||
|
- Stop at common reply-separator patterns
|
||||||
|
"""
|
||||||
|
lines = text.splitlines()
|
||||||
|
result: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith(">"):
|
||||||
|
continue
|
||||||
|
# Common separators used by email clients
|
||||||
|
if re.match(r"^-{3,}|^_{3,}|^={3,}", stripped):
|
||||||
|
break
|
||||||
|
if re.match(r"^On .+ wrote:$", stripped):
|
||||||
|
break
|
||||||
|
if re.match(r"^Am .+ schrieb .+:$", stripped): # German Outlook/Thunderbird
|
||||||
|
break
|
||||||
|
if "-----Original Message-----" in stripped:
|
||||||
|
break
|
||||||
|
result.append(line)
|
||||||
|
return "\n".join(result).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_message_id(from_addr: str) -> str:
|
||||||
|
domain = from_addr.split("@")[-1] if "@" in from_addr else "meister-eder.local"
|
||||||
|
return f"<{time.time():.6f}.{id(from_addr)}@{domain}>"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_quoted_block(original_text: str, from_addr: str) -> str:
|
||||||
|
"""Format original_text as a standard email quote block.
|
||||||
|
|
||||||
|
Produces the classic:
|
||||||
|
|
||||||
|
On <date>, <from> wrote:
|
||||||
|
> line 1
|
||||||
|
> line 2
|
||||||
|
"""
|
||||||
|
date_str = time.strftime("%a, %d %b %Y %H:%M", time.localtime())
|
||||||
|
header = f"Am {date_str} schrieb {from_addr}:"
|
||||||
|
quoted_lines = "\n".join(
|
||||||
|
f"> {line}" for line in original_text.splitlines()
|
||||||
|
)
|
||||||
|
return f"\n\n{header}\n{quoted_lines}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main class
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class EmailChannel:
|
||||||
|
"""Wraps IMAP polling and SMTP sending for the email conversation channel."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
imap_host: str,
|
||||||
|
imap_port: int,
|
||||||
|
smtp_host: str,
|
||||||
|
smtp_port: int,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
use_ssl: bool = True,
|
||||||
|
use_tls: bool = True,
|
||||||
|
registration_email: str = "",
|
||||||
|
) -> None:
|
||||||
|
self._imap_host = imap_host
|
||||||
|
self._imap_port = imap_port
|
||||||
|
self._smtp_host = smtp_host
|
||||||
|
self._smtp_port = smtp_port
|
||||||
|
self._username = username
|
||||||
|
self._password = password
|
||||||
|
self._use_ssl = use_ssl
|
||||||
|
self._use_tls = use_tls
|
||||||
|
self._from_email = registration_email or username
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# IMAP — receive
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def fetch_unread_messages(self) -> list[dict]:
|
||||||
|
"""Poll the inbox and return all unread messages as structured dicts.
|
||||||
|
|
||||||
|
Each dict contains:
|
||||||
|
from — sender email address (use this as conversation key)
|
||||||
|
subject — decoded subject line
|
||||||
|
message_id — Message-ID of this inbound email (for reply threading)
|
||||||
|
in_reply_to — In-Reply-To header (for reply threading, may be empty)
|
||||||
|
references — References header (for reply threading, may be empty)
|
||||||
|
body — stripped plain-text body (quoted text removed)
|
||||||
|
|
||||||
|
Note: ``thread_id`` is no longer returned. Conversation matching is done
|
||||||
|
by ``from`` (sender email address), not by threading headers.
|
||||||
|
"""
|
||||||
|
messages: list[dict] = []
|
||||||
|
try:
|
||||||
|
imap = self._connect_imap()
|
||||||
|
imap.select("INBOX")
|
||||||
|
|
||||||
|
_, data = imap.search(None, "UNSEEN")
|
||||||
|
msg_nums = data[0].split()
|
||||||
|
|
||||||
|
for num in msg_nums:
|
||||||
|
try:
|
||||||
|
_, raw_data = imap.fetch(num, "(RFC822)")
|
||||||
|
raw = raw_data[0][1]
|
||||||
|
msg = email.message_from_bytes(raw)
|
||||||
|
|
||||||
|
from_addr = email.utils.parseaddr(msg.get("From", ""))[1]
|
||||||
|
subject = _decode_header(msg.get("Subject", "(no subject)"))
|
||||||
|
message_id = msg.get("Message-ID", "").strip()
|
||||||
|
in_reply_to = msg.get("In-Reply-To", "").strip()
|
||||||
|
references = msg.get("References", "").strip()
|
||||||
|
|
||||||
|
raw_body = _extract_text(msg)
|
||||||
|
body = _strip_quoted_text(raw_body)
|
||||||
|
|
||||||
|
if not body.strip():
|
||||||
|
imap.store(num, "+FLAGS", "\\Seen")
|
||||||
|
continue
|
||||||
|
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"from": from_addr,
|
||||||
|
"subject": subject,
|
||||||
|
"message_id": message_id,
|
||||||
|
"in_reply_to": in_reply_to,
|
||||||
|
"references": references,
|
||||||
|
"body": body,
|
||||||
|
"raw_body": raw_body,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
imap.store(num, "+FLAGS", "\\Seen")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error processing IMAP message %s", num)
|
||||||
|
|
||||||
|
imap.logout()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("IMAP connection/fetch error")
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# SMTP — send
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def send_reply(
|
||||||
|
self,
|
||||||
|
to: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
in_reply_to: str = "",
|
||||||
|
references: str = "",
|
||||||
|
quoted_text: str = "",
|
||||||
|
quoted_from: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Send an email reply.
|
||||||
|
|
||||||
|
If quoted_text is provided it is appended to body as a standard
|
||||||
|
``> ``-prefixed quote block so parents can see what they wrote.
|
||||||
|
|
||||||
|
Returns the new Message-ID so the caller can track the thread.
|
||||||
|
"""
|
||||||
|
new_message_id = _generate_message_id(self._from_email)
|
||||||
|
|
||||||
|
# Ensure subject starts with "Re:"
|
||||||
|
if not subject.lower().startswith("re:"):
|
||||||
|
subject = f"Re: {subject}"
|
||||||
|
|
||||||
|
# Build References chain
|
||||||
|
ref_parts = [r for r in [references, in_reply_to] if r]
|
||||||
|
new_references = " ".join(ref_parts)
|
||||||
|
|
||||||
|
# Append quoted original message
|
||||||
|
if quoted_text.strip():
|
||||||
|
body = body + _build_quoted_block(quoted_text, quoted_from or to)
|
||||||
|
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["From"] = self._from_email
|
||||||
|
msg["To"] = to
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["Message-ID"] = new_message_id
|
||||||
|
if in_reply_to:
|
||||||
|
msg["In-Reply-To"] = in_reply_to
|
||||||
|
if new_references:
|
||||||
|
msg["References"] = new_references
|
||||||
|
|
||||||
|
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||||
|
|
||||||
|
if not self._smtp_host:
|
||||||
|
logger.warning("SMTP not configured — reply NOT sent to %s: %s", to, subject)
|
||||||
|
logger.debug("Reply body:\n%s", body)
|
||||||
|
return new_message_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._use_tls:
|
||||||
|
server = smtplib.SMTP(self._smtp_host, self._smtp_port)
|
||||||
|
server.starttls()
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port)
|
||||||
|
|
||||||
|
server.login(self._username, self._password)
|
||||||
|
server.sendmail(self._from_email, [to], msg.as_string())
|
||||||
|
server.quit()
|
||||||
|
logger.info("Reply sent to %s (thread %s)", to, in_reply_to or new_message_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send reply to %s", to)
|
||||||
|
|
||||||
|
return new_message_id
|
||||||
|
|
||||||
|
def send_reminder(
|
||||||
|
self,
|
||||||
|
to: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
in_reply_to: str = "",
|
||||||
|
references: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Send a reminder email for an incomplete registration."""
|
||||||
|
self.send_reply(
|
||||||
|
to=to,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
in_reply_to=in_reply_to,
|
||||||
|
references=references,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _connect_imap(self) -> imaplib.IMAP4:
|
||||||
|
if self._use_ssl:
|
||||||
|
conn = imaplib.IMAP4_SSL(self._imap_host, self._imap_port)
|
||||||
|
else:
|
||||||
|
conn = imaplib.IMAP4(self._imap_host, self._imap_port)
|
||||||
|
conn.login(self._username, self._password)
|
||||||
|
return conn
|
||||||
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Configuration loaded from environment variables."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except ImportError:
|
||||||
|
pass # python-dotenv is optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
# AI model — litellm format, e.g. "anthropic/claude-opus-4-6" or "openai/gpt-4o".
|
||||||
|
# The matching API key must be set as an env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
|
||||||
|
ai_model: str = "anthropic/claude-opus-4-6"
|
||||||
|
|
||||||
|
# Email — IMAP (receiving)
|
||||||
|
imap_host: str = ""
|
||||||
|
imap_port: int = 993
|
||||||
|
imap_username: str = ""
|
||||||
|
imap_password: str = ""
|
||||||
|
imap_use_ssl: bool = True
|
||||||
|
|
||||||
|
# Email — SMTP (sending)
|
||||||
|
smtp_host: str = ""
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_use_tls: bool = True
|
||||||
|
|
||||||
|
# Registration email address shown to parents
|
||||||
|
registration_email: str = ""
|
||||||
|
|
||||||
|
# Admin notification routing.
|
||||||
|
# Each leader receives mail only when a day in their group is booked.
|
||||||
|
# For testing, point all three to your own address.
|
||||||
|
admin_email_indoor: str = "" # Indoor leader (Andrea Sigrist) — To when indoor booked
|
||||||
|
admin_email_outdoor: str = "" # Outdoor leader (Barbara Gross) — To when outdoor booked
|
||||||
|
admin_email_cc: str = "" # Always Cc'd (Markus Graf / admin); comma-separated if multiple
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
data_dir: Path = field(default_factory=lambda: Path("data"))
|
||||||
|
knowledge_base_dir: Path = field(
|
||||||
|
default_factory=lambda: Path(
|
||||||
|
"openspec/changes/define-project-scope/content/knowledge-base"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Polling interval in seconds
|
||||||
|
poll_interval: int = 60
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "Config":
|
||||||
|
return cls(
|
||||||
|
ai_model=os.getenv("AI_MODEL", "anthropic/claude-opus-4-6"),
|
||||||
|
imap_host=os.getenv("IMAP_HOST", ""),
|
||||||
|
imap_port=int(os.getenv("IMAP_PORT", "993")),
|
||||||
|
imap_username=os.getenv("IMAP_USERNAME", ""),
|
||||||
|
imap_password=os.getenv("IMAP_PASSWORD", ""),
|
||||||
|
imap_use_ssl=os.getenv("IMAP_USE_SSL", "true").lower() == "true",
|
||||||
|
smtp_host=os.getenv("SMTP_HOST", ""),
|
||||||
|
smtp_port=int(os.getenv("SMTP_PORT", "587")),
|
||||||
|
smtp_use_tls=os.getenv("SMTP_USE_TLS", "true").lower() == "true",
|
||||||
|
registration_email=os.getenv("REGISTRATION_EMAIL", ""),
|
||||||
|
admin_email_indoor=os.getenv("ADMIN_EMAIL_INDOOR", ""),
|
||||||
|
admin_email_outdoor=os.getenv("ADMIN_EMAIL_OUTDOOR", ""),
|
||||||
|
admin_email_cc=os.getenv("ADMIN_EMAIL_CC", ""),
|
||||||
|
data_dir=Path(os.getenv("DATA_DIR", "data")),
|
||||||
|
knowledge_base_dir=Path(
|
||||||
|
os.getenv(
|
||||||
|
"KNOWLEDGE_BASE_DIR",
|
||||||
|
"openspec/changes/define-project-scope/content/knowledge-base",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
poll_interval=int(os.getenv("POLL_INTERVAL", "60")),
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Load admin-editable knowledge-base markdown files into memory."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBase:
|
||||||
|
"""Reads markdown files from *kb_dir* and exposes them as a single string."""
|
||||||
|
|
||||||
|
def __init__(self, kb_dir: Path) -> None:
|
||||||
|
self._dir = kb_dir
|
||||||
|
self._content: dict[str, str] = {}
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
if not self._dir.exists():
|
||||||
|
logger.warning("Knowledge-base directory not found: %s", self._dir)
|
||||||
|
return
|
||||||
|
for path in sorted(self._dir.glob("*.md")):
|
||||||
|
self._content[path.stem] = path.read_text(encoding="utf-8")
|
||||||
|
logger.info("Loaded %d knowledge-base file(s) from %s", len(self._content), self._dir)
|
||||||
|
|
||||||
|
def get_all(self) -> str:
|
||||||
|
"""Return every KB file concatenated with section headers."""
|
||||||
|
if not self._content:
|
||||||
|
return "(No knowledge-base content available.)"
|
||||||
|
sections = [
|
||||||
|
f"### {name.upper().replace('-', ' ')}\n\n{content}"
|
||||||
|
for name, content in self._content.items()
|
||||||
|
]
|
||||||
|
return "\n\n---\n\n".join(sections)
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
"""Re-read all files from disk (useful when admins update content)."""
|
||||||
|
self._content = {}
|
||||||
|
self._load()
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
"""LLM completion via litellm — supports any provider with a single call."""
|
||||||
|
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
|
||||||
|
def complete(model: str, system: str, messages: list) -> str:
|
||||||
|
"""Call any LLM and return the response text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: litellm model string, e.g. "anthropic/claude-opus-4-6" or
|
||||||
|
"openai/gpt-4o". The matching API key must be set as an
|
||||||
|
environment variable (ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
|
||||||
|
system: System prompt text.
|
||||||
|
messages: List of objects with .role and .content attributes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The model's reply as a plain string.
|
||||||
|
"""
|
||||||
|
api_messages = [{"role": "system", "content": system}]
|
||||||
|
api_messages += [{"role": m.role, "content": m.content} for m in messages]
|
||||||
|
response = litellm.completion(model=model, messages=api_messages, max_tokens=2048)
|
||||||
|
return response.choices[0].message.content
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Conversation state model — persisted per sender email address."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .registration import RegistrationData
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChatMessage:
|
||||||
|
role: str # "user" or "assistant"
|
||||||
|
content: str
|
||||||
|
timestamp: str = field(default_factory=_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConversationState:
|
||||||
|
conversation_id: str # normalized sender email address
|
||||||
|
language: str = "de" # "de" or "en"
|
||||||
|
flow_step: str = "greeting" # current step in registration flow
|
||||||
|
registration: RegistrationData = field(default_factory=RegistrationData)
|
||||||
|
messages: list = field(default_factory=list) # list[ChatMessage]
|
||||||
|
parent_email: str = ""
|
||||||
|
parent_name: Optional[str] = None
|
||||||
|
created_at: str = field(default_factory=_now)
|
||||||
|
updated_at: str = field(default_factory=_now)
|
||||||
|
last_activity: str = field(default_factory=_now)
|
||||||
|
completed: bool = False
|
||||||
|
reminder_count: int = 0
|
||||||
|
# Most recent inbound Message-ID — used for reply threading headers only,
|
||||||
|
# NOT for conversation matching (which is always by email address).
|
||||||
|
last_inbound_message_id: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"conversation_id": self.conversation_id,
|
||||||
|
"language": self.language,
|
||||||
|
"flow_step": self.flow_step,
|
||||||
|
"registration": self.registration.to_dict(),
|
||||||
|
"messages": [
|
||||||
|
{"role": m.role, "content": m.content, "timestamp": m.timestamp}
|
||||||
|
for m in self.messages
|
||||||
|
],
|
||||||
|
"parent_email": self.parent_email,
|
||||||
|
"parent_name": self.parent_name,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
"last_activity": self.last_activity,
|
||||||
|
"completed": self.completed,
|
||||||
|
"reminder_count": self.reminder_count,
|
||||||
|
"last_inbound_message_id": self.last_inbound_message_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ConversationState":
|
||||||
|
state = cls(conversation_id=data["conversation_id"])
|
||||||
|
state.language = data.get("language", "de")
|
||||||
|
state.flow_step = data.get("flow_step", "greeting")
|
||||||
|
state.registration = RegistrationData.from_dict(data.get("registration", {}))
|
||||||
|
state.messages = [
|
||||||
|
ChatMessage(
|
||||||
|
role=m["role"],
|
||||||
|
content=m["content"],
|
||||||
|
timestamp=m.get("timestamp", ""),
|
||||||
|
)
|
||||||
|
for m in data.get("messages", [])
|
||||||
|
]
|
||||||
|
state.parent_email = data.get("parent_email", "")
|
||||||
|
state.parent_name = data.get("parent_name")
|
||||||
|
state.created_at = data.get("created_at", "")
|
||||||
|
state.updated_at = data.get("updated_at", "")
|
||||||
|
state.last_activity = data.get("last_activity", "")
|
||||||
|
state.completed = data.get("completed", False)
|
||||||
|
state.reminder_count = data.get("reminder_count", 0)
|
||||||
|
state.last_inbound_message_id = data.get("last_inbound_message_id", "")
|
||||||
|
return state
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Registration data models matching the JSON schema in registration-schema.json."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BookingDay:
|
||||||
|
day: str # "monday", "wednesday", "thursday"
|
||||||
|
type: str # "indoor", "outdoor"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Booking:
|
||||||
|
playgroup_types: list = field(default_factory=list) # ["indoor", "outdoor"]
|
||||||
|
selected_days: list = field(default_factory=list) # list[BookingDay]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChildInfo:
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
date_of_birth: Optional[str] = None # YYYY-MM-DD
|
||||||
|
special_needs: Optional[str] = None # text or "None"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParentGuardian:
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
street_address: Optional[str] = None
|
||||||
|
postal_code: Optional[str] = None # 4-digit Swiss code
|
||||||
|
city: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EmergencyContact:
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RegistrationData:
|
||||||
|
child: ChildInfo = field(default_factory=ChildInfo)
|
||||||
|
parent_guardian: ParentGuardian = field(default_factory=ParentGuardian)
|
||||||
|
emergency_contact: EmergencyContact = field(default_factory=EmergencyContact)
|
||||||
|
booking: Booking = field(default_factory=Booking)
|
||||||
|
|
||||||
|
def is_complete(self) -> bool:
|
||||||
|
"""Return True when all required schema fields are present."""
|
||||||
|
return (
|
||||||
|
bool(self.child.full_name)
|
||||||
|
and bool(self.child.date_of_birth)
|
||||||
|
and self.child.special_needs is not None
|
||||||
|
and bool(self.parent_guardian.full_name)
|
||||||
|
and bool(self.parent_guardian.street_address)
|
||||||
|
and bool(self.parent_guardian.postal_code)
|
||||||
|
and bool(self.parent_guardian.city)
|
||||||
|
and bool(self.parent_guardian.phone)
|
||||||
|
and bool(self.parent_guardian.email)
|
||||||
|
and bool(self.emergency_contact.full_name)
|
||||||
|
and bool(self.emergency_contact.phone)
|
||||||
|
and len(self.booking.playgroup_types) > 0
|
||||||
|
and len(self.booking.selected_days) > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"child": {
|
||||||
|
"fullName": self.child.full_name,
|
||||||
|
"dateOfBirth": self.child.date_of_birth,
|
||||||
|
"specialNeeds": self.child.special_needs,
|
||||||
|
},
|
||||||
|
"parentGuardian": {
|
||||||
|
"fullName": self.parent_guardian.full_name,
|
||||||
|
"streetAddress": self.parent_guardian.street_address,
|
||||||
|
"postalCode": self.parent_guardian.postal_code,
|
||||||
|
"city": self.parent_guardian.city,
|
||||||
|
"phone": self.parent_guardian.phone,
|
||||||
|
"email": self.parent_guardian.email,
|
||||||
|
},
|
||||||
|
"emergencyContact": {
|
||||||
|
"fullName": self.emergency_contact.full_name,
|
||||||
|
"phone": self.emergency_contact.phone,
|
||||||
|
},
|
||||||
|
"booking": {
|
||||||
|
"playgroupTypes": self.booking.playgroup_types,
|
||||||
|
"selectedDays": [
|
||||||
|
{"day": d.day, "type": d.type}
|
||||||
|
for d in self.booking.selected_days
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "RegistrationData":
|
||||||
|
reg = cls()
|
||||||
|
if child := data.get("child", {}):
|
||||||
|
reg.child = ChildInfo(
|
||||||
|
full_name=child.get("fullName"),
|
||||||
|
date_of_birth=child.get("dateOfBirth"),
|
||||||
|
special_needs=child.get("specialNeeds"),
|
||||||
|
)
|
||||||
|
if parent := data.get("parentGuardian", {}):
|
||||||
|
reg.parent_guardian = ParentGuardian(
|
||||||
|
full_name=parent.get("fullName"),
|
||||||
|
street_address=parent.get("streetAddress"),
|
||||||
|
postal_code=parent.get("postalCode"),
|
||||||
|
city=parent.get("city"),
|
||||||
|
phone=parent.get("phone"),
|
||||||
|
email=parent.get("email"),
|
||||||
|
)
|
||||||
|
if emergency := data.get("emergencyContact", {}):
|
||||||
|
reg.emergency_contact = EmergencyContact(
|
||||||
|
full_name=emergency.get("fullName"),
|
||||||
|
phone=emergency.get("phone"),
|
||||||
|
)
|
||||||
|
if booking := data.get("booking", {}):
|
||||||
|
reg.booking = Booking(
|
||||||
|
playgroup_types=booking.get("playgroupTypes", []),
|
||||||
|
selected_days=[
|
||||||
|
BookingDay(day=d["day"], type=d["type"])
|
||||||
|
for d in booking.get("selectedDays", [])
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return reg
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""Admin email notifications — new registrations and registration updates."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from datetime import date, datetime
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
from ..models.registration import RegistrationData
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminNotifier:
|
||||||
|
"""Sends formatted admin notification emails.
|
||||||
|
|
||||||
|
Handles two notification types:
|
||||||
|
- New registration completed → "New Registration: …"
|
||||||
|
- Existing registration updated → "Registration Updated: …" (with field diff)
|
||||||
|
|
||||||
|
When *smtp_host* is empty the notifier logs and skips sending (dev mode).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
smtp_host: str,
|
||||||
|
smtp_port: int,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
use_tls: bool = True,
|
||||||
|
from_email: str = "",
|
||||||
|
indoor_email: str = "",
|
||||||
|
outdoor_email: str = "",
|
||||||
|
cc_emails: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._smtp_host = smtp_host
|
||||||
|
self._smtp_port = smtp_port
|
||||||
|
self._username = username
|
||||||
|
self._password = password
|
||||||
|
self._use_tls = use_tls
|
||||||
|
self._from_email = from_email or username
|
||||||
|
self._indoor_email = indoor_email
|
||||||
|
self._outdoor_email = outdoor_email
|
||||||
|
self._cc_emails: list[str] = cc_emails or []
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def notify_admin(
|
||||||
|
self,
|
||||||
|
registration: RegistrationData,
|
||||||
|
registration_id: str,
|
||||||
|
version: int,
|
||||||
|
conversation_id: str,
|
||||||
|
channel: str,
|
||||||
|
) -> None:
|
||||||
|
"""Send notification for a newly completed registration (version 1)."""
|
||||||
|
types = registration.booking.playgroup_types
|
||||||
|
to_addresses = self._recipients_for(types)
|
||||||
|
if not to_addresses:
|
||||||
|
logger.warning(
|
||||||
|
"No leader email configured for types %s — new-registration notification skipped.",
|
||||||
|
types,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
subject = (
|
||||||
|
f"Neue Anmeldung: {registration.child.full_name} "
|
||||||
|
f"– {self._format_types(types)}"
|
||||||
|
)
|
||||||
|
body = self._build_new_body(registration, registration_id, version, channel)
|
||||||
|
|
||||||
|
self._send(
|
||||||
|
to=to_addresses,
|
||||||
|
cc=self._cc_emails,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
reply_to=registration.parent_guardian.email or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
def notify_registration_update(
|
||||||
|
self,
|
||||||
|
registration: RegistrationData,
|
||||||
|
registration_id: str,
|
||||||
|
version: int,
|
||||||
|
change_summary: dict,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Send notification when an existing registration is updated."""
|
||||||
|
types = registration.booking.playgroup_types
|
||||||
|
to_addresses = self._recipients_for(types)
|
||||||
|
if not to_addresses:
|
||||||
|
logger.warning(
|
||||||
|
"No leader email configured for types %s — update notification skipped.",
|
||||||
|
types,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
subject = f"Anmeldung aktualisiert: {registration.child.full_name}"
|
||||||
|
body = self._build_update_body(registration, registration_id, version, change_summary)
|
||||||
|
|
||||||
|
self._send(
|
||||||
|
to=to_addresses,
|
||||||
|
cc=self._cc_emails,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
reply_to=registration.parent_guardian.email or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Routing helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _recipients_for(self, types: list[str]) -> list[str]:
|
||||||
|
"""Return To addresses based on which playgroup types are booked."""
|
||||||
|
recipients = []
|
||||||
|
if "indoor" in types and self._indoor_email:
|
||||||
|
recipients.append(self._indoor_email)
|
||||||
|
if "outdoor" in types and self._outdoor_email:
|
||||||
|
recipients.append(self._outdoor_email)
|
||||||
|
return recipients
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Formatting helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_types(types: list[str]) -> str:
|
||||||
|
has_indoor = "indoor" in types
|
||||||
|
has_outdoor = "outdoor" in types
|
||||||
|
if has_indoor and has_outdoor:
|
||||||
|
return "Innen- und Waldspielgruppe"
|
||||||
|
if has_indoor:
|
||||||
|
return "Innenspielgruppe"
|
||||||
|
if has_outdoor:
|
||||||
|
return "Waldspielgruppe"
|
||||||
|
return "Spielgruppe"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_age(dob_str: str) -> str:
|
||||||
|
try:
|
||||||
|
dob = datetime.strptime(dob_str, "%Y-%m-%d").date()
|
||||||
|
today = date.today()
|
||||||
|
years = today.year - dob.year - (
|
||||||
|
(today.month, today.day) < (dob.month, dob.day)
|
||||||
|
)
|
||||||
|
months = (today.month - dob.month) % 12
|
||||||
|
return f"{years} Jahre, {months} Monate"
|
||||||
|
except Exception:
|
||||||
|
return dob_str
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_dob(dob_str: str) -> str:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y")
|
||||||
|
except Exception:
|
||||||
|
return dob_str or ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_monthly_fee(registration: RegistrationData) -> str:
|
||||||
|
indoor_days = sum(1 for d in registration.booking.selected_days if d.type == "indoor")
|
||||||
|
outdoor_days = sum(1 for d in registration.booking.selected_days if d.type == "outdoor")
|
||||||
|
fee = 0
|
||||||
|
if indoor_days == 1:
|
||||||
|
fee += 130
|
||||||
|
elif indoor_days == 2:
|
||||||
|
fee += 260
|
||||||
|
elif indoor_days >= 3:
|
||||||
|
fee += 390
|
||||||
|
if outdoor_days >= 1:
|
||||||
|
fee += 250
|
||||||
|
return f"CHF {fee}.-"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_days(registration: RegistrationData) -> str:
|
||||||
|
day_map = {"monday": "Montag", "wednesday": "Mittwoch", "thursday": "Donnerstag"}
|
||||||
|
type_map = {"indoor": "Innenspielgruppe", "outdoor": "Waldspielgruppe"}
|
||||||
|
return ", ".join(
|
||||||
|
f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})"
|
||||||
|
for d in registration.booking.selected_days
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_change_summary(change_summary: dict) -> str:
|
||||||
|
"""Render field changes as a human-readable list."""
|
||||||
|
lines = []
|
||||||
|
for field_path, values in sorted(change_summary.items()):
|
||||||
|
old_val, new_val = values["old"], values["new"]
|
||||||
|
lines.append(f" {field_path}:")
|
||||||
|
lines.append(f" Alt: {old_val}")
|
||||||
|
lines.append(f" Neu: {new_val}")
|
||||||
|
return "\n".join(lines) if lines else " (keine Änderungen erkannt)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Email body builders
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_new_body(
|
||||||
|
self,
|
||||||
|
registration: RegistrationData,
|
||||||
|
registration_id: str,
|
||||||
|
version: int,
|
||||||
|
channel: str,
|
||||||
|
) -> str:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
pg = registration.parent_guardian
|
||||||
|
ec = registration.emergency_contact
|
||||||
|
channel_de = {"email": "E-Mail", "chat": "Chat"}.get(channel.lower(), channel.title())
|
||||||
|
|
||||||
|
return (
|
||||||
|
"===============================================\n"
|
||||||
|
"NEUE SPIELGRUPPEN-ANMELDUNG\n"
|
||||||
|
"===============================================\n"
|
||||||
|
"\n"
|
||||||
|
f"Eingereicht: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n"
|
||||||
|
f"Kanal: {channel_de}\n"
|
||||||
|
f"Anmelde-ID: {registration_id} (Version {version})\n"
|
||||||
|
"\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
"ANGABEN ZUM KIND\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
f"Name: {registration.child.full_name}\n"
|
||||||
|
f"Geburtsdatum: {self._format_dob(registration.child.date_of_birth or '')} "
|
||||||
|
f"(Alter: {self._calculate_age(registration.child.date_of_birth or '')})\n"
|
||||||
|
f"Bes. Bedürfnisse: {registration.child.special_needs or 'Keine'}\n"
|
||||||
|
"\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
"SPIELGRUPPEN-AUSWAHL\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
f"Art: {self._format_types(registration.booking.playgroup_types)}\n"
|
||||||
|
f"Tage: {self._format_days(registration)}\n"
|
||||||
|
"\n"
|
||||||
|
f"Monatlicher Beitrag: {self._calculate_monthly_fee(registration)}\n"
|
||||||
|
"(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung)\n"
|
||||||
|
"\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
"ELTERN / ERZIEHUNGSBERECHTIGTE\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
f"Name: {pg.full_name}\n"
|
||||||
|
f"Adresse: {pg.street_address}\n"
|
||||||
|
f" {pg.postal_code} {pg.city}\n"
|
||||||
|
f"Telefon: {pg.phone}\n"
|
||||||
|
f"E-Mail: {pg.email}\n"
|
||||||
|
"\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
"NOTFALLKONTAKT\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
f"Name: {ec.full_name}\n"
|
||||||
|
f"Telefon: {ec.phone}\n"
|
||||||
|
"\n"
|
||||||
|
"===============================================\n"
|
||||||
|
"\n"
|
||||||
|
"Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_update_body(
|
||||||
|
self,
|
||||||
|
registration: RegistrationData,
|
||||||
|
registration_id: str,
|
||||||
|
version: int,
|
||||||
|
change_summary: dict,
|
||||||
|
) -> str:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
pg = registration.parent_guardian
|
||||||
|
|
||||||
|
return (
|
||||||
|
"===============================================\n"
|
||||||
|
"ANMELDUNGS-AKTUALISIERUNG\n"
|
||||||
|
"===============================================\n"
|
||||||
|
"\n"
|
||||||
|
f"Aktualisiert: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n"
|
||||||
|
f"Anmelde-ID: {registration_id} (Version {version})\n"
|
||||||
|
f"Kind: {registration.child.full_name}\n"
|
||||||
|
f"Eltern-E-Mail: {pg.email}\n"
|
||||||
|
"\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
"WAS HAT SICH GEÄNDERT\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
f"{self._format_change_summary(change_summary)}\n"
|
||||||
|
"\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
"AKTUELLE ANMELDUNG (nach Aktualisierung)\n"
|
||||||
|
"-----------------------------------------------\n"
|
||||||
|
f"Spielgruppe: {self._format_types(registration.booking.playgroup_types)}\n"
|
||||||
|
f"Tage: {self._format_days(registration)}\n"
|
||||||
|
f"Monatl. Beitrag: {self._calculate_monthly_fee(registration)}\n"
|
||||||
|
"\n"
|
||||||
|
f"Elternteil: {pg.full_name}\n"
|
||||||
|
f"Adresse: {pg.street_address}, {pg.postal_code} {pg.city}\n"
|
||||||
|
f"Telefon: {pg.phone}\n"
|
||||||
|
"\n"
|
||||||
|
"===============================================\n"
|
||||||
|
"\n"
|
||||||
|
"Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# SMTP dispatch
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _send(
|
||||||
|
self,
|
||||||
|
to: list[str],
|
||||||
|
cc: list[str],
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
reply_to: str = "",
|
||||||
|
) -> None:
|
||||||
|
if not self._smtp_host:
|
||||||
|
logger.warning(
|
||||||
|
"SMTP not configured — notification NOT sent. Would have emailed %s (CC: %s): %s",
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
subject,
|
||||||
|
)
|
||||||
|
logger.debug("Notification body:\n%s", body)
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["From"] = self._from_email
|
||||||
|
msg["To"] = ", ".join(to)
|
||||||
|
msg["CC"] = ", ".join(cc)
|
||||||
|
msg["Subject"] = subject
|
||||||
|
if reply_to:
|
||||||
|
msg["Reply-To"] = reply_to
|
||||||
|
|
||||||
|
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||||
|
all_recipients = to + cc
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._use_tls:
|
||||||
|
server = smtplib.SMTP(self._smtp_host, self._smtp_port)
|
||||||
|
server.starttls()
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port)
|
||||||
|
|
||||||
|
server.login(self._username, self._password)
|
||||||
|
server.sendmail(self._from_email, all_recipients, msg.as_string())
|
||||||
|
server.quit()
|
||||||
|
logger.info("Notification sent to %s", all_recipients)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send notification to %s", all_recipients)
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""File-based JSON storage for conversations and completed registrations.
|
||||||
|
|
||||||
|
Conversations are keyed by the sender's normalized email address so that a
|
||||||
|
parent who sends a new email (instead of replying) continues the same
|
||||||
|
conversation. Completed registrations are stored with versioning so every
|
||||||
|
update produces a new numbered version rather than overwriting the original.
|
||||||
|
|
||||||
|
Directory layout::
|
||||||
|
|
||||||
|
data/
|
||||||
|
conversations/
|
||||||
|
parent_at_example.com.json # one file per unique sender address
|
||||||
|
registrations/
|
||||||
|
parent_at_example.com/
|
||||||
|
v1_2024-09-15T10-30-00Z.json # initial registration
|
||||||
|
v2_2024-10-03T14-22-10Z.json # updated registration
|
||||||
|
current.json # copy of the latest version
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..models.conversation import ConversationState
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def normalize_email(email: str) -> str:
|
||||||
|
"""Return a canonical email address for matching and storage.
|
||||||
|
|
||||||
|
Lowercases and strips whitespace. ``Maria@Example.com`` → ``maria@example.com``.
|
||||||
|
"""
|
||||||
|
return email.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _email_to_filename(email: str) -> str:
|
||||||
|
"""Convert a normalized email address to a safe filename stem.
|
||||||
|
|
||||||
|
``parent@example.com`` → ``parent_at_example.com``
|
||||||
|
"""
|
||||||
|
return normalize_email(email).replace("@", "_at_")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp_for_filename() -> str:
|
||||||
|
"""Return a filesystem-safe ISO-8601-ish timestamp (no colons)."""
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _diff_registrations(old: dict, new: dict) -> dict[str, tuple]:
|
||||||
|
"""Return a mapping of field_path → (old_value, new_value) for changed fields."""
|
||||||
|
changes: dict[str, tuple] = {}
|
||||||
|
|
||||||
|
def _flatten(d: dict, prefix: str = "") -> dict:
|
||||||
|
out: dict = {}
|
||||||
|
for k, v in d.items():
|
||||||
|
key = f"{prefix}.{k}" if prefix else k
|
||||||
|
if isinstance(v, dict):
|
||||||
|
out.update(_flatten(v, key))
|
||||||
|
else:
|
||||||
|
out[key] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
old_flat = _flatten(old)
|
||||||
|
new_flat = _flatten(new)
|
||||||
|
|
||||||
|
all_keys = set(old_flat) | set(new_flat)
|
||||||
|
for key in sorted(all_keys):
|
||||||
|
o = old_flat.get(key)
|
||||||
|
n = new_flat.get(key)
|
||||||
|
if o != n:
|
||||||
|
changes[key] = (o, n)
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ConversationStore
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ConversationStore:
|
||||||
|
"""Persists ConversationState and registration versions on disk."""
|
||||||
|
|
||||||
|
def __init__(self, data_dir: Path) -> None:
|
||||||
|
self._conversations_dir = data_dir / "conversations"
|
||||||
|
self._registrations_dir = data_dir / "registrations"
|
||||||
|
self._conversations_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._registrations_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Conversation CRUD — keyed by normalized email address
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load(self, email_address: str) -> ConversationState | None:
|
||||||
|
"""Load a conversation by sender email address. Returns None if not found."""
|
||||||
|
path = self._conversation_path(email_address)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return ConversationState.from_dict(data)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load conversation for %s", email_address)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Alias for clarity in call sites that emphasise the email-lookup semantic
|
||||||
|
find_by_email = load
|
||||||
|
|
||||||
|
def save(self, state: ConversationState) -> None:
|
||||||
|
"""Persist a conversation state to disk."""
|
||||||
|
path = self._conversation_path(state.parent_email or state.conversation_id)
|
||||||
|
try:
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(state.to_dict(), ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to save conversation for %s", state.conversation_id)
|
||||||
|
|
||||||
|
def delete(self, email_address: str) -> None:
|
||||||
|
"""Remove a conversation file."""
|
||||||
|
path = self._conversation_path(email_address)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def list_incomplete(self) -> list[ConversationState]:
|
||||||
|
"""Return all conversations that have not yet been completed."""
|
||||||
|
states: list[ConversationState] = []
|
||||||
|
for path in self._conversations_dir.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
state = ConversationState.from_dict(data)
|
||||||
|
if not state.completed:
|
||||||
|
states.append(state)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not read conversation file %s", path)
|
||||||
|
return states
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Versioned registration storage
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def save_registration(self, state: ConversationState) -> tuple[str, int]:
|
||||||
|
"""Store the first version of a completed registration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (registration_dir_key, version_number).
|
||||||
|
"""
|
||||||
|
email_key = _email_to_filename(state.parent_email or state.conversation_id)
|
||||||
|
reg_dir = self._registrations_dir / email_key
|
||||||
|
reg_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
version = 1
|
||||||
|
record = self._build_record(state.registration.to_dict(), version, state)
|
||||||
|
|
||||||
|
self._write_version(reg_dir, version, record)
|
||||||
|
logger.info("Saved initial registration v%d for %s", version, email_key)
|
||||||
|
return email_key, version
|
||||||
|
|
||||||
|
def save_registration_version(
|
||||||
|
self,
|
||||||
|
state: ConversationState,
|
||||||
|
change_summary: dict[str, tuple],
|
||||||
|
) -> tuple[str, int]:
|
||||||
|
"""Store an updated registration as a new version.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Current conversation state with updated registration data.
|
||||||
|
change_summary: Dict of field_path → (old_value, new_value).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (registration_dir_key, new_version_number).
|
||||||
|
"""
|
||||||
|
email_key = _email_to_filename(state.parent_email or state.conversation_id)
|
||||||
|
reg_dir = self._registrations_dir / email_key
|
||||||
|
reg_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
history = self.get_registration_history(state.parent_email or state.conversation_id)
|
||||||
|
version = len(history) + 1
|
||||||
|
|
||||||
|
record = self._build_record(state.registration.to_dict(), version, state)
|
||||||
|
record["metadata"]["changeSummary"] = {
|
||||||
|
k: {"old": v[0], "new": v[1]} for k, v in change_summary.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
self._write_version(reg_dir, version, record)
|
||||||
|
logger.info("Saved registration v%d for %s", version, email_key)
|
||||||
|
return email_key, version
|
||||||
|
|
||||||
|
def get_registration_history(self, email_address: str) -> list[dict]:
|
||||||
|
"""Return all registration versions for an email address, oldest first."""
|
||||||
|
email_key = _email_to_filename(email_address)
|
||||||
|
reg_dir = self._registrations_dir / email_key
|
||||||
|
if not reg_dir.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
records: list[dict] = []
|
||||||
|
for path in sorted(reg_dir.glob("v*.json")):
|
||||||
|
try:
|
||||||
|
records.append(json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not read registration version %s", path)
|
||||||
|
return records
|
||||||
|
|
||||||
|
def get_current_registration(self, email_address: str) -> dict | None:
|
||||||
|
"""Return the latest registration version for an email address."""
|
||||||
|
email_key = _email_to_filename(email_address)
|
||||||
|
current_path = self._registrations_dir / email_key / "current.json"
|
||||||
|
if not current_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(current_path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load current registration for %s", email_address)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_registrations(self) -> list[dict]:
|
||||||
|
"""Return the current (latest) registration for every known email address."""
|
||||||
|
records: list[dict] = []
|
||||||
|
for email_dir in sorted(self._registrations_dir.iterdir()):
|
||||||
|
if not email_dir.is_dir():
|
||||||
|
continue
|
||||||
|
current = email_dir / "current.json"
|
||||||
|
if current.exists():
|
||||||
|
try:
|
||||||
|
records.append(json.loads(current.read_text(encoding="utf-8")))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not read %s", current)
|
||||||
|
return records
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _conversation_path(self, email_address: str) -> Path:
|
||||||
|
return self._conversations_dir / f"{_email_to_filename(email_address)}.json"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_record(reg_data: dict, version: int, state: ConversationState) -> dict:
|
||||||
|
record = dict(reg_data)
|
||||||
|
record["metadata"] = {
|
||||||
|
"version": version,
|
||||||
|
"submittedAt": _now(),
|
||||||
|
"channel": "email",
|
||||||
|
"parentEmail": state.parent_email,
|
||||||
|
"conversationId": state.conversation_id,
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_version(reg_dir: Path, version: int, record: dict) -> None:
|
||||||
|
ts = _timestamp_for_filename()
|
||||||
|
version_path = reg_dir / f"v{version}_{ts}.json"
|
||||||
|
version_path.write_text(
|
||||||
|
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
# Keep current.json as a plain copy of the latest version
|
||||||
|
(reg_dir / "current.json").write_text(
|
||||||
|
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Shared pytest fixtures."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.models.conversation import ConversationState, ChatMessage
|
||||||
|
from src.models.registration import (
|
||||||
|
RegistrationData,
|
||||||
|
ChildInfo,
|
||||||
|
ParentGuardian,
|
||||||
|
EmergencyContact,
|
||||||
|
Booking,
|
||||||
|
BookingDay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def complete_registration() -> RegistrationData:
|
||||||
|
"""A fully populated RegistrationData that passes is_complete()."""
|
||||||
|
return RegistrationData(
|
||||||
|
child=ChildInfo(
|
||||||
|
full_name="Lena Muster",
|
||||||
|
date_of_birth="2022-03-15",
|
||||||
|
special_needs="None",
|
||||||
|
),
|
||||||
|
parent_guardian=ParentGuardian(
|
||||||
|
full_name="Anna Muster",
|
||||||
|
street_address="Hauptstrasse 1",
|
||||||
|
postal_code="8117",
|
||||||
|
city="Fällanden",
|
||||||
|
phone="044 123 45 67",
|
||||||
|
email="anna.muster@example.com",
|
||||||
|
),
|
||||||
|
emergency_contact=EmergencyContact(
|
||||||
|
full_name="Hans Muster",
|
||||||
|
phone="079 123 45 67",
|
||||||
|
),
|
||||||
|
booking=Booking(
|
||||||
|
playgroup_types=["indoor"],
|
||||||
|
selected_days=[BookingDay(day="monday", type="indoor")],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_state() -> ConversationState:
|
||||||
|
"""A brand-new ConversationState for a parent email."""
|
||||||
|
return ConversationState(
|
||||||
|
conversation_id="anna.muster@example.com",
|
||||||
|
parent_email="anna.muster@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def state_with_messages(fresh_state) -> ConversationState:
|
||||||
|
"""A ConversationState with a couple of chat turns."""
|
||||||
|
fresh_state.messages = [
|
||||||
|
ChatMessage(role="user", content="Hallo, ich möchte mein Kind anmelden."),
|
||||||
|
ChatMessage(role="assistant", content="Hallo! Wie heisst dein Kind?"),
|
||||||
|
ChatMessage(role="user", content="Lena Muster"),
|
||||||
|
]
|
||||||
|
return fresh_state
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""Tests for EmailAgent — the conversation orchestrator."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from src.agent.core import EmailAgent
|
||||||
|
from src.models.conversation import ConversationState
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers / fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
VALID_LLM_REPLY = json.dumps({
|
||||||
|
"reply": "Wie heisst dein Kind?",
|
||||||
|
"updates": {},
|
||||||
|
"next_step": "child_name",
|
||||||
|
"registration_complete": False,
|
||||||
|
"language": "de",
|
||||||
|
})
|
||||||
|
|
||||||
|
COMPLETION_LLM_REPLY = json.dumps({
|
||||||
|
"reply": "Vielen Dank, dein Kind ist angemeldet!",
|
||||||
|
"updates": {},
|
||||||
|
"next_step": "done",
|
||||||
|
"registration_complete": True,
|
||||||
|
"language": "de",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_kb():
|
||||||
|
kb = MagicMock()
|
||||||
|
kb.get_all.return_value = "# FAQ\nSome knowledge base content."
|
||||||
|
return kb
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_store():
|
||||||
|
store = MagicMock()
|
||||||
|
store.load.return_value = None # no prior conversation by default
|
||||||
|
store.save_registration.return_value = ("anna.muster@example.com", 1)
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_notifier():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def agent(mock_kb, mock_store, mock_notifier):
|
||||||
|
return EmailAgent(
|
||||||
|
model="anthropic/claude-opus-4-6",
|
||||||
|
kb=mock_kb,
|
||||||
|
store=mock_store,
|
||||||
|
notifier=mock_notifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# process_message — new conversation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessMessageNewConversation:
|
||||||
|
def test_creates_new_state_when_none_exists(self, agent, mock_store):
|
||||||
|
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||||
|
agent.process_message("anna.muster@example.com", "Hallo")
|
||||||
|
|
||||||
|
saved_state = mock_store.save.call_args[0][0]
|
||||||
|
assert saved_state.conversation_id == "anna.muster@example.com"
|
||||||
|
|
||||||
|
def test_returns_llm_reply_text(self, agent):
|
||||||
|
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||||
|
reply = agent.process_message("anna.muster@example.com", "Hallo")
|
||||||
|
|
||||||
|
assert reply == "Wie heisst dein Kind?"
|
||||||
|
|
||||||
|
def test_user_message_added_to_history(self, agent, mock_store):
|
||||||
|
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||||
|
agent.process_message("anna.muster@example.com", "Hallo, ich möchte anmelden")
|
||||||
|
|
||||||
|
state = mock_store.save.call_args[0][0]
|
||||||
|
assert any(m.role == "user" and "anmelden" in m.content for m in state.messages)
|
||||||
|
|
||||||
|
def test_assistant_reply_added_to_history(self, agent, mock_store):
|
||||||
|
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||||
|
agent.process_message("anna.muster@example.com", "Hallo")
|
||||||
|
|
||||||
|
state = mock_store.save.call_args[0][0]
|
||||||
|
assert any(m.role == "assistant" for m in state.messages)
|
||||||
|
|
||||||
|
def test_normalizes_email_key(self, agent, mock_store):
|
||||||
|
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||||
|
agent.process_message("Anna.Muster@EXAMPLE.COM", "Hallo")
|
||||||
|
|
||||||
|
state = mock_store.save.call_args[0][0]
|
||||||
|
assert state.conversation_id == "anna.muster@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# process_message — existing conversation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessMessageExistingConversation:
|
||||||
|
def test_loads_existing_state(self, agent, mock_store, fresh_state):
|
||||||
|
mock_store.load.return_value = fresh_state
|
||||||
|
|
||||||
|
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||||
|
agent.process_message("anna.muster@example.com", "Lena")
|
||||||
|
|
||||||
|
mock_store.load.assert_called_once()
|
||||||
|
|
||||||
|
def test_flow_step_updated(self, agent, mock_store, fresh_state):
|
||||||
|
mock_store.load.return_value = fresh_state
|
||||||
|
|
||||||
|
reply_with_step = json.dumps({
|
||||||
|
"reply": "Wann ist Lena geboren?",
|
||||||
|
"updates": {"child.fullName": "Lena"},
|
||||||
|
"next_step": "child_dob",
|
||||||
|
"registration_complete": False,
|
||||||
|
"language": "de",
|
||||||
|
})
|
||||||
|
|
||||||
|
with patch("src.llm.complete", return_value=reply_with_step):
|
||||||
|
agent.process_message("anna.muster@example.com", "Lena")
|
||||||
|
|
||||||
|
state = mock_store.save.call_args[0][0]
|
||||||
|
assert state.flow_step == "child_dob"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# process_message — registration completion
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistrationCompletion:
|
||||||
|
def test_notifier_called_on_completion(self, agent, mock_store, mock_notifier, complete_registration):
|
||||||
|
state = ConversationState(
|
||||||
|
conversation_id="anna.muster@example.com",
|
||||||
|
parent_email="anna.muster@example.com",
|
||||||
|
)
|
||||||
|
state.registration = complete_registration
|
||||||
|
mock_store.load.return_value = state
|
||||||
|
|
||||||
|
with patch("src.llm.complete", return_value=COMPLETION_LLM_REPLY):
|
||||||
|
agent.process_message("anna.muster@example.com", "Ja, alles korrekt")
|
||||||
|
|
||||||
|
mock_notifier.notify_admin.assert_called_once()
|
||||||
|
|
||||||
|
def test_state_marked_completed(self, agent, mock_store, complete_registration):
|
||||||
|
state = ConversationState(
|
||||||
|
conversation_id="anna.muster@example.com",
|
||||||
|
parent_email="anna.muster@example.com",
|
||||||
|
)
|
||||||
|
state.registration = complete_registration
|
||||||
|
mock_store.load.return_value = state
|
||||||
|
|
||||||
|
with patch("src.llm.complete", return_value=COMPLETION_LLM_REPLY):
|
||||||
|
agent.process_message("anna.muster@example.com", "Ja")
|
||||||
|
|
||||||
|
saved = mock_store.save.call_args[0][0]
|
||||||
|
assert saved.completed is True
|
||||||
|
|
||||||
|
def test_notifier_not_called_when_already_completed(self, agent, mock_store, mock_notifier, complete_registration):
|
||||||
|
state = ConversationState(
|
||||||
|
conversation_id="anna.muster@example.com",
|
||||||
|
parent_email="anna.muster@example.com",
|
||||||
|
)
|
||||||
|
state.registration = complete_registration
|
||||||
|
state.completed = True # already done
|
||||||
|
mock_store.load.return_value = state
|
||||||
|
|
||||||
|
with patch("src.llm.complete", return_value=COMPLETION_LLM_REPLY):
|
||||||
|
agent.process_message("anna.muster@example.com", "Noch eine Frage")
|
||||||
|
|
||||||
|
mock_notifier.notify_admin.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fallback on LLM error
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFallbackOnLLMError:
|
||||||
|
def test_returns_german_fallback_by_default(self, agent):
|
||||||
|
with patch("src.llm.complete", side_effect=RuntimeError("API down")):
|
||||||
|
reply = agent.process_message("anna.muster@example.com", "Hallo")
|
||||||
|
|
||||||
|
assert "technisches Problem" in reply or "Entschuldigung" in reply
|
||||||
|
|
||||||
|
def test_returns_english_fallback_when_language_is_en(self, agent, mock_store, fresh_state):
|
||||||
|
fresh_state.language = "en"
|
||||||
|
mock_store.load.return_value = fresh_state
|
||||||
|
|
||||||
|
with patch("src.llm.complete", side_effect=RuntimeError("API down")):
|
||||||
|
reply = agent.process_message("anna.muster@example.com", "Hello")
|
||||||
|
|
||||||
|
assert "technical issue" in reply.lower() or "sorry" in reply.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _parse_llm_response
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseLlmResponse:
|
||||||
|
def test_parses_plain_json(self, agent):
|
||||||
|
payload = '{"reply": "Hi", "updates": {}, "next_step": "greeting", "registration_complete": false, "language": "de"}'
|
||||||
|
result = agent._parse_llm_response(payload)
|
||||||
|
assert result["reply"] == "Hi"
|
||||||
|
|
||||||
|
def test_parses_fenced_json(self, agent):
|
||||||
|
payload = '```json\n{"reply": "Hi", "updates": {}}\n```'
|
||||||
|
result = agent._parse_llm_response(payload)
|
||||||
|
assert result["reply"] == "Hi"
|
||||||
|
|
||||||
|
def test_parses_json_embedded_in_text(self, agent):
|
||||||
|
payload = 'Sure, here is the response: {"reply": "Hi", "updates": {}}'
|
||||||
|
result = agent._parse_llm_response(payload)
|
||||||
|
assert result["reply"] == "Hi"
|
||||||
|
|
||||||
|
def test_falls_back_to_raw_text_when_no_json(self, agent):
|
||||||
|
result = agent._parse_llm_response("Ich bin ein Hilfsroboter")
|
||||||
|
assert result["reply"] == "Ich bin ein Hilfsroboter"
|
||||||
|
|
||||||
|
def test_fallback_has_safe_defaults(self, agent):
|
||||||
|
result = agent._parse_llm_response("plain text")
|
||||||
|
assert result["registration_complete"] is False
|
||||||
|
assert result["updates"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _apply_updates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyUpdates:
|
||||||
|
def test_sets_child_name(self, agent, fresh_state):
|
||||||
|
agent._apply_updates(fresh_state, {"child.fullName": "Lena Muster"})
|
||||||
|
assert fresh_state.registration.child.full_name == "Lena Muster"
|
||||||
|
|
||||||
|
def test_sets_child_dob(self, agent, fresh_state):
|
||||||
|
agent._apply_updates(fresh_state, {"child.dateOfBirth": "2022-03-15"})
|
||||||
|
assert fresh_state.registration.child.date_of_birth == "2022-03-15"
|
||||||
|
|
||||||
|
def test_sets_parent_email(self, agent, fresh_state):
|
||||||
|
agent._apply_updates(fresh_state, {"parentGuardian.email": "test@example.com"})
|
||||||
|
assert fresh_state.registration.parent_guardian.email == "test@example.com"
|
||||||
|
|
||||||
|
def test_sets_emergency_contact(self, agent, fresh_state):
|
||||||
|
agent._apply_updates(fresh_state, {"emergencyContact.phone": "079 111 22 33"})
|
||||||
|
assert fresh_state.registration.emergency_contact.phone == "079 111 22 33"
|
||||||
|
|
||||||
|
def test_sets_booking_days(self, agent, fresh_state):
|
||||||
|
agent._apply_updates(fresh_state, {
|
||||||
|
"booking.selectedDays": [{"day": "wednesday", "type": "indoor"}]
|
||||||
|
})
|
||||||
|
assert fresh_state.registration.booking.selected_days[0].day == "wednesday"
|
||||||
|
|
||||||
|
def test_ignores_none_values(self, agent, fresh_state):
|
||||||
|
fresh_state.registration.child.full_name = "Lena"
|
||||||
|
agent._apply_updates(fresh_state, {"child.fullName": None})
|
||||||
|
assert fresh_state.registration.child.full_name == "Lena"
|
||||||
|
|
||||||
|
def test_ignores_unknown_keys(self, agent, fresh_state):
|
||||||
|
agent._apply_updates(fresh_state, {"unknown.key": "value"}) # should not raise
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Tests for KnowledgeBase loader."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.knowledge_base.loader import KnowledgeBase
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def kb_dir(tmp_path) -> Path:
|
||||||
|
"""A temporary knowledge-base directory with a couple of markdown files."""
|
||||||
|
(tmp_path / "faq.md").write_text("# FAQ\nWann beginnt die Spielgruppe?\nIm August.")
|
||||||
|
(tmp_path / "fees.md").write_text("# Fees\nCHF 130 per month.")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def kb(kb_dir) -> KnowledgeBase:
|
||||||
|
return KnowledgeBase(kb_dir)
|
||||||
|
|
||||||
|
|
||||||
|
class TestKnowledgeBaseLoading:
|
||||||
|
def test_get_all_includes_file_content(self, kb):
|
||||||
|
content = kb.get_all()
|
||||||
|
assert "FAQ" in content
|
||||||
|
assert "Fees" in content
|
||||||
|
|
||||||
|
def test_get_all_concatenates_multiple_files(self, kb):
|
||||||
|
content = kb.get_all()
|
||||||
|
assert "CHF 130" in content
|
||||||
|
assert "Spielgruppe" in content
|
||||||
|
|
||||||
|
def test_reload_picks_up_new_file(self, kb, kb_dir):
|
||||||
|
(kb_dir / "schedule.md").write_text("# Schedule\nMonday 9:00")
|
||||||
|
kb.reload()
|
||||||
|
assert "Schedule" in kb.get_all()
|
||||||
|
|
||||||
|
def test_empty_directory_returns_empty_string(self, tmp_path):
|
||||||
|
kb = KnowledgeBase(tmp_path)
|
||||||
|
assert kb.get_all() == "" or isinstance(kb.get_all(), str)
|
||||||
|
|
||||||
|
def test_nonexistent_directory_does_not_raise_on_init(self, tmp_path):
|
||||||
|
# Should either handle gracefully or raise — just must not crash silently
|
||||||
|
missing = tmp_path / "does_not_exist"
|
||||||
|
try:
|
||||||
|
kb = KnowledgeBase(missing)
|
||||||
|
kb.get_all()
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
pass # Acceptable to raise on missing dir
|
||||||
|
|
||||||
|
def test_get_all_returns_string(self, kb):
|
||||||
|
assert isinstance(kb.get_all(), str)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Tests for the litellm wrapper in src/llm.py."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import llm
|
||||||
|
from src.models.conversation import ChatMessage
|
||||||
|
|
||||||
|
|
||||||
|
class TestLlmComplete:
|
||||||
|
def test_returns_model_reply(self, mocker):
|
||||||
|
mock_response = mocker.MagicMock()
|
||||||
|
mock_response.choices[0].message.content = "Hallo! Wie heisst dein Kind?"
|
||||||
|
mocker.patch("litellm.completion", return_value=mock_response)
|
||||||
|
|
||||||
|
result = llm.complete("anthropic/claude-opus-4-6", "system prompt", [])
|
||||||
|
|
||||||
|
assert result == "Hallo! Wie heisst dein Kind?"
|
||||||
|
|
||||||
|
def test_passes_model_to_litellm(self, mocker):
|
||||||
|
mock_response = mocker.MagicMock()
|
||||||
|
mock_response.choices[0].message.content = "ok"
|
||||||
|
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
|
||||||
|
|
||||||
|
llm.complete("openai/gpt-4o", "system", [])
|
||||||
|
|
||||||
|
call_kwargs = mock_completion.call_args.kwargs
|
||||||
|
assert call_kwargs["model"] == "openai/gpt-4o"
|
||||||
|
|
||||||
|
def test_system_prompt_prepended_as_system_message(self, mocker):
|
||||||
|
mock_response = mocker.MagicMock()
|
||||||
|
mock_response.choices[0].message.content = "ok"
|
||||||
|
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
|
||||||
|
|
||||||
|
llm.complete("anthropic/claude-opus-4-6", "You are helpful.", [])
|
||||||
|
|
||||||
|
messages = mock_completion.call_args.kwargs["messages"]
|
||||||
|
assert messages[0] == {"role": "system", "content": "You are helpful."}
|
||||||
|
|
||||||
|
def test_chat_messages_appended_after_system(self, mocker):
|
||||||
|
mock_response = mocker.MagicMock()
|
||||||
|
mock_response.choices[0].message.content = "ok"
|
||||||
|
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
|
||||||
|
|
||||||
|
chat = [
|
||||||
|
ChatMessage(role="user", content="Hallo"),
|
||||||
|
ChatMessage(role="assistant", content="Guten Tag"),
|
||||||
|
]
|
||||||
|
llm.complete("anthropic/claude-opus-4-6", "system", chat)
|
||||||
|
|
||||||
|
messages = mock_completion.call_args.kwargs["messages"]
|
||||||
|
assert messages[1] == {"role": "user", "content": "Hallo"}
|
||||||
|
assert messages[2] == {"role": "assistant", "content": "Guten Tag"}
|
||||||
|
|
||||||
|
def test_max_tokens_passed(self, mocker):
|
||||||
|
mock_response = mocker.MagicMock()
|
||||||
|
mock_response.choices[0].message.content = "ok"
|
||||||
|
mock_completion = mocker.patch("litellm.completion", return_value=mock_response)
|
||||||
|
|
||||||
|
llm.complete("anthropic/claude-opus-4-6", "system", [])
|
||||||
|
|
||||||
|
assert mock_completion.call_args.kwargs["max_tokens"] == 2048
|
||||||
|
|
||||||
|
def test_litellm_exception_propagates(self, mocker):
|
||||||
|
mocker.patch("litellm.completion", side_effect=RuntimeError("API error"))
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="API error"):
|
||||||
|
llm.complete("anthropic/claude-opus-4-6", "system", [])
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Tests for data models: RegistrationData and ConversationState."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.models.registration import (
|
||||||
|
RegistrationData,
|
||||||
|
ChildInfo,
|
||||||
|
ParentGuardian,
|
||||||
|
EmergencyContact,
|
||||||
|
Booking,
|
||||||
|
BookingDay,
|
||||||
|
)
|
||||||
|
from src.models.conversation import ConversationState, ChatMessage
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RegistrationData.is_complete()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistrationDataIsComplete:
|
||||||
|
def test_complete_registration_passes(self, complete_registration):
|
||||||
|
assert complete_registration.is_complete() is True
|
||||||
|
|
||||||
|
def test_empty_registration_fails(self):
|
||||||
|
assert RegistrationData().is_complete() is False
|
||||||
|
|
||||||
|
def test_missing_child_name_fails(self, complete_registration):
|
||||||
|
complete_registration.child.full_name = None
|
||||||
|
assert complete_registration.is_complete() is False
|
||||||
|
|
||||||
|
def test_missing_dob_fails(self, complete_registration):
|
||||||
|
complete_registration.child.date_of_birth = None
|
||||||
|
assert complete_registration.is_complete() is False
|
||||||
|
|
||||||
|
def test_missing_special_needs_fails(self, complete_registration):
|
||||||
|
complete_registration.child.special_needs = None
|
||||||
|
assert complete_registration.is_complete() is False
|
||||||
|
|
||||||
|
def test_missing_parent_name_fails(self, complete_registration):
|
||||||
|
complete_registration.parent_guardian.full_name = None
|
||||||
|
assert complete_registration.is_complete() is False
|
||||||
|
|
||||||
|
def test_missing_parent_email_fails(self, complete_registration):
|
||||||
|
complete_registration.parent_guardian.email = None
|
||||||
|
assert complete_registration.is_complete() is False
|
||||||
|
|
||||||
|
def test_missing_emergency_contact_fails(self, complete_registration):
|
||||||
|
complete_registration.emergency_contact.full_name = None
|
||||||
|
assert complete_registration.is_complete() is False
|
||||||
|
|
||||||
|
def test_missing_booking_days_fails(self, complete_registration):
|
||||||
|
complete_registration.booking.selected_days = []
|
||||||
|
assert complete_registration.is_complete() is False
|
||||||
|
|
||||||
|
def test_missing_playgroup_types_fails(self, complete_registration):
|
||||||
|
complete_registration.booking.playgroup_types = []
|
||||||
|
assert complete_registration.is_complete() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RegistrationData serialisation round-trip
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistrationDataSerialization:
|
||||||
|
def test_to_dict_contains_expected_keys(self, complete_registration):
|
||||||
|
d = complete_registration.to_dict()
|
||||||
|
assert "child" in d
|
||||||
|
assert "parentGuardian" in d
|
||||||
|
assert "emergencyContact" in d
|
||||||
|
assert "booking" in d
|
||||||
|
|
||||||
|
def test_to_dict_child_fields(self, complete_registration):
|
||||||
|
d = complete_registration.to_dict()
|
||||||
|
assert d["child"]["fullName"] == "Lena Muster"
|
||||||
|
assert d["child"]["dateOfBirth"] == "2022-03-15"
|
||||||
|
assert d["child"]["specialNeeds"] == "None"
|
||||||
|
|
||||||
|
def test_to_dict_parent_fields(self, complete_registration):
|
||||||
|
d = complete_registration.to_dict()
|
||||||
|
assert d["parentGuardian"]["email"] == "anna.muster@example.com"
|
||||||
|
assert d["parentGuardian"]["postalCode"] == "8117"
|
||||||
|
|
||||||
|
def test_to_dict_booking_fields(self, complete_registration):
|
||||||
|
d = complete_registration.to_dict()
|
||||||
|
assert d["booking"]["playgroupTypes"] == ["indoor"]
|
||||||
|
assert d["booking"]["selectedDays"] == [{"day": "monday", "type": "indoor"}]
|
||||||
|
|
||||||
|
def test_from_dict_round_trip(self, complete_registration):
|
||||||
|
d = complete_registration.to_dict()
|
||||||
|
restored = RegistrationData.from_dict(d)
|
||||||
|
assert restored.child.full_name == complete_registration.child.full_name
|
||||||
|
assert restored.parent_guardian.email == complete_registration.parent_guardian.email
|
||||||
|
assert restored.emergency_contact.phone == complete_registration.emergency_contact.phone
|
||||||
|
assert len(restored.booking.selected_days) == len(complete_registration.booking.selected_days)
|
||||||
|
|
||||||
|
def test_from_dict_outdoor_booking(self):
|
||||||
|
data = {
|
||||||
|
"child": {"fullName": "Tim", "dateOfBirth": "2021-01-01", "specialNeeds": "None"},
|
||||||
|
"parentGuardian": {
|
||||||
|
"fullName": "Eva", "streetAddress": "Seeweg 2", "postalCode": "8117",
|
||||||
|
"city": "Fällanden", "phone": "044 000 00 00", "email": "eva@example.com",
|
||||||
|
},
|
||||||
|
"emergencyContact": {"fullName": "Bob", "phone": "079 000 00 00"},
|
||||||
|
"booking": {
|
||||||
|
"playgroupTypes": ["outdoor"],
|
||||||
|
"selectedDays": [{"day": "monday", "type": "outdoor"}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
reg = RegistrationData.from_dict(data)
|
||||||
|
assert reg.booking.playgroup_types == ["outdoor"]
|
||||||
|
assert reg.booking.selected_days[0].day == "monday"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ConversationState serialisation round-trip
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestConversationStateSerialization:
|
||||||
|
def test_to_dict_contains_expected_keys(self, fresh_state):
|
||||||
|
d = fresh_state.to_dict()
|
||||||
|
assert "conversation_id" in d
|
||||||
|
assert "language" in d
|
||||||
|
assert "flow_step" in d
|
||||||
|
assert "messages" in d
|
||||||
|
assert "completed" in d
|
||||||
|
|
||||||
|
def test_default_language_is_german(self, fresh_state):
|
||||||
|
assert fresh_state.language == "de"
|
||||||
|
|
||||||
|
def test_default_flow_step_is_greeting(self, fresh_state):
|
||||||
|
assert fresh_state.flow_step == "greeting"
|
||||||
|
|
||||||
|
def test_default_completed_is_false(self, fresh_state):
|
||||||
|
assert fresh_state.completed is False
|
||||||
|
|
||||||
|
def test_from_dict_round_trip(self, state_with_messages):
|
||||||
|
state_with_messages.language = "en"
|
||||||
|
state_with_messages.flow_step = "parent_name"
|
||||||
|
d = state_with_messages.to_dict()
|
||||||
|
restored = ConversationState.from_dict(d)
|
||||||
|
assert restored.conversation_id == state_with_messages.conversation_id
|
||||||
|
assert restored.language == "en"
|
||||||
|
assert restored.flow_step == "parent_name"
|
||||||
|
assert len(restored.messages) == len(state_with_messages.messages)
|
||||||
|
|
||||||
|
def test_messages_serialized_with_role_and_content(self, state_with_messages):
|
||||||
|
d = state_with_messages.to_dict()
|
||||||
|
assert d["messages"][0]["role"] == "user"
|
||||||
|
assert "Hallo" in d["messages"][0]["content"]
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Tests for AdminNotifier helper methods."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.notifications.notifier import AdminNotifier
|
||||||
|
from src.models.registration import RegistrationData, Booking, BookingDay
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def notifier():
|
||||||
|
return AdminNotifier(
|
||||||
|
smtp_host="smtp.example.com",
|
||||||
|
smtp_port=587,
|
||||||
|
username="agent@example.com",
|
||||||
|
password="secret",
|
||||||
|
use_tls=True,
|
||||||
|
from_email="agent@example.com",
|
||||||
|
indoor_email="andrea@example.com",
|
||||||
|
outdoor_email="barbara@example.com",
|
||||||
|
cc_emails=["markus@example.com"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _format_types
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatTypes:
|
||||||
|
def test_indoor_label(self, notifier):
|
||||||
|
assert "Innen" in notifier._format_types(["indoor"]) or "indoor" in notifier._format_types(["indoor"]).lower()
|
||||||
|
|
||||||
|
def test_outdoor_label(self, notifier):
|
||||||
|
assert "Wald" in notifier._format_types(["outdoor"]) or "outdoor" in notifier._format_types(["outdoor"]).lower()
|
||||||
|
|
||||||
|
def test_both_labels(self, notifier):
|
||||||
|
result = notifier._format_types(["indoor", "outdoor"])
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _calculate_age
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateAge:
|
||||||
|
def test_returns_age_string(self, notifier):
|
||||||
|
result = notifier._calculate_age("2022-01-01")
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_invalid_dob_returns_original_string(self, notifier):
|
||||||
|
result = notifier._calculate_age("not-a-date")
|
||||||
|
assert result == "not-a-date"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _calculate_monthly_fee
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateMonthlyFee:
|
||||||
|
def test_indoor_one_day(self, notifier, complete_registration):
|
||||||
|
complete_registration.booking = Booking(
|
||||||
|
playgroup_types=["indoor"],
|
||||||
|
selected_days=[BookingDay(day="monday", type="indoor")],
|
||||||
|
)
|
||||||
|
fee = notifier._calculate_monthly_fee(complete_registration)
|
||||||
|
assert "130" in fee
|
||||||
|
|
||||||
|
def test_indoor_two_days(self, notifier, complete_registration):
|
||||||
|
complete_registration.booking = Booking(
|
||||||
|
playgroup_types=["indoor"],
|
||||||
|
selected_days=[
|
||||||
|
BookingDay(day="monday", type="indoor"),
|
||||||
|
BookingDay(day="wednesday", type="indoor"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
fee = notifier._calculate_monthly_fee(complete_registration)
|
||||||
|
assert "260" in fee
|
||||||
|
|
||||||
|
def test_indoor_three_days(self, notifier, complete_registration):
|
||||||
|
complete_registration.booking = Booking(
|
||||||
|
playgroup_types=["indoor"],
|
||||||
|
selected_days=[
|
||||||
|
BookingDay(day="monday", type="indoor"),
|
||||||
|
BookingDay(day="wednesday", type="indoor"),
|
||||||
|
BookingDay(day="thursday", type="indoor"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
fee = notifier._calculate_monthly_fee(complete_registration)
|
||||||
|
assert "390" in fee
|
||||||
|
|
||||||
|
def test_outdoor_one_day(self, notifier, complete_registration):
|
||||||
|
complete_registration.booking = Booking(
|
||||||
|
playgroup_types=["outdoor"],
|
||||||
|
selected_days=[BookingDay(day="monday", type="outdoor")],
|
||||||
|
)
|
||||||
|
fee = notifier._calculate_monthly_fee(complete_registration)
|
||||||
|
assert "250" in fee
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _send — SMTP interaction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSend:
|
||||||
|
def test_send_calls_smtp(self, notifier, mocker):
|
||||||
|
# _send uses smtplib.SMTP directly (not as context manager)
|
||||||
|
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||||
|
mock_server = mock_smtp_cls.return_value
|
||||||
|
|
||||||
|
notifier._send(
|
||||||
|
to=["admin@example.com"],
|
||||||
|
cc=["cc@example.com"],
|
||||||
|
subject="Test",
|
||||||
|
body="Hello",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_server.sendmail.assert_called_once()
|
||||||
|
|
||||||
|
def test_send_includes_all_recipients(self, notifier, mocker):
|
||||||
|
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||||
|
mock_server = mock_smtp_cls.return_value
|
||||||
|
|
||||||
|
notifier._send(
|
||||||
|
to=["a@example.com"],
|
||||||
|
cc=["b@example.com"],
|
||||||
|
subject="Test",
|
||||||
|
body="Hello",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_args = mock_server.sendmail.call_args
|
||||||
|
recipients = call_args[0][1] # positional arg: to_addrs
|
||||||
|
assert "a@example.com" in recipients
|
||||||
|
assert "b@example.com" in recipients
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Tests for ConversationStore and storage helpers."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.storage.json_store import (
|
||||||
|
ConversationStore,
|
||||||
|
normalize_email,
|
||||||
|
_diff_registrations,
|
||||||
|
)
|
||||||
|
from src.models.conversation import ConversationState
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# normalize_email
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeEmail:
|
||||||
|
def test_lowercases(self):
|
||||||
|
assert normalize_email("Anna.Muster@Example.COM") == "anna.muster@example.com"
|
||||||
|
|
||||||
|
def test_strips_whitespace(self):
|
||||||
|
assert normalize_email(" user@example.com ") == "user@example.com"
|
||||||
|
|
||||||
|
def test_already_normalized(self):
|
||||||
|
assert normalize_email("user@example.com") == "user@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _diff_registrations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiffRegistrations:
|
||||||
|
def test_detects_changed_field(self):
|
||||||
|
old = {"child": {"fullName": "Lena"}}
|
||||||
|
new = {"child": {"fullName": "Lena Muster"}}
|
||||||
|
diff = _diff_registrations(old, new)
|
||||||
|
assert "child.fullName" in diff
|
||||||
|
assert diff["child.fullName"] == ("Lena", "Lena Muster")
|
||||||
|
|
||||||
|
def test_unchanged_fields_not_included(self):
|
||||||
|
old = {"child": {"fullName": "Lena", "dateOfBirth": "2022-01-01"}}
|
||||||
|
new = {"child": {"fullName": "Lena", "dateOfBirth": "2022-01-01"}}
|
||||||
|
assert _diff_registrations(old, new) == {}
|
||||||
|
|
||||||
|
def test_nested_change_detected(self):
|
||||||
|
old = {"parentGuardian": {"email": "old@example.com"}}
|
||||||
|
new = {"parentGuardian": {"email": "new@example.com"}}
|
||||||
|
diff = _diff_registrations(old, new)
|
||||||
|
assert "parentGuardian.email" in diff
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ConversationStore — CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_path) -> ConversationStore:
|
||||||
|
return ConversationStore(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConversationStoreCRUD:
|
||||||
|
def test_load_returns_none_for_unknown_email(self, store):
|
||||||
|
assert store.load("nobody@example.com") is None
|
||||||
|
|
||||||
|
def test_save_and_load_round_trip(self, store, fresh_state):
|
||||||
|
store.save(fresh_state)
|
||||||
|
loaded = store.load(fresh_state.parent_email)
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.conversation_id == fresh_state.conversation_id
|
||||||
|
|
||||||
|
def test_save_overwrites_existing(self, store, fresh_state):
|
||||||
|
store.save(fresh_state)
|
||||||
|
fresh_state.language = "en"
|
||||||
|
store.save(fresh_state)
|
||||||
|
loaded = store.load(fresh_state.parent_email)
|
||||||
|
assert loaded.language == "en"
|
||||||
|
|
||||||
|
def test_delete_removes_conversation(self, store, fresh_state):
|
||||||
|
store.save(fresh_state)
|
||||||
|
store.delete(fresh_state.parent_email)
|
||||||
|
assert store.load(fresh_state.parent_email) is None
|
||||||
|
|
||||||
|
def test_delete_nonexistent_is_silent(self, store):
|
||||||
|
store.delete("ghost@example.com") # should not raise
|
||||||
|
|
||||||
|
def test_list_incomplete_returns_non_completed(self, store, fresh_state):
|
||||||
|
store.save(fresh_state)
|
||||||
|
incomplete = store.list_incomplete()
|
||||||
|
assert any(s.conversation_id == fresh_state.conversation_id for s in incomplete)
|
||||||
|
|
||||||
|
def test_list_incomplete_excludes_completed(self, store, fresh_state):
|
||||||
|
fresh_state.completed = True
|
||||||
|
store.save(fresh_state)
|
||||||
|
incomplete = store.list_incomplete()
|
||||||
|
assert all(not s.completed for s in incomplete)
|
||||||
|
|
||||||
|
def test_find_by_email_is_alias_for_load(self, store, fresh_state):
|
||||||
|
store.save(fresh_state)
|
||||||
|
assert store.find_by_email(fresh_state.parent_email) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ConversationStore — registration versioning
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistrationVersioning:
|
||||||
|
def test_save_registration_creates_version_1(self, store, fresh_state, complete_registration):
|
||||||
|
fresh_state.registration = complete_registration
|
||||||
|
fresh_state.completed = True
|
||||||
|
email_key, version = store.save_registration(fresh_state)
|
||||||
|
assert version == 1
|
||||||
|
# email_key is the filesystem-safe form (@ → _at_)
|
||||||
|
assert email_key == "anna.muster_at_example.com"
|
||||||
|
|
||||||
|
def test_save_registration_writes_current_json(self, store, fresh_state, complete_registration, tmp_path):
|
||||||
|
fresh_state.registration = complete_registration
|
||||||
|
fresh_state.completed = True
|
||||||
|
email_key, _ = store.save_registration(fresh_state)
|
||||||
|
current = tmp_path / "registrations" / email_key / "current.json"
|
||||||
|
assert current.exists()
|
||||||
|
|
||||||
|
def test_save_registration_version_increments(self, store, fresh_state, complete_registration):
|
||||||
|
fresh_state.registration = complete_registration
|
||||||
|
fresh_state.completed = True
|
||||||
|
store.save_registration(fresh_state)
|
||||||
|
_, v2 = store.save_registration_version(
|
||||||
|
fresh_state, {"child.fullName": ("Old", "New")}
|
||||||
|
)
|
||||||
|
assert v2 == 2
|
||||||
|
|
||||||
|
def test_get_current_registration_returns_latest(self, store, fresh_state, complete_registration):
|
||||||
|
fresh_state.registration = complete_registration
|
||||||
|
fresh_state.completed = True
|
||||||
|
store.save_registration(fresh_state)
|
||||||
|
current = store.get_current_registration(fresh_state.parent_email)
|
||||||
|
assert current is not None
|
||||||
|
assert current["metadata"]["version"] == 1
|
||||||
|
|
||||||
|
def test_get_registration_history_returns_all_versions(self, store, fresh_state, complete_registration):
|
||||||
|
fresh_state.registration = complete_registration
|
||||||
|
fresh_state.completed = True
|
||||||
|
store.save_registration(fresh_state)
|
||||||
|
store.save_registration_version(fresh_state, {"child.fullName": ("A", "B")})
|
||||||
|
history = store.get_registration_history(fresh_state.parent_email)
|
||||||
|
assert len(history) == 2
|
||||||
|
|
||||||
|
def test_list_registrations_includes_saved(self, store, fresh_state, complete_registration):
|
||||||
|
fresh_state.registration = complete_registration
|
||||||
|
fresh_state.completed = True
|
||||||
|
store.save_registration(fresh_state)
|
||||||
|
registrations = store.list_registrations()
|
||||||
|
assert len(registrations) == 1
|
||||||
Reference in New Issue
Block a user