feat(chat): implement web chat interface with accessibility
Core implementation: - chat_app.py: Chainlit entry point with @cl.on_chat_start, @cl.on_message (streaming via llm.stream_complete), @cl.on_chat_end Reuses Config, KnowledgeBase, ConversationStore, AdminNotifier from src/ Handles registration completion, post-completion updates, new-child flow - src/llm.py: add stream_complete() generator (litellm stream=True) alongside existing complete(); tests added in tests/test_llm.py - src/agent/response_parser.py: extract parse_llm_response(), apply_updates(), fallback_message() from EmailAgent into shared module EmailAgent now delegates to these functions (no logic change) Chainlit configuration: - chainlit.toml: telemetry off, German default, custom CSS + JS paths - chainlit.md: German welcome page with playgroup info Accessibility (WCAG 2.1 AA): - public/custom.css: contrast overrides (≥4.5:1), prefers-reduced-motion (static "…" replaces animated dots), skip link styles, 100dvh fix - public/accessibility.js: MutationObserver injects aria-live="polite" on message list, focus management after agent replies, skip link element Other: - .gitignore: add .chainlit/ (Chainlit runtime, auto-generated) - openspec/config.yaml: populate context field with tech stack - openspec/changes/implement-web-chat/tasks.md: mark completed tasks 95 tests pass. https://claude.ai/code/session_01SUWzMzFvSfWiHXA2p6rPg9
This commit is contained in:
+212
@@ -0,0 +1,212 @@
|
||||
#!/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."""
|
||||
session_id = str(uuid.uuid4())
|
||||
state = ConversationState(conversation_id=session_id)
|
||||
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
|
||||
state.messages.append(ChatMessage(role="user", content=message.content))
|
||||
|
||||
# --- Build system prompt ---
|
||||
system = build_system_prompt(_kb, state)
|
||||
|
||||
# --- Stream LLM response ---
|
||||
msg = cl.Message(content="")
|
||||
full_content = ""
|
||||
|
||||
try:
|
||||
for chunk in llm.stream_complete(_config.ai_model, system, state.messages):
|
||||
await msg.stream_token(chunk)
|
||||
full_content += chunk
|
||||
await msg.send()
|
||||
except Exception:
|
||||
logger.exception("LLM streaming failed for session %s", state.conversation_id)
|
||||
error_text = fallback_message(state.language)
|
||||
await cl.Message(content=error_text).send()
|
||||
# Don't update state — let parent retry
|
||||
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
|
||||
|
||||
# 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
|
||||
)
|
||||
Reference in New Issue
Block a user