2026-05-16-custom-prompt-and-greeting.md 51 KB

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):

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
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:

    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:

    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:

-- 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
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
python -m pytest tests/ -q

Expected: 155+ tests pass (1 new test added, no failures).

  • Step 8: Commit
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:

# --- 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:

        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:

        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:

        if role_config.get("mode") == "normal":
            ai_greeting = IMAGE_RESPONSE_GREETING

Replace IMAGE_RESPONSE_GREETING with DEFAULT_NORMAL_GREETING:

        if role_config.get("mode") == "normal":
            ai_greeting = DEFAULT_NORMAL_GREETING
  • Step 5: Verify no stale references remain
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
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
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:

"""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
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:

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:

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

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
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:

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

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:

    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:

        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:

        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:

class CreateSessionRequest(BaseModel):
    # ... existing fields
    systemPrompt: str | None = None
    greeting: str | None = None

In the POST /session route handler, plumb the new fields through:

    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
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
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
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:

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

        # 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
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:

@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
python -m pytest tests/api/test_dialogue_greeting.py -v

Expected: all tests PASS.

  • Step 7: Run full suite for regression check
python -m pytest tests/ -q

Expected: 155+ original tests + 5–6 new tests from this task; all green.

  • Step 8: Commit
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:

  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:

  systemPrompt?: string
  greeting?: string
  • Step 4: Run type check
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
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:

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
npm run type-check 2>&1 | grep -E "services/speaking" | head -10

Expected: zero errors in services/speaking.ts.

  • Step 4: Commit
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:

  videoScene: {
    enabled: false,
    systemPrompt: '',
    greeting: '',
  },
  nonVideoScene: {
    systemPrompt: '',
    greeting: '',
  },
  • Step 3: Add the hydrateDefaults action

Inside the store's actions: { ... } block, near toggleVideoScene, add:

    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
npm run type-check 2>&1 | grep -E "store/speaking" | head -10

Expected: zero errors in store/speaking.ts.

  • Step 5: Commit
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:

  "ssAiConfig": "AI 配置",
  "ssCustomGreeting": "会话开场白",
  "ssCustomGreetingHint": "这句话会逐字作为 AI 第一句话,不会再走模型生成",
  "ssCustomSystemPrompt": "系统提示词 (System Prompt)",
  "ssCustomSystemPromptPlaceholderHint": "可用占位符: {video_description}",
  "ssAiConfigDefaultsLoadError": "默认值加载失败,请刷新",
  • Step 2: Add the same keys with English translations in en.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
  "ssAiConfig": "AI 配置",
  "ssCustomGreeting": "會話開場白",
  "ssCustomGreetingHint": "這句話會逐字作為 AI 第一句話,不會再走模型生成",
  "ssCustomSystemPrompt": "系統提示詞 (System Prompt)",
  "ssCustomSystemPromptPlaceholderHint": "可用佔位符: {video_description}",
  "ssAiConfigDefaultsLoadError": "默認值載入失敗,請刷新",
  • Step 4: Commit
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:

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:

        <!-- 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>
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:

.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
npm run type-check 2>&1 | grep -E "TopicDiscussionConfig" | head -10

Expected: zero errors.

  • Step 6: Commit
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):

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:

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
npm run type-check 2>&1 | grep -E "TopicDiscussionPreview" | head -10

Expected: zero errors.

  • Step 5: Commit
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:

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:

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
npm run type-check 2>&1 | grep -E "llmService|TopicDiscussionPreview" | head -10

Expected: zero errors.

  • Step 4: Commit
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
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
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
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:

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