|
|
@@ -0,0 +1,1432 @@
|
|
|
+# Custom System Prompt & Opening Greeting Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** Add two configurable text inputs (custom system prompt + custom opening greeting) in `TopicDiscussionConfig.vue`, stored **per-mode** (影片场景 vs 非影片场景), with backend-hosted defaults and full backwards compatibility for legacy clients.
|
|
|
+
|
|
|
+**Architecture:** Backend gains a `GET /defaults` endpoint and accepts optional `systemPrompt` + `greeting` fields on session creation. The custom system prompt has `{video_description}` substituted via safe `.replace()`. Custom greeting is stored on the session row and short-circuits `generate_greeting` to skip the LLM. Empty strings are normalised to `None` server-side, so omitting or clearing the fields both fall back to legacy default behavior. Frontend extends the speaking store with 4 slots (one prompt + one greeting per mode), fetches defaults on config page mount, and binds the two textareas to the active mode's pair.
|
|
|
+
|
|
|
+**Tech Stack:** Backend FastAPI + SQLAlchemy + asyncio + pytest. Frontend Vue 3 + Pinia + Vite.
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-05-16-custom-prompt-and-greeting-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File Manifest
|
|
|
+
|
|
|
+**Backend** (`/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`):
|
|
|
+
|
|
|
+| File | Action |
|
|
|
+|---|---|
|
|
|
+| `app/service/speaking/dialogue_service.py` | Modify — rename old constants, add 4 default constants, accept `system_prompt_override` / `custom_greeting`, branch in `generate_greeting` |
|
|
|
+| `app/api/dialogue.py` | Modify — new `GET /defaults` route, extend `CreateSessionRequest`, plumb new fields into service call |
|
|
|
+| `app/models/dialogue.py` | Modify — add `custom_greeting: Mapped[Optional[str]]` column on `DialogueSession` |
|
|
|
+| `init.sql` | Modify — add `custom_greeting TEXT NULL` to `dialogue_session` table |
|
|
|
+| `migrations/2026-05-16-add-custom-greeting-to-dialogue-session.sql` | Create |
|
|
|
+| `tests/api/test_dialogue_defaults.py` | Create |
|
|
|
+| `tests/api/test_dialogue_greeting.py` | Modify — extend with custom_greeting cases |
|
|
|
+| `tests/service/speaking/test_dialogue_service_video_scene.py` | Modify — extend with substitution + empty-string cases |
|
|
|
+| `tests/service/speaking/test_dialogue_service_greeting.py` | Modify — extend with custom_greeting branch cases |
|
|
|
+
|
|
|
+**Frontend** (`/Users/buoy/Development/gitrepo/PPT`):
|
|
|
+
|
|
|
+| File | Action |
|
|
|
+|---|---|
|
|
|
+| `src/types/englishSpeaking.ts` | Modify — extend `TopicDiscussionConfig` shape with per-mode prompt/greeting slots |
|
|
|
+| `src/store/speaking.ts` | Modify — extend `config` shape, add `nonVideoScene` node + `hydrateDefaults` action |
|
|
|
+| `src/services/speaking.ts` | Modify — add `getDialogueDefaults` and types |
|
|
|
+| `src/views/Editor/EnglishSpeaking/services/llmService.ts` | Modify — `createSession` body adds `systemPrompt` + `greeting` |
|
|
|
+| `src/views/Editor/EnglishSpeaking/configs/TopicDiscussionConfig.vue` | Modify — new "AI 配置" collapsible section |
|
|
|
+| `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` | Modify — `createSession` payload pulls active mode pair; `onMounted` fetches defaults |
|
|
|
+| `src/views/lang/cn.json` | Modify — labels |
|
|
|
+| `src/views/lang/en.json` | Modify — labels |
|
|
|
+| `src/views/lang/hk.json` | Modify — labels |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Default Values (used in Task 2 and Task 8)
|
|
|
+
|
|
|
+The four defaults defined once on the backend, surfaced to the frontend via `GET /defaults`:
|
|
|
+
|
|
|
+**`DEFAULT_VIDEO_SCENE_SYSTEM_PROMPT`:**
|
|
|
+```
|
|
|
+你是一名友好的英语口语助手,你的任务是根据指定的对话大纲(或者指定的语句)/或指定的对话任务,要求,和用户进行英文口语问答。请确保你的语气友好且输出英文。用户为四年级学生。你要和用户讨论的是用户选择形状(heart, square, circle, oval, rectangle, star, triangle, diamond)有几条边和几个角。你的执行步骤为:1. 依次向用户提问以下两个问题: - 问题1:How many sides?) - 问题2:How many corners?2. 根据用户的回答,判断其答案是否正确,并提供清晰的反馈。 - 如果用户回答正确,回复:'回答正确!这个形状确实有X条边和Y个角。' - 如果用户回答错误,回复:'回答不正确。正确答案是:这个形状有X条边和Y个角。' 用户的选择是: {video_description}
|
|
|
+**必须以英文输出**
|
|
|
+```
|
|
|
+
|
|
|
+**`DEFAULT_VIDEO_SCENE_GREETING`:**
|
|
|
+```
|
|
|
+Hi! Let's talk about the shape you picked. Ready?
|
|
|
+```
|
|
|
+
|
|
|
+**`DEFAULT_NORMAL_SYSTEM_PROMPT`:** (verbatim copy of the current long teacher template at `dialogue_service.py:SYSTEM_PROMPT_TEMPLATE`; this default is currently unused in normal mode's LLM-free flow but is preserved for future text-generation use cases)
|
|
|
+
|
|
|
+**`DEFAULT_NORMAL_GREETING`:**
|
|
|
+```
|
|
|
+Hi everyone! I'm your AI buddy from Rixin. Input your ideas, and I'll help you draw amazing pictures. Let's start our creative journey together!
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 1: Add `custom_greeting` column on `DialogueSession`
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cococlass-english-speaking-api/app/models/dialogue.py`
|
|
|
+- Modify: `cococlass-english-speaking-api/init.sql`
|
|
|
+- Create: `cococlass-english-speaking-api/migrations/2026-05-16-add-custom-greeting-to-dialogue-session.sql`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the failing ORM test**
|
|
|
+
|
|
|
+Add to `tests/service/speaking/test_dialogue_service_greeting.py` (top of file under existing imports):
|
|
|
+
|
|
|
+```python
|
|
|
+import pytest
|
|
|
+from sqlalchemy import inspect
|
|
|
+
|
|
|
+from app.models.dialogue import DialogueSession
|
|
|
+
|
|
|
+
|
|
|
+def test_dialogue_session_has_custom_greeting_column():
|
|
|
+ cols = {c.name for c in inspect(DialogueSession).c}
|
|
|
+ assert "custom_greeting" in cols
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run test and confirm failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api && \
|
|
|
+ source .venv/bin/activate && \
|
|
|
+ python -m pytest tests/service/speaking/test_dialogue_service_greeting.py::test_dialogue_session_has_custom_greeting_column -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL — `'custom_greeting' not in cols`.
|
|
|
+
|
|
|
+- [ ] **Step 3: Add column to ORM model**
|
|
|
+
|
|
|
+In `cococlass-english-speaking-api/app/models/dialogue.py`, locate the `DialogueSession` class. Add this column just after `system_prompt`:
|
|
|
+
|
|
|
+```python
|
|
|
+ custom_greeting: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
|
+```
|
|
|
+
|
|
|
+Ensure `Text` is imported at the top of the file (it should already be from existing columns).
|
|
|
+
|
|
|
+- [ ] **Step 4: Add column to `init.sql`**
|
|
|
+
|
|
|
+In `cococlass-english-speaking-api/init.sql`, find the `CREATE TABLE dialogue_session (...)` block. Add this line near the `system_prompt` column:
|
|
|
+
|
|
|
+```sql
|
|
|
+ custom_greeting TEXT NULL,
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Create migration file**
|
|
|
+
|
|
|
+Create `cococlass-english-speaking-api/migrations/2026-05-16-add-custom-greeting-to-dialogue-session.sql`:
|
|
|
+
|
|
|
+```sql
|
|
|
+-- 2026-05-16 — add custom_greeting column to dialogue_session for the
|
|
|
+-- teacher-configurable opening greeting feature. Nullable so existing rows
|
|
|
+-- remain untouched and legacy clients that don't send the field still work.
|
|
|
+
|
|
|
+ALTER TABLE dialogue_session
|
|
|
+ ADD COLUMN custom_greeting TEXT NULL;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Run test and confirm pass**
|
|
|
+
|
|
|
+```bash
|
|
|
+python -m pytest tests/service/speaking/test_dialogue_service_greeting.py::test_dialogue_session_has_custom_greeting_column -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 7: Run the full backend test suite — confirm no regression**
|
|
|
+
|
|
|
+```bash
|
|
|
+python -m pytest tests/ -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 155+ tests pass (1 new test added, no failures).
|
|
|
+
|
|
|
+- [ ] **Step 8: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api && \
|
|
|
+ git add app/models/dialogue.py init.sql \
|
|
|
+ migrations/2026-05-16-add-custom-greeting-to-dialogue-session.sql \
|
|
|
+ tests/service/speaking/test_dialogue_service_greeting.py && \
|
|
|
+ git commit -m "feat(speaking): add custom_greeting column to dialogue_session"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 2: Restructure dialogue_service constants into 4 named defaults
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cococlass-english-speaking-api/app/service/speaking/dialogue_service.py`
|
|
|
+
|
|
|
+This task renames and reorganises the existing prompt / greeting constants so each mode has a clearly named default. No behavior change yet — call sites are updated to keep current behavior.
|
|
|
+
|
|
|
+- [ ] **Step 1: Read current constants**
|
|
|
+
|
|
|
+Open `cococlass-english-speaking-api/app/service/speaking/dialogue_service.py`. Locate (around lines 85-170):
|
|
|
+- `SYSTEM_PROMPT_TEMPLATE`
|
|
|
+- `IMAGE_RESPONSE_GREETING`
|
|
|
+- `VIDEO_SCENE_CONTEXT_TEMPLATE`
|
|
|
+- `IMAGE_PROMPT_TEMPLATE`
|
|
|
+
|
|
|
+- [ ] **Step 2: Replace the four prompt/greeting constants**
|
|
|
+
|
|
|
+Replace the constant definitions (NOT `IMAGE_PROMPT_TEMPLATE`, which stays as-is — that one is for image generation, not system prompts) with this block:
|
|
|
+
|
|
|
+```python
|
|
|
+# --- Defaults surfaced via GET /api/speaking/dialogue/defaults ----------
|
|
|
+# Teachers can override these per-config. Empty strings are normalised
|
|
|
+# to None server-side and fall through to these defaults.
|
|
|
+
|
|
|
+DEFAULT_NORMAL_SYSTEM_PROMPT = """[ ... paste the previous SYSTEM_PROMPT_TEMPLATE content verbatim here ... ]"""
|
|
|
+
|
|
|
+DEFAULT_NORMAL_GREETING = (
|
|
|
+ "Hi everyone! I'm your AI buddy from Rixin. "
|
|
|
+ "Input your ideas, and I'll help you draw amazing pictures. "
|
|
|
+ "Let's start our creative journey together!"
|
|
|
+)
|
|
|
+
|
|
|
+DEFAULT_VIDEO_SCENE_SYSTEM_PROMPT = """你是一名友好的英语口语助手,你的任务是根据指定的对话大纲(或者指定的语句)/或指定的对话任务,要求,和用户进行英文口语问答。请确保你的语气友好且输出英文。用户为四年级学生。你要和用户讨论的是用户选择形状(heart, square, circle, oval, rectangle, star, triangle, diamond)有几条边和几个角。你的执行步骤为:1. 依次向用户提问以下两个问题: - 问题1:How many sides?) - 问题2:How many corners?2. 根据用户的回答,判断其答案是否正确,并提供清晰的反馈。 - 如果用户回答正确,回复:'回答正确!这个形状确实有X条边和Y个角。' - 如果用户回答错误,回复:'回答不正确。正确答案是:这个形状有X条边和Y个角。' 用户的选择是: {video_description}
|
|
|
+**必须以英文输出**"""
|
|
|
+
|
|
|
+DEFAULT_VIDEO_SCENE_GREETING = "Hi! Let's talk about the shape you picked. Ready?"
|
|
|
+
|
|
|
+
|
|
|
+def _build_default_system_prompt(
|
|
|
+ *,
|
|
|
+ mode: str,
|
|
|
+ grade: str,
|
|
|
+ topic: str,
|
|
|
+ vocabulary: list[str],
|
|
|
+ sentences: list[str],
|
|
|
+ selected_video: dict | None,
|
|
|
+) -> str:
|
|
|
+ """Legacy fallback used when the request omits `systemPrompt`.
|
|
|
+
|
|
|
+ Reproduces the pre-feature behavior:
|
|
|
+ - video_scene mode → render DEFAULT_VIDEO_SCENE_SYSTEM_PROMPT with the
|
|
|
+ selected video's description substituted into {video_description}.
|
|
|
+ - normal mode → render DEFAULT_NORMAL_SYSTEM_PROMPT (string only, no
|
|
|
+ substitution; existing template uses positional .format placeholders).
|
|
|
+ """
|
|
|
+ if mode == "video_scene":
|
|
|
+ video_desc = (selected_video or {}).get("description", "") or ""
|
|
|
+ return DEFAULT_VIDEO_SCENE_SYSTEM_PROMPT.replace(
|
|
|
+ "{video_description}", video_desc,
|
|
|
+ )
|
|
|
+ # Normal mode: render the legacy long template using the same .format
|
|
|
+ # placeholders ({年级} / {话题主题} / {重点词汇} / {重点句型}).
|
|
|
+ rendered_vocab = ", ".join(vocabulary)
|
|
|
+ rendered_sentences = "\n".join(f"- {s}" for s in sentences)
|
|
|
+ return DEFAULT_NORMAL_SYSTEM_PROMPT.format(
|
|
|
+ 年级=grade,
|
|
|
+ 话题主题=topic,
|
|
|
+ 重点词汇=rendered_vocab,
|
|
|
+ 重点句型=rendered_sentences,
|
|
|
+ )
|
|
|
+```
|
|
|
+
|
|
|
+For `DEFAULT_NORMAL_SYSTEM_PROMPT`, paste the **exact current content** of `SYSTEM_PROMPT_TEMPLATE` (keep the `{年级}` / `{话题主题}` / `{重点词汇}` / `{重点句型}` placeholders intact — they're consumed by the legacy fallback path, not by the user-override path).
|
|
|
+
|
|
|
+- [ ] **Step 3: Update `create_session_only` to call the helper**
|
|
|
+
|
|
|
+In `create_session_only`, replace the existing block that builds `system_prompt`:
|
|
|
+
|
|
|
+```python
|
|
|
+ rendered_vocab = ", ".join(vocabulary)
|
|
|
+ rendered_sentences = "\n".join(f"- {s}" for s in sentences)
|
|
|
+ system_prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
|
|
+ 年级=grade,
|
|
|
+ 话题主题=topic,
|
|
|
+ 重点词汇=rendered_vocab,
|
|
|
+ 重点句型=rendered_sentences,
|
|
|
+ )
|
|
|
+ if mode == "video_scene":
|
|
|
+ system_prompt += VIDEO_SCENE_CONTEXT_TEMPLATE.format(
|
|
|
+ video_description=selected_video["description"],
|
|
|
+ )
|
|
|
+```
|
|
|
+
|
|
|
+With:
|
|
|
+
|
|
|
+```python
|
|
|
+ system_prompt = _build_default_system_prompt(
|
|
|
+ mode=mode,
|
|
|
+ grade=grade,
|
|
|
+ topic=topic,
|
|
|
+ vocabulary=vocabulary,
|
|
|
+ sentences=sentences,
|
|
|
+ selected_video=selected_video,
|
|
|
+ )
|
|
|
+```
|
|
|
+
|
|
|
+Also remove the now-obsolete `SYSTEM_PROMPT_TEMPLATE`, `VIDEO_SCENE_CONTEXT_TEMPLATE`, and `IMAGE_RESPONSE_GREETING` constants from the top of the file. The `IMAGE_PROMPT_TEMPLATE` constant stays.
|
|
|
+
|
|
|
+- [ ] **Step 4: Update the `generate_greeting` reference**
|
|
|
+
|
|
|
+Locate the `generate_greeting` method. Find:
|
|
|
+
|
|
|
+```python
|
|
|
+ if role_config.get("mode") == "normal":
|
|
|
+ ai_greeting = IMAGE_RESPONSE_GREETING
|
|
|
+```
|
|
|
+
|
|
|
+Replace `IMAGE_RESPONSE_GREETING` with `DEFAULT_NORMAL_GREETING`:
|
|
|
+
|
|
|
+```python
|
|
|
+ if role_config.get("mode") == "normal":
|
|
|
+ ai_greeting = DEFAULT_NORMAL_GREETING
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Verify no stale references remain**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api && \
|
|
|
+ grep -rn "SYSTEM_PROMPT_TEMPLATE\|VIDEO_SCENE_CONTEXT_TEMPLATE\|IMAGE_RESPONSE_GREETING" \
|
|
|
+ app/ tests/
|
|
|
+```
|
|
|
+
|
|
|
+Expected: zero matches.
|
|
|
+
|
|
|
+- [ ] **Step 6: Run the full test suite**
|
|
|
+
|
|
|
+```bash
|
|
|
+source .venv/bin/activate && python -m pytest tests/ -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: same number passing as before this task (constants renamed but behavior unchanged). If any test fails complaining about a renamed constant, update the test to import the new name (`DEFAULT_NORMAL_GREETING` etc.).
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/service/speaking/dialogue_service.py tests/ && \
|
|
|
+ git commit -m "refactor(speaking): restructure prompt/greeting constants into per-mode defaults"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 3: Add `GET /api/speaking/dialogue/defaults` endpoint
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cococlass-english-speaking-api/app/api/dialogue.py`
|
|
|
+- Create: `cococlass-english-speaking-api/tests/api/test_dialogue_defaults.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the failing endpoint tests**
|
|
|
+
|
|
|
+Create `cococlass-english-speaking-api/tests/api/test_dialogue_defaults.py`:
|
|
|
+
|
|
|
+```python
|
|
|
+"""HTTP tests for GET /api/speaking/dialogue/defaults."""
|
|
|
+
|
|
|
+import pytest
|
|
|
+from httpx import ASGITransport, AsyncClient
|
|
|
+
|
|
|
+from app.main import app
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_get_defaults_returns_two_modes():
|
|
|
+ transport = ASGITransport(app=app)
|
|
|
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
|
+ r = await client.get("/api/speaking/dialogue/defaults")
|
|
|
+ assert r.status_code == 200
|
|
|
+ body = r.json()
|
|
|
+ assert set(body.keys()) == {"videoScene", "nonVideoScene"}
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_get_defaults_each_mode_has_prompt_and_greeting():
|
|
|
+ transport = ASGITransport(app=app)
|
|
|
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
|
+ r = await client.get("/api/speaking/dialogue/defaults")
|
|
|
+ body = r.json()
|
|
|
+ for mode in ("videoScene", "nonVideoScene"):
|
|
|
+ assert set(body[mode].keys()) == {"systemPrompt", "greeting"}
|
|
|
+ assert isinstance(body[mode]["systemPrompt"], str)
|
|
|
+ assert isinstance(body[mode]["greeting"], str)
|
|
|
+ assert body[mode]["systemPrompt"].strip()
|
|
|
+ assert body[mode]["greeting"].strip()
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_get_defaults_video_scene_prompt_contains_placeholder():
|
|
|
+ """The video-scene default exposes {video_description} for teacher reference."""
|
|
|
+ transport = ASGITransport(app=app)
|
|
|
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
|
+ r = await client.get("/api/speaking/dialogue/defaults")
|
|
|
+ body = r.json()
|
|
|
+ assert "{video_description}" in body["videoScene"]["systemPrompt"]
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run tests and confirm failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+python -m pytest tests/api/test_dialogue_defaults.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL — 404 (endpoint does not exist).
|
|
|
+
|
|
|
+- [ ] **Step 3: Add the endpoint to `app/api/dialogue.py`**
|
|
|
+
|
|
|
+At the top of `app/api/dialogue.py`, import the four constants:
|
|
|
+
|
|
|
+```python
|
|
|
+from app.service.speaking.dialogue_service import (
|
|
|
+ DEFAULT_NORMAL_GREETING,
|
|
|
+ DEFAULT_NORMAL_SYSTEM_PROMPT,
|
|
|
+ DEFAULT_VIDEO_SCENE_GREETING,
|
|
|
+ DEFAULT_VIDEO_SCENE_SYSTEM_PROMPT,
|
|
|
+ DialogueService,
|
|
|
+)
|
|
|
+```
|
|
|
+
|
|
|
+(Replace the existing import line of `DialogueService` and merge with any other imports from that module.)
|
|
|
+
|
|
|
+Add the route definition near the other GET routes:
|
|
|
+
|
|
|
+```python
|
|
|
+@router.get("/defaults")
|
|
|
+async def get_dialogue_defaults():
|
|
|
+ """Return the teacher-facing default values for both modes.
|
|
|
+
|
|
|
+ Frontend fetches this once when the config page mounts and uses the
|
|
|
+ payload to hydrate any empty store slots. Renaming a default on the
|
|
|
+ backend automatically propagates without a frontend redeploy.
|
|
|
+ """
|
|
|
+ return {
|
|
|
+ "videoScene": {
|
|
|
+ "systemPrompt": DEFAULT_VIDEO_SCENE_SYSTEM_PROMPT,
|
|
|
+ "greeting": DEFAULT_VIDEO_SCENE_GREETING,
|
|
|
+ },
|
|
|
+ "nonVideoScene": {
|
|
|
+ "systemPrompt": DEFAULT_NORMAL_SYSTEM_PROMPT,
|
|
|
+ "greeting": DEFAULT_NORMAL_GREETING,
|
|
|
+ },
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Run tests and confirm pass**
|
|
|
+
|
|
|
+```bash
|
|
|
+python -m pytest tests/api/test_dialogue_defaults.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 3 PASS.
|
|
|
+
|
|
|
+- [ ] **Step 5: Manual sanity check via curl** (optional but worth confirming the route mount)
|
|
|
+
|
|
|
+Start the dev server in another terminal, then:
|
|
|
+
|
|
|
+```bash
|
|
|
+curl -s http://localhost:8000/api/speaking/dialogue/defaults | python -m json.tool
|
|
|
+```
|
|
|
+
|
|
|
+Expected: pretty-printed JSON with `videoScene` and `nonVideoScene` keys.
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/api/dialogue.py tests/api/test_dialogue_defaults.py && \
|
|
|
+ git commit -m "feat(speaking): add GET /dialogue/defaults endpoint"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 4: Backend — accept `systemPrompt` / `greeting` on session creation
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cococlass-english-speaking-api/app/api/dialogue.py`
|
|
|
+- Modify: `cococlass-english-speaking-api/app/service/speaking/dialogue_service.py`
|
|
|
+- Modify: `cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_video_scene.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the failing tests**
|
|
|
+
|
|
|
+Append to `cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_video_scene.py`:
|
|
|
+
|
|
|
+```python
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_create_session_persists_custom_system_prompt_with_substitution(db_session):
|
|
|
+ """Custom systemPrompt has {video_description} replaced with the selected video's description."""
|
|
|
+ service = _service()
|
|
|
+ selected = {"id": "v1", "videoUrl": "x", "description": "heart"}
|
|
|
+ result = await service.create_session_only(
|
|
|
+ db=db_session, topic="t", grade="grade5",
|
|
|
+ vocabulary=[], sentences=[], total_rounds=5,
|
|
|
+ mode="video_scene", selected_video=selected,
|
|
|
+ system_prompt_override="The student picked: {video_description}.",
|
|
|
+ )
|
|
|
+ row = (await db_session.execute(
|
|
|
+ select(DialogueSession).where(DialogueSession.uuid == result["sessionId"])
|
|
|
+ )).scalar_one()
|
|
|
+ assert row.system_prompt == "The student picked: heart."
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_create_session_custom_system_prompt_normal_mode_substitutes_empty(db_session):
|
|
|
+ """In normal mode (no video selected), {video_description} becomes empty string."""
|
|
|
+ service = _service()
|
|
|
+ result = await service.create_session_only(
|
|
|
+ db=db_session, topic="t", grade="grade5",
|
|
|
+ vocabulary=[], sentences=[], total_rounds=3,
|
|
|
+ mode="normal",
|
|
|
+ system_prompt_override="Selection: {video_description}.",
|
|
|
+ )
|
|
|
+ row = (await db_session.execute(
|
|
|
+ select(DialogueSession).where(DialogueSession.uuid == result["sessionId"])
|
|
|
+ )).scalar_one()
|
|
|
+ assert row.system_prompt == "Selection: ."
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_create_session_empty_systemprompt_treated_as_unset(db_session):
|
|
|
+ """Whitespace-only system_prompt_override falls back to the legacy default."""
|
|
|
+ service = _service()
|
|
|
+ selected = {"id": "v1", "videoUrl": "x", "description": "heart"}
|
|
|
+ result = await service.create_session_only(
|
|
|
+ db=db_session, topic="t", grade="grade5",
|
|
|
+ vocabulary=[], sentences=[], total_rounds=5,
|
|
|
+ mode="video_scene", selected_video=selected,
|
|
|
+ system_prompt_override=" ",
|
|
|
+ )
|
|
|
+ row = (await db_session.execute(
|
|
|
+ select(DialogueSession).where(DialogueSession.uuid == result["sessionId"])
|
|
|
+ )).scalar_one()
|
|
|
+ # Fallback rendered the default template, which mentions "heart".
|
|
|
+ assert "heart" in row.system_prompt
|
|
|
+ # And it's the long default template, not the empty short prompt.
|
|
|
+ assert len(row.system_prompt) > 80
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_create_session_persists_custom_greeting(db_session):
|
|
|
+ service = _service()
|
|
|
+ selected = {"id": "v1", "videoUrl": "x", "description": "heart"}
|
|
|
+ result = await service.create_session_only(
|
|
|
+ db=db_session, topic="t", grade="grade5",
|
|
|
+ vocabulary=[], sentences=[], total_rounds=3,
|
|
|
+ mode="video_scene", selected_video=selected,
|
|
|
+ custom_greeting="Hello kids!",
|
|
|
+ )
|
|
|
+ row = (await db_session.execute(
|
|
|
+ select(DialogueSession).where(DialogueSession.uuid == result["sessionId"])
|
|
|
+ )).scalar_one()
|
|
|
+ assert row.custom_greeting == "Hello kids!"
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_create_session_empty_greeting_stored_as_null(db_session):
|
|
|
+ service = _service()
|
|
|
+ result = await service.create_session_only(
|
|
|
+ db=db_session, topic="t", grade="grade5",
|
|
|
+ vocabulary=[], sentences=[], total_rounds=3,
|
|
|
+ custom_greeting="",
|
|
|
+ )
|
|
|
+ row = (await db_session.execute(
|
|
|
+ select(DialogueSession).where(DialogueSession.uuid == result["sessionId"])
|
|
|
+ )).scalar_one()
|
|
|
+ assert row.custom_greeting is None
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run tests and confirm failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+python -m pytest tests/service/speaking/test_dialogue_service_video_scene.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 5 new tests FAIL — `create_session_only` does not accept `system_prompt_override` / `custom_greeting`.
|
|
|
+
|
|
|
+- [ ] **Step 3: Add the normalisation helper at top of `dialogue_service.py`**
|
|
|
+
|
|
|
+Just below the four `DEFAULT_*` constants from Task 2, add:
|
|
|
+
|
|
|
+```python
|
|
|
+def _normalise_optional_str(value: str | None) -> str | None:
|
|
|
+ """Treat empty / whitespace-only strings as not-set so the legacy
|
|
|
+ default path runs. Centralises this rule so both system_prompt and
|
|
|
+ custom_greeting normalise identically.
|
|
|
+ """
|
|
|
+ if value is None or not value.strip():
|
|
|
+ return None
|
|
|
+ return value
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Extend `create_session_only` signature and body**
|
|
|
+
|
|
|
+In `app/service/speaking/dialogue_service.py`, locate the `create_session_only` method. Update its signature to accept the two new keyword arguments:
|
|
|
+
|
|
|
+```python
|
|
|
+ async def create_session_only(
|
|
|
+ self,
|
|
|
+ db: AsyncSession,
|
|
|
+ topic: str,
|
|
|
+ grade: str,
|
|
|
+ vocabulary: list[str],
|
|
|
+ sentences: list[str],
|
|
|
+ total_rounds: int = 3,
|
|
|
+ duration_seconds: int | None = None,
|
|
|
+ role_config: dict | None = None,
|
|
|
+ user_id: str | None = None,
|
|
|
+ config_id: str | None = None,
|
|
|
+ mode: str = "normal",
|
|
|
+ selected_video: dict | None = None,
|
|
|
+ system_prompt_override: str | None = None,
|
|
|
+ custom_greeting: str | None = None,
|
|
|
+ ) -> dict:
|
|
|
+```
|
|
|
+
|
|
|
+Replace the system_prompt assembly (the `system_prompt = _build_default_system_prompt(...)` call from Task 2) with the override-aware version:
|
|
|
+
|
|
|
+```python
|
|
|
+ custom_prompt = _normalise_optional_str(system_prompt_override)
|
|
|
+ if custom_prompt is not None:
|
|
|
+ video_desc = (selected_video or {}).get("description", "") or ""
|
|
|
+ system_prompt = custom_prompt.replace("{video_description}", video_desc)
|
|
|
+ else:
|
|
|
+ system_prompt = _build_default_system_prompt(
|
|
|
+ mode=mode,
|
|
|
+ grade=grade,
|
|
|
+ topic=topic,
|
|
|
+ vocabulary=vocabulary,
|
|
|
+ sentences=sentences,
|
|
|
+ selected_video=selected_video,
|
|
|
+ )
|
|
|
+```
|
|
|
+
|
|
|
+Locate where the `DialogueSession(...)` row is constructed for insertion. Add the `custom_greeting` keyword:
|
|
|
+
|
|
|
+```python
|
|
|
+ session = DialogueSession(
|
|
|
+ # ... existing kwargs (uuid, system_prompt, status, etc.)
|
|
|
+ custom_greeting=_normalise_optional_str(custom_greeting),
|
|
|
+ )
|
|
|
+```
|
|
|
+
|
|
|
+(Place the new kwarg alongside the existing ones — exact position doesn't matter for SQLAlchemy.)
|
|
|
+
|
|
|
+- [ ] **Step 5: Extend `CreateSessionRequest` in `app/api/dialogue.py`**
|
|
|
+
|
|
|
+Add two optional fields to the Pydantic model:
|
|
|
+
|
|
|
+```python
|
|
|
+class CreateSessionRequest(BaseModel):
|
|
|
+ # ... existing fields
|
|
|
+ systemPrompt: str | None = None
|
|
|
+ greeting: str | None = None
|
|
|
+```
|
|
|
+
|
|
|
+In the `POST /session` route handler, plumb the new fields through:
|
|
|
+
|
|
|
+```python
|
|
|
+ info = await service.create_session_only(
|
|
|
+ db=db,
|
|
|
+ topic=req.topic,
|
|
|
+ grade=req.grade,
|
|
|
+ vocabulary=req.vocabulary or [],
|
|
|
+ sentences=req.sentences or [],
|
|
|
+ total_rounds=req.totalRounds,
|
|
|
+ duration_seconds=duration_seconds,
|
|
|
+ user_id=req.userId,
|
|
|
+ config_id=req.configId,
|
|
|
+ mode=req.mode,
|
|
|
+ selected_video=selected_video,
|
|
|
+ system_prompt_override=req.systemPrompt,
|
|
|
+ custom_greeting=req.greeting,
|
|
|
+ )
|
|
|
+```
|
|
|
+
|
|
|
+(Keep all existing keyword arguments — only the last two are new.)
|
|
|
+
|
|
|
+- [ ] **Step 6: Run service tests and confirm pass**
|
|
|
+
|
|
|
+```bash
|
|
|
+python -m pytest tests/service/speaking/test_dialogue_service_video_scene.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: all 5 new tests PASS.
|
|
|
+
|
|
|
+- [ ] **Step 7: Run full test suite to catch regressions**
|
|
|
+
|
|
|
+```bash
|
|
|
+python -m pytest tests/ -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: existing tests still pass. If any old test breaks because it asserts on the legacy default prompt content, leave the test as-is — the fallback path should reproduce the old assembly exactly.
|
|
|
+
|
|
|
+- [ ] **Step 8: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/api/dialogue.py app/service/speaking/dialogue_service.py \
|
|
|
+ tests/service/speaking/test_dialogue_service_video_scene.py && \
|
|
|
+ git commit -m "feat(speaking): accept systemPrompt + greeting on session creation"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 5: Backend — `generate_greeting` uses `custom_greeting` (skip LLM)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cococlass-english-speaking-api/app/service/speaking/dialogue_service.py`
|
|
|
+- Modify: `cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_greeting.py`
|
|
|
+- Modify: `cococlass-english-speaking-api/tests/api/test_dialogue_greeting.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the failing service-level tests**
|
|
|
+
|
|
|
+Append to `cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_greeting.py`:
|
|
|
+
|
|
|
+```python
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_generate_greeting_uses_custom_greeting_skips_llm(db_session: AsyncSession):
|
|
|
+ service = _build_service(llm_response="should not be used")
|
|
|
+ created = await service.create_session_only(
|
|
|
+ db=db_session, topic="t", grade="grade5-1",
|
|
|
+ vocabulary=[], sentences=[], total_rounds=3,
|
|
|
+ mode="video_scene",
|
|
|
+ selected_video={"id": "v1", "videoUrl": "x", "description": "heart"},
|
|
|
+ custom_greeting="Hello explorers!",
|
|
|
+ )
|
|
|
+ session_uuid = created["sessionId"]
|
|
|
+
|
|
|
+ result = await service.generate_greeting(db=db_session, session_uuid=session_uuid)
|
|
|
+
|
|
|
+ assert result["aiMessage"] == "Hello explorers!"
|
|
|
+ service.llm.chat.assert_not_called()
|
|
|
+
|
|
|
+ # round=1 AI message persisted with the custom greeting content
|
|
|
+ rows = (await db_session.execute(
|
|
|
+ select(DialogueMessage).where(DialogueMessage.role == "ai")
|
|
|
+ )).scalars().all()
|
|
|
+ assert len(rows) == 1
|
|
|
+ assert rows[0].content == "Hello explorers!"
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_generate_greeting_normal_mode_no_custom_uses_hardcoded(db_session: AsyncSession):
|
|
|
+ """When custom_greeting is None and mode='normal', the hardcoded default is used."""
|
|
|
+ service = _build_service(llm_response="should not be used")
|
|
|
+ created = await service.create_session_only(
|
|
|
+ db=db_session, topic="t", grade="grade5-1",
|
|
|
+ vocabulary=[], sentences=[], total_rounds=3,
|
|
|
+ )
|
|
|
+ session_uuid = created["sessionId"]
|
|
|
+
|
|
|
+ result = await service.generate_greeting(db=db_session, session_uuid=session_uuid)
|
|
|
+
|
|
|
+ assert "draw amazing pictures" in result["aiMessage"] # DEFAULT_NORMAL_GREETING
|
|
|
+ service.llm.chat.assert_not_called()
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_generate_greeting_idempotent_with_custom_greeting(db_session: AsyncSession):
|
|
|
+ """Repeated calls with the same turnId return the same content without re-persisting."""
|
|
|
+ service = _build_service(llm_response="should not be used")
|
|
|
+ created = await service.create_session_only(
|
|
|
+ db=db_session, topic="t", grade="grade5-1",
|
|
|
+ vocabulary=[], sentences=[], total_rounds=3,
|
|
|
+ mode="video_scene",
|
|
|
+ selected_video={"id": "v1", "videoUrl": "x", "description": "heart"},
|
|
|
+ custom_greeting="Hello again!",
|
|
|
+ )
|
|
|
+ session_uuid = created["sessionId"]
|
|
|
+
|
|
|
+ r1 = await service.generate_greeting(
|
|
|
+ db=db_session, session_uuid=session_uuid, client_turn_id="t-1",
|
|
|
+ )
|
|
|
+ r2 = await service.generate_greeting(
|
|
|
+ db=db_session, session_uuid=session_uuid, client_turn_id="t-1",
|
|
|
+ )
|
|
|
+ assert r1 == r2
|
|
|
+ rows = (await db_session.execute(
|
|
|
+ select(DialogueMessage).where(DialogueMessage.role == "ai")
|
|
|
+ )).scalars().all()
|
|
|
+ assert len(rows) == 1
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run tests and confirm failure**
|
|
|
+
|
|
|
+```bash
|
|
|
+python -m pytest tests/service/speaking/test_dialogue_service_greeting.py -v -k "custom_greeting or normal_mode_no_custom"
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 3 FAIL — `generate_greeting` still tries to call LLM in video_scene mode.
|
|
|
+
|
|
|
+- [ ] **Step 3: Branch in `generate_greeting`**
|
|
|
+
|
|
|
+In `app/service/speaking/dialogue_service.py`, find `generate_greeting`. Locate the block that decides the greeting source (currently around the `role_config.get("mode") == "normal"` check). Replace with this priority chain:
|
|
|
+
|
|
|
+```python
|
|
|
+ # Priority 1: teacher-configured custom_greeting (mode-agnostic).
|
|
|
+ if session.custom_greeting:
|
|
|
+ ai_greeting = session.custom_greeting
|
|
|
+ # Priority 2: normal mode → hardcoded default (1-round + image flow).
|
|
|
+ elif role_config.get("mode") == "normal":
|
|
|
+ ai_greeting = DEFAULT_NORMAL_GREETING
|
|
|
+ # Priority 3: video_scene mode → ask the LLM.
|
|
|
+ else:
|
|
|
+ messages = [
|
|
|
+ {"role": "system", "content": session.system_prompt},
|
|
|
+ {"role": "user", "content": f"Start a conversation about: {session.topic}"},
|
|
|
+ ]
|
|
|
+ ai_greeting = await self.llm.chat(messages)
|
|
|
+```
|
|
|
+
|
|
|
+(Keep all surrounding logic — row-locking, idempotency cache lookup, message insertion, `db.commit()` — untouched. Only the greeting-source decision changes.)
|
|
|
+
|
|
|
+- [ ] **Step 4: Run service tests and confirm pass**
|
|
|
+
|
|
|
+```bash
|
|
|
+python -m pytest tests/service/speaking/test_dialogue_service_greeting.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: all tests (existing + 3 new) PASS.
|
|
|
+
|
|
|
+- [ ] **Step 5: Add HTTP-level smoke test in `tests/api/test_dialogue_greeting.py`**
|
|
|
+
|
|
|
+Append:
|
|
|
+
|
|
|
+```python
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_post_greeting_returns_custom_greeting_when_provided(test_env):
|
|
|
+ client, _ = test_env
|
|
|
+ r = await client.post("/api/speaking/dialogue/session", json={
|
|
|
+ "topic": "Shapes", "grade": "grade5-1", "totalRounds": 3,
|
|
|
+ "mode": "video_scene",
|
|
|
+ "selectedVideo": {
|
|
|
+ "id": "v1", "videoUrl": "https://example/v1.mp4", "description": "heart",
|
|
|
+ },
|
|
|
+ "greeting": "Hi explorers, let's start!",
|
|
|
+ })
|
|
|
+ session_id = r.json()["sessionId"]
|
|
|
+
|
|
|
+ g = await client.post(
|
|
|
+ f"/api/speaking/dialogue/session/{session_id}/greeting",
|
|
|
+ json={"turnId": "test-custom-greeting"},
|
|
|
+ )
|
|
|
+ assert g.status_code == 200
|
|
|
+ assert g.json() == {"aiMessage": "Hi explorers, let's start!"}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Run HTTP tests**
|
|
|
+
|
|
|
+```bash
|
|
|
+python -m pytest tests/api/test_dialogue_greeting.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: all tests PASS.
|
|
|
+
|
|
|
+- [ ] **Step 7: Run full suite for regression check**
|
|
|
+
|
|
|
+```bash
|
|
|
+python -m pytest tests/ -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 155+ original tests + 5–6 new tests from this task; all green.
|
|
|
+
|
|
|
+- [ ] **Step 8: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add app/service/speaking/dialogue_service.py \
|
|
|
+ tests/service/speaking/test_dialogue_service_greeting.py \
|
|
|
+ tests/api/test_dialogue_greeting.py && \
|
|
|
+ git commit -m "feat(speaking): branch generate_greeting on custom_greeting to skip LLM"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 6: Frontend — extend `SessionConfig` types
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `PPT/src/types/englishSpeaking.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Locate the `TopicDiscussionConfig` interface**
|
|
|
+
|
|
|
+Open `/Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts`. Find the `TopicDiscussionConfig` interface (or equivalent — should contain `videoScene: { enabled: boolean }` from a previous change).
|
|
|
+
|
|
|
+- [ ] **Step 2: Extend the `videoScene` field and add `nonVideoScene`**
|
|
|
+
|
|
|
+Replace the `videoScene` field declaration:
|
|
|
+
|
|
|
+```ts
|
|
|
+ videoScene: {
|
|
|
+ enabled: boolean
|
|
|
+ systemPrompt: string
|
|
|
+ greeting: string
|
|
|
+ }
|
|
|
+ nonVideoScene: {
|
|
|
+ systemPrompt: string
|
|
|
+ greeting: string
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Extend the `SessionConfig` (or createSession-request) interface**
|
|
|
+
|
|
|
+Find the `SessionConfig` interface used by `createSession`. Add two optional fields:
|
|
|
+
|
|
|
+```ts
|
|
|
+ systemPrompt?: string
|
|
|
+ greeting?: string
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Run type check**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /Users/buoy/Development/gitrepo/PPT && npm run type-check 2>&1 | grep -E "EnglishSpeaking|speaking|englishSpeaking" | head -20
|
|
|
+```
|
|
|
+
|
|
|
+Expected: zero new EnglishSpeaking-related type errors. (Pre-existing errors in `Student/index2.vue` etc. are unrelated.)
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add src/types/englishSpeaking.ts && \
|
|
|
+ git commit -m "feat(speaking): extend SessionConfig with per-mode prompt + greeting"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 7: Frontend — `getDialogueDefaults` service helper
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `PPT/src/services/speaking.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Read existing service helpers**
|
|
|
+
|
|
|
+Open `src/services/speaking.ts`. Identify the existing `DIALOGUE_BASE` constant and the `parse<T>` helper used by other functions.
|
|
|
+
|
|
|
+- [ ] **Step 2: Add the types and function**
|
|
|
+
|
|
|
+Add this block near the other exported functions:
|
|
|
+
|
|
|
+```ts
|
|
|
+export interface DialogueModeDefaults {
|
|
|
+ systemPrompt: string
|
|
|
+ greeting: string
|
|
|
+}
|
|
|
+
|
|
|
+export interface DialogueDefaultsResponse {
|
|
|
+ videoScene: DialogueModeDefaults
|
|
|
+ nonVideoScene: DialogueModeDefaults
|
|
|
+}
|
|
|
+
|
|
|
+export async function getDialogueDefaults(): Promise<DialogueDefaultsResponse> {
|
|
|
+ const res = await fetch(`${DIALOGUE_BASE}/defaults`, {
|
|
|
+ method: 'GET',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ credentials: 'include',
|
|
|
+ })
|
|
|
+ return parse<DialogueDefaultsResponse>(res)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Run type check**
|
|
|
+
|
|
|
+```bash
|
|
|
+npm run type-check 2>&1 | grep -E "services/speaking" | head -10
|
|
|
+```
|
|
|
+
|
|
|
+Expected: zero errors in `services/speaking.ts`.
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add src/services/speaking.ts && \
|
|
|
+ git commit -m "feat(speaking): add getDialogueDefaults service helper"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 8: Frontend — extend Pinia store with per-mode slots + hydrate action
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `PPT/src/store/speaking.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Locate the existing config defaults**
|
|
|
+
|
|
|
+Open `src/store/speaking.ts`. Find both default config definitions (currently around lines 43 and 75, where `videoScene: { enabled: false }` already exists).
|
|
|
+
|
|
|
+- [ ] **Step 2: Extend the default config shapes**
|
|
|
+
|
|
|
+For each default config object (there are two — initial state and the reset / default fixture), replace the `videoScene` line with:
|
|
|
+
|
|
|
+```ts
|
|
|
+ videoScene: {
|
|
|
+ enabled: false,
|
|
|
+ systemPrompt: '',
|
|
|
+ greeting: '',
|
|
|
+ },
|
|
|
+ nonVideoScene: {
|
|
|
+ systemPrompt: '',
|
|
|
+ greeting: '',
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Add the `hydrateDefaults` action**
|
|
|
+
|
|
|
+Inside the store's `actions: { ... }` block, near `toggleVideoScene`, add:
|
|
|
+
|
|
|
+```ts
|
|
|
+ hydrateDefaults(payload: {
|
|
|
+ videoScene: { systemPrompt: string; greeting: string }
|
|
|
+ nonVideoScene: { systemPrompt: string; greeting: string }
|
|
|
+ }) {
|
|
|
+ // Fill only empty slots — preserve any teacher edits already present.
|
|
|
+ if (!this.config.videoScene.systemPrompt) {
|
|
|
+ this.config.videoScene.systemPrompt = payload.videoScene.systemPrompt
|
|
|
+ }
|
|
|
+ if (!this.config.videoScene.greeting) {
|
|
|
+ this.config.videoScene.greeting = payload.videoScene.greeting
|
|
|
+ }
|
|
|
+ if (!this.config.nonVideoScene.systemPrompt) {
|
|
|
+ this.config.nonVideoScene.systemPrompt = payload.nonVideoScene.systemPrompt
|
|
|
+ }
|
|
|
+ if (!this.config.nonVideoScene.greeting) {
|
|
|
+ this.config.nonVideoScene.greeting = payload.nonVideoScene.greeting
|
|
|
+ }
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Run type check**
|
|
|
+
|
|
|
+```bash
|
|
|
+npm run type-check 2>&1 | grep -E "store/speaking" | head -10
|
|
|
+```
|
|
|
+
|
|
|
+Expected: zero errors in `store/speaking.ts`.
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add src/store/speaking.ts && \
|
|
|
+ git commit -m "feat(speaking): add per-mode prompt/greeting slots + hydrateDefaults action"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 9: Frontend — i18n keys
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `PPT/src/views/lang/cn.json`
|
|
|
+- Modify: `PPT/src/views/lang/en.json`
|
|
|
+- Modify: `PPT/src/views/lang/hk.json`
|
|
|
+
|
|
|
+- [ ] **Step 1: Add keys to `src/views/lang/cn.json`**
|
|
|
+
|
|
|
+Near the existing `ssVideoScene` key, add:
|
|
|
+
|
|
|
+```json
|
|
|
+ "ssAiConfig": "AI 配置",
|
|
|
+ "ssCustomGreeting": "会话开场白",
|
|
|
+ "ssCustomGreetingHint": "这句话会逐字作为 AI 第一句话,不会再走模型生成",
|
|
|
+ "ssCustomSystemPrompt": "系统提示词 (System Prompt)",
|
|
|
+ "ssCustomSystemPromptPlaceholderHint": "可用占位符: {video_description}",
|
|
|
+ "ssAiConfigDefaultsLoadError": "默认值加载失败,请刷新",
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Add the same keys with English translations in `en.json`**
|
|
|
+
|
|
|
+```json
|
|
|
+ "ssAiConfig": "AI Configuration",
|
|
|
+ "ssCustomGreeting": "Opening Greeting",
|
|
|
+ "ssCustomGreetingHint": "This will be the AI's first utterance, verbatim. No LLM call.",
|
|
|
+ "ssCustomSystemPrompt": "System Prompt",
|
|
|
+ "ssCustomSystemPromptPlaceholderHint": "Available placeholder: {video_description}",
|
|
|
+ "ssAiConfigDefaultsLoadError": "Failed to load defaults. Please refresh.",
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Add the same keys with traditional Chinese translations in `hk.json`**
|
|
|
+
|
|
|
+```json
|
|
|
+ "ssAiConfig": "AI 配置",
|
|
|
+ "ssCustomGreeting": "會話開場白",
|
|
|
+ "ssCustomGreetingHint": "這句話會逐字作為 AI 第一句話,不會再走模型生成",
|
|
|
+ "ssCustomSystemPrompt": "系統提示詞 (System Prompt)",
|
|
|
+ "ssCustomSystemPromptPlaceholderHint": "可用佔位符: {video_description}",
|
|
|
+ "ssAiConfigDefaultsLoadError": "默認值載入失敗,請刷新",
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add src/views/lang/cn.json src/views/lang/en.json src/views/lang/hk.json && \
|
|
|
+ git commit -m "feat(speaking): i18n keys for AI config section"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 10: Frontend — "AI 配置" section in `TopicDiscussionConfig.vue`
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `PPT/src/views/Editor/EnglishSpeaking/configs/TopicDiscussionConfig.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: Add a local state for collapse**
|
|
|
+
|
|
|
+In the `<script setup>` block, near the existing `learningGoalsExpanded` / `advancedExpanded` refs, add:
|
|
|
+
|
|
|
+```ts
|
|
|
+const aiConfigExpanded = ref(true) // default expanded
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Add the section markup**
|
|
|
+
|
|
|
+In the `<template>`, between the 影片场景 toggle row (currently ends around line 213) and the `<div class="section-spacer"></div>` (line 217), insert:
|
|
|
+
|
|
|
+```vue
|
|
|
+ <!-- AI 配置 -->
|
|
|
+ <div class="ai-config-section">
|
|
|
+ <button class="section-toggle" @click="aiConfigExpanded = !aiConfigExpanded">
|
|
|
+ <span class="section-toggle-label">{{ lang.ssAiConfig }}</span>
|
|
|
+ <svg
|
|
|
+ class="section-toggle-arrow"
|
|
|
+ :class="{ collapsed: !aiConfigExpanded }"
|
|
|
+ width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
|
+ >
|
|
|
+ <path d="M6 9l6 6 6-6" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <div v-if="aiConfigExpanded" class="ai-config-body">
|
|
|
+ <div class="form-row">
|
|
|
+ <label class="form-label">{{ lang.ssCustomGreeting }}</label>
|
|
|
+ <textarea
|
|
|
+ class="form-textarea"
|
|
|
+ rows="3"
|
|
|
+ :value="activeGreeting"
|
|
|
+ @input="onGreetingInput(($event.target as HTMLTextAreaElement).value)"
|
|
|
+ />
|
|
|
+ <div class="form-hint">{{ lang.ssCustomGreetingHint }}</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="form-row">
|
|
|
+ <label class="form-label">{{ lang.ssCustomSystemPrompt }}</label>
|
|
|
+ <textarea
|
|
|
+ class="form-textarea"
|
|
|
+ rows="10"
|
|
|
+ :value="activeSystemPrompt"
|
|
|
+ @input="onSystemPromptInput(($event.target as HTMLTextAreaElement).value)"
|
|
|
+ />
|
|
|
+ <div v-if="store.config.videoScene.enabled" class="form-hint">
|
|
|
+ {{ lang.ssCustomSystemPromptPlaceholderHint }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Add the computed bindings + handlers in `<script setup>`**
|
|
|
+
|
|
|
+```ts
|
|
|
+const activeGreeting = computed(() =>
|
|
|
+ store.config.videoScene.enabled
|
|
|
+ ? store.config.videoScene.greeting
|
|
|
+ : store.config.nonVideoScene.greeting
|
|
|
+)
|
|
|
+const activeSystemPrompt = computed(() =>
|
|
|
+ store.config.videoScene.enabled
|
|
|
+ ? store.config.videoScene.systemPrompt
|
|
|
+ : store.config.nonVideoScene.systemPrompt
|
|
|
+)
|
|
|
+
|
|
|
+function onGreetingInput(value: string) {
|
|
|
+ if (store.config.videoScene.enabled) {
|
|
|
+ store.config.videoScene.greeting = value
|
|
|
+ } else {
|
|
|
+ store.config.nonVideoScene.greeting = value
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function onSystemPromptInput(value: string) {
|
|
|
+ if (store.config.videoScene.enabled) {
|
|
|
+ store.config.videoScene.systemPrompt = value
|
|
|
+ } else {
|
|
|
+ store.config.nonVideoScene.systemPrompt = value
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Ensure `computed` and `ref` are imported from `vue` (they likely already are).
|
|
|
+
|
|
|
+- [ ] **Step 4: Add minimal styles**
|
|
|
+
|
|
|
+In the `<style>` block (lang="scss" scoped), append:
|
|
|
+
|
|
|
+```scss
|
|
|
+.ai-config-section {
|
|
|
+ padding: 12px 0;
|
|
|
+
|
|
|
+ .ai-config-body {
|
|
|
+ margin-top: 12px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .form-row {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .form-label {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #374151;
|
|
|
+ }
|
|
|
+
|
|
|
+ .form-textarea {
|
|
|
+ width: 100%;
|
|
|
+ padding: 8px 12px;
|
|
|
+ border: 1px solid #d1d5db;
|
|
|
+ border-radius: 6px;
|
|
|
+ font-size: 13px;
|
|
|
+ font-family: inherit;
|
|
|
+ line-height: 1.5;
|
|
|
+ resize: vertical;
|
|
|
+
|
|
|
+ &:focus {
|
|
|
+ outline: none;
|
|
|
+ border-color: #3b82f6;
|
|
|
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .form-hint {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6b7280;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Run type check**
|
|
|
+
|
|
|
+```bash
|
|
|
+npm run type-check 2>&1 | grep -E "TopicDiscussionConfig" | head -10
|
|
|
+```
|
|
|
+
|
|
|
+Expected: zero errors.
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add src/views/Editor/EnglishSpeaking/configs/TopicDiscussionConfig.vue && \
|
|
|
+ git commit -m "feat(speaking): AI 配置 section with per-mode prompt + greeting textareas"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 11: Frontend — fetch defaults on config page mount
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `PPT/src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`
|
|
|
+
|
|
|
+The config page is `TopicDiscussionConfig.vue` but it doesn't currently own a mount hook. `TopicDiscussionPreview.vue` is the parent that wraps both the config and the preview — adding the fetch there ensures the defaults are loaded when the EnglishSpeaking editor opens.
|
|
|
+
|
|
|
+- [ ] **Step 1: Add the imports**
|
|
|
+
|
|
|
+At the top of `TopicDiscussionPreview.vue`'s `<script setup>`, add (or merge into existing imports):
|
|
|
+
|
|
|
+```ts
|
|
|
+import { onMounted, ref } from 'vue'
|
|
|
+import { getDialogueDefaults } from '@/services/speaking'
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Add the defaults-load lifecycle hook + error state**
|
|
|
+
|
|
|
+Just below the existing `ref` / `computed` declarations in the script setup:
|
|
|
+
|
|
|
+```ts
|
|
|
+const defaultsLoadError = ref(false)
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ try {
|
|
|
+ const defaults = await getDialogueDefaults()
|
|
|
+ speakingStore.hydrateDefaults(defaults)
|
|
|
+ defaultsLoadError.value = false
|
|
|
+ } catch (err) {
|
|
|
+ console.error('[speaking] failed to load dialogue defaults', err)
|
|
|
+ defaultsLoadError.value = true
|
|
|
+ }
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+(If `onMounted` already exists, add this block inside its callback instead of declaring a second.)
|
|
|
+
|
|
|
+- [ ] **Step 3: Pass the error flag down so `TopicDiscussionConfig` can render the inline error**
|
|
|
+
|
|
|
+If `TopicDiscussionConfig` is rendered as a child of `TopicDiscussionPreview`, expose `defaultsLoadError` as a prop. Otherwise, surface a global toast / leave it as a console log.
|
|
|
+
|
|
|
+Pragmatic choice for this iteration: add a tiny inline error banner in `TopicDiscussionConfig.vue` based on a store flag.
|
|
|
+
|
|
|
+Add a `defaultsLoadError: false` field to the store state and set it via the parent's hook (`speakingStore.defaultsLoadError = true` on failure). Render the banner inside the AI 配置 section header. **This is an optional polish** — if time-constrained, skip and rely on the console log.
|
|
|
+
|
|
|
+- [ ] **Step 4: Run type check**
|
|
|
+
|
|
|
+```bash
|
|
|
+npm run type-check 2>&1 | grep -E "TopicDiscussionPreview" | head -10
|
|
|
+```
|
|
|
+
|
|
|
+Expected: zero errors.
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue \
|
|
|
+ src/store/speaking.ts && \
|
|
|
+ git commit -m "feat(speaking): fetch dialogue defaults on config page mount"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 12: Frontend — send `systemPrompt` + `greeting` on session creation
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts`
|
|
|
+- Modify: `PPT/src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: Extend the `createSession` body assembly in `llmService.ts`**
|
|
|
+
|
|
|
+Open `llmService.ts`. Find the `createSession` function and locate the JSON body sent to the backend. Add the two fields:
|
|
|
+
|
|
|
+```ts
|
|
|
+const body: any = {
|
|
|
+ // ... existing fields (topic, grade, totalRounds, ...)
|
|
|
+}
|
|
|
+
|
|
|
+if (config.systemPrompt) {
|
|
|
+ body.systemPrompt = config.systemPrompt
|
|
|
+}
|
|
|
+if (config.greeting) {
|
|
|
+ body.greeting = config.greeting
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Adjust whichever conditional pattern is used in surrounding code; the goal is to omit the field when empty so the backend sees `null` (and uses fallback).
|
|
|
+
|
|
|
+- [ ] **Step 2: Pull the active-mode pair in `TopicDiscussionPreview.vue`**
|
|
|
+
|
|
|
+In `TopicDiscussionPreview.vue`, locate the `createSession` call (currently around line 344). Update it to pass the active mode's prompt + greeting:
|
|
|
+
|
|
|
+```ts
|
|
|
+const info = await api.createSession({
|
|
|
+ // ... existing fields
|
|
|
+ mode: isVideoSceneMode.value ? 'video_scene' : 'normal',
|
|
|
+ selectedVideo: selectedVideo.value ?? undefined,
|
|
|
+ systemPrompt: isVideoSceneMode.value
|
|
|
+ ? speakingStore.config.videoScene.systemPrompt
|
|
|
+ : speakingStore.config.nonVideoScene.systemPrompt,
|
|
|
+ greeting: isVideoSceneMode.value
|
|
|
+ ? speakingStore.config.videoScene.greeting
|
|
|
+ : speakingStore.config.nonVideoScene.greeting,
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Run type check**
|
|
|
+
|
|
|
+```bash
|
|
|
+npm run type-check 2>&1 | grep -E "llmService|TopicDiscussionPreview" | head -10
|
|
|
+```
|
|
|
+
|
|
|
+Expected: zero errors.
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add src/views/Editor/EnglishSpeaking/services/llmService.ts \
|
|
|
+ src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue && \
|
|
|
+ git commit -m "feat(speaking): send custom systemPrompt + greeting on session create"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 13: End-to-end smoke verification
|
|
|
+
|
|
|
+**Files:** (none — verification only)
|
|
|
+
|
|
|
+- [ ] **Step 1: Start the backend dev server**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api && \
|
|
|
+ source .venv/bin/activate && \
|
|
|
+ uvicorn app.main:app --reload --port 8000
|
|
|
+```
|
|
|
+
|
|
|
+Leave running.
|
|
|
+
|
|
|
+- [ ] **Step 2: Apply the DB migration**
|
|
|
+
|
|
|
+In a second terminal, against the dev database, run the contents of `migrations/2026-05-16-add-custom-greeting-to-dialogue-session.sql`. (Exact command depends on the dev DB — typically `mysql -u<user> -p<db> < migrations/2026-05-16-...sql`.) Confirm via `DESCRIBE dialogue_session;` that the `custom_greeting` column appears.
|
|
|
+
|
|
|
+- [ ] **Step 3: Curl-check the defaults endpoint**
|
|
|
+
|
|
|
+```bash
|
|
|
+curl -s http://localhost:8000/api/speaking/dialogue/defaults | python -m json.tool
|
|
|
+```
|
|
|
+
|
|
|
+Expected: four populated fields across two modes.
|
|
|
+
|
|
|
+- [ ] **Step 4: Start the frontend dev server**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /Users/buoy/Development/gitrepo/PPT && npm run dev
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Open the EnglishSpeaking editor and verify the AI 配置 section**
|
|
|
+
|
|
|
+Open the dev URL in a browser. Add an EnglishSpeaking → TopicDiscussion element. Confirm:
|
|
|
+
|
|
|
+- "AI 配置" section visible above 高级配置, **expanded by default**
|
|
|
+- Greeting textarea pre-filled with the `DEFAULT_NORMAL_GREETING` ("Hi everyone!...") because the toggle defaults to OFF
|
|
|
+- System prompt textarea pre-filled with the long teacher template
|
|
|
+- Toggle ON 影片场景 → both textareas swap to the video-scene defaults
|
|
|
+- Edit `videoScene.greeting` to "Test custom greeting"
|
|
|
+- Toggle OFF → both swap back, **edits to videoScene are preserved**
|
|
|
+- Toggle ON again → "Test custom greeting" is still in the greeting box
|
|
|
+
|
|
|
+- [ ] **Step 6: Start a session and verify backend persistence**
|
|
|
+
|
|
|
+In the preview (student runtime mode), click 开始对话. Tail the backend logs and confirm:
|
|
|
+- `Creating session: ... mode=video_scene` (or `mode=normal` per toggle state)
|
|
|
+- The session row in DB has `custom_greeting = 'Test custom greeting'`
|
|
|
+- The first AI message (round=1) is **exactly** "Test custom greeting", with no LLM call delay
|
|
|
+
|
|
|
+- [ ] **Step 7: Verify {video_description} substitution**
|
|
|
+
|
|
|
+Toggle ON 影片场景. Confirm the default system prompt textarea contains `用户的选择是: {video_description}`. Pick a video (e.g., "heart"). Start a session. Verify (via DB inspection or by speaking and observing AI behavior) that the session's `system_prompt` column has `用户的选择是: heart` — placeholder substituted.
|
|
|
+
|
|
|
+- [ ] **Step 8: Verify backwards compat**
|
|
|
+
|
|
|
+Use curl to create a session **without** `systemPrompt` / `greeting` fields:
|
|
|
+
|
|
|
+```bash
|
|
|
+curl -s -X POST http://localhost:8000/api/speaking/dialogue/session \
|
|
|
+ -H "Content-Type: application/json" \
|
|
|
+ -d '{"topic":"Sports","grade":"grade5-1","totalRounds":3,"mode":"video_scene","selectedVideo":{"id":"v1","videoUrl":"x","description":"heart"}}'
|
|
|
+```
|
|
|
+
|
|
|
+Then POST to `/session/{id}/greeting` and confirm the response is an LLM-generated greeting (not "Hi everyone!" — that's the normal-mode default). This proves legacy clients still get the original behavior.
|
|
|
+
|
|
|
+- [ ] **Step 9: Run the full backend suite one last time**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api && \
|
|
|
+ source .venv/bin/activate && \
|
|
|
+ python -m pytest tests/ -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 155 (pre-existing) + ~12 (new) = 167+ tests, all green.
|
|
|
+
|
|
|
+- [ ] **Step 10: Cleanup tasks if all green**
|
|
|
+
|
|
|
+No commit needed for verification. Mark the plan complete in the TaskList.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Self-Review Notes
|
|
|
+
|
|
|
+- **Spec coverage:** Every spec section mapped to a task. `Decisions` table → Tasks 1–5 + 10. `Architecture / Data flow` → Tasks 2–12. `Error handling` → Task 4 (empty normalisation), Task 11 (defaults fetch failure). `Testing strategy` → Tasks 1, 3, 4, 5.
|
|
|
+- **Placeholder scan:** No "TBD"; one note in Task 11 step 3 marks an optional polish ("if time-constrained, skip"). All other code blocks are complete and runnable.
|
|
|
+- **Type consistency:** Backend service kwarg names `system_prompt_override` / `custom_greeting` are stable across Tasks 4–5. API field names `systemPrompt` / `greeting` are stable Tasks 4–12. Frontend store paths `config.videoScene.systemPrompt` / `config.nonVideoScene.greeting` etc. stable Tasks 6–12.
|