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
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 |
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!
custom_greeting column on DialogueSessionFiles:
cococlass-english-speaking-api/app/models/dialogue.pycococlass-english-speaking-api/init.sqlCreate: 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
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.
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).
init.sqlIn 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,
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;
python -m pytest tests/service/speaking/test_dialogue_service_greeting.py::test_dialogue_session_has_custom_greeting_column -v
Expected: PASS.
python -m pytest tests/ -q
Expected: 155+ tests pass (1 new test added, no failures).
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"
Files:
cococlass-english-speaking-api/app/service/speaking/dialogue_service.pyThis 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.
Open cococlass-english-speaking-api/app/service/speaking/dialogue_service.py. Locate (around lines 85-170):
SYSTEM_PROMPT_TEMPLATEIMAGE_RESPONSE_GREETINGVIDEO_SCENE_CONTEXT_TEMPLATEIMAGE_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).
create_session_only to call the helperIn 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.
generate_greeting referenceLocate 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
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.
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.).
git add app/service/speaking/dialogue_service.py tests/ && \
git commit -m "refactor(speaking): restructure prompt/greeting constants into per-mode defaults"
GET /api/speaking/dialogue/defaults endpointFiles:
cococlass-english-speaking-api/app/api/dialogue.pyCreate: 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"]
python -m pytest tests/api/test_dialogue_defaults.py -v
Expected: FAIL — 404 (endpoint does not exist).
app/api/dialogue.pyAt 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,
},
}
python -m pytest tests/api/test_dialogue_defaults.py -v
Expected: 3 PASS.
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.
git add app/api/dialogue.py tests/api/test_dialogue_defaults.py && \
git commit -m "feat(speaking): add GET /dialogue/defaults endpoint"
systemPrompt / greeting on session creationFiles:
cococlass-english-speaking-api/app/api/dialogue.pycococlass-english-speaking-api/app/service/speaking/dialogue_service.pyModify: 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
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.
dialogue_service.pyJust 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
create_session_only signature and bodyIn 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.)
CreateSessionRequest in app/api/dialogue.pyAdd 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.)
python -m pytest tests/service/speaking/test_dialogue_service_video_scene.py -v
Expected: all 5 new tests PASS.
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.
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"
generate_greeting uses custom_greeting (skip LLM)Files:
cococlass-english-speaking-api/app/service/speaking/dialogue_service.pycococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_greeting.pyModify: 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
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.
generate_greetingIn 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.)
python -m pytest tests/service/speaking/test_dialogue_service_greeting.py -v
Expected: all tests (existing + 3 new) PASS.
tests/api/test_dialogue_greeting.pyAppend:
@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!"}
python -m pytest tests/api/test_dialogue_greeting.py -v
Expected: all tests PASS.
python -m pytest tests/ -q
Expected: 155+ original tests + 5–6 new tests from this task; all green.
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"
SessionConfig typesFiles:
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).
videoScene field and add nonVideoSceneReplace the videoScene field declaration:
videoScene: {
enabled: boolean
systemPrompt: string
greeting: string
}
nonVideoScene: {
systemPrompt: string
greeting: string
}
SessionConfig (or createSession-request) interfaceFind the SessionConfig interface used by createSession. Add two optional fields:
systemPrompt?: string
greeting?: string
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.)
git add src/types/englishSpeaking.ts && \
git commit -m "feat(speaking): extend SessionConfig with per-mode prompt + greeting"
getDialogueDefaults service helperFiles:
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.
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)
}
npm run type-check 2>&1 | grep -E "services/speaking" | head -10
Expected: zero errors in services/speaking.ts.
git add src/services/speaking.ts && \
git commit -m "feat(speaking): add getDialogueDefaults service helper"
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).
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: '',
},
hydrateDefaults actionInside 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
}
},
npm run type-check 2>&1 | grep -E "store/speaking" | head -10
Expected: zero errors in store/speaking.ts.
git add src/store/speaking.ts && \
git commit -m "feat(speaking): add per-mode prompt/greeting slots + hydrateDefaults action"
Files:
PPT/src/views/lang/cn.jsonPPT/src/views/lang/en.jsonModify: 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": "默认值加载失败,请刷新",
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.",
hk.json "ssAiConfig": "AI 配置",
"ssCustomGreeting": "會話開場白",
"ssCustomGreetingHint": "這句話會逐字作為 AI 第一句話,不會再走模型生成",
"ssCustomSystemPrompt": "系統提示詞 (System Prompt)",
"ssCustomSystemPromptPlaceholderHint": "可用佔位符: {video_description}",
"ssAiConfigDefaultsLoadError": "默認值載入失敗,請刷新",
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"
TopicDiscussionConfig.vueFiles:
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
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>
<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).
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;
}
}
npm run type-check 2>&1 | grep -E "TopicDiscussionConfig" | head -10
Expected: zero errors.
git add src/views/Editor/EnglishSpeaking/configs/TopicDiscussionConfig.vue && \
git commit -m "feat(speaking): AI 配置 section with per-mode prompt + greeting textareas"
Files:
PPT/src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vueThe 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.
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'
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.)
TopicDiscussionConfig can render the inline errorIf 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.
npm run type-check 2>&1 | grep -E "TopicDiscussionPreview" | head -10
Expected: zero errors.
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"
systemPrompt + greeting on session creationFiles:
PPT/src/views/Editor/EnglishSpeaking/services/llmService.tsModify: 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).
TopicDiscussionPreview.vueIn 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,
})
npm run type-check 2>&1 | grep -E "llmService|TopicDiscussionPreview" | head -10
Expected: zero errors.
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"
Files: (none — verification only)
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api && \
source .venv/bin/activate && \
uvicorn app.main:app --reload --port 8000
Leave running.
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.
curl -s http://localhost:8000/api/speaking/dialogue/defaults | python -m json.tool
Expected: four populated fields across two modes.
cd /Users/buoy/Development/gitrepo/PPT && npm run dev
Open the dev URL in a browser. Add an EnglishSpeaking → TopicDiscussion element. Confirm:
DEFAULT_NORMAL_GREETING ("Hi everyone!...") because the toggle defaults to OFFvideoScene.greeting to "Test custom greeting"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)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.
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.
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.
No commit needed for verification. Mark the plan complete in the TaskList.
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.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.