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>
- 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
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
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>
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
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
- 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
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
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
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
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
- 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>
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
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
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
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
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
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
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
- 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
fetch_unread_messages now returns both `body` (stripped, for the LLM)
and `raw_body` (full with nested quotes, for the outgoing reply).
main.py passes raw_body as quoted_text so each reply carries the
complete conversation thread, not just the single last message.
https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
Previously routing was hardcoded (Andrea for indoor, Barbara for outdoor).
Then it was replaced with a flat ADMIN_EMAILS list which lost the routing.
This commit restores routing via three separate env vars:
ADMIN_EMAIL_INDOOR — indoor leader, To when indoor days are booked
ADMIN_EMAIL_OUTDOOR — outdoor leader, To when outdoor days are booked
ADMIN_EMAIL_CC — always Cc'd (comma-separated for multiple)
For testing, set all three to your own address so no real leader gets mail.
Changes:
- Config: replaced admin_emails with admin_email_indoor/outdoor/cc fields
- AdminNotifier: replaced admin_emails param with indoor_email/outdoor_email/
cc_emails; _recipients_for() restored as an instance method using these
- main.py: wires the three new config fields into AdminNotifier
- .env.example: documents the three new variables with production defaults
- Tests: fixture updated to use new params
https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
Previously the To/Cc addresses were hardcoded in notifier.py (Andrea,
Barbara, Markus). This caused accidental emails to production contacts
during testing.
Changes:
- New ADMIN_EMAILS env var: comma-separated list of addresses.
First address → To; remaining addresses → Cc.
- AdminNotifier now accepts admin_emails list; warns and skips if empty.
- Removed hardcoded _INDOOR_EMAIL / _OUTDOOR_EMAIL / _ADMIN_CC_EMAIL
constants and the _recipients_for() routing method.
- Config.from_env() parses ADMIN_EMAILS into a list.
- main.py passes config.admin_emails to AdminNotifier.
- .env.example documents the new variable with production example.
- Tests: fixture updated; TestRecipientsFor removed (routing gone).
For testing: ADMIN_EMAILS=you@example.com
For production: ADMIN_EMAILS=andrea.sigrist@gmx.net,baba.laeubli@gmail.com,spielgruppen@familien-verein.chhttps://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
notifier.py:
- All email body text translated to German (section headers, labels,
day names, playgroup type names, age format, change diff labels)
- Subject lines changed to German: "Neue Anmeldung:" / "Anmeldung aktualisiert:"
- Channel label localised: "E-Mail" / "Chat"
- Fallback special needs label changed to "Keine"
prompts.py:
- New rule: always store free-text field values (especially specialNeeds)
in German in `updates`, translating from the parent's language if needed;
use "Keine" for no special needs
https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
Two prompt changes:
1. Greeting step: explicitly tell parents they can write in any language
(German, English, French, Italian, Spanish, …) and the agent will reply
in the same language. Also kick off info collection immediately by asking
for child name + DOB in the greeting reply.
2. Personality: replace the "1–2 questions at a time" rule with a strategy
that gathers all relevant questions per step in one message (woven into
natural sentences, not a form), and explicitly re-asks any unanswered
questions before advancing — no open question is silently skipped.
https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
The LLM previously only received the stripped email body, giving it no
way to extract the sender's email address for the parentGuardian.email
field. Prepend Von:/Betreff: headers to every message so the LLM can
read the From address and subject without asking the parent for them.
The quoted_text sent back in the reply still uses only msg["body"] so
the quote block stays clean.
https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw