Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bd11e6acd | ||
|
|
13cb35111d | ||
|
|
72189d2b7b | ||
|
|
981948c106 | ||
|
|
808be0185f | ||
|
|
e4b4cdd41e | ||
|
|
e204e29b09 | ||
|
|
9fdbe341be | ||
|
|
08bee013f9 | ||
|
|
da0e6823f0 | ||
|
|
b0ebd40302 | ||
|
|
cb78b90332 | ||
|
|
eb2b69557e | ||
|
|
3b32652d3e | ||
|
|
bab0e5fe5e | ||
|
|
905debb48e | ||
|
|
2fac05c4ee |
@@ -15,6 +15,12 @@
|
|||||||
# gemini/gemini-2.0-flash
|
# gemini/gemini-2.0-flash
|
||||||
AI_MODEL=anthropic/claude-opus-4-6
|
AI_MODEL=anthropic/claude-opus-4-6
|
||||||
|
|
||||||
|
# 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 +73,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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -45,14 +45,14 @@ 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` | litellm model string, e.g. `anthropic/claude-opus-4-6` or `openai/gpt-4o` |
|
||||||
| `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 |
|
||||||
@@ -85,9 +85,43 @@ GEMINI_API_KEY=...
|
|||||||
|
|
||||||
## 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 +129,26 @@ 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.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Running tests
|
### Running tests
|
||||||
|
|||||||
+27
@@ -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 Englisch**: Schreib in der Sprache, die dir lieber ist
|
||||||
|
|
||||||
|
## Spielgruppen
|
||||||
|
|
||||||
|
| | Innenspielgruppe | Waldspielgruppe |
|
||||||
|
|---|---|---|
|
||||||
|
| **Tage** | Mo / Mi / Do | Mo |
|
||||||
|
| **Zeit** | 09:00–11:30 | 09:00–14: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.*
|
||||||
@@ -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"
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
#!/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()],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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 Deutsch oder Englisch schreiben — 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
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
)
|
||||||
@@ -63,7 +63,13 @@ def build_components(config: Config):
|
|||||||
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()],
|
||||||
)
|
)
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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
|
||||||
+26
-5
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ 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",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -25,5 +26,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"
|
||||||
|
|||||||
+9
-77
@@ -1,17 +1,16 @@
|
|||||||
"""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__)
|
||||||
|
|
||||||
@@ -32,8 +31,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
|
||||||
@@ -99,7 +100,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)
|
||||||
@@ -141,7 +142,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 +193,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)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
|
)
|
||||||
@@ -50,6 +50,11 @@ 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":
|
||||||
return cls(
|
return cls(
|
||||||
@@ -74,4 +79,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
@@ -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
|
||||||
|
|||||||
@@ -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", [])
|
||||||
|
|||||||
Reference in New Issue
Block a user