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:
Claude
2026-02-22 07:42:29 +00:00
parent 08bee013f9
commit 9fdbe341be
13 changed files with 2289 additions and 98 deletions
+18 -18
View File
@@ -1,22 +1,22 @@
## 1. Add Chainlit Dependency
- [ ] 1.1 Run `uv add chainlit` to add Chainlit to `pyproject.toml` (matches existing `uv`-based workflow; do not create `requirements.txt`)
- [ ] 1.2 Create `chainlit.toml` at the project root with: `name = "Spielgruppe Pumuckl"`, `enable_telemetry = false`, `custom_css = "/public/custom.css"`, `default_language = "de"`
- [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
- [ ] 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)
- [ ] 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
- [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
- [ ] 3.1 Create `chat_app.py` at the project root with a `@cl.on_chat_start` handler that:
- [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`)
- [ ] 3.2 Add a `@cl.on_message` handler in `chat_app.py` that:
- [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`
@@ -26,27 +26,27 @@
- 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`
- [ ] 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`)
- [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
- [ ] 4.1 Create `public/` directory at the project root
- [ ] 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
- [ ] 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
- [ ] 4.4 Add CSS in `public/custom.css` for the skip link: hidden by default, visible and highlighted on `:focus`, and targeting `#chat-input`
- [ ] 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
- [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
- [ ] 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`
- [ ] 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()`
- [ ] 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
- [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
- [ ] 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)
- [ ] 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)
- [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
@@ -68,4 +68,4 @@
- [ ] 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
- [ ] 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
- [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