Author SHA1 Message Date
gurixandClaude Sonnet 4.6 f509d984ba docs: add Docker Compose section to README
Documents the docker-compose.yml setup added in PR #7, including how
to start both services, volume mounts for persistent data, and common
operational commands (logs, rebuild).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:13:16 +01:00
Markus GrafandGitHub fdf674d8d6 Merge pull request #8 from gurix/claude/fix-email-loop-prevention-bD8vX
Add loop detection and automated sender handling
2026-02-26 22:20:15 +01:00
Claude 514d235a2f docs(openspec): add change artifacts for email-loop-prevention
Creates openspec/changes/email-loop-prevention/ with all artifacts:

- proposal.md: Why (MAILER-DAEMON bounce loop incident), what changes
  (automated sender detection + message-count cap), capabilities affected
- design.md: Two-layer defence rationale, detection signal table, decisions
  on channel/agent boundary split, 20-message cap, one-shot alert, CC-only
  routing; risks and trade-offs documented
- specs/email-channel/spec.md: ADDED requirements for detect_automated_message(),
  is_automated/automated_reason message dict fields, no-reply guarantee
- specs/registration-notifications/spec.md: ADDED requirements for
  notify_loop_escalation() — alert content, one-shot guarantee, CC-only
  routing, dev-mode guard
- tasks.md: All 6 sections fully checked (implementation already complete)

https://claude.ai/code/session_01KwvR5hDPjSuJg4kvw5b5e5
2026-02-26 21:06:53 +00:00
Claude 491c2d3495 test: add tests for email loop prevention
Add tests/test_email_loop_prevention.py (47 tests) covering:
- detect_automated_message(): sender patterns (mailer-daemon, postmaster,
  noreply, …), RFC headers (Auto-Submitted, X-Auto-Response-Suppress,
  multipart/report, X-Loop, Precedence), subject heuristics (German and
  English bounce/OOO phrases), and negative cases for normal senders
- EmailAgent.handle_automated_message(): state creation, loop_escalated
  flag, one-shot admin alert, silent drop on repeat, notifier failure safety
- EmailAgent.process_message() message-count cap: at-limit still processes,
  over-limit returns "", escalation called once, duplicate prevention,
  notifier failure safety, constant value assertion
- AdminNotifier.notify_loop_escalation(): SMTP dispatch, [WARNUNG] subject,
  body content, no-CC guard, no-SMTP dev-mode guard

Add TestConversationStateLoopEscalated to test_models.py (5 tests):
default value, to_dict inclusion, True round-trip, from_dict backward
compatibility with persisted conversations missing the key.

All 168 tests pass.

https://claude.ai/code/session_01KwvR5hDPjSuJg4kvw5b5e5
2026-02-26 21:03:01 +00:00
Claude 9dac54fe81 fix: prevent email loop by detecting bounces and capping conversation length
- email_channel.py: add detect_automated_message() that inspects RFC 3834
  Auto-Submitted, mailer-daemon/postmaster sender patterns, X-Loop,
  multipart/report Content-Type, Precedence, and subject heuristics.
  fetch_unread_messages() now includes is_automated / automated_reason
  in every message dict.

- main.py: if is_automated is set, call agent.handle_automated_message()
  instead of process_message() — no reply is ever sent to a bounce source.

- agent/core.py: add MAX_USER_MESSAGES = 20 cap; process_message() returns
  "" without replying once a conversation exceeds the limit and calls
  notifier.notify_loop_escalation() on first breach.  New public method
  handle_automated_message() records the event and triggers the same
  one-shot admin alert.

- models/conversation.py: add loop_escalated: bool field (persisted) so
  the admin alert fires at most once per conversation.

- notifications/notifier.py: add notify_loop_escalation() which sends a
  plain-text warning to the admin CC list (Markus Graf / spielgruppen@).

https://claude.ai/code/session_01KwvR5hDPjSuJg4kvw5b5e5
2026-02-26 20:46:09 +00:00
Markus GrafandGitHub fa6b520f19 Merge pull request #7 from gurix/claude/add-docker-compose-setup-PoDxl
Add Docker support with Compose configuration
2026-02-26 21:32:19 +01:00
gurixandClaude Sonnet 4.6 cab16078a2 document SIMPLE_MODEL and THINKING_BUDGET env vars in README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 17:43:35 +01:00
Claude 6866d126cb Add Docker Compose setup for web chat and email worker
Two services sharing a single image:
- web: Chainlit chat interface on port 8000
- email-worker: IMAP polling agent (main.py)

./data is mounted for persistent storage (registrations + conversations).
./openspec is mounted read-only so knowledge-base markdown files can be
edited on the host without rebuilding the image.

Configuration via .env (see .env.example).

https://claude.ai/code/session_015tJePX9eyQENYpJUF8XvvK
2026-02-26 16:31:11 +00:00
gurix e3bdfbcb9e Merge remote-tracking branch 'origin/claude/registration-confirmation-email-9c8Uh' 2026-02-23 22:54:20 +01:00
gurixandClaude Sonnet 4.6 91ba940951 update language hint to reflect any-language support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:52:57 +01:00
Markus GrafandGitHub 52901dd7c4 Merge pull request #6 from gurix/claude/registration-confirmation-email-9c8Uh
Add registration confirmation email spec configuration
2026-02-23 22:51:48 +01:00
gurixandClaude Sonnet 4.6 8275286647 fix gemini 3 flash model name to gemini-3-flash-preview
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:51:04 +01:00
gurixandClaude Sonnet 4.6 75691879aa add jinja2 and pyyaml to project dependencies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:19:30 +01:00
gurixandClaude Sonnet 4.6 602b8eddc3 introduce separate SIMPLE_MODEL config for lightweight tasks
Add a distinct simple_model field alongside ai_model so operators can
route cheap, simple tasks (e.g. email-label translation) to a low-cost
model while keeping the strong model for parent conversations. The two
models can be from different providers (e.g. Gemini + Haiku). If
SIMPLE_MODEL is unset, falls back to AI_MODEL with a warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:18:28 +01:00
Claude 14fde88c89 replace static translation files with LLM-based i18n
Instead of maintaining a YAML file per language, German (de.yaml) is the
single source of truth. For any other language, the label strings are
translated on demand via an LLM call and cached in memory — no static
files to maintain, any language the parent writes in is served
automatically.

- Add src/notifications/i18n.py: get_strings(), _translate() via
  litellm.completion, in-memory cache, clear_cache() for tests
- Passthrough keys (reg_fee_amount, deposit_amount) are never sent to
  the LLM so currency amounts are guaranteed to be unchanged
- Falls back to German silently if the LLM call fails
- Remove src/notifications/i18n/en.yaml (no longer needed)
- Remove load_strings() from context.py (moved to i18n.py)
- Add model parameter to AdminNotifier (defaults to claude-haiku)
- Add TestGetStrings suite covering: no LLM for German, LLM called for
  others, caching, fallback, passthrough key preservation
- Add autouse reset_translation_cache fixture to isolate tests

https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna
2026-02-23 16:08:27 +00:00
Claude f1d0ad045c simplify calculate_monthly_fee to linear formula
Rates are CHF 130/indoor day and CHF 250/outdoor day — exactly linear.
Replace the if/elif table with indoor_days * 130 + outdoor_days * 250.

https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna
2026-02-23 15:06:47 +00:00
Claude 54d88d1d64 refactor notifications to MVC: extract templates, context, and renderer
- Move all email layout to Jinja2 templates (4 files: admin_new,
  admin_update, parent_confirmation html+txt)
- Extract pure helper/context-builder functions to context.py
  (format_types, calculate_age, format_dob, calculate_monthly_fee,
  format_days, build_admin_new_context, build_admin_update_context,
  build_parent_context)
- Move i18n label strings out of Python into YAML locale files
  (i18n/de.yaml, i18n/en.yaml) — admin-editable without code changes
- Add renderer.py as thin Jinja2 wrapper (render_template)
- Reduce notifier.py to routing + SMTP dispatch only
- Update tests to call context functions directly and render
  templates via renderer instead of calling private builder methods
- Add jinja2 and pyyaml as explicit dependencies in pyproject.toml

https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna
2026-02-23 10:05:03 +00:00
Claude b2e04fa07b implement registration-confirmation-email
- Add qrbill and pillow dependencies for Swiss QR-bill PNG generation
- Add _STRINGS_DE / _STRINGS_EN bilingual string tables to AdminNotifier
- Add _generate_qr_bill_png(): Swiss QR code with cross overlay (PNG bytes)
- Add _build_parent_html(): HTML confirmation with inline cid:qrbill image
- Add _build_parent_text(): plain-text fallback with IBAN in full
- Add notify_parent(): multipart/mixed MIME email to parent on completion
- Persist metadata.language in _build_record() in json_store.py
- Wire notify_parent() into src/agent/core.py and chat_app.py completion events
- Add 9 tests for notify_parent, _generate_qr_bill_png, language fallback
- Update tasks.md: tasks 1–5 complete; task 6 (smoke test) remains manual

112 tests passing.

https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna
2026-02-22 21:23:32 +00:00
Claude 1632433b6a add specs and tasks for registration-confirmation-email
specs/registration-notifications/spec.md:
- MODIFIED: parent confirmation email sent on completion (alongside admin)
- Bilingual body (de/en), QR-bill embedded inline, plain-text fallback
- Language persisted as metadata.language in registration record

tasks.md:
- 6 sections: qrbill dep, language persistence, notify_parent() impl,
  wire into both completion sites (email agent + chat), tests, smoke test

https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna
2026-02-22 21:05:32 +00:00
Claude 9981a9121e update design: match confirmation email language to parent's language
- Narrow the Non-Goal to just the QR-bill slip labels (SIX standard),
  not the surrounding email body
- Add language field to RegistrationData via new metadata dataclass,
  aligned with registration-schema.json metadata object
- Confirmation email body rendered in detected language (de/en)
- Two template dicts in notifier; admin notifications stay German-only
- Remove language-mismatch risk entry; replace with detection-accuracy note

https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna
2026-02-22 20:58:17 +00:00
Claude 42a01a011e add design for registration-confirmation-email
Covers: notify_parent() on AdminNotifier, multipart HTML email with
inline Swiss QR-bill (qrbill library), CHF 80 fixed payment data,
German default language, best-effort error handling.

https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna
2026-02-22 20:49:43 +00:00
Claude e8cf170007 add proposal for registration-confirmation-email
Parents currently receive no confirmation after submitting a registration.
This change adds an HTML confirmation email with full registration summary,
payment instructions, and a Swiss QR-bill for the CHF 80 registration fee.

https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna
2026-02-22 20:46:24 +00:00
Claude c6fd7497aa scaffold registration-confirmation-email change
Creates the OpenSpec change directory for sending HTML confirmation
emails to parents on registration completion, including payment
instructions and Swiss QR-bill code.

https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna
2026-02-22 20:44:12 +00:00
Markus GrafandGitHub 43ec87e151 Merge pull request #5 from gurix/claude/implement-web-chat-ObR0B
chore: scaffold implement-web-chat OpenSpec change
2026-02-22 21:33:33 +01:00
Claude 1bd11e6acd feat: inject today's date into system prompt and add extended thinking support
Fixes age validation errors caused by the LLM not knowing the current date.

Changes:
- prompts.py: inject date.today() at the top of both system prompts so the
  LLM can accurately calculate a child's age from their date of birth
- llm.py: add optional thinking_budget parameter to complete(); when set,
  passes thinking={"type": "enabled", "budget_tokens": N} to litellm and
  raises max_tokens to thinking_budget + 4096 (Anthropic models only)
- config.py: add thinking_budget field, read from THINKING_BUDGET env var
- .env.example: document the THINKING_BUDGET option
- core.py: pass thinking_budget through to llm.complete()
- main.py: pass thinking_budget when constructing EmailAgent
- chat_app.py: switch from stream_complete to asyncio.to_thread(complete)
  so extended thinking works and so only the reply field is shown to
  the parent (not the raw JSON wrapper)

To enable extended thinking set THINKING_BUDGET=8000 in .env.

https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
2026-02-22 19:57:32 +00:00
gurixandClaude Sonnet 4.6 13cb35111d fix(chat): simplify user label to German; expose host via env var
- Change user message author label from "Du / You" to "Du" (German-first)
- Add CHAINLIT_HOST env var to .env.example so the server listens on all
  interfaces and is reachable from outside localhost

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 20:45:33 +01:00
Claude 72189d2b7b fix(chat): use native async LLM call to prevent session reset on message submit
The previous implementation used asyncio.to_thread(llm.complete) to avoid
blocking the event loop, but Chainlit's contextvars context is not reliably
propagated across thread boundaries, causing the session to reset and clear
the message history on each user submission.

Changes:
- Add llm.acomplete() using litellm.acompletion() (native coroutine)
- Replace asyncio.to_thread() in on_message with await llm.acomplete()
- Store the welcome message in state.messages so it is replayed on reconnect
- Persist state to cl.user_session immediately after appending the user's
  message (before the LLM call) so reconnect detection has the latest history
- Add pytest-asyncio dev dependency and asyncio_mode = "auto" config
- Add 6 async tests for acomplete() in tests/test_llm.py

https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
2026-02-22 12:38:20 +00:00
Claude 981948c106 fix(chat): restore message history on reconnect; fix event loop blocking
Two related issues caused the screen to clear after each answer:

1. Blocking event loop: the synchronous llm.stream_complete() for-loop
   was running directly in the async on_message handler, blocking the
   event loop for the full LLM response duration. This caused the
   WebSocket to time out and Chainlit to reconnect after each message.

   Fix: replace stream_complete() with asyncio.to_thread(llm.complete)
   so the network-bound LLM call runs in a thread pool and the event
   loop (and WebSocket) stay alive throughout.

2. Reconnect resets history: on_chat_start always created a fresh empty
   state and sent the welcome message, even on WebSocket reconnections
   where cl.user_session still held the existing conversation.

   Fix: if cl.user_session["state"] is already present, replay the
   stored message history into the new thread instead of starting fresh.

https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
2026-02-22 08:30:52 +00:00
Claude 808be0185f fix(chat): show only reply text to user, not raw JSON
The LLM returns a structured JSON object. Previously, raw tokens were
streamed directly to the user via msg.stream_token(), causing the full
JSON blob to appear in the chat.

Fix: collect all chunks silently, parse the JSON, then send only the
reply field with cl.Message(content=reply_text).send(). The JSON fields
(updates, next_step, registration_complete, language, intent) are still
processed in the background — parents never see them.

https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
2026-02-22 08:16:12 +00:00
Claude e4b4cdd41e docs(readme): document web chat setup and running
Add dedicated "Web chat" section under Running with:
- chainlit run command and port/host flags
- Minimum required env vars for chat-only deployments
  (no IMAP needed; only AI model + SMTP + admin emails)
- "Running both channels together" example with two terminals
- Note that both channels share DATA_DIR and notification config

https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
2026-02-22 08:09:37 +00:00
Claude e204e29b09 chore: add .chainlit/ to .gitignore
Chainlit auto-generates this directory at runtime (translations, config).
It should not be tracked in version control.

https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
2026-02-22 07:43:02 +00:00
Claude 9fdbe341be feat(chat): implement web chat interface with accessibility
Core implementation:
- chat_app.py: Chainlit entry point with @cl.on_chat_start,
  @cl.on_message (streaming via llm.stream_complete), @cl.on_chat_end
  Reuses Config, KnowledgeBase, ConversationStore, AdminNotifier from src/
  Handles registration completion, post-completion updates, new-child flow

- src/llm.py: add stream_complete() generator (litellm stream=True)
  alongside existing complete(); tests added in tests/test_llm.py

- src/agent/response_parser.py: extract parse_llm_response(),
  apply_updates(), fallback_message() from EmailAgent into shared module
  EmailAgent now delegates to these functions (no logic change)

Chainlit configuration:
- chainlit.toml: telemetry off, German default, custom CSS + JS paths
- chainlit.md: German welcome page with playgroup info

Accessibility (WCAG 2.1 AA):
- public/custom.css: contrast overrides (≥4.5:1), prefers-reduced-motion
  (static "…" replaces animated dots), skip link styles, 100dvh fix
- public/accessibility.js: MutationObserver injects aria-live="polite"
  on message list, focus management after agent replies, skip link element

Other:
- .gitignore: add .chainlit/ (Chainlit runtime, auto-generated)
- openspec/config.yaml: populate context field with tech stack
- openspec/changes/implement-web-chat/tasks.md: mark completed tasks

95 tests pass.

https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
2026-02-22 07:42:29 +00:00
Claude 08bee013f9 docs(opsx/implement-web-chat): revise tasks to match existing ecosystem
Key corrections from codebase analysis:
- Use `uv add chainlit` (not requirements.txt) — project already uses uv+pyproject.toml
- Remove .env.example task — file already exists with full config
- Add task to extend src/llm.py with stream_complete() using litellm streaming
  instead of calling Anthropic SDK directly
- Reuse existing src/ modules directly in chat_app.py:
  Config, KnowledgeBase, ConversationStore, AdminNotifier, prompts.py
- chat_app.py lives at project root (parallel to main.py), not in a new app/ dir
- Parse LLM response with existing _parse_llm_response logic from core.py
- Session state stored in cl.user_session, keyed by Chainlit session ID

https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
2026-02-22 07:20:18 +00:00
Claude da0e6823f0 docs(openspec): add tasks for implement-web-chat change
35 implementation tasks across 9 groups:
1. Project setup (requirements.txt, .env, chainlit.toml, dirs)
2. Chainlit app shell (on_chat_start, on_message, welcome message)
3. Accessibility CSS (contrast overrides, reduced-motion, skip link, dvh)
4. Agent core stub (process_message, session state wiring)
5. ARIA and focus management (live region, post-reply focus move)
6. Session management (refresh persistence, disconnect message)
7. Mobile testing (iOS Safari, Android Chrome, landscape)
8. Accessibility verification (axe-core, keyboard, VoiceOver, motion)
9. Final integration check

https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
2026-02-22 05:21:23 +00:00
Claude b0ebd40302 docs(openspec): add chat-interface spec for implement-web-chat change
MODIFIED spec extending the scoping-phase chat-interface requirements with:
- WCAG 2.1 AA compliance (automated + manual screen reader)
- Full keyboard navigation and no keyboard traps
- Skip-to-content link
- ARIA live regions for agent messages (polite, completed only)
- Focus management after agent reply
- Colour contrast ≥ 4.5:1 (normal text), ≥ 3:1 (large text)
- prefers-reduced-motion: static indicator fallback
- Mobile viewport / virtual keyboard visibility
- Session persistence and graceful disconnect notification
- Chainlit implementation constraints and telemetry-off requirement

https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
2026-02-22 05:18:43 +00:00
Claude cb78b90332 docs(openspec): add design for implement-web-chat change
Selects Chainlit as the chat UI library (AI-native, Python, handles
WebSocket/streaming/session out of the box). Establishes Python as the
project language. Documents accessibility gap mitigations (ARIA live
regions, focus management, reduced-motion, contrast overrides). Defines
project layout and Chainlit configuration.

https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
2026-02-22 05:15:25 +00:00
Claude eb2b69557e docs(openspec): add proposal for implement-web-chat change
Defines why the web chat is being built now, what changes (chat-interface
capability moving from spec to implementation), and adds accessibility as
a first-class requirement (WCAG 2.1 AA, keyboard nav, ARIA live regions).

https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
2026-02-22 05:12:17 +00:00
Claude 3b32652d3e chore: scaffold implement-web-chat OpenSpec change
Creates the change directory for the web chat implementation with
.openspec.yaml metadata. First artifact (proposal.md) is pending.

https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
2026-02-22 05:10:29 +00:00
Markus GrafandGitHub bab0e5fe5e Merge pull request #4 from gurix/claude/clarify-api-key-docs-McxCw
Update documentation for multi-language support and API keys
2026-02-21 23:14:29 +01:00
Claude 905debb48e Clarify API key docs and language support in README
- Remove ANTHROPIC_API_KEY and OPENAI_API_KEY from the Required
  variables table; they are provider-specific, not universally
  required. Add a note pointing readers to the Switching AI providers
  section instead.
- Replace "Supports German and English; defaults to German" with
  "Responds in any language the parent uses; defaults to German" to
  accurately reflect that the agent is fully language-agnostic.

