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
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
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
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
Tell the LLM explicitly that the reply field must be plain text with no
markdown (no bold, italic, headers, bullet points, or backticks). Email
clients display raw text so markdown syntax would appear as literal
characters rather than formatting.
https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
Add standard > -prefixed quote block to outbound replies so parents can
see what they wrote in the previous message, matching natural email client
behaviour. The quote header uses the German "Am <date> schrieb <addr>:"
convention (matching Outlook/Thunderbird).
- email_channel.py: add _build_quoted_block() helper; extend send_reply()
with optional quoted_text/quoted_from params
- main.py: pass msg["body"] and msg["from"] as quoted_text/quoted_from
https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
- prompts.py: correct age restrictions (indoor ≥2 yrs, outdoor ≥2.5 yrs)
Previously had indoor ≥2.5 and outdoor ≥3, which was too restrictive
- README.md: add full setup and configuration guide covering prerequisites,
installation, env var reference, provider switching, cron scheduling,
running tests, and knowledge base editing
https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
Drops the src/providers/ package (base class, AnthropicProvider,
OpenAIProvider, factory) in favour of a single src/llm.py that calls
litellm.completion() directly. litellm handles provider routing,
authentication, and SDK differences for 100+ providers without any
code we need to maintain.
Changes:
- Delete src/providers/ entirely
- Add src/llm.py — one complete() function wrapping litellm
- src/agent/core.py: EmailAgent takes model: str instead of LLMProvider
- src/config.py: ai_provider + api key fields → single ai_model string
in litellm format (e.g. "anthropic/claude-opus-4-6")
- main.py: remove provider factory wiring; pass config.ai_model to agent
- .env.example: simplify AI section, show litellm model string examples
- pyproject.toml: replace anthropic + openai deps with litellm>=1.0.0
- uv.lock: regenerated
https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
Closes the gap where parents sending a new email (instead of replying)
would lose their registration progress. All changes follow the
email-based-conversation-matching OpenSpec change.
Key changes
-----------
storage/json_store.py
- normalize_email() helper (lowercase + trim)
- Conversations now keyed by sender email address, not thread ID
- Versioned registration storage: data/registrations/<email>/v<N>_<ts>.json
- current.json always reflects the latest version
- save_registration() returns (email_key, version) tuple
- save_registration_version() for updates with change_summary
- get_registration_history() returns all versions in order
models/conversation.py
- Added last_inbound_message_id field for reply threading (not matching)
channels/email_channel.py
- fetch_unread_messages() no longer exposes thread_id
- Conversation matching removed from channel layer (now in agent)
- Removed _resolve_thread_id() — threading headers kept for SMTP only
agent/core.py
- process_message() takes parent_email + inbound_message_id (no thread ID)
- Looks up conversation by normalized email address
- Post-completion handler: detects intent (question / update / new_child)
- Registration updates: diffs old vs new, versions storage, notifies admin
agent/prompts.py
- build_system_prompt() dispatches to registration or post-completion prompt
- Post-completion prompt guides LLM to return intent field
- Reminder language updated: no expiration threats
notifications/notifier.py
- notify_admin() accepts version parameter
- notify_registration_update() sends "Registration Updated" emails with diff
- _build_update_body() includes field-level old→new change summary
main.py
- Poll loop passes parent_email + inbound_message_id to agent (no thread_id)
https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw