Browse Source

docs(speaking): implementation plan for custom prompt + greeting

13 bite-sized tasks covering DB migration, backend defaults endpoint,
service-layer overrides, frontend per-mode store slots, AI 配置 UI,
and end-to-end smoke verification. TDD-first for backend; type-check +
manual smoke for frontend (no vitest in EnglishSpeaking module).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee 1 month ago
parent
commit
5ed9f08e78
1 changed files with 1432 additions and 0 deletions
  1. 1432 0
      docs/superpowers/plans/2026-05-16-custom-prompt-and-greeting.md

+ 1432 - 0
docs/superpowers/plans/2026-05-16-custom-prompt-and-greeting.md

@@ -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.