https://claude.ai/code/session_01F9RoUQYKktPrmsvemSYrPk
2026-02-21 22:13:33 +00:00
Markus GrafandGitHub 2fac05c4ee Merge pull request #2 from gurix/claude/email-agent-multi-model-c3ShZ
Implement Python email agent with multi-model AI support
2026-02-21 23:06:40 +01:00
52 changed files with 5660 additions and 299 deletions
+22
View File
@@ -0,0 +1,22 @@
# Secrets — never bake into the image
.env
# Version control
.git/
.gitignore
# Python build artefacts
__pycache__/
*.pyc
*.pyo
.pytest_cache/
# Tests — not needed at runtime
tests/
# Persistent data — mounted as a volume at runtime
data/
# Tool configs — not needed in the container
.claude/
.gemini/
+24 -5
View File
@@ -6,14 +6,27 @@
# --------------------------------------------------------------- # ---------------------------------------------------------------
# --------------------------------------------------------------- # ---------------------------------------------------------------
# AI Model (via litellm — supports any provider) # AI Models (via litellm — supports any provider)
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Use litellm model strings: "<provider>/<model-name>" # Use litellm model strings: "<provider>/<model-name>"
# Examples: #
# anthropic/claude-opus-4-6 (default) # AI_MODEL — primary model for conversation with parents (complex reasoning).
# openai/gpt-4o # SIMPLE_MODEL — lightweight model for simple tasks, e.g. email-label translation.
# gemini/gemini-2.0-flash # Can be from a different provider than AI_MODEL.
# If unset, falls back to AI_MODEL with a warning in the logs.
#
# Examples — mixing providers:
# AI_MODEL=gemini/gemini-3-pro-preview + SIMPLE_MODEL=gemini/gemini-3-flash-preview
# AI_MODEL=anthropic/claude-opus-4-6 + SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
# AI_MODEL=gemini/gemini-3-pro-preview + SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
AI_MODEL=anthropic/claude-opus-4-6 AI_MODEL=anthropic/claude-opus-4-6
SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
# Extended thinking — Anthropic models only (leave unset to disable).
# Enables a reasoning phase before the model's reply, which improves
# accuracy on age calculations, logic-heavy questions, and edge cases.
# Recommended value: 8000 (tokens). Must be less than max_tokens.
# THINKING_BUDGET=8000
# --------------------------------------------------------------- # ---------------------------------------------------------------
# API Keys — set the one matching your chosen model's provider # API Keys — set the one matching your chosen model's provider
@@ -67,3 +80,9 @@ KNOWLEDGE_BASE_DIR=openspec/changes/define-project-scope/content/knowledge-base
# --------------------------------------------------------------- # ---------------------------------------------------------------
# How often (in seconds) to check the inbox for new messages. # How often (in seconds) to check the inbox for new messages.
POLL_INTERVAL=60 POLL_INTERVAL=60
# ---------------------------------------------------------------
# Web chat server
# ---------------------------------------------------------------
# Listen on all interfaces so the app is reachable from outside localhost.
CHAINLIT_HOST=0.0.0.0
+3
View File
@@ -20,6 +20,9 @@ env/
# Agent data (conversations and registrations stored at runtime) # Agent data (conversations and registrations stored at runtime)
data/ data/
# Chainlit runtime (auto-generated; not authored)
.chainlit/
# IDE # IDE
.idea/ .idea/
.vscode/ .vscode/
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.13-slim
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
# Install Python dependencies — cached independently of application code
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
# Copy application source
COPY src/ ./src/
COPY chat_app.py main.py chainlit.toml ./
COPY public/ ./public/
# Make the venv's binaries (chainlit, python, etc.) available directly
ENV PATH="/app/.venv/bin:$PATH"
# data/ and openspec/ are expected to be mounted at runtime
+100 -16
View File
@@ -10,7 +10,7 @@ Replaces a static Google Forms workflow with an AI agent that guides parents thr
- Answers questions about fees, schedule, and policies from a curated knowledge base - Answers questions about fees, schedule, and policies from a curated knowledge base
- Validates and stores completed registrations as structured data - Validates and stores completed registrations as structured data
- Notifies playgroup administrators on completion, routed by playgroup type - Notifies playgroup administrators on completion, routed by playgroup type
- Supports German and English; defaults to German - Responds in any language the parent uses; defaults to German
## Channels ## Channels
@@ -44,50 +44,91 @@ cp .env.example .env
| Variable | Description | | Variable | Description |
|---|---| |---|---|
| `AI_MODEL` | litellm model string, e.g. `anthropic/claude-opus-4-6` or `openai/gpt-4o` | | `AI_MODEL` | Primary model (litellm string), e.g. `anthropic/claude-opus-4-6` |
| `ANTHROPIC_API_KEY` | API key for Anthropic models |
| `OPENAI_API_KEY` | API key for OpenAI models (if using OpenAI) |
| `IMAP_HOST` | IMAP server hostname for receiving parent emails | | `IMAP_HOST` | IMAP server hostname for receiving parent emails |
| `IMAP_USERNAME` | Email account username | | `IMAP_USERNAME` | Email account username |
| `IMAP_PASSWORD` | Email account password | | `IMAP_PASSWORD` | Email account password |
| `SMTP_HOST` | SMTP server hostname for sending replies | | `SMTP_HOST` | SMTP server hostname for sending replies |
| `REGISTRATION_EMAIL` | Sender address shown to parents | | `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 ### Optional variables
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `SIMPLE_MODEL` | _(falls back to `AI_MODEL`)_ | Lightweight model for simple tasks (e.g. email-label translation). Can be from a different provider. Logs a warning if unset. |
| `THINKING_BUDGET` | _(disabled)_ | Token budget for extended thinking — Anthropic models only. Recommended: `8000`. |
| `IMAP_PORT` | `993` | IMAP port | | `IMAP_PORT` | `993` | IMAP port |
| `IMAP_USE_SSL` | `true` | Use SSL for IMAP | | `IMAP_USE_SSL` | `true` | Use SSL for IMAP |
| `SMTP_PORT` | `587` | SMTP port | | `SMTP_PORT` | `587` | SMTP port |
| `SMTP_USE_TLS` | `true` | Use STARTTLS for SMTP | | `SMTP_USE_TLS` | `true` | Use STARTTLS for SMTP |
| `CHAINLIT_HOST` | `localhost` | Host the web chat binds to. Set to `0.0.0.0` to expose externally. |
| `DATA_DIR` | `data/` | Directory for conversation state and completed registrations | | `DATA_DIR` | `data/` | Directory for conversation state and completed registrations |
| `KNOWLEDGE_BASE_DIR` | `openspec/…/knowledge-base` | Path to admin-editable knowledge base markdown files | | `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) | | `POLL_INTERVAL` | `60` | Seconds between inbox polls (only used when running as a daemon) |
### Switching AI providers ### Switching AI providers
`AI_MODEL` uses [litellm](https://docs.litellm.ai/docs/providers) model strings — any supported provider works without code changes: Both `AI_MODEL` and `SIMPLE_MODEL` use [litellm](https://docs.litellm.ai/docs/providers) model strings — any supported provider works without code changes. The two models can be from different providers:
```bash ```bash
# Anthropic (default) # Anthropic for both (default)
AI_MODEL=anthropic/claude-opus-4-6 AI_MODEL=anthropic/claude-opus-4-6
SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_API_KEY=sk-ant-...
# OpenAI # Google Gemini for both
AI_MODEL=openai/gpt-4o AI_MODEL=gemini/gemini-3-pro-preview
OPENAI_API_KEY=sk-... SIMPLE_MODEL=gemini/gemini-3-flash-preview
# Google Gemini
AI_MODEL=gemini/gemini-2.0-flash
GEMINI_API_KEY=... GEMINI_API_KEY=...
# Mixed providers
AI_MODEL=gemini/gemini-3-pro-preview
SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
GEMINI_API_KEY=...
ANTHROPIC_API_KEY=sk-ant-...
``` ```
## Running ## Running
### As a cron job (recommended) ### Web chat
The agent is a plain script — no long-running daemon needed. Schedule it with cron and use `flock` to prevent overlapping runs: Start the web chat interface:
```bash
uv run chainlit run chat_app.py
```
The chat opens at **http://localhost:8000** by default.
To listen on a different port or host:
```bash
uv run chainlit run chat_app.py --port 8080 --host 0.0.0.0
```
**Minimum required env vars for the web chat:**
| Variable | Description |
|---|---|
| `AI_MODEL` | litellm model string, e.g. `anthropic/claude-opus-4-6` |
| `ANTHROPIC_API_KEY` | (or the key for your chosen provider) |
| `SMTP_HOST` / `SMTP_PORT` | For admin notification emails on registration completion |
| `IMAP_USERNAME` / `IMAP_PASSWORD` | Used as SMTP credentials |
| `ADMIN_EMAIL_INDOOR` | Andrea Sigrist — notified when indoor group is booked |
| `ADMIN_EMAIL_OUTDOOR` | Barbara Gross — notified when outdoor group is booked |
| `ADMIN_EMAIL_CC` | Markus Graf — always CC'd on notifications |
IMAP variables (`IMAP_HOST`, etc.) are not required for the web chat — only for the email channel.
### Email channel
The email agent polls an IMAP inbox and replies via SMTP. No web server required.
**As a cron job (recommended)**
Schedule with cron and use `flock` to prevent overlapping runs:
```cron ```cron
*/5 * * * * flock -n /tmp/meister-eder-email.lock uv run python main.py */5 * * * * flock -n /tmp/meister-eder-email.lock uv run python main.py
@@ -95,12 +136,55 @@ The agent is a plain script — no long-running daemon needed. Schedule it with
`flock -n` exits immediately if a previous run is still in progress, so the script is always safe to schedule aggressively. `flock -n` exits immediately if a previous run is still in progress, so the script is always safe to schedule aggressively.
### Manually **Manually**
```bash ```bash
uv run python main.py uv run python main.py
``` ```
### Running both channels together
The web chat and email agent are independent processes — run them side by side:
```bash
# Terminal 1 — web chat
uv run chainlit run chat_app.py
# Terminal 2 — email polling
uv run python main.py
```
Completed registrations from both channels are stored in the same `DATA_DIR` (default: `data/`) and share the same admin notification configuration.
### Docker Compose (recommended for production)
A `docker-compose.yml` is provided that runs both services together with shared persistent storage:
```bash
cp .env.example .env
# fill in .env, then:
docker compose up -d
```
| Service | What it runs |
|---|---|
| `web` | Chainlit web chat at `http://localhost:8000` |
| `email-worker` | Email polling agent (`main.py`) |
Both services mount `./data` for shared registration storage and `./openspec` (read-only) for the knowledge base. Restarting a service does not lose conversation state.
To view logs:
```bash
docker compose logs -f
```
To rebuild after a code change:
```bash
docker compose up -d --build
```
## Development ## Development
### Running tests ### Running tests
@@ -117,4 +201,4 @@ The agent answers parent questions from markdown files in the knowledge base dir
### Adding a new AI provider ### 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. Set `AI_MODEL` (and optionally `SIMPLE_MODEL`) to any [litellm-supported model string](https://docs.litellm.ai/docs/providers) and set the corresponding API key environment variable. No code changes required.
+27
View File
@@ -0,0 +1,27 @@
# Spielgruppe Pumuckl
Willkommen beim Anmeldeassistenten der **Spielgruppe Pumuckl** (Familienverein Fällanden).
Ich helfe dir, dein Kind für die Spielgruppe anzumelden — schnell und unkompliziert per Chat.
## Was ich tun kann
- **Anmeldung**: Ich führe dich Schritt für Schritt durch die Anmeldung
- **Fragen beantworten**: Preise, Zeiten, Reglement — frag einfach
- **Deutsch oder jede andere Sprache**: Schreib in der Sprache, die dir lieber ist
## Spielgruppen
| | Innenspielgruppe | Waldspielgruppe |
|---|---|---|
| **Tage** | Mo / Mi / Do | Mo |
| **Zeit** | 09:0011:30 | 09:0014:00 |
| **Alter** | ab 2.5 Jahren | ab 3 Jahren |
## Kontakt
Bei Fragen zum Chat oder zur Anmeldung: **spielgruppen@familien-verein.ch**
---
*Für längere Pausen empfehlen wir die Anmeldung per E-Mail, da der Chat-Verlauf nur für die aktuelle Browser-Sitzung gespeichert wird.*
+18
View File
@@ -0,0 +1,18 @@
[project]
# Project name shown in browser title / header
name = "Spielgruppe Pumuckl"
# Never send usage data to Chainlit cloud
enable_telemetry = false
[UI]
name = "Spielgruppe Pumuckl"
# German is the default language; the agent switches automatically to English
# if the parent writes in English
default_language = "de"
# Accessibility overrides on top of the default Chainlit theme
custom_css = "/public/custom.css"
# Accessibility JS: ARIA live region, focus management, skip-to-content link
custom_js = "/public/accessibility.js"
[meta]
generated_by = "2.x"
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Meister-Eder — Web Chat Interface for Spielgruppe Pumuckl.
Usage
-----
Copy `.env.example` to `.env`, fill in your credentials, then run:
chainlit run chat_app.py
The app serves a web chat interface at http://localhost:8000.
Parents can register their child or ask questions in real time.
Environment variables (see .env.example):
AI_MODEL litellm model string (default: anthropic/claude-opus-4-6)
ANTHROPIC_API_KEY Required for Anthropic models
SMTP_HOST / SMTP_PORT For admin notifications (optional in dev)
ADMIN_EMAIL_INDOOR / ADMIN_EMAIL_OUTDOOR / ADMIN_EMAIL_CC Notification routing
DATA_DIR Directory for completed registration JSON (default: data/)
"""
import logging
import uuid
from datetime import datetime, timezone
import chainlit as cl
from src import llm
from src.agent.prompts import build_system_prompt
from src.agent.response_parser import apply_updates, fallback_message, parse_llm_response
from src.config import Config
from src.knowledge_base.loader import KnowledgeBase
from src.models.conversation import ChatMessage, ConversationState
from src.models.registration import RegistrationData
from src.notifications.notifier import AdminNotifier
from src.storage.json_store import ConversationStore, _diff_registrations
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Shared components — initialised once when the server starts.
# These are read-only after startup and safe to share across sessions.
# ---------------------------------------------------------------------------
_config = Config.from_env()
_kb = KnowledgeBase(_config.knowledge_base_dir)
_store = ConversationStore(_config.data_dir)
_notifier = AdminNotifier(
smtp_host=_config.smtp_host,
smtp_port=_config.smtp_port,
username=_config.imap_username,
password=_config.imap_password,
use_tls=_config.smtp_use_tls,
from_email=_config.registration_email,
indoor_email=_config.admin_email_indoor,
outdoor_email=_config.admin_email_outdoor,
cc_emails=[e.strip() for e in _config.admin_email_cc.split(",") if e.strip()],
model=_config.simple_model,
)
# ---------------------------------------------------------------------------
# Welcome message (German default, per spec)
# ---------------------------------------------------------------------------
_WELCOME_DE = (
"Hallo! Ich bin der Anmeldeassistent der Spielgruppe Pumuckl. "
"Ich kann dir helfen, dein Kind anzumelden, oder deine Fragen zur Spielgruppe beantworten.\n\n"
"Du kannst mir auf die Sprache schreiben die du am besten kannst — ich antworte in derselben Sprache.\n\n"
"Womit kann ich dir helfen?"
)
# ---------------------------------------------------------------------------
# Chainlit lifecycle handlers
# ---------------------------------------------------------------------------
@cl.on_chat_start
async def on_chat_start() -> None:
"""Initialise a fresh conversation state and greet the parent.
If cl.user_session already holds state (WebSocket reconnect after a
network drop), replay the existing message history so the parent sees
the full conversation rather than a blank screen.
"""
existing = cl.user_session.get("state")
if existing:
# Reconnected — restore visual history from our stored state
state = ConversationState.from_dict(existing)
logger.info(
"Session reconnected: %s (%d messages)",
state.conversation_id,
len(state.messages),
)
for msg in state.messages:
author = "Spielgruppe Pumuckl" if msg.role == "assistant" else "Du"
await cl.Message(content=msg.content, author=author).send()
return
# Brand new session
session_id = str(uuid.uuid4())
state = ConversationState(conversation_id=session_id)
# Store the welcome in history so it's replayed if the session reconnects.
state.messages.append(ChatMessage(role="assistant", content=_WELCOME_DE))
cl.user_session.set("state", state.to_dict())
logger.info("Chat session started: %s", session_id)
await cl.Message(content=_WELCOME_DE).send()
@cl.on_message
async def on_message(message: cl.Message) -> None:
"""Process one parent message and stream the agent's reply."""
# --- Restore state from session ---
state = ConversationState.from_dict(cl.user_session.get("state"))
now = datetime.now(timezone.utc).isoformat()
state.last_activity = now
# Append parent's message to history and persist immediately so that any
# WebSocket reconnect during the LLM call can replay the full conversation.
state.messages.append(ChatMessage(role="user", content=message.content))
cl.user_session.set("state", state.to_dict())
# --- Build system prompt ---
system = build_system_prompt(_kb, state)
# --- Call LLM natively async (supports extended thinking; no event-loop blocking) ---
try:
full_content = await llm.acomplete(
_config.ai_model, system, state.messages, _config.thinking_budget
)
except Exception:
logger.exception("LLM call failed for session %s", state.conversation_id)
error_text = fallback_message(state.language)
await cl.Message(content=error_text).send()
return
# --- Parse and apply LLM response ---
parsed = parse_llm_response(full_content)
reply_text: str = parsed.get("reply", full_content)
updates: dict = parsed.get("updates", {}) or {}
next_step: str = parsed.get("next_step", state.flow_step)
is_complete: bool = bool(parsed.get("registration_complete", False))
language: str = parsed.get("language", state.language)
intent: str = parsed.get("intent", "")
apply_updates(state, updates)
state.flow_step = next_step
state.language = language
state.updated_at = now
# Send the reply text to the parent (only the human-readable reply, not the JSON wrapper)
await cl.Message(content=reply_text).send()
# Append assistant reply to history
state.messages.append(ChatMessage(role="assistant", content=reply_text))
# --- Handle registration completion ---
if is_complete and not state.completed:
state.completed = True
try:
email_key, version = _store.save_registration(state)
_notifier.notify_admin(
registration=state.registration,
registration_id=email_key,
version=version,
conversation_id=state.conversation_id,
channel="chat",
)
logger.info("Registration complete for session %s", state.conversation_id)
except Exception:
logger.exception(
"Failed to save/notify for session %s", state.conversation_id
)
try:
_notifier.notify_parent(
registration=state.registration,
language=state.language,
)
except Exception:
logger.exception(
"Failed to send parent confirmation for session %s", state.conversation_id
)
# --- Handle post-completion update intent ---
if state.completed and intent == "update" and any(v is not None for v in updates.values()):
_handle_registration_update(state)
# --- Handle new-child reset ---
if state.completed and intent == "new_child":
state.registration = RegistrationData()
state.completed = False
state.flow_step = "child_name"
logger.info("New child registration started for session %s", state.conversation_id)
# --- Persist updated state ---
cl.user_session.set("state", state.to_dict())
@cl.on_chat_end
async def on_chat_end() -> None:
"""Log session end. Hook for future email-reminder integration."""
state_dict = cl.user_session.get("state")
if state_dict:
conversation_id = state_dict.get("conversation_id", "unknown")
completed = state_dict.get("completed", False)
logger.info(
"Chat session ended: %s (completed=%s)", conversation_id, completed
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _handle_registration_update(state: ConversationState) -> None:
"""Version the registration record and notify admin of changes."""
# Re-apply and diff from the stored current version
current = _store.get_current_registration(state.conversation_id)
if current is None:
return
change_summary = _diff_registrations(current, state.registration.to_dict())
if not change_summary:
return
try:
email_key, version = _store.save_registration_version(state, change_summary)
_notifier.notify_registration_update(
registration=state.registration,
registration_id=email_key,
version=version,
change_summary=change_summary,
conversation_id=state.conversation_id,
)
logger.info(
"Registration updated to v%d for session %s", version, state.conversation_id
)
except Exception:
logger.exception(
"Failed to save update for session %s", state.conversation_id
)
+26
View File
@@ -0,0 +1,26 @@
services:
# Web chat interface — Chainlit served at http://localhost:8000
web:
build: .
command: chainlit run chat_app.py --host 0.0.0.0 --port 8000
ports:
- "8000:8000"
env_file: .env
volumes:
# Persistent storage for conversations and completed registrations
- ./data:/app/data
# Knowledge-base markdown files — edit on the host, no rebuild needed
- ./openspec:/app/openspec:ro
restart: unless-stopped
# Email polling agent — checks the inbox every POLL_INTERVAL seconds
email-worker:
build: .
command: python main.py
env_file: .env
volumes:
# Shares the same data directory as the web service
- ./data:/app/data
- ./openspec:/app/openspec:ro
restart: unless-stopped
+29 -1
View File
@@ -61,9 +61,16 @@ def build_components(config: Config):
indoor_email=config.admin_email_indoor, indoor_email=config.admin_email_indoor,
outdoor_email=config.admin_email_outdoor, outdoor_email=config.admin_email_outdoor,
cc_emails=[e.strip() for e in config.admin_email_cc.split(",") if e.strip()], cc_emails=[e.strip() for e in config.admin_email_cc.split(",") if e.strip()],
model=config.simple_model,
) )
agent = EmailAgent(model=config.ai_model, kb=kb, store=store, notifier=notifier) agent = EmailAgent(
model=config.ai_model,
kb=kb,
store=store,
notifier=notifier,
thinking_budget=config.thinking_budget,
)
channel = EmailChannel( channel = EmailChannel(
imap_host=config.imap_host, imap_host=config.imap_host,
@@ -91,6 +98,27 @@ def run_poll_loop(agent: EmailAgent, channel: EmailChannel, poll_interval: int)
for msg in messages: for msg in messages:
logger.info("Processing message from %s", msg["from"]) logger.info("Processing message from %s", msg["from"])
try: try:
# ----------------------------------------------------------
# Bounce / automated-sender guard
# If the channel layer flagged this as an automated message
# (bounce, out-of-office, delivery failure, …) we must NOT
# reply — that would create or worsen an email loop.
# Instead, alert the admin once and drop the message.
# ----------------------------------------------------------
if msg.get("is_automated"):
logger.warning(
"Automated/bounce message from %s — reason: %s — not replying",
msg["from"],
msg.get("automated_reason", "unknown"),
)
agent.handle_automated_message(
sender_email=msg["from"],
subject=msg["subject"],
reason=msg.get("automated_reason", "automated sender detected"),
inbound_message_id=msg["message_id"],
)
continue
# Prepend email headers so the LLM can extract the # Prepend email headers so the LLM can extract the
# sender's address and subject (e.g. to fill in # sender's address and subject (e.g. to fill in
# parentGuardian.email automatically). # parentGuardian.email automatically).
@@ -0,0 +1,76 @@
## Context
The email poll loop (`main.py`) fetches all unread messages and passes each one to `EmailAgent.process_message()`, which calls the LLM and returns a reply. The reply is then sent via SMTP. There was no check to determine whether the inbound message came from a human or an automated system. Any message that arrived in the inbox — including MAILER-DAEMON bounces triggered by the agent's own previous reply — was processed and replied to, completing the loop.
## Goals / Non-Goals
**Goals:**
- Prevent the agent from replying to automated/bounce messages
- Alert admin once when an automated loop is detected
- Enforce a hard upper bound on conversation length as a secondary safety net
- Persist escalation state so alerts are not repeated across poll cycles
**Non-Goals:**
- General spam detection
- Blocking specific sender addresses permanently
- Exposing loop-detection configuration via the admin UI
## Decisions
### 1. Two-layer defence
**Decision**: Implement two independent checks in sequence:
1. Header-based automated sender detection (catches known patterns immediately)
2. Message-count cap (catches anything that slips through layer 1)
**Rationale**: Neither layer is infallible alone. Header-based detection covers RFC-standard signals and common patterns, but edge cases exist (e.g. a forwarding alias that strips headers). The count cap is a last-resort guarantee that no conversation runs forever.
### 2. Detection at the channel layer, handling in the agent
**Decision**: `email_channel.py` performs the header analysis and adds `is_automated` / `automated_reason` to the message dict. `main.py` checks the flag and calls `agent.handle_automated_message()` instead of `agent.process_message()`.
**Rationale**: The channel layer already has access to the raw `email.message.Message` object with all headers. The agent layer has access to conversation state and the notifier. Splitting cleanly at the channel/agent boundary keeps each layer doing what it does best without coupling them further.
**Alternative considered**: Detecting in the agent by inspecting the message text. Rejected — by that point the raw headers are gone, and text-based detection is less reliable than header-based.
### 3. Detection signals (in priority order)
| Signal | Standard | Reliability |
|---|---|---|
| Sender local-part: `mailer-daemon`, `postmaster`, `noreply`, `no-reply`, `bounce`, … | RFC 5321 §4.5.4 | Very high |
| `Auto-Submitted:``no` | RFC 3834 | Very high |
| `X-Auto-Response-Suppress:` present | MS Exchange | Very high |
| `Content-Type: multipart/report` | RFC 3462 | Very high |
| `X-Loop:` present | MTA convention | High |
| `Precedence: bulk` or `junk` | Common practice | Medium |
| Subject heuristics (Undelivered Mail, Out of Office, Abwesenheitsnotiz, …) | — | Medium |
`Precedence: list` is intentionally excluded — mailing-list messages may be legitimate.
### 4. Message-count cap set at 20
**Decision**: `MAX_USER_MESSAGES = 20`. If `process_message()` is called when there are already more than 20 user messages in the history, return `""` (no reply) and escalate to admin.
**Rationale**: A typical registration takes 812 exchanges. 20 gives ample room for slow or verbose conversations while still catching runaway loops. The value is a module-level constant so it can be changed without config infrastructure overhead.
### 5. One-shot admin alert via `loop_escalated` flag
**Decision**: Add `loop_escalated: bool` to `ConversationState`. The admin is notified exactly once per conversation. Subsequent automated messages or over-limit polls are silently dropped after the flag is set.
**Rationale**: The admin needs to know something is wrong, but receiving one alert per bounce (which may arrive many times per minute) would create inbox spam worse than the original problem.
**Implementation**: The flag is persisted to JSON so it survives agent restarts.
### 6. Admin notification routed to CC list
**Decision**: Loop-escalation alerts go to `self._cc_emails` (Markus Graf / `ADMIN_EMAIL_CC`), not to playgroup leaders.
**Rationale**: This is a system/infrastructure issue, not a registration event. The CC address is the designated admin (Markus Graf) who handles operational issues. Playgroup leaders do not need to see these alerts.
## Risks / Trade-offs
**False positives** → A legitimate parent using a `noreply@` alias could be silently blocked. This is an unlikely edge case; the subject/header checks require multiple signals for ambiguous senders. A missed registration is recoverable — admin gets the alert and can follow up manually.
**False negatives** → A clever loop that uses a normal-looking sender address and no automated headers would slip past layer 1. The 20-message cap catches it.
**Completed conversations** → The count cap applies to all conversations, including completed ones with many post-completion Q&A exchanges. A very chatty parent could theoretically hit the cap after registration is done. Acceptable for MVP — the cap is high enough that normal use is unaffected.
@@ -0,0 +1,30 @@
## Why
The email channel had no protection against automated message loops. When the agent sent a reply that bounced (e.g. due to an invalid recipient address or a misconfigured mail server), the bounce message arrived back in the inbox. The agent treated it as a new inbound message, generated another reply, which bounced again — creating an infinite loop.
A real incident demonstrated this: a `MAILER-DAEMON@tacitus2.sui-inter.net` bounce began accumulating replies indefinitely, consuming LLM quota and filling the inbox with noise while the agent remained unaware it was talking to an automated system.
Beyond bounce loops, any automated sender — out-of-office replies, mailing-list software, delivery status notifications — can trigger this pattern if the agent replies to them. The system needs to recognise non-human senders and refuse to reply.
A secondary risk: a legitimate but very slow conversation (or a forwarding-alias loop that bypasses simple bounce detection) could still accumulate messages indefinitely. A hard cap on conversation length provides a safety net.
## What Changes
- **Detect automated/bounce senders** before the agent replies — inspect email headers and sender address patterns to identify non-human messages
- **Skip sending any reply** to automated messages — silence breaks the loop
- **Alert the admin once** when an automated sender is detected, so a human can investigate
- **Cap conversation length** at 20 inbound messages — if a conversation has not completed after 20 user messages, stop responding and alert the admin
- **Track escalation state** per conversation so admin alerts fire at most once
### Non-Goals
- Spam filtering (automated detection is specific to loop-causing patterns, not general spam)
- Automatic unsubscribe/block of senders
- Forwarding the original problem email to the admin (admin receives only a warning notification)
## Capabilities
### Modified Capabilities
- `email-channel`: Add automated/bounce sender detection; skip replies for flagged messages
- `registration-notifications`: Add loop-escalation alert type sent to admin CC address
@@ -0,0 +1,54 @@
## ADDED Requirements
### Requirement: Automated sender detection
The system SHALL detect whether an inbound email was sent by an automated system rather than a human, before the message is processed by the agent.
#### Scenario: MAILER-DAEMON sender
- **WHEN** an email arrives with a sender local-part of `mailer-daemon`, `postmaster`, `noreply`, `no-reply`, `donotreply`, or `bounce` (case-insensitive)
- **THEN** the system SHALL flag the message as automated with a reason string identifying the sender pattern
#### Scenario: RFC 3834 Auto-Submitted header
- **WHEN** an email contains an `Auto-Submitted` header with any value other than `no`
- **THEN** the system SHALL flag the message as automated, citing the header value in the reason
#### Scenario: Auto-Submitted: no is not automated
- **WHEN** an email contains `Auto-Submitted: no`
- **THEN** the system SHALL NOT flag the message as automated based on this header
#### Scenario: Microsoft Exchange auto-reply suppression
- **WHEN** an email contains an `X-Auto-Response-Suppress` header (any value)
- **THEN** the system SHALL flag the message as automated
#### Scenario: Delivery Status Notification (RFC 3462)
- **WHEN** an email has `Content-Type: multipart/report`
- **THEN** the system SHALL flag the message as automated, as this indicates a machine-generated delivery status or read receipt
#### Scenario: X-Loop header
- **WHEN** an email contains an `X-Loop` header (any value)
- **THEN** the system SHALL flag the message as automated
#### Scenario: Bulk or junk precedence
- **WHEN** an email has a `Precedence` header with value `bulk` or `junk`
- **THEN** the system SHALL flag the message as automated
#### Scenario: Bounce / OOO subject line
- **WHEN** an email subject matches patterns indicating delivery failure or automated response (e.g. "Undelivered Mail", "Mail Delivery Failed", "Out of Office", "Abwesenheitsnotiz", "Automatische Antwort")
- **THEN** the system SHALL flag the message as automated
#### Scenario: Normal parent message
- **WHEN** an email has a normal human sender address and no automated-sender headers
- **THEN** the system SHALL NOT flag the message as automated
### Requirement: Automated messages are never replied to
The system SHALL NOT send any reply to a message flagged as automated.
#### Scenario: Bounce message arrives
- **WHEN** the system receives a message flagged as automated
- **THEN** the system SHALL mark the message as read (IMAP Seen flag)
- **AND** the system SHALL call the agent's automated-message handler
- **AND** the system SHALL NOT send any outbound email reply
### Requirement: Message dict includes automation flag
Every message returned by `fetch_unread_messages()` SHALL include:
- `is_automated` (boolean): whether the message was flagged as automated
- `automated_reason` (string): human-readable reason if flagged, empty string otherwise
@@ -0,0 +1,31 @@
## ADDED Requirements
### Requirement: Loop escalation alert to admin
The system SHALL send a plain-text warning email to the admin when a conversation is stopped due to an automated sender or message-count cap breach.
#### Scenario: First automated message from a sender
- **WHEN** the first automated/bounce message is received from a sender address
- **THEN** the system SHALL send one alert email to the admin CC address list
- **AND** the subject SHALL begin with `[WARNUNG]` for easy inbox filtering
- **AND** the subject SHALL include the sender's email address
- **AND** the body SHALL include: sender address, conversation ID, detection reason, and message count
- **AND** no further alert SHALL be sent for subsequent automated messages from the same sender
#### Scenario: Conversation exceeds message-count cap
- **WHEN** a conversation accumulates more than 20 inbound user messages without completing
- **THEN** the system SHALL send one alert email to the admin CC address list on first breach
- **AND** the body SHALL identify the conversation and state that the message limit was exceeded
- **AND** no further alert SHALL be sent for subsequent messages in the same capped conversation
#### Scenario: No admin CC address configured
- **WHEN** `ADMIN_EMAIL_CC` is not set and a loop escalation is triggered
- **THEN** the system SHALL log a warning
- **AND** the system SHALL NOT attempt an SMTP connection
#### Scenario: No SMTP host configured (dev mode)
- **WHEN** `SMTP_HOST` is not set and a loop escalation is triggered
- **THEN** the system SHALL log the notification content
- **AND** the system SHALL NOT attempt an SMTP connection
### Requirement: Alert routing
Loop escalation alerts SHALL be sent only to the admin CC list (`ADMIN_EMAIL_CC`). They SHALL NOT be sent to playgroup leaders (Andrea Sigrist, Barbara Gross), as loop detection is an operational concern, not a registration event.
@@ -0,0 +1,43 @@
## 1. Automated Sender Detection (email_channel.py)
- [x] 1.1 Add `_AUTOMATED_SENDER_RE` regex for known non-human local-parts (mailer-daemon, postmaster, noreply, no-reply, donotreply, bounce, …)
- [x] 1.2 Add `_AUTOMATED_SUBJECT_RE` regex for bounce/OOO subject patterns (German + English)
- [x] 1.3 Implement `detect_automated_message(raw_msg, from_addr) → (bool, str)` checking all signals in priority order: sender pattern → Auto-Submitted → X-Auto-Response-Suppress → multipart/report → X-Loop → Precedence → subject
- [x] 1.4 Add `is_automated` and `automated_reason` fields to the dict returned by `fetch_unread_messages()`
## 2. Poll Loop Guard (main.py)
- [x] 2.1 In `run_poll_loop()`, check `msg.get("is_automated")` before calling `agent.process_message()`
- [x] 2.2 If automated: log a warning, call `agent.handle_automated_message()`, and `continue` (skip `send_reply`)
## 3. Agent — Automated Message Handler (agent/core.py)
- [x] 3.1 Add `MAX_USER_MESSAGES = 20` module-level constant
- [x] 3.2 Implement `handle_automated_message(sender_email, subject, reason, inbound_message_id)` method
- [x] 3.3 In `handle_automated_message`: load or create state; set `loop_escalated = True`; call `notify_loop_escalation()` once; silently skip if already escalated; save state
- [x] 3.4 In `process_message()`, after appending the user message, count user messages; if count > `MAX_USER_MESSAGES` and not escalated: set `loop_escalated = True`, call `notify_loop_escalation()`, return `""`
- [x] 3.5 If already escalated and over limit: silently save state and return `""`
## 4. Conversation State (models/conversation.py)
- [x] 4.1 Add `loop_escalated: bool = False` field to `ConversationState`
- [x] 4.2 Include `loop_escalated` in `to_dict()`
- [x] 4.3 Restore `loop_escalated` in `from_dict()` with default `False` for backward compatibility
## 5. Admin Notification (notifications/notifier.py)
- [x] 5.1 Implement `notify_loop_escalation(sender_email, conversation_id, reason, message_count)` method
- [x] 5.2 Route alert to `self._cc_emails` only (not playgroup leaders)
- [x] 5.3 Subject: `[WARNUNG] Automatische E-Mail / Endlosschleife erkannt: {sender_email}`
- [x] 5.4 Body: sender, conversation ID, message count, reason, call-to-action in German
- [x] 5.5 Guard: if no CC emails configured, log warning and return without SMTP call
## 6. Tests
- [x] 6.1 `TestDetectAutomatedMessageBySender` — mailer-daemon, postmaster, noreply, no-reply, donotreply, bounce; normal parent address not flagged
- [x] 6.2 `TestDetectAutomatedMessageByHeaders` — Auto-Submitted (auto-replied, auto-generated, no); X-Auto-Response-Suppress; multipart/report; X-Loop; Precedence bulk/junk; Precedence list not flagged
- [x] 6.3 `TestDetectAutomatedMessageBySubject` — Undelivered Mail, Mail Delivery Failed, Out of Office, Abwesenheitsnotiz, Automatische Antwort; case-insensitive; normal subject not flagged
- [x] 6.4 `TestHandleAutomatedMessage` — sets loop_escalated; calls notifier once; creates state when none exists; drops silently if already escalated; notifier failure does not propagate; inbound message ID stored
- [x] 6.5 `TestProcessMessageCountCap` — at limit still processes; over limit returns ""; sets loop_escalated; calls notifier once; no duplicate alert; notifier failure does not propagate; constant equals 20
- [x] 6.6 `TestNotifyLoopEscalation` — sends to CC; [WARNUNG] in subject; sender in subject; reason in body; message count in body; no-CC guard; no-SMTP guard
- [x] 6.7 `TestConversationStateLoopEscalated` (test_models.py) — default False; to_dict includes key; True round-trip; from_dict backward compatibility
@@ -0,0 +1,9 @@
name: implement-web-chat
schema: spec-driven
status: in-progress
created: 2026-02-22
artifacts:
proposal: pending
design: pending
specs: pending
tasks: pending
@@ -0,0 +1,132 @@
## Context
The `chat-interface` capability is specified but not yet built. This change implements it. The core agent (LLM conversation logic) is defined separately; this design covers the web frontend, its real-time transport, session management, and how the chat layer connects to the agent core.
The tech stack for the entire project is decided here as part of this first implementation change, since the chat interface is the most visible component and its runtime shapes the whole backend.
## Goals / Non-Goals
**Goals:**
- Deliver a working, accessible web chat interface parents can use to register
- Choose a library that provides WCAG 2.1 AA compliance out of the box or close to it
- Keep the frontend thin: no business logic, just message in / message out
- Establish the Python tech stack and project layout for all subsequent changes
**Non-Goals:**
- Custom chat UI built from scratch (we use a library)
- Authentication / login before chatting
- Persistent chat history across separate browser sessions (session-scoped only)
- Admin-facing UI (out of scope for this project entirely)
## Decisions
### 1. Chat UI Library: Chainlit
**Decision**: Use [Chainlit](https://github.com/Chainlit/chainlit) as the chat interface framework.
**Rationale**:
Chainlit is purpose-built for AI assistant chat interfaces. It handles everything the spec requires without building it from scratch:
- Real-time streaming responses (SSE / WebSocket)
- Typing indicators while the agent generates
- Session management (server-side, survives page refresh within the same session)
- Message history display with clear agent / user attribution
- Built-in mobile-responsive layout
- Python-native: integrates directly with the Anthropic SDK with no bridging layer
**Accessibility baseline**: Chainlit's React frontend uses semantic HTML and has basic ARIA support. Gaps (see Risk section) are filled with CSS overrides and custom header components.
**Alternatives considered**:
| Option | Why rejected |
|--------|-------------|
| React + `@chatscope/chat-ui-kit-react` | Requires a separate Node.js build pipeline and a backend bridge; more moving parts for a small project |
| Gradio | Data-science oriented; poor accessibility; limited chat customisation |
| FastAPI + HTMX (server-rendered) | Accessible by default but no real-time streaming without complex SSE setup; typing indicators are awkward |
| Custom React app | Reimplements what Chainlit provides; no accessibility gains justify the cost |
### 2. Language and Runtime: Python
**Decision**: Python as the sole backend language.
**Rationale**: The Anthropic SDK is first-class in Python. Email processing (IMAP/SMTP), file I/O for the knowledge base, and JSON storage are all well-supported. Chainlit is Python-native. Using one language for the entire stack minimises operational complexity for a small project.
### 3. Real-Time Transport: Chainlit's built-in WebSocket / SSE
**Decision**: Rely on Chainlit's managed real-time layer; do not implement a separate WebSocket server.
**Rationale**: Chainlit handles connection lifecycle, reconnection, and streaming out of the box. The agent core is invoked inside Chainlit's `@cl.on_message` handler and streams tokens back with `cl.Message.stream_token()`. There is no need for a separate transport layer.
### 4. Session State: Chainlit User Session
**Decision**: Store in-progress registration state in `cl.user_session` (server-side, keyed by Chainlit's session ID).
**Rationale**: Chainlit provides a per-connection server-side dict (`cl.user_session`) that persists across page refreshes within the same browser session. This satisfies the spec requirement that conversation history and state survive a refresh. No external state store (Redis, database) is needed for MVP.
**Trade-off**: State is lost when the server restarts. For a small playgroup this is acceptable; parents are encouraged to use email if they need to resume days later.
### 5. Accessibility Gaps and Mitigations
Chainlit covers most WCAG 2.1 AA requirements but has known gaps:
| Gap | Mitigation |
|----|-----------|
| ARIA live region for incoming messages | Add `aria-live="polite"` via Chainlit's custom CSS / element override on the message list container |
| Focus management after agent reply | Inject a small JS snippet via Chainlit's `head` config to move focus to the latest message |
| Colour contrast of default theme | Override with a high-contrast custom CSS theme (≥ 4.5:1 for all text) |
| Animated typing dots | Wrap in `@media (prefers-reduced-motion: reduce)` to hide or swap for a static indicator |
| Skip-to-content link | Add via Chainlit's custom `header` HTML config |
### 6. Project Layout
```
meister-eder/
├── app/
│ ├── chat.py # Chainlit entry point (@cl.on_chat_start, @cl.on_message)
│ ├── agent/
│ │ ├── core.py # Channel-agnostic agent logic (shared with email)
│ │ └── tools.py # Agent tool definitions (knowledge base lookup, etc.)
│ ├── knowledge_base/ # Markdown files (symlink or copy of content/knowledge-base/)
│ └── storage/
│ └── registrations/ # JSON registration records
├── public/
│ └── custom.css # Accessibility overrides for Chainlit
├── chainlit.md # Welcome message shown in chat (German default)
├── chainlit.toml # Chainlit configuration (theme, title, etc.)
└── requirements.txt
```
### 7. Chainlit Configuration
Key settings in `chainlit.toml`:
```toml
[project]
name = "Spielgruppe Pumuckl"
enable_telemetry = false
[UI]
name = "Spielgruppe Pumuckl"
default_language = "de"
# Custom CSS applied on top of default theme
custom_css = "/public/custom.css"
[meta]
generated_by = "1.x"
```
Welcome message (`chainlit.md`) is written in German with an English fallback comment, matching the agent personality spec.
## Risks / Trade-offs
**Chainlit version stability**: Chainlit's API has changed between major versions. Pin to a specific minor version in `requirements.txt` and document upgrade steps.
**Accessibility completeness**: Chainlit's built-in accessibility is not fully audited. The mitigations in Decision 5 address known gaps; a manual screen-reader test (NVDA/VoiceOver) should be part of the implementation verification.
**Session loss on server restart**: Acceptable for MVP. Documented in the chat UI welcome message ("for longer registrations, consider email").
**Mobile keyboard overlap**: On small screens, the browser's virtual keyboard can obscure the chat input. Chainlit's layout is mobile-responsive but may need a CSS viewport-height fix (`dvh` units) for iOS Safari.
## Open Questions
- Should the Chainlit app be the same process as future admin/export endpoints, or run separately? (Likely separate; Chainlit's web server is not designed to host arbitrary REST APIs.)
- What domain/subdomain will the chat be served from? (Affects CORS config if the agent API is separate.)
@@ -0,0 +1,38 @@
## Why
The `chat-interface` capability was defined in the scoping phase as a must-have for the initial release, but no implementation exists yet. This change delivers it.
Parents need a zero-friction way to start a registration or ask questions without setting up email threads. A web chat interface lowers that barrier: they navigate to a URL, start typing, and they're immediately talking to the agent. The interface must work equally well on a mobile phone held in one hand while watching a toddler.
Accessibility is a first-class concern. Parents may rely on screen readers, keyboard navigation, or high-contrast displays. An inaccessible registration interface excludes families with disabilities—contrary to the playgroup's values.
Using a mature, accessibility-tested chat UI library rather than building from scratch means we inherit ARIA compliance, keyboard navigation, focus management, and screen reader support without reimplementing them.
## What Changes
- **ADDED `chat-interface` implementation**: A deployable web application that renders the chat UI, connects to the core agent, and manages browser-session state. Built on top of an existing accessible chat UI library.
### What this does NOT include
- The core conversational agent (separate concern, shared with email channel)
- Email channel implementation
- Backend API for the agent (defined separately; this change specifies only the frontend and its integration contract)
## Capabilities
### Modified Capabilities
- `chat-interface`: Moves from specified-but-unbuilt to implemented. Requirements remain as defined in the existing spec, with accessibility requirements added:
- WCAG 2.1 AA compliance
- Full keyboard navigation (no mouse required)
- Screen reader compatibility (ARIA live regions for incoming messages)
- Sufficient colour contrast (≥ 4.5:1 for normal text)
- Focus management (focus moves to new agent messages; skip-to-content link)
- Respects `prefers-reduced-motion` (no animated typing dots if disabled)
## Impact
- **Parents**: Can access the registration agent from any browser without email setup; works on mobile phones; usable by parents with accessibility needs
- **Infrastructure**: Adds a web server serving the chat frontend; WebSocket or SSE connection to the agent backend
- **Dependencies**: One new runtime dependency — a well-maintained, accessible chat UI library (to be decided in design); the existing agent core (no changes required to the core)
- **Admin**: No impact; admin does not interact with the chat interface
@@ -0,0 +1,173 @@
## MODIFIED Requirements
> Extends the `chat-interface` spec from `define-project-scope`. All previously
> defined requirements remain in force. This spec adds accessibility requirements
> and implementation constraints introduced by this change.
---
### Requirement: WCAG 2.1 AA compliance
The chat interface SHALL conform to WCAG 2.1 Level AA in all user-facing interactions.
#### Scenario: Automated accessibility check passes
- **WHEN** an automated accessibility audit (e.g. axe-core) is run against the chat page
- **THEN** it SHALL report zero Level A and Level AA violations
#### Scenario: Manual screen reader test passes
- **WHEN** a user navigates the chat using NVDA (Windows) or VoiceOver (macOS/iOS)
- **THEN** they SHALL be able to read all messages and send a new message without using a mouse
---
### Requirement: Full keyboard navigation
The chat interface SHALL be fully operable using only a keyboard.
#### Scenario: Sending a message by keyboard
- **WHEN** a parent focuses the text input and types a message
- **THEN** they SHALL be able to submit it with the Enter key without pressing a mouse button
#### Scenario: Tab order is logical
- **WHEN** a parent presses Tab repeatedly from the top of the page
- **THEN** focus SHALL move through interactive elements in a logical reading order (skip link → message list → text input → send button)
#### Scenario: No keyboard trap
- **WHEN** focus enters any component (e.g. the text input)
- **THEN** the parent SHALL be able to move focus out again using only the keyboard
---
### Requirement: Skip-to-content link
The chat interface SHALL provide a skip navigation link as the first focusable element.
#### Scenario: Skip link is visible on focus
- **WHEN** a keyboard user presses Tab on the chat page for the first time
- **THEN** a "Skip to chat" link SHALL become visible and, when activated, move focus directly to the message input
---
### Requirement: Screen reader announcements for new messages
Incoming agent messages SHALL be announced to screen readers without requiring focus change.
#### Scenario: Agent reply announced
- **WHEN** the agent sends a new message
- **THEN** a screen reader SHALL announce the message content via an ARIA live region (`aria-live="polite"`)
#### Scenario: In-progress stream not announced mid-token
- **WHEN** the agent is streaming a response token by token
- **THEN** the live region SHALL NOT announce each individual token; only the completed message SHALL be announced
---
### Requirement: Focus moves to agent reply on completion
After the agent finishes a response, keyboard focus SHALL be placed near the new message.
#### Scenario: Focus after reply
- **WHEN** the agent finishes generating a response
- **THEN** focus SHALL move to the new agent message element (or a wrapper with `tabindex="-1"`) so the parent can read it immediately with a screen reader
---
### Requirement: Sufficient colour contrast
All text in the chat interface SHALL meet WCAG 2.1 SC 1.4.3 contrast requirements.
#### Scenario: Normal text contrast
- **WHEN** any text of normal size is rendered
- **THEN** its contrast ratio against the background SHALL be ≥ 4.5:1
#### Scenario: Large text contrast
- **WHEN** any text at 18pt (or 14pt bold) or larger is rendered
- **THEN** its contrast ratio against the background SHALL be ≥ 3:1
#### Scenario: Input placeholder contrast
- **WHEN** the text input displays placeholder text
- **THEN** the placeholder contrast ratio SHALL be ≥ 4.5:1
---
### Requirement: Typing indicator respects reduced-motion preference
The agent-processing indicator SHALL not use animation when the user has requested reduced motion.
#### Scenario: Reduced motion active
- **WHEN** the OS or browser has `prefers-reduced-motion: reduce` set
- **AND** the agent is generating a response
- **THEN** the typing indicator SHALL display as a static element (e.g. "…" text) with no animation
#### Scenario: Reduced motion not active
- **WHEN** `prefers-reduced-motion` is not set or is set to `no-preference`
- **THEN** the typing indicator MAY display an animated element (e.g. bouncing dots)
---
### Requirement: Mobile viewport input visibility
The text input SHALL remain visible when the virtual keyboard is open on mobile devices.
#### Scenario: iOS Safari virtual keyboard
- **WHEN** a parent taps the text input on an iOS device and the virtual keyboard appears
- **THEN** the input field SHALL remain in view and not be obscured by the keyboard
#### Scenario: Android Chrome virtual keyboard
- **WHEN** a parent taps the text input on an Android device and the virtual keyboard appears
- **THEN** the input field SHALL remain in view and the message list SHALL scroll to show the latest message above the keyboard
---
### Requirement: Session state survives page refresh
Conversation history and registration state SHALL be preserved when the parent reloads the page within the same browser session.
#### Scenario: Page refresh mid-conversation
- **WHEN** a parent refreshes the browser tab during an active conversation
- **THEN** the full conversation history SHALL be displayed and the registration state SHALL be intact on reconnect
#### Scenario: Server restart loses state (acceptable degradation)
- **WHEN** the server restarts while a session is active
- **THEN** the parent SHALL see a notification that the session ended and be invited to start a new conversation
---
### Requirement: Session-loss notification on disconnect
If the connection to the server is lost and cannot be recovered, the interface SHALL inform the parent.
#### Scenario: Unrecoverable disconnect
- **WHEN** the WebSocket / SSE connection is lost and reconnection fails after a reasonable timeout
- **THEN** the interface SHALL display a message explaining the session ended and suggest using email for longer registration processes
---
### Requirement: Welcome message in the parent's language
The chat interface SHALL display a welcome message in German by default, with automatic language adaptation.
#### Scenario: Default welcome message
- **WHEN** a parent opens the chat interface
- **THEN** the welcome message SHALL be displayed in German
#### Scenario: Language adaptation
- **WHEN** a parent sends their first message in English
- **THEN** the agent SHALL respond in English for the remainder of the session
---
## ADDED Requirements
### Requirement: Chainlit-based implementation
The chat interface SHALL be implemented using the Chainlit framework.
#### Scenario: Application entry point
- **WHEN** the server starts
- **THEN** it SHALL run a Chainlit application with `@cl.on_chat_start` and `@cl.on_message` handlers
#### Scenario: Telemetry disabled
- **WHEN** the application runs
- **THEN** Chainlit telemetry SHALL be disabled (`enable_telemetry = false` in `chainlit.toml`)
---
### Requirement: Custom accessibility CSS applied
The Chainlit default theme SHALL be extended with a custom CSS file that addresses accessibility gaps.
#### Scenario: Custom CSS loaded
- **WHEN** the chat page is served
- **THEN** the custom CSS file (`/public/custom.css`) SHALL be loaded and applied on top of the default Chainlit theme
#### Scenario: Custom CSS addresses contrast and motion
- **WHEN** the page renders
- **THEN** the custom CSS SHALL include contrast overrides achieving ≥ 4.5:1 for all normal text AND a `prefers-reduced-motion` block that suppresses typing-dot animation
@@ -0,0 +1,71 @@
## 1. Add Chainlit Dependency
- [x] 1.1 Run `uv add chainlit` to add Chainlit to `pyproject.toml` (matches existing `uv`-based workflow; do not create `requirements.txt`)
- [x] 1.2 Create `chainlit.toml` at the project root with: `name = "Spielgruppe Pumuckl"`, `enable_telemetry = false`, `custom_css = "/public/custom.css"`, `default_language = "de"`
## 2. Add Streaming Support to src/llm.py
- [x] 2.1 Add a `stream_complete(model: str, system: str, messages: list)` generator function to `src/llm.py` that calls `litellm.completion(..., stream=True)` and yields text chunks (parallels the existing `complete()` function; both share the same `api_messages` build logic)
- [x] 2.2 Add tests for `stream_complete` in `tests/test_llm.py` — verify chunks are yielded, empty deltas are skipped, and the model/system/messages args are forwarded correctly
## 3. Create Chainlit Entry Point
- [x] 3.1 Create `chat_app.py` at the project root with a `@cl.on_chat_start` handler that:
- Loads `Config.from_env()` from `src/config.py`
- Initialises `KnowledgeBase`, `ConversationStore`, `AdminNotifier` from existing `src/` modules
- Creates a fresh `ConversationState` (use Chainlit's session ID as `conversation_id`; no email address required at this stage)
- Stores state dict in `cl.user_session["state"]`
- Sends the German welcome message (drawn from `content/sample-responses.md`)
- [x] 3.2 Add a `@cl.on_message` handler in `chat_app.py` that:
- Deserialises `ConversationState` from `cl.user_session["state"]`
- Appends the parent's message to `state.messages` as a `ChatMessage(role="user", ...)`
- Builds the system prompt via `build_system_prompt()` from `src/agent/prompts.py`
- Creates a `cl.Message(content="")` and streams chunks from `stream_complete()` using `msg.stream_token(chunk)`, then calls `msg.send()`
- Parses the completed response text for JSON (reuse `_parse_llm_response` logic from `src/agent/core.py` — extract into `src/agent/response_parser.py` if needed)
- Applies field updates to `ConversationState.registration`; updates `state.flow_step` and `state.language`
- Appends the assistant reply to `state.messages`
- Serialises state back to `cl.user_session["state"]`
- When `registration_complete` is true: saves registration via `ConversationStore`, sends admin notification via `AdminNotifier`, sets `state.completed = True`
- [x] 3.3 Create `chainlit.md` at the project root with the German welcome/intro text shown in the Chainlit sidebar (plain markdown; drawn from `content/agent-personality.md`)
## 4. Accessibility CSS
- [x] 4.1 Create `public/` directory at the project root
- [x] 4.2 Create `public/custom.css` with colour contrast overrides: all normal text ≥ 4.5:1, large text ≥ 3:1, and placeholder text ≥ 4.5:1 — measure Chainlit's default colours with a contrast checker and override as needed
- [x] 4.3 Add a `@media (prefers-reduced-motion: reduce)` block to `public/custom.css` that hides the animated typing-dot element and replaces it with a static `"…"` pseudo-element
- [x] 4.4 Add CSS in `public/custom.css` for the skip link: hidden by default, visible and highlighted on `:focus`, and targeting `#chat-input`
- [x] 4.5 Add `min-height: 100dvh` (dynamic viewport height) to the chat container selector in `public/custom.css` so the input is not obscured by iOS Safari's virtual keyboard
## 5. ARIA and Focus Management
- [x] 5.1 Inspect the Chainlit message list container selector in a running browser (dev tools) and add `aria-live="polite"` and `aria-atomic="false"` to it via a `MutationObserver` JS snippet injected through `chainlit.toml`'s `[UI] custom_js` or as `public/accessibility.js`
- [x] 5.2 Extend the JS snippet so that after each completed agent message (after `msg.send()`, not during streaming) focus moves to the new message element via `element.setAttribute("tabindex", "-1"); element.focus()`
- [x] 5.3 Add the skip link HTML element to the page via the same JS snippet or Chainlit's `[UI] custom_header` config so it is the first focusable element
## 6. Session Management
- [ ] 6.1 Test manually: start a conversation in the browser, refresh the page, verify that `cl.user_session["state"]` is restored and conversation history is displayed
- [x] 6.2 Add a `@cl.on_chat_end` handler in `chat_app.py` that logs the session end (no action needed for MVP, but provides a hook for future email-reminder integration)
- [x] 6.3 Add a disconnect/reconnect message in Chainlit configuration: "Deine Sitzung ist abgelaufen. Starte ein neues Gespräch oder nutze E-Mail für eine längere Pause." (and English equivalent)
## 7. Mobile Testing
- [ ] 7.1 Test on iOS Safari (device or BrowserStack): tap the text input while virtual keyboard is open — confirm input is not obscured
- [ ] 7.2 Test on Android Chrome: tap input, confirm message list scrolls to keep the latest message visible above the keyboard
- [ ] 7.3 Test landscape orientation on a mobile screen: confirm no horizontal scroll and layout is usable
## 8. Accessibility Verification
- [ ] 8.1 Run the axe-core browser extension against the running chat page and fix all reported Level A and Level AA violations
- [ ] 8.2 Verify keyboard-only flow: Tab → skip link becomes visible → activate → focus moves to message input → type message → Enter → agent replies → focus moves to new message
- [ ] 8.3 Test with VoiceOver (macOS or iOS): navigate to input, send a message, confirm agent reply is announced via the live region without manual focus movement
- [ ] 8.4 Set OS `prefers-reduced-motion: reduce` and confirm the typing indicator shows as static text (not animated)
- [ ] 8.5 Spot-check contrast of all rendered text elements; document which selectors required overrides in `public/custom.css`
## 9. Final Integration Check
- [ ] 9.1 Confirm `chainlit run chat_app.py` starts cleanly with no warnings or import errors
- [ ] 9.2 Complete an end-to-end registration through the chat interface: verify the registration JSON is saved to `data/registrations/` and the admin notification email is sent
- [ ] 9.3 Confirm Chainlit telemetry is disabled: open the network tab and verify no requests go to Chainlit analytics endpoints
- [ ] 9.4 Confirm `ANTHROPIC_API_KEY` and other secrets are not printed in logs or error output
- [x] 9.5 Update the `context` field in `openspec/config.yaml` with the finalised tech stack: Python 3.13, Chainlit, LiteLLM, uv, file-based JSON storage
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-22
@@ -0,0 +1,101 @@
## Context
When a registration is completed, `AdminNotifier.notify_admin()` sends an email to the relevant playgroup leaders. The parent receives nothing — no acknowledgement, no summary, no payment details.
The existing `AdminNotifier` in `src/notifications/notifier.py` handles both new-registration and update notifications to admins. It uses `MIMEMultipart("alternative")` and sends via the configured SMTP server. All the SMTP plumbing already works.
This change adds a parent-facing confirmation email triggered at the same point as the admin notification.
## Goals / Non-Goals
**Goals:**
- Parent receives an HTML confirmation email immediately after registration is stored
- Email contains full registration summary (all 13 fields)
- Email contains payment instructions for the CHF 80 registration fee
- Email contains a Swiss QR-bill (QR code image) embedded inline so the parent can pay via banking app or print
**Non-Goals:**
- Translating the QR-bill slip labels themselves (the SIX Group standard mandates German/French/Italian for the payment slip fields — surrounding email text is translated, but the slip is not)
- Sending a reminder if the parent hasn't paid (payment tracking is out of scope)
- Generating a full PDF invoice (QR code embedded in HTML email is sufficient)
- Sibling discount handling in the QR-bill amount (CHF 80 is always fixed for the registration fee)
## Decisions
### 1. Extend `AdminNotifier` vs. New Class
**Decision**: Add `notify_parent()` to the existing `AdminNotifier` class (renamed conceptually; kept in the same file for now).
**Rationale**: The SMTP plumbing (`_send`, `_smtp_host`, credentials) is already there. A `notify_parent` method reuses all of it. Splitting into a separate class would require duplicating constructor parameters and SMTP setup for no structural benefit at this stage.
**Trade-off**: `AdminNotifier` becomes slightly misnamed. Acceptable — the class handles all outbound notification emails. Rename in a future refactor if needed.
### 2. HTML Email Format
**Decision**: Send `multipart/alternative` with both plain-text and HTML parts. The HTML part is the primary view; plain-text is fallback.
**Rationale**: Matches the existing `_send` method's `MIMEMultipart("alternative")` pattern. HTML is needed to embed the QR-bill image inline.
**QR image embedding**: Use `multipart/related` wrapping the HTML part, with the QR PNG attached as `Content-ID` (`cid:qrbill`). This is the standard approach for inline images that don't appear as attachments.
**Structure:**
```
multipart/mixed
└── multipart/alternative
├── text/plain (fallback)
└── multipart/related
├── text/html (references cid:qrbill)
└── image/png (Content-ID: qrbill, inline)
```
### 3. Swiss QR-Bill Generation
**Decision**: Use the `qrbill` Python library to generate the QR code image.
**Rationale**: `qrbill` implements the Swiss QR-bill standard (SIX Group spec) directly. It accepts IBAN, payee address, amount, and currency, and outputs an SVG or PNG. No external services required.
**Fixed payment data** (hardcoded in the notifier, not in config — this is stable bank data):
- IBAN: `CH14 0900 0000 4930 8018 8`
- Payee: Familienverein Fällanden Spielgruppen, c/o Markus Graf, Huebwisstrase 5, 8117 Fällanden
- Amount: CHF 80.00
- Currency: CHF
- Reference type: NON (no structured reference)
**Output**: PNG bytes, embedded as inline image in HTML email.
**Dependency**: Add `qrbill` to `pyproject.toml` dependencies.
### 4. Language
**Decision**: Add a `language` field to `RegistrationData` (default `"de"`). The agent sets it when it detects the parent's language during conversation. The confirmation email body is rendered in the stored language. The QR-bill slip labels are fixed German/French/Italian per the SIX Group standard and are not translated.
**Supported values**: `"de"` (German, default) and `"en"` (English). Other values fall back to `"de"`.
**Where it lives in the model**: A new `metadata` field on `RegistrationData` (a `Metadata` dataclass) with fields `submitted_at`, `channel`, `conversation_id`, and `language`. This also aligns with the JSON schema in `registration-schema.json` which already defines a `metadata` object with those keys. The `language` field is added to both the Python model and the JSON schema.
**Template strategy**: Two string-template dicts (one per language) for all user-visible strings in the confirmation email. The notifier selects the dict based on `registration.metadata.language`. Admin notifications remain German-only (admins are Swiss German speakers).
**Rationale**: Parents who conversed in English reasonably expect an English confirmation. Storing language in the model (rather than passing it as a parameter) means it's persisted with the registration and available for future use (e.g. update notifications, reminders).
### 5. Trigger Point
**Decision**: Call `notify_parent()` immediately after `notify_admin()` at the same trigger site — wherever `notify_admin` is currently called (in `src/agent/core.py` or equivalent).
**Rationale**: The parent notification is a direct consequence of the same event (registration completed). No separate trigger or queue needed.
**Error isolation**: If the parent email fails, log the error but do not fail the registration or block the admin notification. Both notifications are best-effort.
## Risks / Trade-offs
**`qrbill` library maturity**: Actively maintained, used in production Swiss applications. Risk is low. If the library is unavailable, the QR code can be omitted and the plain IBAN text still enables payment.
**Inline image rendering**: Some email clients block inline images by default (Outlook, some mobile clients). The plain-text fallback and the raw IBAN text in the HTML body ensure the payment info is always readable even if the QR image is blocked.
**SMTP failure for parent email**: Parent notification is non-critical (the registration is already stored). Failure is logged as a warning, not an exception.
**Language detection accuracy**: The agent infers language from conversation content. Misdetection is possible but low-risk — a parent who receives a German email when they expected English can still understand the registration summary. The QR-bill is universally recognisable regardless of surrounding language.
## Open Questions
- Should the confirmation email also include the monthly subscription fee (in addition to the CHF 80 registration fee), or only the registration fee QR-bill? The CHF 80 one-time fee is the immediate action required; the monthly fee is recurring and not yet payable. **Proposed answer: include both as informational text, but the QR-bill is for CHF 80 only.**
- Should the parent's email be CC'd on the admin notification, or kept as a separate send? **Proposed answer: separate send — keeps admin and parent content distinct.**
@@ -0,0 +1,31 @@
## Why
When a parent completes registration, they currently receive no confirmation. They have no record of what they submitted, no clarity on next steps, and no way to pay the registration fee without separately asking for bank details. This creates uncertainty for parents and additional back-and-forth for admins.
A confirmation email closes this gap: the parent gets a clear summary of their registration, knows exactly what they agreed to, and can pay immediately using the included Swiss QR-bill.
## What Changes
- **Send HTML confirmation email to the parent** immediately when a registration is completed and stored
- **Include full registration summary** — all fields the parent filled out, formatted clearly
- **Include payment instructions** (German text) for the CHF 80 registration fee with IBAN and payee details
- **Include a Swiss QR-bill** (payment QR code) so the parent can pay directly from their banking app or print-to-pay
- The confirmation is sent in the **same language** the parent used during the conversation (German or English), but the QR-bill and payment block are always in German (banking standard)
## Capabilities
### Modified Capabilities
- `registration-notifications`: Currently only notifies admins. Extended to also send a confirmation to the parent's email address upon completion.
### New Capabilities
*None — this extends an existing capability*
## Impact
- **Parents**: Receive immediate, clear confirmation with everything they need — what was registered and how to pay. No need to ask for bank details.
- **Admins**: Fewer follow-up inquiries about "did my registration go through?" and "where do I pay?". Payment is initiated earlier.
- **Email deliverability**: System must send to parent email, not just admin addresses. Parent email is already a required field in the registration schema.
- **Swiss QR-bill generation**: Requires a library to generate the QR code image from the payment data (IBAN, amount, payee address). The QR code is embedded inline in the HTML email.
- **Bilingual**: Confirmation body adapts to the parent's language. The payment section uses German regardless (Swiss QR-bill standard).
@@ -0,0 +1,81 @@
## MODIFIED Requirements
### Requirement: Notify parent on completed registration
The system SHALL send an HTML confirmation email to the parent immediately after a registration is completed and stored.
#### Scenario: Confirmation sent to parent email
- **WHEN** a registration is completed
- **THEN** the system SHALL send a confirmation email to the address in `parentGuardian.email`
#### Scenario: Confirmation sent before or alongside admin notification
- **WHEN** a registration is completed
- **THEN** both the admin notification and the parent confirmation SHALL be dispatched in the same completion event; failure of either SHALL be logged but SHALL NOT block the other or fail the registration
#### Scenario: No confirmation for incomplete registration
- **WHEN** a registration is not yet complete (any required field missing)
- **THEN** no confirmation email SHALL be sent to the parent
---
### Requirement: Confirmation email contains full registration summary
The confirmation email SHALL include a summary of all registration data the parent submitted.
#### Scenario: All required fields present in confirmation
- **WHEN** the confirmation email is sent
- **THEN** it SHALL include child name, date of birth, special needs, selected playgroup type(s), selected days, parent/guardian contact details, and emergency contact
#### Scenario: Monthly fee shown as informational text
- **WHEN** the confirmation email is sent
- **THEN** it SHALL display the calculated monthly subscription fee as informational text (not a payment request)
---
### Requirement: Confirmation email contains payment instructions for registration fee
The confirmation email SHALL include instructions for paying the one-time CHF 80 registration fee.
#### Scenario: IBAN and payee shown as text
- **WHEN** the confirmation email is sent
- **THEN** it SHALL display the payee name, IBAN, and amount in plain text so the parent can pay manually if the QR code is not rendered
#### Scenario: Swiss QR-bill embedded inline
- **WHEN** the confirmation email is sent
- **THEN** it SHALL include a Swiss QR-bill image (per SIX Group standard) embedded inline as a `Content-ID` referenced image within the HTML part
- **AND** the QR-bill SHALL encode: IBAN `CH14 0900 0000 4930 8018 8`, payee Familienverein Fällanden Spielgruppen (Huebwisstrase 5, 8117 Fällanden), amount CHF 80.00, currency CHF, reference type NON
#### Scenario: QR-bill fallback for non-HTML clients
- **WHEN** a parent's email client does not render HTML
- **THEN** the plain-text part SHALL include the IBAN and payee details in full so payment is still possible without the QR code
---
### Requirement: Confirmation email language matches parent's detected language
The confirmation email body SHALL be rendered in the language detected during the conversation.
#### Scenario: German parent receives German confirmation
- **WHEN** the conversation language is `"de"`
- **THEN** the confirmation email body SHALL be in German
#### Scenario: English-speaking parent receives English confirmation
- **WHEN** the conversation language is `"en"`
- **THEN** the confirmation email body SHALL be in English
#### Scenario: Unknown language falls back to German
- **WHEN** the stored language value is not `"de"` or `"en"`
- **THEN** the confirmation email SHALL be sent in German
#### Scenario: QR-bill slip labels are not translated
- **WHEN** the confirmation email is rendered in any language
- **THEN** the Swiss QR-bill payment slip labels SHALL remain in German (per SIX Group standard; the slip is internationally recognisable without translation)
---
### Requirement: Parent's conversation language is persisted in the registration record
The language detected during the parent's conversation SHALL be stored in the completed registration record.
#### Scenario: Language written to registration record
- **WHEN** a registration is stored
- **THEN** the JSON record SHALL include a `metadata.language` field containing the detected language code (`"de"` or `"en"`)
#### Scenario: Language defaults to German when not detected
- **WHEN** no language was explicitly detected during the conversation
- **THEN** `metadata.language` SHALL be `"de"`
@@ -0,0 +1,52 @@
## 1. Add `qrbill` Dependency
- [x] 1.1 Add `qrbill` to `[project.dependencies]` in `pyproject.toml`
- [x] 1.2 Run `uv lock` to update the lockfile
- [x] 1.3 Verify `qrbill` imports successfully in a smoke test or REPL
## 2. Persist Language in Registration Record
- [x] 2.1 Update `ConversationStore._build_record()` in `src/storage/json_store.py` to include `language` from `state.language` in the `metadata` dict
- [x] 2.2 Update `ConversationStore.save_registration()` and `save_registration_version()` signatures to accept/forward `state` (already does — confirm `_build_record` receives the full state)
- [x] 2.3 Add a test in `tests/test_storage.py` asserting that the saved record's `metadata.language` matches `state.language`
## 3. Add `notify_parent()` to `AdminNotifier`
- [x] 3.1 Add a `_generate_qr_bill_png()` static/class method to `AdminNotifier` using `qrbill` with fixed payment data:
- IBAN: `CH14 0900 0000 4930 8018 8`
- Payee: Familienverein Fällanden Spielgruppen, Huebwisstrase 5, 8117 Fällanden
- Amount: `80.00`, Currency: `CHF`, Reference type: NON
- Returns raw PNG `bytes`
- [x] 3.2 Add bilingual string template dicts `_STRINGS_DE` and `_STRINGS_EN` (module-level constants) covering all user-visible strings in the confirmation email (subject, section headers, fee labels, payment instructions text, closing)
- [x] 3.3 Add `_build_parent_html()` method: renders full HTML confirmation email body using the appropriate string dict, embedding the QR image via `cid:qrbill`; includes registration summary and both monthly fee (informational) and CHF 80 registration fee (with IBAN text + QR reference)
- [x] 3.4 Add `_build_parent_text()` method: renders the plain-text fallback, including all summary fields and IBAN/payee details in plain text (no image)
- [x] 3.5 Add `notify_parent()` public method:
- Parameters: `registration: RegistrationData`, `language: str = "de"`
- Select string dict based on `language`; fall back to `"de"` for unknown values
- Call `_generate_qr_bill_png()` to get PNG bytes
- Build MIME structure: `multipart/mixed` > `multipart/alternative` > plain text part + `multipart/related` > HTML part + inline PNG (`Content-Disposition: inline`, `Content-ID: <qrbill>`)
- Call `_send()` with `to=[registration.parent_guardian.email]`, empty `cc`, localised subject, the assembled MIME message
- If `_smtp_host` is empty (dev mode), log and skip as with `notify_admin`
## 4. Wire `notify_parent()` into Completion Events
- [x] 4.1 In `src/agent/core.py` `_handle_registration()`: after the existing `notify_admin()` try/except block, add a parallel try/except block calling `self._notifier.notify_parent(registration=state.registration, language=state.language)`
- [x] 4.2 In `chat_app.py` `on_message()`: after the existing `notify_admin()` call inside the completion block, add a parallel try/except block calling `_notifier.notify_parent(registration=state.registration, language=state.language)`
- [x] 4.3 Verify both call sites log a warning (not an exception) on failure, and the registration completion path continues normally
## 5. Tests
- [x] 5.1 Add `tests/test_notifier.py` tests for `notify_parent()`:
- `test_notify_parent_calls_send`: mock `_send` and assert it is called with `to=[parent_email]`
- `test_notify_parent_german_subject`: assert subject contains German text when `language="de"`
- `test_notify_parent_english_subject`: assert subject contains English text when `language="en"`
- `test_notify_parent_unknown_language_falls_back_to_de`: assert `language="fr"` produces German subject
- `test_notify_parent_no_smtp_skips_send`: when `smtp_host=""`, `_send` is NOT called
- [x] 5.2 Add a test asserting that the plain-text body contains the IBAN string `CH14` when `smtp_host` is empty (inspecting log or body build directly)
- [x] 5.3 Add a test for `_generate_qr_bill_png()` asserting it returns `bytes` with non-zero length (requires `qrbill` installed)
## 6. Manual Smoke Test
- [ ] 6.1 Run `chainlit run chat_app.py` locally (or the email poller), complete a registration end-to-end, and verify the parent confirmation email arrives with the inline QR image rendered correctly
- [ ] 6.2 Verify the admin notification still arrives unchanged alongside the parent confirmation
- [ ] 6.3 Verify the saved `current.json` for the registration includes `metadata.language`
+26 -5
View File
@@ -3,11 +3,32 @@ schema: spec-driven
# Project context (optional) # Project context (optional)
# This is shown to AI when creating artifacts. # This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc. # Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example: context: |
# context: | Tech stack:
# Tech stack: TypeScript, React, Node.js - Language: Python 3.13+
# We use conventional commits - Package manager: uv (pyproject.toml — never requirements.txt)
# Domain: e-commerce platform - Chat interface: Chainlit 2.x (entry point: chat_app.py)
- LLM access: LiteLLM (provider-agnostic; src/llm.py wraps litellm.completion)
- Email channel: IMAP/SMTP via stdlib (imaplib, smtplib); entry point: main.py
- Storage: file-based JSON (no database); data/ directory
- Knowledge base: admin-editable Markdown files in openspec/.../knowledge-base/
- Tests: pytest + pytest-mock (tests/ directory)
Key architectural decisions:
- Channel-agnostic agent core: src/agent/ (prompts, response_parser) is shared
between email (src/agent/core.py / EmailAgent) and chat (chat_app.py)
- LLM streaming: src/llm.stream_complete() yields chunks via litellm stream=True
- Session state for chat: cl.user_session (Chainlit server-side, survives page refresh)
- Accessibility: public/custom.css (contrast, dvh, reduced-motion) +
public/accessibility.js (ARIA live region, focus management, skip link)
- Admin notifications routed by playgroup type (indoor→Andrea, outdoor→Barbara, CC Markus)
Conventions:
- German is the default language; agent auto-detects and switches to English
- Use informal "du" in German agent responses
- All knowledge base content stays in Markdown (non-technical admins can edit it)
- Schema validation required before saving any completed registration
- Never hardcode fee amounts or contact details in application code
# Per-artifact rules (optional) # Per-artifact rules (optional)
# Add custom rules for specific artifacts. # Add custom rules for specific artifacts.
+162
View File
@@ -0,0 +1,162 @@
/**
* Spielgruppe Pumuckl — Accessibility enhancements for Chainlit
*
* This script runs after the page loads and makes three changes:
*
* 1. Injects a "Skip to chat" link as the first focusable element so keyboard
* users can bypass the header and jump straight to the message input.
*
* 2. Adds aria-live="polite" to the message list container so screen readers
* announce new agent replies without requiring a focus change.
*
* 3. Observes the message list for newly completed agent messages and moves
* keyboard focus to the latest one so screen reader users can read it
* immediately after it appears.
*
* NOTE: Chainlit renders a React app, so the DOM is not fully available on
* DOMContentLoaded. We use a MutationObserver to wait for the message
* container to appear before attaching further observers.
*/
(function () {
"use strict";
// ── 1. Skip-to-content link ─────────────────────────────────────────────
function injectSkipLink() {
if (document.getElementById("skip-to-chat")) return; // already injected
const link = document.createElement("a");
link.id = "skip-to-chat";
link.href = "#chat-input";
link.textContent = "Zum Chat springen / Skip to chat";
// Clicking moves focus to the textarea inside #chat-input
link.addEventListener("click", function (e) {
e.preventDefault();
const target =
document.querySelector("#chat-input textarea") ||
document.querySelector("[data-testid='chat-input'] textarea") ||
document.querySelector("textarea");
if (target) {
target.focus();
}
});
document.body.insertBefore(link, document.body.firstChild);
}
// ── 2. ARIA live region on the message list ──────────────────────────────
/**
* Selectors to try for the message list container.
* Chainlit's class names may change between versions; list several candidates.
*/
const MESSAGE_LIST_SELECTORS = [
"[data-testid='message-list']",
".message-list",
"[class*='MessageList']",
"[class*='messages']",
".cl-message-list",
];
function findMessageList() {
for (const sel of MESSAGE_LIST_SELECTORS) {
const el = document.querySelector(sel);
if (el) return el;
}
return null;
}
function applyLiveRegion(container) {
if (container.dataset.liveRegionApplied) return;
container.setAttribute("aria-live", "polite");
container.setAttribute("aria-atomic", "false");
container.setAttribute("aria-relevant", "additions");
container.dataset.liveRegionApplied = "true";
}
// ── 3. Focus management after agent replies ──────────────────────────────
/**
* Selectors for individual assistant message elements.
* We look for the last one after a new addition.
*/
const ASSISTANT_MESSAGE_SELECTORS = [
"[data-testid='assistant-message']",
"[data-author='assistant']",
"[class*='assistant']",
".cl-message[data-role='assistant']",
];
let _lastFocusedMessage = null;
function focusLatestAssistantMessage(container) {
let latest = null;
for (const sel of ASSISTANT_MESSAGE_SELECTORS) {
const all = container.querySelectorAll(sel);
if (all.length > 0) {
latest = all[all.length - 1];
break;
}
}
// Fallback: grab the last direct child of the message list
if (!latest) {
const children = container.children;
latest = children[children.length - 1] || null;
}
if (!latest || latest === _lastFocusedMessage) return;
_lastFocusedMessage = latest;
// tabindex="-1" lets us focus() without adding the element to tab order
latest.setAttribute("tabindex", "-1");
latest.focus({ preventScroll: false });
}
// ── Bootstrap: wait for Chainlit to render, then attach everything ───────
let _messageListObserver = null;
function onMessageListFound(messageList) {
applyLiveRegion(messageList);
// Watch for new messages being added
_messageListObserver = new MutationObserver(function (mutations) {
const hasAdditions = mutations.some(function (m) {
return m.addedNodes.length > 0;
});
if (hasAdditions) {
// Small delay lets Chainlit finish rendering the new message element
setTimeout(function () {
focusLatestAssistantMessage(messageList);
}, 150);
}
});
_messageListObserver.observe(messageList, { childList: true, subtree: true });
}
// Watch the body for the message list to appear (Chainlit is a SPA)
const _rootObserver = new MutationObserver(function () {
injectSkipLink();
const messageList = findMessageList();
if (messageList) {
onMessageListFound(messageList);
// No need to keep watching once we found the container
_rootObserver.disconnect();
}
});
_rootObserver.observe(document.body, { childList: true, subtree: true });
// Also try immediately in case the app rendered synchronously
injectSkipLink();
const messageList = findMessageList();
if (messageList) {
onMessageListFound(messageList);
_rootObserver.disconnect();
}
})();
+112
View File
@@ -0,0 +1,112 @@
/*
* Spielgruppe Pumuckl — Accessibility overrides for Chainlit
*
* Goals:
* - WCAG 2.1 AA colour contrast (≥ 4.5:1 for normal text, ≥ 3:1 for large text)
* - prefers-reduced-motion: replace animated typing dots with static indicator
* - Skip-to-content link visible on keyboard focus
* - iOS Safari virtual keyboard: use dynamic viewport height so input stays visible
*/
/* ─── Skip-to-content link ──────────────────────────────────────────────── */
#skip-to-chat {
position: absolute;
top: -9999px;
left: 8px;
z-index: 9999;
padding: 8px 16px;
background: #1a56db; /* WCAG AA on white: contrast ≈ 5.9:1 */
color: #ffffff;
font-size: 1rem;
font-weight: 600;
border-radius: 4px;
text-decoration: none;
white-space: nowrap;
}
#skip-to-chat:focus {
top: 8px;
outline: 3px solid #f97316;
outline-offset: 2px;
}
/* ─── Viewport height fix for iOS Safari virtual keyboard ───────────────── */
/*
* 100dvh (dynamic viewport height) shrinks when the virtual keyboard opens,
* keeping the message input visible. Falls back to 100vh on older browsers.
*/
#root,
.cl-app,
[data-testid="layout"],
.main-container {
min-height: 100vh;
min-height: 100dvh;
}
/* ─── Colour contrast overrides ─────────────────────────────────────────── */
/*
* Chainlit's default light theme uses mid-grey text (#6b7280) on white,
* which gives ≈ 4.0:1 — below the 4.5:1 AA threshold for normal text.
* Override to #595f6b which measures ≈ 4.6:1 on #ffffff.
*
* Chainlit's dark theme text is #d1d5db on #1c1c1e (≈ 10:1) — already passes.
* We only override the light theme muted/secondary colour.
*/
/* Muted/secondary text — bump from #6b7280 (4.0:1) to #595f6b (≈ 4.6:1) */
.text-gray-500,
[class*="text-muted"],
[class*="secondary"] {
color: #595f6b !important;
}
/* Input placeholder — same issue; force dark enough value */
input::placeholder,
textarea::placeholder {
color: #595f6b !important;
opacity: 1; /* Firefox reduces opacity by default */
}
/* ─── Reduced-motion: replace animated typing dots with static "…" ──────── */
/*
* Chainlit shows a bouncing-dots animation while the agent is generating.
* When prefers-reduced-motion is set, hide the animation and show "…" instead.
*/
@media (prefers-reduced-motion: reduce) {
/* Hide animated dot elements (Chainlit uses span.dot or similar) */
.typing-indicator span,
.loader span,
[class*="typing"] span,
[class*="loader"] span,
[class*="bounce"] {
animation: none !important;
visibility: hidden;
}
/* Show a static ellipsis as the parent container's content */
.typing-indicator::after,
.loader::after,
[class*="typing"]::after,
[class*="loader"]::after {
content: "…";
visibility: visible;
display: inline-block;
color: inherit;
font-size: 1.25rem;
line-height: 1;
letter-spacing: 0.05em;
}
/* Suppress all other CSS transitions and animations */
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+9
View File
@@ -10,6 +10,11 @@ dependencies = [
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
# Registration schema validation # Registration schema validation
"jsonschema>=4.23.0", "jsonschema>=4.23.0",
"chainlit>=2.9.6",
"qrbill>=1.2.0",
"pillow>=12.1.1",
"jinja2>=3.0.0",
"pyyaml>=6.0.0",
] ]
[project.scripts] [project.scripts]
@@ -25,5 +30,9 @@ packages = ["src"]
[tool.uv] [tool.uv]
dev-dependencies = [ dev-dependencies = [
"pytest>=8.0.0", "pytest>=8.0.0",
"pytest-asyncio>=1.3.0",
"pytest-mock>=3.14.0", "pytest-mock>=3.14.0",
] ]
[tool.pytest.ini_options]
asyncio_mode = "auto"
+112 -77
View File
@@ -1,20 +1,24 @@
"""EmailAgent — the channel-agnostic conversation orchestrator.""" """EmailAgent — the channel-agnostic conversation orchestrator."""
import json
import logging import logging
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from ..models.conversation import ConversationState, ChatMessage from ..models.conversation import ConversationState, ChatMessage
from ..models.registration import BookingDay, RegistrationData from ..models.registration import RegistrationData
from .. import llm from .. import llm
from ..knowledge_base.loader import KnowledgeBase from ..knowledge_base.loader import KnowledgeBase
from ..storage.json_store import ConversationStore, normalize_email, _diff_registrations from ..storage.json_store import ConversationStore, normalize_email, _diff_registrations
from ..notifications.notifier import AdminNotifier from ..notifications.notifier import AdminNotifier
from .prompts import build_system_prompt from .prompts import build_system_prompt
from .response_parser import apply_updates, fallback_message, parse_llm_response
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Maximum number of inbound user messages before the conversation is stopped and
# escalated to the admin. This prevents runaway loops that slip through automated
# sender detection (e.g. a forwarding alias that bounces the agent's own replies).
MAX_USER_MESSAGES = 20
class EmailAgent: class EmailAgent:
"""Processes one inbound email and returns the agent's reply text. """Processes one inbound email and returns the agent's reply text.
@@ -32,8 +36,10 @@ class EmailAgent:
kb: KnowledgeBase, kb: KnowledgeBase,
store: ConversationStore, store: ConversationStore,
notifier: AdminNotifier, notifier: AdminNotifier,
thinking_budget: int | None = None,
) -> None: ) -> None:
self._model = model self._model = model
self._thinking_budget = thinking_budget
self._kb = kb self._kb = kb
self._store = store self._store = store
self._notifier = notifier self._notifier = notifier
@@ -77,6 +83,43 @@ class EmailAgent:
# Append the user's message to history # Append the user's message to history
state.messages.append(ChatMessage(role="user", content=message_text)) state.messages.append(ChatMessage(role="user", content=message_text))
# ------------------------------------------------------------------
# Hard message-count cap — stop conversations that have gone on too
# long without completing (covers loops that bypass automated-sender
# detection, e.g. a broken forwarding alias).
# ------------------------------------------------------------------
user_msg_count = sum(1 for m in state.messages if m.role == "user")
if user_msg_count > MAX_USER_MESSAGES:
if not state.loop_escalated:
state.loop_escalated = True
state.updated_at = now
self._store.save(state)
reason = (
f"conversation exceeded {MAX_USER_MESSAGES} inbound messages "
f"without completing"
)
logger.warning(
"Conversation %s exceeded message limit (%d user messages) — escalating",
email_key,
user_msg_count,
)
try:
self._notifier.notify_loop_escalation(
sender_email=parent_email,
conversation_id=email_key,
reason=reason,
message_count=user_msg_count,
)
except Exception:
logger.exception("Failed to send loop escalation notification for %s", email_key)
else:
logger.warning(
"Conversation %s still exceeding message limit — already escalated, ignoring",
email_key,
)
self._store.save(state)
return ""
# Route to the appropriate handler # Route to the appropriate handler
if state.completed: if state.completed:
reply_text = self._handle_post_completion(state) reply_text = self._handle_post_completion(state)
@@ -90,6 +133,60 @@ class EmailAgent:
return reply_text return reply_text
def handle_automated_message(
self,
sender_email: str,
subject: str,
reason: str,
inbound_message_id: str = "",
) -> None:
"""Handle an inbound message detected as automated/bounce.
Does NOT send any reply (to avoid looping). Alerts the admin once per
conversation — subsequent automated messages from the same sender are
silently dropped after the first alert.
"""
email_key = normalize_email(sender_email)
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
message_count = sum(1 for m in state.messages if m.role == "user")
if state.loop_escalated:
logger.info(
"Automated message from %s (already escalated) — dropping silently", sender_email
)
self._store.save(state)
return
state.loop_escalated = True
state.updated_at = now
self._store.save(state)
logger.warning(
"Automated/bounce message from %s — reason: %s — alerting admin", sender_email, reason
)
try:
self._notifier.notify_loop_escalation(
sender_email=sender_email,
conversation_id=email_key,
reason=reason,
message_count=message_count,
)
except Exception:
logger.exception(
"Failed to send loop escalation notification for automated sender %s", email_key
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Registration flow # Registration flow
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -99,7 +196,7 @@ class EmailAgent:
system = build_system_prompt(self._kb, state) system = build_system_prompt(self._kb, state)
try: try:
content = llm.complete(self._model, system, state.messages) content = llm.complete(self._model, system, state.messages, self._thinking_budget)
parsed = self._parse_llm_response(content) parsed = self._parse_llm_response(content)
except Exception: except Exception:
logger.exception("LLM call failed for %s", state.conversation_id) logger.exception("LLM call failed for %s", state.conversation_id)
@@ -128,6 +225,13 @@ class EmailAgent:
) )
except Exception: except Exception:
logger.exception("Failed to send admin notification for %s", email_key) logger.exception("Failed to send admin notification for %s", email_key)
try:
self._notifier.notify_parent(
registration=state.registration,
language=state.language,
)
except Exception:
logger.exception("Failed to send parent confirmation for %s", email_key)
logger.info("Registration complete for %s", state.conversation_id) logger.info("Registration complete for %s", state.conversation_id)
return reply_text return reply_text
@@ -141,7 +245,7 @@ class EmailAgent:
system = build_system_prompt(self._kb, state) system = build_system_prompt(self._kb, state)
try: try:
content = llm.complete(self._model, system, state.messages) content = llm.complete(self._model, system, state.messages, self._thinking_budget)
parsed = self._parse_llm_response(content) parsed = self._parse_llm_response(content)
except Exception: except Exception:
logger.exception("LLM call failed (post-completion) for %s", state.conversation_id) logger.exception("LLM call failed (post-completion) for %s", state.conversation_id)
@@ -192,79 +296,10 @@ class EmailAgent:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _parse_llm_response(self, content: str) -> dict: def _parse_llm_response(self, content: str) -> dict:
"""Extract the JSON payload from the LLM's raw output.""" return parse_llm_response(content)
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: def _fallback_message(self, state: ConversationState) -> str:
if state.language == "en": return fallback_message(state.language)
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: def _apply_updates(self, state: ConversationState, updates: dict) -> None:
"""Write extracted field values into the RegistrationData object.""" apply_updates(state, updates)
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)
+9
View File
@@ -1,6 +1,7 @@
"""Build the system prompt sent to the LLM on every turn.""" """Build the system prompt sent to the LLM on every turn."""
import json import json
from datetime import date
from ..knowledge_base.loader import KnowledgeBase from ..knowledge_base.loader import KnowledgeBase
from ..models.conversation import ConversationState from ..models.conversation import ConversationState
@@ -154,8 +155,12 @@ def _build_registration_prompt(kb: KnowledgeBase, state: ConversationState) -> s
reg_json = json.dumps(state.registration.to_dict(), ensure_ascii=False, indent=2) reg_json = json.dumps(state.registration.to_dict(), ensure_ascii=False, indent=2)
step_hint = STEP_DESCRIPTIONS.get(state.flow_step, "Continue the conversation.") step_hint = STEP_DESCRIPTIONS.get(state.flow_step, "Continue the conversation.")
today = date.today().isoformat()
return f"""You are the registration assistant for Spielgruppe Pumuckl, run by Familienverein Fällanden in Fällanden, Switzerland. You help parents register their children for the playgroup and answer questions about it. return f"""You are the registration assistant for Spielgruppe Pumuckl, run by Familienverein Fällanden in Fällanden, Switzerland. You help parents register their children for the playgroup and answer questions about it.
**Today's date is {today}.** Use this exact date when calculating a child's age from their date of birth.
{_PERSONALITY} {_PERSONALITY}
## Registration Flow (8 steps) ## Registration Flow (8 steps)
@@ -200,8 +205,12 @@ def _build_post_completion_prompt(kb: KnowledgeBase, state: ConversationState) -
reg_json = json.dumps(state.registration.to_dict(), ensure_ascii=False, indent=2) reg_json = json.dumps(state.registration.to_dict(), ensure_ascii=False, indent=2)
child_name = state.registration.child.full_name or "their child" child_name = state.registration.child.full_name or "their child"
today = date.today().isoformat()
return f"""You are the registration assistant for Spielgruppe Pumuckl, run by Familienverein Fällanden in Fällanden, Switzerland. return f"""You are the registration assistant for Spielgruppe Pumuckl, run by Familienverein Fällanden in Fällanden, Switzerland.
**Today's date is {today}.**
{_PERSONALITY} {_PERSONALITY}
## Context: Registration Already Complete ## Context: Registration Already Complete
+100
View File
@@ -0,0 +1,100 @@
"""Parse and apply LLM JSON responses — shared between email and chat channels."""
import json
import logging
import re
from ..models.conversation import ConversationState
from ..models.registration import BookingDay
logger = logging.getLogger(__name__)
def parse_llm_response(content: str) -> dict:
"""Extract the JSON payload from the LLM's raw output.
Tries three strategies in order:
1. Entire content is a fenced code block (```json ... ```)
2. Entire content is a bare JSON object
3. JSON object embedded somewhere in the text
Falls back to wrapping raw text as a ``reply`` if nothing parses.
"""
text = content.strip()
fence_match = re.match(r"^```(?:json)?\s*\n(.*?)\n```\s*$", text, re.DOTALL)
if fence_match:
text = fence_match.group(1).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
brace_match = re.search(r"\{.*\}", text, re.DOTALL)
if brace_match:
try:
return json.loads(brace_match.group())
except json.JSONDecodeError:
pass
logger.warning("Could not parse LLM response as JSON — using raw text as reply.")
return {
"reply": content,
"intent": "question",
"updates": {},
"next_step": "greeting",
"registration_complete": False,
"language": "de",
}
def apply_updates(state: ConversationState, updates: dict) -> None:
"""Write extracted field values into the RegistrationData on *state*."""
reg = state.registration
field_map = {
"child.fullName": lambda v: setattr(reg.child, "full_name", v),
"child.dateOfBirth": lambda v: setattr(reg.child, "date_of_birth", v),
"child.specialNeeds": lambda v: setattr(reg.child, "special_needs", v),
"parentGuardian.fullName": lambda v: (
setattr(reg.parent_guardian, "full_name", v),
setattr(state, "parent_name", v),
),
"parentGuardian.streetAddress": lambda v: setattr(reg.parent_guardian, "street_address", v),
"parentGuardian.postalCode": lambda v: setattr(reg.parent_guardian, "postal_code", str(v)),
"parentGuardian.city": lambda v: setattr(reg.parent_guardian, "city", v),
"parentGuardian.phone": lambda v: setattr(reg.parent_guardian, "phone", v),
"parentGuardian.email": lambda v: setattr(reg.parent_guardian, "email", v),
"emergencyContact.fullName": lambda v: setattr(reg.emergency_contact, "full_name", v),
"emergencyContact.phone": lambda v: setattr(reg.emergency_contact, "phone", v),
}
for key, value in updates.items():
if value is None:
continue
if key in field_map:
field_map[key](value)
elif key == "booking.playgroupTypes" and isinstance(value, list):
reg.booking.playgroup_types = value
elif key == "booking.selectedDays" and isinstance(value, list):
reg.booking.selected_days = [
BookingDay(day=d["day"], type=d["type"])
for d in value
if isinstance(d, dict) and "day" in d and "type" in d
]
else:
logger.debug("Unknown update key ignored: %s", key)
def fallback_message(language: str) -> str:
"""Return a safe error message in the parent's detected language."""
if language == "en":
return (
"I'm sorry, I'm having a technical issue right now. "
"Please try again in a moment or contact us directly."
)
return (
"Entschuldigung, ich habe gerade ein technisches Problem. "
"Bitte versuche es gleich nochmal oder kontaktiere uns direkt."
)
+73
View File
@@ -89,6 +89,75 @@ def _strip_quoted_text(text: str) -> str:
return "\n".join(result).strip() return "\n".join(result).strip()
# ---------------------------------------------------------------------------
# Automated / bounce message detection
# ---------------------------------------------------------------------------
# Local parts of sender addresses that are never humans (RFC 5321 §4.5.4, common practice).
_AUTOMATED_SENDER_RE = re.compile(
r"^(mailer-daemon|postmaster|noreply|no-reply|no\.reply|do-not-reply|"
r"donotreply|bounce|bounce\+.*|delivery|mail-delivery|mail\.delivery)$",
re.IGNORECASE,
)
# Subject lines that indicate delivery failure or automated responses.
_AUTOMATED_SUBJECT_RE = re.compile(
r"(undelivered mail|undeliverable|delivery (failed|status|notification)|"
r"mail delivery (failed|error)|returned to sender|mailer-daemon|"
r"auto.?reply|out of office|außer haus|abwesenheitsnotiz|automatische antwort)",
re.IGNORECASE,
)
def detect_automated_message(raw_msg: email.message.Message, from_addr: str) -> tuple[bool, str]:
"""Detect whether an email was generated by an automated system, not a human.
Checks (in order of reliability):
1. Sender local-part (mailer-daemon, postmaster, noreply, …)
2. Auto-Submitted header (RFC 3834)
3. X-Auto-Response-Suppress header (Microsoft Exchange)
4. Content-Type: multipart/report (RFC 3462 — Delivery Status Notifications)
5. X-Loop header
6. Precedence: bulk/junk
7. Subject-line heuristics
Returns:
(True, reason_string) if automated, (False, "") otherwise.
"""
local = from_addr.split("@")[0] if "@" in from_addr else from_addr
if _AUTOMATED_SENDER_RE.match(local):
return True, f"sender matches automated address pattern: {from_addr}"
# RFC 3834 — Auto-Submitted header
auto_submitted = raw_msg.get("Auto-Submitted", "").strip().lower()
if auto_submitted and auto_submitted != "no":
return True, f"Auto-Submitted: {auto_submitted}"
# Microsoft Exchange — suppresses auto-replies
if raw_msg.get("X-Auto-Response-Suppress"):
return True, "X-Auto-Response-Suppress header present"
# RFC 3462 — multipart/report is used for DSNs and MDNs
if raw_msg.get_content_type() == "multipart/report":
return True, "Content-Type: multipart/report (delivery status notification)"
# X-Loop — set by mailing-list managers and some MTAs to break loops
if raw_msg.get("X-Loop"):
return True, f"X-Loop: {raw_msg.get('X-Loop')}"
# Precedence header
precedence = raw_msg.get("Precedence", "").strip().lower()
if precedence in ("bulk", "junk", "auto_reply"):
return True, f"Precedence: {precedence}"
# Subject heuristic (weakest — only flag when highly specific)
subject = _decode_header(raw_msg.get("Subject", ""))
if _AUTOMATED_SUBJECT_RE.search(subject):
return True, f"subject matches automated pattern: {subject!r}"
return False, ""
def _generate_message_id(from_addr: str) -> str: def _generate_message_id(from_addr: str) -> str:
domain = from_addr.split("@")[-1] if "@" in from_addr else "meister-eder.local" domain = from_addr.split("@")[-1] if "@" in from_addr else "meister-eder.local"
return f"<{time.time():.6f}.{id(from_addr)}@{domain}>" return f"<{time.time():.6f}.{id(from_addr)}@{domain}>"
@@ -185,6 +254,8 @@ class EmailChannel:
imap.store(num, "+FLAGS", "\\Seen") imap.store(num, "+FLAGS", "\\Seen")
continue continue
is_automated, automated_reason = detect_automated_message(msg, from_addr)
messages.append( messages.append(
{ {
"from": from_addr, "from": from_addr,
@@ -194,6 +265,8 @@ class EmailChannel:
"references": references, "references": references,
"body": body, "body": body,
"raw_body": raw_body, "raw_body": raw_body,
"is_automated": is_automated,
"automated_reason": automated_reason,
} }
) )
imap.store(num, "+FLAGS", "\\Seen") imap.store(num, "+FLAGS", "\\Seen")
+32 -2
View File
@@ -1,9 +1,12 @@
"""Configuration loaded from environment variables.""" """Configuration loaded from environment variables."""
import logging
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__)
try: try:
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@@ -13,10 +16,17 @@ except ImportError:
@dataclass @dataclass
class Config: class Config:
# AI model — litellm format, e.g. "anthropic/claude-opus-4-6" or "openai/gpt-4o". # Primary model for conversation with parents — litellm format.
# The matching API key must be set as an env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, …). # The matching API key must be set as an env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
# Example: "anthropic/claude-opus-4-6", "google/gemini-2.0-flash", "openai/gpt-4o"
ai_model: str = "anthropic/claude-opus-4-6" ai_model: str = "anthropic/claude-opus-4-6"
# Lightweight model for simple tasks such as email-label translation.
# Can be from a different provider than ai_model.
# If not configured (SIMPLE_MODEL env var unset), falls back to ai_model with a warning.
# Example: "anthropic/claude-haiku-4-5-20251001", "openai/gpt-4o-mini"
simple_model: str = ""
# Email — IMAP (receiving) # Email — IMAP (receiving)
imap_host: str = "" imap_host: str = ""
imap_port: int = 993 imap_port: int = 993
@@ -50,10 +60,25 @@ class Config:
# Polling interval in seconds # Polling interval in seconds
poll_interval: int = 60 poll_interval: int = 60
# Extended thinking — Anthropic models only.
# When set, enables the thinking phase before the LLM replies.
# Recommended value: 8000 (tokens). Set to None/unset to disable.
thinking_budget: int | None = None
@classmethod @classmethod
def from_env(cls) -> "Config": def from_env(cls) -> "Config":
ai_model = os.getenv("AI_MODEL", "anthropic/claude-opus-4-6")
simple_model = os.getenv("SIMPLE_MODEL", "")
if not simple_model:
logger.warning(
"SIMPLE_MODEL not configured — falling back to AI_MODEL (%s) for simple tasks "
"(set SIMPLE_MODEL to a cheaper model, e.g. anthropic/claude-haiku-4-5-20251001)",
ai_model,
)
simple_model = ai_model
return cls( return cls(
ai_model=os.getenv("AI_MODEL", "anthropic/claude-opus-4-6"), ai_model=ai_model,
simple_model=simple_model,
imap_host=os.getenv("IMAP_HOST", ""), imap_host=os.getenv("IMAP_HOST", ""),
imap_port=int(os.getenv("IMAP_PORT", "993")), imap_port=int(os.getenv("IMAP_PORT", "993")),
imap_username=os.getenv("IMAP_USERNAME", ""), imap_username=os.getenv("IMAP_USERNAME", ""),
@@ -74,4 +99,9 @@ class Config:
) )
), ),
poll_interval=int(os.getenv("POLL_INTERVAL", "60")), poll_interval=int(os.getenv("POLL_INTERVAL", "60")),
thinking_budget=(
int(os.getenv("THINKING_BUDGET"))
if os.getenv("THINKING_BUDGET")
else None
),
) )
+78 -2
View File
@@ -1,9 +1,50 @@
"""LLM completion via litellm — supports any provider with a single call.""" """LLM completion via litellm — supports any provider with a single call."""
from collections.abc import Generator
import litellm import litellm
def complete(model: str, system: str, messages: list) -> str: async def acomplete(
model: str,
system: str,
messages: list,
thinking_budget: int | None = None,
) -> str:
"""Call any LLM asynchronously and return the response text.
This is the async equivalent of ``complete()`` — use this from async
handlers (e.g. Chainlit's ``@cl.on_message``) to avoid blocking the
event loop and losing framework context variables.
Args:
model: litellm model string, e.g. "anthropic/claude-opus-4-6".
system: System prompt text.
messages: List of objects with .role and .content attributes.
thinking_budget: When set, enables extended thinking (Anthropic models
only). See ``complete()`` for details.
Returns:
The model's reply as a plain string.
"""
api_messages = [{"role": "system", "content": system}]
api_messages += [{"role": m.role, "content": m.content} for m in messages]
kwargs: dict = {"model": model, "messages": api_messages, "max_tokens": 2048}
if thinking_budget is not None:
kwargs["thinking"] = {"type": "enabled", "budget_tokens": thinking_budget}
kwargs["max_tokens"] = thinking_budget + 4096
response = await litellm.acompletion(**kwargs)
return response.choices[0].message.content
def complete(
model: str,
system: str,
messages: list,
thinking_budget: int | None = None,
) -> str:
"""Call any LLM and return the response text. """Call any LLM and return the response text.
Args: Args:
@@ -12,11 +53,46 @@ def complete(model: str, system: str, messages: list) -> str:
environment variable (ANTHROPIC_API_KEY, OPENAI_API_KEY, …). environment variable (ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
system: System prompt text. system: System prompt text.
messages: List of objects with .role and .content attributes. messages: List of objects with .role and .content attributes.
thinking_budget: When set, enables extended thinking (Anthropic models
only). The value is the token budget for the thinking phase; the
final ``max_tokens`` is set to ``thinking_budget + 4096`` so the
model has enough room to both think and reply.
Returns: Returns:
The model's reply as a plain string. The model's reply as a plain string.
""" """
api_messages = [{"role": "system", "content": system}] api_messages = [{"role": "system", "content": system}]
api_messages += [{"role": m.role, "content": m.content} for m in messages] api_messages += [{"role": m.role, "content": m.content} for m in messages]
response = litellm.completion(model=model, messages=api_messages, max_tokens=2048)
kwargs: dict = {"model": model, "messages": api_messages, "max_tokens": 2048}
if thinking_budget is not None:
kwargs["thinking"] = {"type": "enabled", "budget_tokens": thinking_budget}
# max_tokens must exceed budget_tokens or the API returns an error.
kwargs["max_tokens"] = thinking_budget + 4096
response = litellm.completion(**kwargs)
return response.choices[0].message.content return response.choices[0].message.content
def stream_complete(
model: str, system: str, messages: list
) -> Generator[str, None, None]:
"""Call any LLM with streaming and yield text chunks as they arrive.
Args:
model: litellm model string (same format as ``complete``).
system: System prompt text.
messages: List of objects with .role and .content attributes.
Yields:
Non-empty text chunks from the model's streamed response.
"""
api_messages = [{"role": "system", "content": system}]
api_messages += [{"role": m.role, "content": m.content} for m in messages]
response = litellm.completion(
model=model, messages=api_messages, max_tokens=2048, stream=True
)
for chunk in response:
delta = chunk.choices[0].delta.content
if delta:
yield delta
+5
View File
@@ -35,6 +35,9 @@ class ConversationState:
# Most recent inbound Message-ID — used for reply threading headers only, # Most recent inbound Message-ID — used for reply threading headers only,
# NOT for conversation matching (which is always by email address). # NOT for conversation matching (which is always by email address).
last_inbound_message_id: str = "" last_inbound_message_id: str = ""
# Loop / automated-sender prevention.
# Set to True once the admin has been notified; prevents repeated alerts.
loop_escalated: bool = False
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -54,6 +57,7 @@ class ConversationState:
"completed": self.completed, "completed": self.completed,
"reminder_count": self.reminder_count, "reminder_count": self.reminder_count,
"last_inbound_message_id": self.last_inbound_message_id, "last_inbound_message_id": self.last_inbound_message_id,
"loop_escalated": self.loop_escalated,
} }
@classmethod @classmethod
@@ -78,4 +82,5 @@ class ConversationState:
state.completed = data.get("completed", False) state.completed = data.get("completed", False)
state.reminder_count = data.get("reminder_count", 0) state.reminder_count = data.get("reminder_count", 0)
state.last_inbound_message_id = data.get("last_inbound_message_id", "") state.last_inbound_message_id = data.get("last_inbound_message_id", "")
state.loop_escalated = data.get("loop_escalated", False)
return state return state
+204
View File
@@ -0,0 +1,204 @@
"""Pure functions for building email context dicts from registration data."""
from datetime import date, datetime
from ..models.registration import RegistrationData
# ---------------------------------------------------------------------------
# Swiss QR-bill payment constants (stable bank details — not in config)
# ---------------------------------------------------------------------------
QR_IBAN = "CH14 0900 0000 4930 8018 8"
QR_PAYEE = "Familienverein Fällanden Spielgruppen"
QR_STREET = "Huebwisstrase 5"
QR_PCODE = "8117"
QR_CITY = "Fällanden"
# ---------------------------------------------------------------------------
# Formatting helpers (pure functions, no side-effects)
# ---------------------------------------------------------------------------
def format_dob(dob_str: str) -> str:
"""Return DD.MM.YYYY from a YYYY-MM-DD string, or the original on error."""
try:
return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
return dob_str or ""
def calculate_age(dob_str: str) -> str:
"""Return 'X Jahre, Y Monate' from a YYYY-MM-DD string."""
try:
dob = datetime.strptime(dob_str, "%Y-%m-%d").date()
today = date.today()
years = today.year - dob.year - (
(today.month, today.day) < (dob.month, dob.day)
)
months = (today.month - dob.month) % 12
return f"{years} Jahre, {months} Monate"
except Exception:
return dob_str
def format_types(types: list[str]) -> str:
"""German label for a list of playgroup type keys (admin emails)."""
has_indoor = "indoor" in types
has_outdoor = "outdoor" in types
if has_indoor and has_outdoor:
return "Innen- und Waldspielgruppe"
if has_indoor:
return "Innenspielgruppe"
if has_outdoor:
return "Waldspielgruppe"
return "Spielgruppe"
def format_types_i18n(types: list[str], strings: dict) -> str:
"""Localised label for playgroup type keys using the supplied string table."""
type_map: dict = strings["types"]
labels = [type_map.get(t, t) for t in types]
return ", ".join(labels) if labels else ""
def format_days(registration: RegistrationData) -> str:
"""German day + type labels for admin emails."""
day_map = {"monday": "Montag", "wednesday": "Mittwoch", "thursday": "Donnerstag"}
type_map = {"indoor": "Innenspielgruppe", "outdoor": "Waldspielgruppe"}
return ", ".join(
f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})"
for d in registration.booking.selected_days
)
def format_days_i18n(registration: RegistrationData, strings: dict) -> str:
"""Localised day + type labels using the supplied string table."""
day_map: dict = strings["days"]
type_map: dict = strings["types"]
return ", ".join(
f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})"
for d in registration.booking.selected_days
)
def calculate_monthly_fee(registration: RegistrationData) -> str:
"""Compute the monthly fee string from the booking selection."""
indoor_days = sum(1 for d in registration.booking.selected_days if d.type == "indoor")
outdoor_days = sum(1 for d in registration.booking.selected_days if d.type == "outdoor")
fee = indoor_days * 130 + outdoor_days * 250
return f"CHF {fee}.-"
# ---------------------------------------------------------------------------
# Context builders
# ---------------------------------------------------------------------------
def build_admin_new_context(
registration: RegistrationData,
registration_id: str,
version: int,
channel: str,
) -> dict:
"""Build the template context for the admin new-registration email."""
now = datetime.utcnow()
pg = registration.parent_guardian
ec = registration.emergency_contact
ch = registration.child
channel_de = {"email": "E-Mail", "chat": "Chat"}.get(channel.lower(), channel.title())
return {
"submitted_date": now.strftime("%d.%m.%Y"),
"submitted_time": now.strftime("%H:%M"),
"channel": channel_de,
"registration_id": registration_id,
"version": version,
"child_name": ch.full_name or "",
"child_dob": format_dob(ch.date_of_birth or ""),
"child_age": calculate_age(ch.date_of_birth or ""),
"child_needs": ch.special_needs or "Keine",
"playgroup_types": format_types(registration.booking.playgroup_types),
"days": format_days(registration),
"monthly_fee": calculate_monthly_fee(registration),
"parent_name": pg.full_name or "",
"parent_street": pg.street_address or "",
"parent_postal_code": pg.postal_code or "",
"parent_city": pg.city or "",
"parent_phone": pg.phone or "",
"parent_email": pg.email or "",
"emergency_name": ec.full_name or "",
"emergency_phone": ec.phone or "",
}
def build_admin_update_context(
registration: RegistrationData,
registration_id: str,
version: int,
change_summary: dict,
) -> dict:
"""Build the template context for the admin registration-update email."""
now = datetime.utcnow()
pg = registration.parent_guardian
changes = [
{"field": field_path, "old": values["old"], "new": values["new"]}
for field_path, values in sorted(change_summary.items())
]
return {
"updated_date": now.strftime("%d.%m.%Y"),
"updated_time": now.strftime("%H:%M"),
"registration_id": registration_id,
"version": version,
"child_name": registration.child.full_name or "",
"parent_email": pg.email or "",
"changes": changes,
"playgroup_types": format_types(registration.booking.playgroup_types),
"days": format_days(registration),
"monthly_fee": calculate_monthly_fee(registration),
"parent_name": pg.full_name or "",
"parent_street": pg.street_address or "",
"parent_postal_code": pg.postal_code or "",
"parent_city": pg.city or "",
"parent_phone": pg.phone or "",
}
def build_parent_context(
registration: RegistrationData,
strings: dict,
has_qr: bool = True,
) -> dict:
"""Build the template context for the parent confirmation email."""
pg = registration.parent_guardian
ec = registration.emergency_contact
ch = registration.child
parent_name = pg.full_name or pg.email or ""
return {
"lang": "de" if strings.get("none") == "Keine" else "en",
"strings": strings,
"greeting": strings["greeting"].format(name=parent_name),
"child_name": ch.full_name or "",
"child_dob": format_dob(ch.date_of_birth or ""),
"child_needs": ch.special_needs or strings["none"],
"playgroup_types": format_types_i18n(registration.booking.playgroup_types, strings),
"days": format_days_i18n(registration, strings),
"monthly_fee": calculate_monthly_fee(registration),
"has_indoor": "indoor" in registration.booking.playgroup_types,
"has_qr": has_qr,
"parent_name": pg.full_name or "",
"parent_address": pg.street_address or "",
"parent_postal_code": pg.postal_code or "",
"parent_city": pg.city or "",
"parent_phone": pg.phone or "",
"parent_email": pg.email or "",
"emergency_name": ec.full_name or "",
"emergency_phone": ec.phone or "",
"iban": QR_IBAN,
"payee": QR_PAYEE,
"payee_street": QR_STREET,
"payee_postal_code": QR_PCODE,
"payee_city": QR_CITY,
}
+103
View File
@@ -0,0 +1,103 @@
"""i18n support for parent confirmation emails.
German is the canonical source language (de.yaml). For any other language
the German labels are translated on-demand via an LLM call and cached
in-process for the lifetime of the server — no static translation files to
maintain, any language the parent writes in is supported automatically.
"""
import json
import logging
from pathlib import Path
import litellm
import yaml
logger = logging.getLogger(__name__)
_I18N_DIR = Path(__file__).parent / "i18n"
# In-memory translation cache keyed by language code.
_cache: dict[str, dict] = {}
# Pure data values that must never be sent to the LLM for translation.
_PASSTHROUGH_KEYS = {"reg_fee_amount", "deposit_amount"}
_SYSTEM_PROMPT = """\
You are a translation assistant for a Swiss playgroup registration system.
Translate the following JSON label strings from German into {language}.
Rules:
- Return ONLY a valid JSON object with the exact same keys and structure.
- Preserve all {{placeholder}} variables exactly as-is (e.g. {{name}}).
- Preserve all HTML tags and entities exactly (e.g. <strong>, &nbsp;).
- Keep proper nouns untranslated: "Spielgruppe Pumuckl", "Familienverein Fällanden".
- Do not include any explanation or text outside the JSON."""
def get_strings(language: str, model: str) -> dict:
"""Return the label string table for *language*.
For German, loads directly from de.yaml (no LLM call).
For all other languages, translates the German labels via LLM and caches
the result in memory. Falls back to German if the LLM call fails.
"""
if language == "de":
return _load_german()
if language in _cache:
return _cache[language]
german = _load_german()
translated = _translate(german, language, model)
_cache[language] = translated
return translated
def clear_cache() -> None:
"""Evict all cached translations (intended for use in tests)."""
_cache.clear()
def _load_german() -> dict:
with (_I18N_DIR / "de.yaml").open(encoding="utf-8") as fh:
return yaml.safe_load(fh)
def _translate(german: dict, language: str, model: str) -> dict:
"""Translate the German label dict into *language* via LLM.
Returns the German dict unchanged if the LLM call fails or returns
malformed JSON.
"""
passthrough = {k: german[k] for k in _PASSTHROUGH_KEYS if k in german}
to_translate = {k: v for k, v in german.items() if k not in _PASSTHROUGH_KEYS}
system = _SYSTEM_PROMPT.format(language=language)
payload = json.dumps(to_translate, ensure_ascii=False, indent=2)
try:
response = litellm.completion(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": payload},
],
max_tokens=2048,
)
raw = response.choices[0].message.content.strip()
# Strip markdown code fences that some models add
if raw.startswith("```"):
raw = raw[raw.index("\n") + 1 :]
raw = raw[: raw.rfind("```")]
translated: dict = json.loads(raw)
translated.update(passthrough)
return translated
except Exception:
logger.exception(
"Failed to translate email labels into %s — falling back to German", language
)
return german
+57
View File
@@ -0,0 +1,57 @@
subject: "Anmeldebestätigung Spielgruppe Pumuckl"
greeting: "Guten Tag {name}"
intro: >-
Deine Anmeldung für die Spielgruppe Pumuckl ist bei uns eingegangen.
Hier ist eine Zusammenfassung:
child_section: "Angaben zum Kind"
child_name: "Name"
child_dob: "Geburtsdatum"
child_needs: "Besondere Bedürfnisse"
booking_section: "Spielgruppen-Buchung"
booking_type: "Art"
booking_days: "Tage"
fees_section: "Kosten"
monthly_fee: "Monatlicher Beitrag"
reg_fee: "Anmeldegebühr (einmalig, erstes Jahr)"
reg_fee_amount: "CHF 80.00"
deposit: "Reinigungsdepot Innen (rückerstattbar)"
deposit_amount: "CHF 50.00"
parent_section: "Deine Kontaktdaten"
parent_name: "Name"
parent_address: "Adresse"
parent_phone: "Telefon"
parent_email: "E-Mail"
emergency_section: "Notfallkontakt"
emergency_name: "Name"
emergency_phone: "Telefon"
payment_section: "Zahlungsinformationen"
payment_intro: >-
Bitte überweise die Anmeldegebühr von <strong>CHF&nbsp;80.00</strong> auf folgendes Konto.
Du kannst den QR-Code mit deiner Banking-App scannen:
payment_intro_text: "Bitte überweise die Anmeldegebühr von CHF 80.00 auf folgendes Konto:"
iban_label: "IBAN"
payee_label: "Empfänger"
amount_label: "Betrag"
closing: |
Bei Fragen stehen wir dir gerne zur Verfügung. Wir freuen uns auf dein Kind!
Herzliche Grüsse
Spielgruppe Pumuckl
none: "Keine"
days:
monday: "Montag"
wednesday: "Mittwoch"
thursday: "Donnerstag"
types:
indoor: "Innenspielgruppe"
outdoor: "Waldspielgruppe"
+185 -168
View File
@@ -1,12 +1,31 @@
"""Admin email notifications — new registrations and registration updates.""" """Admin email notifications — new registrations, updates, and parent confirmations."""
import io
import logging import logging
import smtplib import smtplib
from datetime import date, datetime from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
import qrcode
import qrcode.constants
from PIL import Image, ImageDraw
from qrbill import QRBill
from ..models.registration import RegistrationData from ..models.registration import RegistrationData
from .context import (
QR_CITY,
QR_IBAN,
QR_PAYEE,
QR_PCODE,
QR_STREET,
build_admin_new_context,
build_admin_update_context,
build_parent_context,
format_types,
)
from .i18n import get_strings
from .renderer import render_template
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -32,6 +51,7 @@ class AdminNotifier:
indoor_email: str = "", indoor_email: str = "",
outdoor_email: str = "", outdoor_email: str = "",
cc_emails: list[str] | None = None, cc_emails: list[str] | None = None,
model: str = "anthropic/claude-haiku-4-5-20251001",
) -> None: ) -> None:
self._smtp_host = smtp_host self._smtp_host = smtp_host
self._smtp_port = smtp_port self._smtp_port = smtp_port
@@ -42,6 +62,7 @@ class AdminNotifier:
self._indoor_email = indoor_email self._indoor_email = indoor_email
self._outdoor_email = outdoor_email self._outdoor_email = outdoor_email
self._cc_emails: list[str] = cc_emails or [] self._cc_emails: list[str] = cc_emails or []
self._model = model
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public API # Public API
@@ -67,9 +88,10 @@ class AdminNotifier:
subject = ( subject = (
f"Neue Anmeldung: {registration.child.full_name} " f"Neue Anmeldung: {registration.child.full_name} "
f" {self._format_types(types)}" f" {format_types(types)}"
) )
body = self._build_new_body(registration, registration_id, version, channel) ctx = build_admin_new_context(registration, registration_id, version, channel)
body = render_template("admin_new.txt.j2", ctx)
self._send( self._send(
to=to_addresses, to=to_addresses,
@@ -98,7 +120,8 @@ class AdminNotifier:
return return
subject = f"Anmeldung aktualisiert: {registration.child.full_name}" subject = f"Anmeldung aktualisiert: {registration.child.full_name}"
body = self._build_update_body(registration, registration_id, version, change_summary) ctx = build_admin_update_context(registration, registration_id, version, change_summary)
body = render_template("admin_update.txt.j2", ctx)
self._send( self._send(
to=to_addresses, to=to_addresses,
@@ -108,6 +131,120 @@ class AdminNotifier:
reply_to=registration.parent_guardian.email or "", reply_to=registration.parent_guardian.email or "",
) )
def notify_loop_escalation(
self,
sender_email: str,
conversation_id: str,
reason: str,
message_count: int,
) -> None:
"""Alert the admin that a conversation was stopped due to a loop or automated sender.
Sent to the CC list (Markus Graf / admin) only — no playgroup leader routing needed.
"""
if not self._cc_emails:
logger.warning(
"No admin CC email configured — loop escalation NOT sent for %s", conversation_id
)
return
subject = f"[WARNUNG] Automatische E-Mail / Endlosschleife erkannt: {sender_email}"
body = (
f"Das Anmeldungssystem hat eine Konversation automatisch gestoppt.\n\n"
f"Absender: {sender_email}\n"
f"Konversations-ID: {conversation_id}\n"
f"Nachrichten: {message_count}\n"
f"Grund: {reason}\n\n"
f"Es wurde keine weitere Antwort gesendet. Bitte prüfen Sie den Sachverhalt "
f"manuell und leiten Sie die Konversation bei Bedarf weiter.\n\n"
f"---\nMeister-Eder Anmeldungssystem"
)
self._send(
to=self._cc_emails,
cc=[],
subject=subject,
body=body,
)
logger.info(
"Loop escalation notification sent to admin for conversation %s (reason: %s)",
conversation_id,
reason,
)
def notify_parent(
self,
registration: RegistrationData,
language: str = "de",
) -> None:
"""Send an HTML confirmation email to the parent with registration summary and QR-bill."""
parent_email = registration.parent_guardian.email
if not parent_email:
logger.warning("No parent email in registration — confirmation not sent.")
return
strings = get_strings(language, self._model)
try:
qr_png = self._generate_qr_bill_png()
except Exception:
logger.exception("Failed to generate QR-bill PNG — omitting image from confirmation")
qr_png = None
ctx = build_parent_context(registration, strings, has_qr=qr_png is not None)
html_body = render_template("parent_confirmation.html.j2", ctx)
text_body = render_template("parent_confirmation.txt.j2", ctx)
subject = strings["subject"]
if not self._smtp_host:
logger.warning(
"SMTP not configured — parent confirmation NOT sent. Would have emailed %s: %s",
parent_email,
subject,
)
logger.debug("Parent confirmation body:\n%s", text_body)
return
# MIME structure:
# multipart/mixed
# └── multipart/alternative
# ├── text/plain (fallback)
# └── multipart/related
# ├── text/html (references cid:qrbill)
# └── image/png (Content-ID: qrbill, inline)
msg_outer = MIMEMultipart("mixed")
msg_outer["From"] = self._from_email
msg_outer["To"] = parent_email
msg_outer["Subject"] = subject
msg_alt = MIMEMultipart("alternative")
msg_alt.attach(MIMEText(text_body, "plain", "utf-8"))
if qr_png is not None:
msg_related = MIMEMultipart("related")
msg_related.attach(MIMEText(html_body, "html", "utf-8"))
img_part = MIMEImage(qr_png, "png")
img_part.add_header("Content-ID", "<qrbill>")
img_part.add_header("Content-Disposition", "inline", filename="qrbill.png")
msg_related.attach(img_part)
msg_alt.attach(msg_related)
else:
msg_alt.attach(MIMEText(html_body, "html", "utf-8"))
msg_outer.attach(msg_alt)
try:
if self._use_tls:
server = smtplib.SMTP(self._smtp_host, self._smtp_port)
server.starttls()
else:
server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port)
server.login(self._username, self._password)
server.sendmail(self._from_email, [parent_email], msg_outer.as_string())
server.quit()
logger.info("Parent confirmation sent to %s", parent_email)
except Exception:
logger.exception("Failed to send parent confirmation to %s", parent_email)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Routing helpers # Routing helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -122,178 +259,58 @@ class AdminNotifier:
return recipients return recipients
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Formatting helpers # QR-bill generation
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@staticmethod @staticmethod
def _format_types(types: list[str]) -> str: def _generate_qr_bill_png() -> bytes:
has_indoor = "indoor" in types """Generate a Swiss QR-bill payment QR code as a PNG image.
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 Uses the fixed registration fee payment data (CHF 80.00).
def _calculate_age(dob_str: str) -> str: The QR code includes the Swiss cross overlay as required by the SIX Group standard.
try:
dob = datetime.strptime(dob_str, "%Y-%m-%d").date() Returns:
today = date.today() PNG image bytes of the QR code.
years = today.year - dob.year - ( """
(today.month, today.day) < (dob.month, dob.day) bill = QRBill(
account=QR_IBAN,
creditor={
"name": QR_PAYEE,
"street": QR_STREET,
"pcode": QR_PCODE,
"city": QR_CITY,
"country": "CH",
},
amount="80.00",
currency="CHF",
) )
months = (today.month - dob.month) % 12 payload = bill.qr_data()
return f"{years} Jahre, {months} Monate"
except Exception:
return dob_str
@staticmethod qr = qrcode.QRCode(
def _format_dob(dob_str: str) -> str: version=None,
try: error_correction=qrcode.constants.ERROR_CORRECT_M,
return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y") box_size=8,
except Exception: border=4,
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
) )
qr.add_data(payload)
qr.make(fit=True)
pil_img: Image.Image = qr.make_image(fill_color="black", back_color="white").get_image()
pil_img = pil_img.convert("RGB")
@staticmethod # Overlay Swiss cross in center (SIX Group standard)
def _format_change_summary(change_summary: dict) -> str: w, h = pil_img.size
"""Render field changes as a human-readable list.""" cross_size = max(int(w * 0.15), 20)
lines = [] cx, cy = w // 2, h // 2
for field_path, values in sorted(change_summary.items()): half = cross_size // 2
old_val, new_val = values["old"], values["new"] bar = cross_size // 5
lines.append(f" {field_path}:") draw = ImageDraw.Draw(pil_img)
lines.append(f" Alt: {old_val}") draw.rectangle([cx - half, cy - half, cx + half, cy + half], fill="white")
lines.append(f" Neu: {new_val}") draw.rectangle([cx - bar // 2, cy - half, cx + bar // 2, cy + half], fill="#FF0000")
return "\n".join(lines) if lines else " (keine Änderungen erkannt)" draw.rectangle([cx - half, cy - bar // 2, cx + half, cy + bar // 2], fill="#FF0000")
# ------------------------------------------------------------------ buf = io.BytesIO()
# Email body builders pil_img.save(buf, format="PNG")
# ------------------------------------------------------------------ return buf.getvalue()
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 # SMTP dispatch
+18
View File
@@ -0,0 +1,18 @@
"""Jinja2 template renderer for email notifications."""
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
_TEMPLATES_DIR = Path(__file__).parent / "templates"
_env = Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
autoescape=select_autoescape(enabled_extensions=["html.j2"]),
keep_trailing_newline=True,
)
def render_template(name: str, context: dict) -> str:
"""Render *name* (relative to the templates directory) with *context*."""
return _env.get_template(name).render(**context)
@@ -0,0 +1,42 @@
===============================================
NEUE SPIELGRUPPEN-ANMELDUNG
===============================================
Eingereicht: {{ submitted_date }} um {{ submitted_time }} Uhr (UTC)
Kanal: {{ channel }}
Anmelde-ID: {{ registration_id }} (Version {{ version }})
-----------------------------------------------
ANGABEN ZUM KIND
-----------------------------------------------
Name: {{ child_name }}
Geburtsdatum: {{ child_dob }} (Alter: {{ child_age }})
Bes. Bedürfnisse: {{ child_needs }}
-----------------------------------------------
SPIELGRUPPEN-AUSWAHL
-----------------------------------------------
Art: {{ playgroup_types }}
Tage: {{ days }}
Monatlicher Beitrag: {{ monthly_fee }}
(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung)
-----------------------------------------------
ELTERN / ERZIEHUNGSBERECHTIGTE
-----------------------------------------------
Name: {{ parent_name }}
Adresse: {{ parent_street }}
{{ parent_postal_code }} {{ parent_city }}
Telefon: {{ parent_phone }}
E-Mail: {{ parent_email }}
-----------------------------------------------
NOTFALLKONTAKT
-----------------------------------------------
Name: {{ emergency_name }}
Telefon: {{ emergency_phone }}
===============================================
Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht.
@@ -0,0 +1,33 @@
===============================================
ANMELDUNGS-AKTUALISIERUNG
===============================================
Aktualisiert: {{ updated_date }} um {{ updated_time }} Uhr (UTC)
Anmelde-ID: {{ registration_id }} (Version {{ version }})
Kind: {{ child_name }}
Eltern-E-Mail: {{ parent_email }}
-----------------------------------------------
WAS HAT SICH GEÄNDERT
-----------------------------------------------
{% for change in changes %}
{{ change.field }}:
Alt: {{ change.old }}
Neu: {{ change.new }}
{% else %}
(keine Änderungen erkannt)
{% endfor %}
-----------------------------------------------
AKTUELLE ANMELDUNG (nach Aktualisierung)
-----------------------------------------------
Spielgruppe: {{ playgroup_types }}
Tage: {{ days }}
Monatl. Beitrag: {{ monthly_fee }}
Elternteil: {{ parent_name }}
Adresse: {{ parent_street }}, {{ parent_postal_code }} {{ parent_city }}
Telefon: {{ parent_phone }}
===============================================
Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht.
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:0 auto;padding:20px;">
<div style="background:#2e7d32;color:white;padding:20px;border-radius:8px 8px 0 0;text-align:center;">
<h1 style="margin:0;font-size:22px;">Spielgruppe Pumuckl</h1>
<p style="margin:4px 0 0;font-size:13px;opacity:.85;">Familienverein Fällanden</p>
</div>
<div style="background:white;border:1px solid #e0e0e0;border-top:none;padding:24px;border-radius:0 0 8px 8px;">
<p>{{ greeting }},</p>
<p>{{ strings.intro }}</p>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.child_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.child_name }}</td><td>{{ child_name }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.child_dob }}</td><td>{{ child_dob }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.child_needs }}</td><td>{{ child_needs }}</td></tr>
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.booking_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.booking_type }}</td><td>{{ playgroup_types }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.booking_days }}</td><td>{{ days }}</td></tr>
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.fees_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.monthly_fee }}</td><td>{{ monthly_fee }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.reg_fee }}</td><td>{{ strings.reg_fee_amount }}</td></tr>
{% if has_indoor %}
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.deposit }}</td><td>{{ strings.deposit_amount }}</td></tr>
{% endif %}
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.payment_section }}</h2>
<p>{{ strings.payment_intro | safe }}</p>
<table style="width:100%;border-collapse:collapse;background:#f8f8f8;border-radius:4px;">
<tr>
<td style="padding:8px;color:#666;width:40%;border-bottom:1px solid #e0e0e0;">{{ strings.iban_label }}</td>
<td style="padding:8px;font-family:monospace;font-weight:bold;border-bottom:1px solid #e0e0e0;">{{ iban }}</td>
</tr>
<tr>
<td style="padding:8px;color:#666;border-bottom:1px solid #e0e0e0;">{{ strings.payee_label }}</td>
<td style="padding:8px;border-bottom:1px solid #e0e0e0;">{{ payee }}<br>{{ payee_street }}, {{ payee_postal_code }} {{ payee_city }}</td>
</tr>
<tr>
<td style="padding:8px;color:#666;">{{ strings.amount_label }}</td>
<td style="padding:8px;font-weight:bold;">CHF 80.00</td>
</tr>
</table>
{% if has_qr %}
<div style="text-align:center;margin:20px 0;">
<img src="cid:qrbill" alt="Swiss QR-Bill" style="max-width:380px;width:100%;border:1px solid #e0e0e0;">
</div>
{% endif %}
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.parent_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.parent_name }}</td><td>{{ parent_name }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_address }}</td><td>{{ parent_address }}, {{ parent_postal_code }} {{ parent_city }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_phone }}</td><td>{{ parent_phone }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_email }}</td><td>{{ parent_email }}</td></tr>
</table>
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.emergency_section }}</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.emergency_name }}</td><td>{{ emergency_name }}</td></tr>
<tr><td style="padding:4px 0;color:#666;">{{ strings.emergency_phone }}</td><td>{{ emergency_phone }}</td></tr>
</table>
<p style="margin-top:24px;white-space:pre-line;">{{ strings.closing }}</p>
</div>
</body>
</html>
@@ -0,0 +1,51 @@
{{ greeting }},
{{ strings.intro }}
===============================================
{{ strings.child_section | upper }}
===============================================
{{ strings.child_name }}: {{ child_name }}
{{ strings.child_dob }}: {{ child_dob }}
{{ strings.child_needs }}: {{ child_needs }}
===============================================
{{ strings.booking_section | upper }}
===============================================
{{ strings.booking_type }}: {{ playgroup_types }}
{{ strings.booking_days }}: {{ days }}
===============================================
{{ strings.fees_section | upper }}
===============================================
{{ strings.monthly_fee }}: {{ monthly_fee }}
{{ strings.reg_fee }}: {{ strings.reg_fee_amount }}
{% if has_indoor %}{{ strings.deposit }}: {{ strings.deposit_amount }}
{% endif %}
===============================================
{{ strings.payment_section | upper }}
===============================================
{{ strings.payment_intro_text }}
{{ strings.iban_label }}: {{ iban }}
{{ strings.payee_label }}: {{ payee }}
{{ payee_street }}, {{ payee_postal_code }} {{ payee_city }}
{{ strings.amount_label }}: CHF 80.00
===============================================
{{ strings.parent_section | upper }}
===============================================
{{ strings.parent_name }}: {{ parent_name }}
{{ strings.parent_address }}: {{ parent_address }}, {{ parent_postal_code }} {{ parent_city }}
{{ strings.parent_phone }}: {{ parent_phone }}
{{ strings.parent_email }}: {{ parent_email }}
===============================================
{{ strings.emergency_section | upper }}
===============================================
{{ strings.emergency_name }}: {{ emergency_name }}
{{ strings.emergency_phone }}: {{ emergency_phone }}
-----------------------------------------------
{{ strings.closing }}
+1
View File
@@ -254,6 +254,7 @@ class ConversationStore:
"channel": "email", "channel": "email",
"parentEmail": state.parent_email, "parentEmail": state.parent_email,
"conversationId": state.conversation_id, "conversationId": state.conversation_id,
"language": state.language,
} }
return record return record
+652
View File
@@ -0,0 +1,652 @@
"""Tests for email loop prevention.
Covers three layers:
- detect_automated_message() — header-based bounce/automated sender detection
- EmailAgent.handle_automated_message() — state tracking, one-shot admin alert
- EmailAgent.process_message() — hard message-count cap (MAX_USER_MESSAGES)
- AdminNotifier.notify_loop_escalation() — escalation email dispatch
"""
import email
import json
import pytest
from unittest.mock import MagicMock, patch
from src.channels.email_channel import detect_automated_message
from src.agent.core import EmailAgent, MAX_USER_MESSAGES
from src.models.conversation import ConversationState, ChatMessage
from src.notifications.notifier import AdminNotifier
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _decode_body(msg_str: str) -> str:
"""Extract the decoded plain-text body from a raw MIME message string."""
parsed = email.message_from_string(msg_str)
if parsed.is_multipart():
for part in parsed.walk():
if part.get_content_type() == "text/plain":
payload = part.get_payload(decode=True)
if payload:
return payload.decode(part.get_content_charset() or "utf-8")
payload = parsed.get_payload(decode=True)
return payload.decode(parsed.get_content_charset() or "utf-8") if payload else ""
def _make_msg(
from_addr: str = "parent@example.com",
subject: str = "Hallo",
extra_headers: dict | None = None,
content_type: str = "text/plain",
) -> email.message.Message:
"""Build a minimal parsed email.message.Message for testing detect_automated_message."""
raw = (
f"From: {from_addr}\r\n"
f"Subject: {subject}\r\n"
f"Content-Type: {content_type}\r\n"
)
for key, value in (extra_headers or {}).items():
raw += f"{key}: {value}\r\n"
raw += "\r\nBody text"
return email.message_from_string(raw)
def _state_with_n_user_messages(n: int, email_addr: str = "loop@example.com") -> ConversationState:
"""Return a ConversationState that already has *n* user messages in its history."""
state = ConversationState(
conversation_id=email_addr,
parent_email=email_addr,
)
for i in range(n):
state.messages.append(ChatMessage(role="user", content=f"Message {i + 1}"))
state.messages.append(ChatMessage(role="assistant", content=f"Reply {i + 1}"))
return state
VALID_LLM_REPLY = json.dumps({
"reply": "Wie heisst dein Kind?",
"updates": {},
"next_step": "child_name",
"registration_complete": False,
"language": "de",
})
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_kb():
kb = MagicMock()
kb.get_all.return_value = "# FAQ\nSome content."
return kb
@pytest.fixture
def mock_store():
store = MagicMock()
store.load.return_value = None
store.save_registration.return_value = ("loop@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,
)
@pytest.fixture
def notifier():
return AdminNotifier(
smtp_host="smtp.example.com",
smtp_port=587,
username="agent@example.com",
password="secret",
use_tls=True,
from_email="agent@example.com",
indoor_email="andrea@example.com",
outdoor_email="barbara@example.com",
cc_emails=["markus@example.com"],
)
@pytest.fixture
def notifier_no_cc():
"""Notifier without any CC recipients — simulates missing ADMIN_EMAIL_CC."""
return AdminNotifier(
smtp_host="smtp.example.com",
smtp_port=587,
username="agent@example.com",
password="secret",
use_tls=True,
from_email="agent@example.com",
cc_emails=[],
)
@pytest.fixture
def notifier_no_smtp():
"""Notifier in dev mode (no SMTP host)."""
return AdminNotifier(
smtp_host="",
smtp_port=587,
username="",
password="",
from_email="agent@example.com",
cc_emails=["markus@example.com"],
)
# ---------------------------------------------------------------------------
# detect_automated_message — sender address patterns
# ---------------------------------------------------------------------------
class TestDetectAutomatedMessageBySender:
def test_mailer_daemon_is_automated(self):
msg = _make_msg(from_addr="MAILER-DAEMON@tacitus2.sui-inter.net")
is_auto, reason = detect_automated_message(msg, "MAILER-DAEMON@tacitus2.sui-inter.net")
assert is_auto is True
assert reason != ""
def test_mailer_daemon_lowercase_is_automated(self):
msg = _make_msg(from_addr="mailer-daemon@example.com")
is_auto, _ = detect_automated_message(msg, "mailer-daemon@example.com")
assert is_auto is True
def test_postmaster_is_automated(self):
msg = _make_msg(from_addr="postmaster@example.com")
is_auto, _ = detect_automated_message(msg, "postmaster@example.com")
assert is_auto is True
def test_noreply_is_automated(self):
msg = _make_msg(from_addr="noreply@example.com")
is_auto, _ = detect_automated_message(msg, "noreply@example.com")
assert is_auto is True
def test_no_reply_hyphen_is_automated(self):
msg = _make_msg(from_addr="no-reply@example.com")
is_auto, _ = detect_automated_message(msg, "no-reply@example.com")
assert is_auto is True
def test_donotreply_is_automated(self):
msg = _make_msg(from_addr="donotreply@example.com")
is_auto, _ = detect_automated_message(msg, "donotreply@example.com")
assert is_auto is True
def test_bounce_is_automated(self):
msg = _make_msg(from_addr="bounce@example.com")
is_auto, _ = detect_automated_message(msg, "bounce@example.com")
assert is_auto is True
def test_normal_parent_email_is_not_automated(self):
msg = _make_msg(from_addr="anna.muster@example.com")
is_auto, reason = detect_automated_message(msg, "anna.muster@example.com")
assert is_auto is False
assert reason == ""
def test_reason_string_mentions_sender(self):
msg = _make_msg(from_addr="MAILER-DAEMON@tacitus2.sui-inter.net")
_, reason = detect_automated_message(msg, "MAILER-DAEMON@tacitus2.sui-inter.net")
assert "MAILER-DAEMON@tacitus2.sui-inter.net" in reason
# ---------------------------------------------------------------------------
# detect_automated_message — RFC / header signals
# ---------------------------------------------------------------------------
class TestDetectAutomatedMessageByHeaders:
def test_auto_submitted_auto_replied(self):
msg = _make_msg(extra_headers={"Auto-Submitted": "auto-replied"})
is_auto, reason = detect_automated_message(msg, "someone@example.com")
assert is_auto is True
assert "auto-replied" in reason
def test_auto_submitted_auto_generated(self):
msg = _make_msg(extra_headers={"Auto-Submitted": "auto-generated"})
is_auto, _ = detect_automated_message(msg, "someone@example.com")
assert is_auto is True
def test_auto_submitted_no_is_not_automated(self):
"""Auto-Submitted: no means the message was composed by a human."""
msg = _make_msg(extra_headers={"Auto-Submitted": "no"})
is_auto, _ = detect_automated_message(msg, "parent@example.com")
assert is_auto is False
def test_x_auto_response_suppress_is_automated(self):
msg = _make_msg(extra_headers={"X-Auto-Response-Suppress": "All"})
is_auto, reason = detect_automated_message(msg, "someone@example.com")
assert is_auto is True
assert "X-Auto-Response-Suppress" in reason
def test_multipart_report_content_type_is_automated(self):
msg = _make_msg(content_type="multipart/report")
is_auto, reason = detect_automated_message(msg, "system@example.com")
assert is_auto is True
assert "multipart/report" in reason
def test_x_loop_header_is_automated(self):
msg = _make_msg(extra_headers={"X-Loop": "spielgruppen@familien-verein.ch"})
is_auto, reason = detect_automated_message(msg, "someone@example.com")
assert is_auto is True
assert "X-Loop" in reason
def test_precedence_bulk_is_automated(self):
msg = _make_msg(extra_headers={"Precedence": "bulk"})
is_auto, reason = detect_automated_message(msg, "list@example.com")
assert is_auto is True
assert "bulk" in reason
def test_precedence_junk_is_automated(self):
msg = _make_msg(extra_headers={"Precedence": "junk"})
is_auto, _ = detect_automated_message(msg, "spam@example.com")
assert is_auto is True
def test_precedence_list_is_not_automated(self):
"""Mailing list messages (Precedence: list) are not considered automated."""
msg = _make_msg(extra_headers={"Precedence": "list"})
is_auto, _ = detect_automated_message(msg, "newsletter@example.com")
assert is_auto is False
# ---------------------------------------------------------------------------
# detect_automated_message — subject heuristics
# ---------------------------------------------------------------------------
class TestDetectAutomatedMessageBySubject:
def test_undelivered_mail_returned_to_sender(self):
msg = _make_msg(subject="Undelivered Mail Returned to Sender")
is_auto, reason = detect_automated_message(msg, "mailer@example.com")
# Caught by sender pattern first, but subject pattern must also flag it
# Test that a neutral sender + bounce subject is still flagged
msg2 = _make_msg(
from_addr="delivery@isp.example.com",
subject="Undelivered Mail Returned to Sender",
)
is_auto2, _ = detect_automated_message(msg2, "delivery@isp.example.com")
assert is_auto2 is True
def test_delivery_failed_subject(self):
msg = _make_msg(
from_addr="system@isp.example.com",
subject="Mail Delivery Failed",
)
is_auto, _ = detect_automated_message(msg, "system@isp.example.com")
assert is_auto is True
def test_out_of_office_subject(self):
msg = _make_msg(
from_addr="colleague@example.com",
subject="Out of Office: Re: Anmeldung",
)
is_auto, _ = detect_automated_message(msg, "colleague@example.com")
assert is_auto is True
def test_abwesenheitsnotiz_subject(self):
msg = _make_msg(
from_addr="colleague@example.com",
subject="Abwesenheitsnotiz: Anmeldung",
)
is_auto, _ = detect_automated_message(msg, "colleague@example.com")
assert is_auto is True
def test_automatische_antwort_subject(self):
msg = _make_msg(
from_addr="colleague@example.com",
subject="Automatische Antwort: Ihre Anfrage",
)
is_auto, _ = detect_automated_message(msg, "colleague@example.com")
assert is_auto is True
def test_normal_registration_subject_is_not_automated(self):
msg = _make_msg(
from_addr="parent@example.com",
subject="Anmeldung meines Kindes",
)
is_auto, _ = detect_automated_message(msg, "parent@example.com")
assert is_auto is False
def test_case_insensitive_subject_matching(self):
msg = _make_msg(
from_addr="system@isp.example.com",
subject="UNDELIVERED MAIL RETURNED TO SENDER",
)
is_auto, _ = detect_automated_message(msg, "system@isp.example.com")
assert is_auto is True
# ---------------------------------------------------------------------------
# EmailAgent.handle_automated_message — state and escalation
# ---------------------------------------------------------------------------
class TestHandleAutomatedMessage:
def test_sets_loop_escalated_on_state(self, agent, mock_store):
"""Calling handle_automated_message marks loop_escalated = True in state."""
agent.handle_automated_message(
sender_email="mailer-daemon@tacitus2.sui-inter.net",
subject="Undelivered Mail Returned to Sender",
reason="sender matches automated address pattern",
)
saved_state = mock_store.save.call_args[0][0]
assert saved_state.loop_escalated is True
def test_calls_notify_loop_escalation(self, agent, mock_notifier):
"""Admin is notified once on the first automated message."""
agent.handle_automated_message(
sender_email="mailer-daemon@tacitus2.sui-inter.net",
subject="Undelivered Mail Returned to Sender",
reason="sender matches automated address pattern",
)
mock_notifier.notify_loop_escalation.assert_called_once()
def test_notify_called_with_correct_sender(self, agent, mock_notifier):
agent.handle_automated_message(
sender_email="MAILER-DAEMON@tacitus2.sui-inter.net",
subject="Bounce",
reason="sender match",
)
call_kwargs = mock_notifier.notify_loop_escalation.call_args[1]
assert call_kwargs["sender_email"] == "MAILER-DAEMON@tacitus2.sui-inter.net"
def test_creates_new_state_when_none_exists(self, agent, mock_store):
"""When no prior state exists, a new ConversationState is created and saved."""
mock_store.load.return_value = None
agent.handle_automated_message(
sender_email="mailer-daemon@tacitus2.sui-inter.net",
subject="Bounce",
reason="automated sender",
)
assert mock_store.save.called
saved_state = mock_store.save.call_args[0][0]
assert saved_state.parent_email == "mailer-daemon@tacitus2.sui-inter.net"
def test_subsequent_automated_message_dropped_silently(self, agent, mock_store, mock_notifier):
"""If loop_escalated is already True, no further notify call is made."""
existing_state = ConversationState(
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
parent_email="mailer-daemon@tacitus2.sui-inter.net",
)
existing_state.loop_escalated = True
mock_store.load.return_value = existing_state
agent.handle_automated_message(
sender_email="mailer-daemon@tacitus2.sui-inter.net",
subject="Bounce again",
reason="automated sender",
)
mock_notifier.notify_loop_escalation.assert_not_called()
def test_state_still_saved_when_already_escalated(self, agent, mock_store, mock_notifier):
"""Even when already escalated, last_activity is updated and state is persisted."""
existing_state = ConversationState(
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
parent_email="mailer-daemon@tacitus2.sui-inter.net",
)
existing_state.loop_escalated = True
mock_store.load.return_value = existing_state
agent.handle_automated_message(
sender_email="mailer-daemon@tacitus2.sui-inter.net",
subject="Bounce again",
reason="automated sender",
)
assert mock_store.save.called
def test_notifier_failure_does_not_propagate(self, agent, mock_store, mock_notifier):
"""A failing notifier must not crash the agent — the state is still saved."""
mock_notifier.notify_loop_escalation.side_effect = RuntimeError("SMTP error")
# Should not raise
agent.handle_automated_message(
sender_email="mailer-daemon@tacitus2.sui-inter.net",
subject="Bounce",
reason="automated sender",
)
assert mock_store.save.called
def test_inbound_message_id_stored(self, agent, mock_store):
"""The inbound Message-ID is persisted for reply-threading purposes."""
agent.handle_automated_message(
sender_email="mailer-daemon@tacitus2.sui-inter.net",
subject="Bounce",
reason="automated sender",
inbound_message_id="<abc123@tacitus2.sui-inter.net>",
)
saved_state = mock_store.save.call_args[0][0]
assert saved_state.last_inbound_message_id == "<abc123@tacitus2.sui-inter.net>"
# ---------------------------------------------------------------------------
# EmailAgent.process_message — hard message-count cap
# ---------------------------------------------------------------------------
class TestProcessMessageCountCap:
def test_at_limit_message_still_processed(self, agent, mock_store):
"""A conversation with exactly MAX_USER_MESSAGES messages is still replied to."""
state = _state_with_n_user_messages(MAX_USER_MESSAGES - 1)
mock_store.load.return_value = state
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
reply = agent.process_message("loop@example.com", "Another message")
assert reply == "Wie heisst dein Kind?"
def test_over_limit_returns_empty_string(self, agent, mock_store):
"""The 21st user message triggers the cap and returns an empty reply."""
state = _state_with_n_user_messages(MAX_USER_MESSAGES)
mock_store.load.return_value = state
with patch("src.llm.complete", return_value=VALID_LLM_REPLY) as mock_llm:
reply = agent.process_message("loop@example.com", "One more message")
assert reply == ""
mock_llm.assert_not_called()
def test_over_limit_sets_loop_escalated(self, agent, mock_store):
"""Hitting the cap marks loop_escalated = True in the persisted state."""
state = _state_with_n_user_messages(MAX_USER_MESSAGES)
mock_store.load.return_value = state
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
agent.process_message("loop@example.com", "One more message")
saved_state = mock_store.save.call_args[0][0]
assert saved_state.loop_escalated is True
def test_over_limit_calls_notify_loop_escalation(self, agent, mock_store, mock_notifier):
"""Hitting the cap triggers one admin escalation notification."""
state = _state_with_n_user_messages(MAX_USER_MESSAGES)
mock_store.load.return_value = state
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
agent.process_message("loop@example.com", "One more message")
mock_notifier.notify_loop_escalation.assert_called_once()
def test_over_limit_no_duplicate_notification_when_already_escalated(
self, agent, mock_store, mock_notifier
):
"""If loop_escalated is already True, no second notification is sent."""
state = _state_with_n_user_messages(MAX_USER_MESSAGES)
state.loop_escalated = True
mock_store.load.return_value = state
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
agent.process_message("loop@example.com", "Yet another message")
mock_notifier.notify_loop_escalation.assert_not_called()
def test_over_limit_notify_failure_does_not_propagate(self, agent, mock_store, mock_notifier):
"""If the notifier raises, the cap still returns '' without crashing."""
mock_notifier.notify_loop_escalation.side_effect = RuntimeError("SMTP down")
state = _state_with_n_user_messages(MAX_USER_MESSAGES)
mock_store.load.return_value = state
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
reply = agent.process_message("loop@example.com", "One more message")
assert reply == ""
def test_max_user_messages_constant_is_twenty(self):
"""The agreed-upon limit from the spec is 20 inbound messages."""
assert MAX_USER_MESSAGES == 20
# ---------------------------------------------------------------------------
# AdminNotifier.notify_loop_escalation — SMTP dispatch
# ---------------------------------------------------------------------------
class TestNotifyLoopEscalation:
def test_sends_email_to_cc_recipients(self, notifier, mocker):
"""The escalation alert is sent to the admin CC address list."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
mock_server = mock_smtp_cls.return_value
notifier.notify_loop_escalation(
sender_email="mailer-daemon@tacitus2.sui-inter.net",
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
reason="sender matches automated address pattern",
message_count=5,
)
mock_server.sendmail.assert_called_once()
call_args = mock_server.sendmail.call_args
recipients = call_args[0][1]
assert "markus@example.com" in recipients
def test_subject_contains_warnung_tag(self, notifier, mocker):
"""Subject must start with [WARNUNG] for easy filtering in the admin inbox."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier.notify_loop_escalation(
sender_email="mailer-daemon@tacitus2.sui-inter.net",
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
reason="automated sender",
message_count=3,
)
import email as email_mod
from email.header import decode_header
parsed = email_mod.message_from_string(captured["msg"])
raw_subject = parsed.get("Subject", "")
parts = decode_header(raw_subject)
subject = "".join(
chunk.decode(enc or "utf-8") if isinstance(chunk, bytes) else chunk
for chunk, enc in parts
)
assert "[WARNUNG]" in subject
def test_subject_contains_sender_address(self, notifier, mocker):
"""The sender address appears in the subject for quick identification."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier.notify_loop_escalation(
sender_email="mailer-daemon@tacitus2.sui-inter.net",
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
reason="automated sender",
message_count=3,
)
assert "mailer-daemon@tacitus2.sui-inter.net" in captured["msg"]
def test_body_contains_reason(self, notifier, mocker):
"""The email body includes the specific detection reason."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier.notify_loop_escalation(
sender_email="test@example.com",
conversation_id="test@example.com",
reason="Content-Type: multipart/report (delivery status notification)",
message_count=7,
)
body = _decode_body(captured["msg"])
assert "multipart/report" in body
def test_body_contains_message_count(self, notifier, mocker):
"""The email body reports the number of messages exchanged."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier.notify_loop_escalation(
sender_email="test@example.com",
conversation_id="test@example.com",
reason="automated sender",
message_count=12,
)
body = _decode_body(captured["msg"])
assert "12" in body
def test_no_cc_emails_skips_smtp(self, notifier_no_cc, mocker):
"""When no admin CC email is configured, no SMTP connection is made."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
notifier_no_cc.notify_loop_escalation(
sender_email="mailer-daemon@tacitus2.sui-inter.net",
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
reason="automated sender",
message_count=3,
)
mock_smtp_cls.assert_not_called()
def test_no_smtp_host_skips_send(self, notifier_no_smtp, mocker):
"""Dev mode (no SMTP host): email is logged but not dispatched."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
notifier_no_smtp.notify_loop_escalation(
sender_email="mailer-daemon@tacitus2.sui-inter.net",
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
reason="automated sender",
message_count=3,
)
mock_smtp_cls.assert_not_called()
+128
View File
@@ -6,6 +6,17 @@ from src import llm
from src.models.conversation import ChatMessage from src.models.conversation import ChatMessage
def _make_async_mock(mocker, content: str):
"""Return an awaitable mock that resolves to a response with the given content."""
mock_response = mocker.MagicMock()
mock_response.choices[0].message.content = content
async def _coro(*args, **kwargs):
return mock_response
return mocker.patch("litellm.acompletion", side_effect=_coro), mock_response
class TestLlmComplete: class TestLlmComplete:
def test_returns_model_reply(self, mocker): def test_returns_model_reply(self, mocker):
mock_response = mocker.MagicMock() mock_response = mocker.MagicMock()
@@ -65,3 +76,120 @@ class TestLlmComplete:
with pytest.raises(RuntimeError, match="API error"): with pytest.raises(RuntimeError, match="API error"):
llm.complete("anthropic/claude-opus-4-6", "system", []) llm.complete("anthropic/claude-opus-4-6", "system", [])
class TestLlmStreamComplete:
def _make_chunk(self, content):
chunk = type("Chunk", (), {})()
choice = type("Choice", (), {})()
delta = type("Delta", (), {"content": content})()
choice.delta = delta
chunk.choices = [choice]
return chunk
def test_yields_chunks(self, mocker):
chunks = [self._make_chunk("Hal"), self._make_chunk("lo!")]
mocker.patch("litellm.completion", return_value=iter(chunks))
result = list(llm.stream_complete("anthropic/claude-opus-4-6", "system", []))
assert result == ["Hal", "lo!"]
def test_skips_empty_deltas(self, mocker):
chunks = [self._make_chunk("Hello"), self._make_chunk(None), self._make_chunk("!")]
mocker.patch("litellm.completion", return_value=iter(chunks))
result = list(llm.stream_complete("anthropic/claude-opus-4-6", "system", []))
assert result == ["Hello", "!"]
def test_passes_stream_true(self, mocker):
mock_completion = mocker.patch("litellm.completion", return_value=iter([]))
list(llm.stream_complete("anthropic/claude-opus-4-6", "system", []))
assert mock_completion.call_args.kwargs["stream"] is True
def test_passes_model(self, mocker):
mock_completion = mocker.patch("litellm.completion", return_value=iter([]))
list(llm.stream_complete("openai/gpt-4o", "system", []))
assert mock_completion.call_args.kwargs["model"] == "openai/gpt-4o"
def test_system_prompt_prepended(self, mocker):
mock_completion = mocker.patch("litellm.completion", return_value=iter([]))
list(llm.stream_complete("anthropic/claude-opus-4-6", "You are helpful.", []))
messages = mock_completion.call_args.kwargs["messages"]
assert messages[0] == {"role": "system", "content": "You are helpful."}
def test_chat_messages_appended(self, mocker):
mock_completion = mocker.patch("litellm.completion", return_value=iter([]))
chat = [ChatMessage(role="user", content="Hallo")]
list(llm.stream_complete("anthropic/claude-opus-4-6", "system", chat))
messages = mock_completion.call_args.kwargs["messages"]
assert messages[1] == {"role": "user", "content": "Hallo"}
class TestLlmAComplete:
@pytest.mark.asyncio
async def test_returns_model_reply(self, mocker):
mock_acompletion, _ = _make_async_mock(mocker, "Hallo! Wie heisst dein Kind?")
result = await llm.acomplete("anthropic/claude-opus-4-6", "system prompt", [])
assert result == "Hallo! Wie heisst dein Kind?"
@pytest.mark.asyncio
async def test_passes_model_to_litellm(self, mocker):
mock_acompletion, _ = _make_async_mock(mocker, "ok")
await llm.acomplete("openai/gpt-4o", "system", [])
call_kwargs = mock_acompletion.call_args.kwargs
assert call_kwargs["model"] == "openai/gpt-4o"
@pytest.mark.asyncio
async def test_system_prompt_prepended_as_system_message(self, mocker):
mock_acompletion, _ = _make_async_mock(mocker, "ok")
await llm.acomplete("anthropic/claude-opus-4-6", "You are helpful.", [])
messages = mock_acompletion.call_args.kwargs["messages"]
assert messages[0] == {"role": "system", "content": "You are helpful."}
@pytest.mark.asyncio
async def test_chat_messages_appended_after_system(self, mocker):
mock_acompletion, _ = _make_async_mock(mocker, "ok")
chat = [
ChatMessage(role="user", content="Hallo"),
ChatMessage(role="assistant", content="Guten Tag"),
]
await llm.acomplete("anthropic/claude-opus-4-6", "system", chat)
messages = mock_acompletion.call_args.kwargs["messages"]
assert messages[1] == {"role": "user", "content": "Hallo"}
assert messages[2] == {"role": "assistant", "content": "Guten Tag"}
@pytest.mark.asyncio
async def test_max_tokens_passed(self, mocker):
mock_acompletion, _ = _make_async_mock(mocker, "ok")
await llm.acomplete("anthropic/claude-opus-4-6", "system", [])
assert mock_acompletion.call_args.kwargs["max_tokens"] == 2048
@pytest.mark.asyncio
async def test_exception_propagates(self, mocker):
async def _raise(*args, **kwargs):
raise RuntimeError("API error")
mocker.patch("litellm.acompletion", side_effect=_raise)
with pytest.raises(RuntimeError, match="API error"):
await llm.acomplete("anthropic/claude-opus-4-6", "system", [])
+42
View File
@@ -150,3 +150,45 @@ class TestConversationStateSerialization:
d = state_with_messages.to_dict() d = state_with_messages.to_dict()
assert d["messages"][0]["role"] == "user" assert d["messages"][0]["role"] == "user"
assert "Hallo" in d["messages"][0]["content"] assert "Hallo" in d["messages"][0]["content"]
# ---------------------------------------------------------------------------
# ConversationState — loop_escalated field
# ---------------------------------------------------------------------------
class TestConversationStateLoopEscalated:
def test_default_loop_escalated_is_false(self, fresh_state):
assert fresh_state.loop_escalated is False
def test_to_dict_includes_loop_escalated(self, fresh_state):
d = fresh_state.to_dict()
assert "loop_escalated" in d
assert d["loop_escalated"] is False
def test_to_dict_reflects_true_when_set(self, fresh_state):
fresh_state.loop_escalated = True
d = fresh_state.to_dict()
assert d["loop_escalated"] is True
def test_from_dict_restores_loop_escalated_true(self, fresh_state):
fresh_state.loop_escalated = True
restored = ConversationState.from_dict(fresh_state.to_dict())
assert restored.loop_escalated is True
def test_from_dict_defaults_to_false_when_key_missing(self):
"""Older persisted conversations without the key deserialise safely."""
data = {
"conversation_id": "old@example.com",
"parent_email": "old@example.com",
"language": "de",
"flow_step": "greeting",
"registration": {},
"messages": [],
"completed": False,
"reminder_count": 0,
"last_inbound_message_id": "",
# loop_escalated intentionally absent
}
state = ConversationState.from_dict(data)
assert state.loop_escalated is False
+234 -24
View File
@@ -1,11 +1,42 @@
"""Tests for AdminNotifier helper methods.""" """Tests for AdminNotifier and notification helper functions."""
import email
import json
from email.header import decode_header
import pytest import pytest
from src.notifications.notifier import AdminNotifier from src.notifications.notifier import AdminNotifier
from src.notifications.context import (
calculate_age,
calculate_monthly_fee,
format_types,
build_parent_context,
)
from src.notifications.i18n import get_strings, clear_cache
from src.notifications.renderer import render_template
from src.models.registration import RegistrationData, Booking, BookingDay from src.models.registration import RegistrationData, Booking, BookingDay
def _decoded_subject(msg_str: str) -> str:
"""Parse a raw MIME message string and return the decoded Subject header."""
msg = email.message_from_string(msg_str)
raw_subject = msg.get("Subject", "")
parts = decode_header(raw_subject)
return "".join(
chunk.decode(enc or "utf-8") if isinstance(chunk, bytes) else chunk
for chunk, enc in parts
)
@pytest.fixture(autouse=True)
def reset_translation_cache():
"""Clear the in-memory translation cache before every test."""
clear_cache()
yield
clear_cache()
@pytest.fixture @pytest.fixture
def notifier(): def notifier():
return AdminNotifier( return AdminNotifier(
@@ -21,54 +52,68 @@ def notifier():
) )
@pytest.fixture
def notifier_no_smtp():
"""Notifier in dev mode (no SMTP host)."""
return AdminNotifier(
smtp_host="",
smtp_port=587,
username="",
password="",
from_email="agent@example.com",
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _format_types # format_types
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestFormatTypes: class TestFormatTypes:
def test_indoor_label(self, notifier): def test_indoor_label(self):
assert "Innen" in notifier._format_types(["indoor"]) or "indoor" in notifier._format_types(["indoor"]).lower() result = format_types(["indoor"])
assert "Innen" in result or "indoor" in result.lower()
def test_outdoor_label(self, notifier): def test_outdoor_label(self):
assert "Wald" in notifier._format_types(["outdoor"]) or "outdoor" in notifier._format_types(["outdoor"]).lower() result = format_types(["outdoor"])
assert "Wald" in result or "outdoor" in result.lower()
def test_both_labels(self, notifier): def test_both_labels(self):
result = notifier._format_types(["indoor", "outdoor"]) result = format_types(["indoor", "outdoor"])
assert len(result) > 0 assert len(result) > 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _calculate_age # calculate_age
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCalculateAge: class TestCalculateAge:
def test_returns_age_string(self, notifier): def test_returns_age_string(self):
result = notifier._calculate_age("2022-01-01") result = calculate_age("2022-01-01")
assert isinstance(result, str) assert isinstance(result, str)
assert len(result) > 0 assert len(result) > 0
def test_invalid_dob_returns_original_string(self, notifier): def test_invalid_dob_returns_original_string(self):
result = notifier._calculate_age("not-a-date") result = calculate_age("not-a-date")
assert result == "not-a-date" assert result == "not-a-date"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _calculate_monthly_fee # calculate_monthly_fee
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCalculateMonthlyFee: class TestCalculateMonthlyFee:
def test_indoor_one_day(self, notifier, complete_registration): def test_indoor_one_day(self, complete_registration):
complete_registration.booking = Booking( complete_registration.booking = Booking(
playgroup_types=["indoor"], playgroup_types=["indoor"],
selected_days=[BookingDay(day="monday", type="indoor")], selected_days=[BookingDay(day="monday", type="indoor")],
) )
fee = notifier._calculate_monthly_fee(complete_registration) fee = calculate_monthly_fee(complete_registration)
assert "130" in fee assert "130" in fee
def test_indoor_two_days(self, notifier, complete_registration): def test_indoor_two_days(self, complete_registration):
complete_registration.booking = Booking( complete_registration.booking = Booking(
playgroup_types=["indoor"], playgroup_types=["indoor"],
selected_days=[ selected_days=[
@@ -76,10 +121,10 @@ class TestCalculateMonthlyFee:
BookingDay(day="wednesday", type="indoor"), BookingDay(day="wednesday", type="indoor"),
], ],
) )
fee = notifier._calculate_monthly_fee(complete_registration) fee = calculate_monthly_fee(complete_registration)
assert "260" in fee assert "260" in fee
def test_indoor_three_days(self, notifier, complete_registration): def test_indoor_three_days(self, complete_registration):
complete_registration.booking = Booking( complete_registration.booking = Booking(
playgroup_types=["indoor"], playgroup_types=["indoor"],
selected_days=[ selected_days=[
@@ -88,15 +133,15 @@ class TestCalculateMonthlyFee:
BookingDay(day="thursday", type="indoor"), BookingDay(day="thursday", type="indoor"),
], ],
) )
fee = notifier._calculate_monthly_fee(complete_registration) fee = calculate_monthly_fee(complete_registration)
assert "390" in fee assert "390" in fee
def test_outdoor_one_day(self, notifier, complete_registration): def test_outdoor_one_day(self, complete_registration):
complete_registration.booking = Booking( complete_registration.booking = Booking(
playgroup_types=["outdoor"], playgroup_types=["outdoor"],
selected_days=[BookingDay(day="monday", type="outdoor")], selected_days=[BookingDay(day="monday", type="outdoor")],
) )
fee = notifier._calculate_monthly_fee(complete_registration) fee = calculate_monthly_fee(complete_registration)
assert "250" in fee assert "250" in fee
@@ -107,7 +152,6 @@ class TestCalculateMonthlyFee:
class TestSend: class TestSend:
def test_send_calls_smtp(self, notifier, mocker): 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_smtp_cls = mocker.patch("smtplib.SMTP")
mock_server = mock_smtp_cls.return_value mock_server = mock_smtp_cls.return_value
@@ -132,6 +176,172 @@ class TestSend:
) )
call_args = mock_server.sendmail.call_args call_args = mock_server.sendmail.call_args
recipients = call_args[0][1] # positional arg: to_addrs recipients = call_args[0][1]
assert "a@example.com" in recipients assert "a@example.com" in recipients
assert "b@example.com" in recipients assert "b@example.com" in recipients
# ---------------------------------------------------------------------------
# get_strings — i18n / LLM translation
# ---------------------------------------------------------------------------
class TestGetStrings:
def test_german_loads_from_yaml_without_llm(self, mocker):
"""German must never trigger an LLM call."""
mock_litellm = mocker.patch("litellm.completion")
strings = get_strings("de", "some-model")
mock_litellm.assert_not_called()
assert strings["subject"] == "Anmeldebestätigung Spielgruppe Pumuckl"
def test_other_language_calls_llm(self, mocker):
"""Non-German languages should call litellm.completion."""
german = get_strings("de", "some-model")
translated = {**german, "subject": "Registration Confirmation Spielgruppe Pumuckl"}
mock_litellm = mocker.patch("litellm.completion")
mock_litellm.return_value.choices[0].message.content = json.dumps(translated)
result = get_strings("en", "some-model")
mock_litellm.assert_called_once()
assert result["subject"] == "Registration Confirmation Spielgruppe Pumuckl"
def test_result_is_cached(self, mocker):
"""The LLM is only called once per language per process lifetime."""
german = get_strings("de", "some-model")
mock_litellm = mocker.patch("litellm.completion")
mock_litellm.return_value.choices[0].message.content = json.dumps(german)
get_strings("fr", "some-model")
get_strings("fr", "some-model")
assert mock_litellm.call_count == 1
def test_llm_failure_falls_back_to_german(self, mocker):
"""If the LLM raises, the German strings are returned silently."""
mocker.patch("litellm.completion", side_effect=RuntimeError("network error"))
result = get_strings("it", "some-model")
assert result["subject"] == "Anmeldebestätigung Spielgruppe Pumuckl"
def test_passthrough_keys_not_altered(self, mocker):
"""reg_fee_amount and deposit_amount must survive translation unchanged."""
german = get_strings("de", "some-model")
# Return translation that omits passthrough keys (as the LLM would)
without_passthrough = {k: v for k, v in german.items()
if k not in {"reg_fee_amount", "deposit_amount"}}
mocker.patch("litellm.completion").return_value.choices[0].message.content = (
json.dumps(without_passthrough)
)
result = get_strings("en", "some-model")
assert result["reg_fee_amount"] == "CHF 80.00"
assert result["deposit_amount"] == "CHF 50.00"
# ---------------------------------------------------------------------------
# notify_parent — parent confirmation email
# ---------------------------------------------------------------------------
class TestNotifyParent:
def test_notify_parent_calls_send(self, notifier, complete_registration, mocker):
"""notify_parent dispatches an email to the parent address."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
mock_server = mock_smtp_cls.return_value
notifier.notify_parent(complete_registration, language="de")
mock_server.sendmail.assert_called_once()
call_args = mock_server.sendmail.call_args
recipients = call_args[0][1]
assert "anna.muster@example.com" in recipients
def test_notify_parent_german_subject(self, notifier, complete_registration, mocker):
"""German language produces a German subject line without any LLM call."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier.notify_parent(complete_registration, language="de")
assert "Anmeldebestätigung" in _decoded_subject(captured["msg"])
def test_notify_parent_english_subject(self, notifier, complete_registration, mocker):
"""English language produces an English subject line via LLM translation."""
german = get_strings("de", "some-model")
english = {**german, "subject": "Registration Confirmation Spielgruppe Pumuckl"}
mocker.patch("litellm.completion").return_value.choices[0].message.content = (
json.dumps(english)
)
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier.notify_parent(complete_registration, language="en")
assert "Registration Confirmation" in _decoded_subject(captured["msg"])
def test_notify_parent_unknown_language_falls_back_to_de(
self, notifier, complete_registration, mocker
):
"""When the LLM call fails, the email is sent in German."""
mocker.patch("litellm.completion", side_effect=RuntimeError("timeout"))
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier.notify_parent(complete_registration, language="fr")
assert "Anmeldebestätigung" in _decoded_subject(captured["msg"])
def test_notify_parent_no_smtp_skips_send(
self, notifier_no_smtp, complete_registration, mocker
):
"""When SMTP host is empty, no sendmail call is made."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
notifier_no_smtp.notify_parent(complete_registration, language="de")
mock_smtp_cls.assert_not_called()
def test_text_body_contains_iban(self, complete_registration):
"""Rendered plain-text body includes the IBAN regardless of language."""
strings = get_strings("de", "some-model")
ctx = build_parent_context(complete_registration, strings, has_qr=False)
text = render_template("parent_confirmation.txt.j2", ctx)
assert "CH14" in text
# ---------------------------------------------------------------------------
# _generate_qr_bill_png
# ---------------------------------------------------------------------------
class TestGenerateQrBillPng:
def test_returns_nonempty_bytes(self, notifier):
"""_generate_qr_bill_png returns a non-empty bytes object (PNG)."""
png = notifier._generate_qr_bill_png()
assert isinstance(png, bytes)
assert len(png) > 0
def test_returns_png_signature(self, notifier):
"""Output starts with the PNG magic bytes."""
png = notifier._generate_qr_bill_png()
assert png[:4] == b"\x89PNG"
+17
View File
@@ -157,3 +157,20 @@ class TestRegistrationVersioning:
store.save_registration(fresh_state) store.save_registration(fresh_state)
registrations = store.list_registrations() registrations = store.list_registrations()
assert len(registrations) == 1 assert len(registrations) == 1
def test_save_registration_persists_language(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
fresh_state.language = "en"
store.save_registration(fresh_state)
current = store.get_current_registration(fresh_state.parent_email)
assert current is not None
assert current["metadata"]["language"] == "en"
def test_save_registration_defaults_language_to_de(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
# language defaults to "de" in ConversationState
store.save_registration(fresh_state)
current = store.get_current_registration(fresh_state.parent_email)
assert current["metadata"]["language"] == "de"
Generated
+1659
View File
File diff suppressed because it is too large Load Diff