diff --git a/openspec/changes/implement-web-chat/tasks.md b/openspec/changes/implement-web-chat/tasks.md index ac2cef1..5239eff 100644 --- a/openspec/changes/implement-web-chat/tasks.md +++ b/openspec/changes/implement-web-chat/tasks.md @@ -1,64 +1,71 @@ -## 1. Project Setup +## 1. Add Chainlit Dependency -- [ ] 1.1 Create `requirements.txt` with pinned versions: `chainlit`, `anthropic`, `python-dotenv` -- [ ] 1.2 Create `.env.example` documenting required environment variables (`ANTHROPIC_API_KEY`, etc.) -- [ ] 1.3 Create `chainlit.toml` with project name, telemetry disabled, custom CSS path, and default language set to German -- [ ] 1.4 Create the directory structure: `app/`, `app/agent/`, `app/knowledge_base/`, `app/storage/registrations/`, `public/` -- [ ] 1.5 Add `.gitignore` entries for `.env`, `app/storage/registrations/`, `__pycache__/`, `.chainlit/` +- [ ] 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"` -## 2. Chainlit Application Shell +## 2. Add Streaming Support to src/llm.py -- [ ] 2.1 Create `app/chat.py` with `@cl.on_chat_start` handler that sends the German welcome message from `channel-config.md` -- [ ] 2.2 Add `@cl.on_message` handler that echoes the received message (placeholder — wired to agent in task 4) -- [ ] 2.3 Create `chainlit.md` with the bilingual welcome message (German default, English comment) -- [ ] 2.4 Verify the app starts with `chainlit run app/chat.py` and the welcome message appears in the browser +- [ ] 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 -## 3. Accessibility CSS +## 3. Create Chainlit Entry Point -- [ ] 3.1 Create `public/custom.css` with colour contrast overrides (all normal text ≥ 4.5:1, large text ≥ 3:1, placeholder ≥ 4.5:1) — measure against Chainlit's default colours with a contrast checker -- [ ] 3.2 Add `@media (prefers-reduced-motion: reduce)` block to `custom.css` that hides animated typing dots and replaces with a static `"…"` pseudo-element -- [ ] 3.3 Add a visually-hidden skip link as the first element in the page via Chainlit's `custom_css` or `head` injection, targeting the message input (`#chat-input`) -- [ ] 3.4 Add CSS to make the skip link visible on `:focus` -- [ ] 3.5 Use `dvh` (dynamic viewport height) units in `custom.css` for the chat container height to prevent iOS Safari virtual keyboard from obscuring the input +- [ ] 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: + - 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` +- [ ] 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. Agent Core Stub +## 4. Accessibility CSS -- [ ] 4.1 Create `app/agent/core.py` with a `process_message(message: str, session_state: dict) -> str` function that returns a hardcoded placeholder reply (full agent logic is a separate change) -- [ ] 4.2 Wire `app/chat.py`'s `@cl.on_message` to call `process_message` and stream the reply back using `cl.Message.stream_token()` -- [ ] 4.3 Initialise `cl.user_session` in `@cl.on_chat_start` with an empty registration state dict (`{"collected": {}, "history": []}`) -- [ ] 4.4 Pass `cl.user_session.get("state")` into `process_message` and write back any state updates it returns +- [ ] 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 ## 5. ARIA and Focus Management -- [ ] 5.1 Identify the Chainlit message list container selector in the rendered DOM (use browser dev tools) -- [ ] 5.2 Add `aria-live="polite"` and `aria-atomic="false"` to the message list container via `custom.css` content injection or a `