Date: 2026-05-16
Branch: feat/word-pronounce-and-img-response
Repos: PPT (frontend) + cococlass-english-speaking-api (backend)
Add two configurable text inputs in TopicDiscussionConfig.vue so a teacher can override the AI's first utterance and the session system prompt without code changes. Each input is stored per mode (影片场景 vs 非影片场景) so toggling does not lose edits. Backend hosts default values via a new endpoint and falls back to current behavior if the fields are omitted (backwards compatible).
Today the AI's first greeting and the session system prompt are fixed in code:
dialogue_service.py:SYSTEM_PROMPT_TEMPLATE — long grade-adaptive teacher templatedialogue_service.py:VIDEO_SCENE_CONTEXT_TEMPLATE — appended for mode='video_scene'dialogue_service.py:IMAGE_RESPONSE_GREETING — hardcoded greeting for mode='normal'Teachers cannot adjust persona, style, or first utterance without a code change and a redeploy. They want to:
{video_description} placeholder) into a custom prompt| Decision | Choice | Rationale |
|---|---|---|
| System prompt semantics | Replace the default template, not append | User wants full control over persona and instructions |
| Placeholder support | {video_description} only, substituted via safe str.replace() |
Avoids .format() exploding on stray braces; covers the one dynamic value teachers actually need |
| Greeting semantics | Verbatim, no LLM | Predictable, fast, matches user intent |
| Pre-fill defaults | Yes, both inputs pre-filled when the teacher opens the config page | Teachers learn by editing a working example rather than from a blank page |
| Source of truth for defaults | Backend, fetched via new endpoint on config-page mount | Single source; changing defaults does not require a frontend redeploy |
| Mode coverage | Both video_scene and normal |
Each mode keeps its own pair of values |
| Storage shape | Per-mode (4 stored slots) | Toggling does not destroy edits to the other mode |
| Backwards compat | Old clients omit the new fields → backend uses existing fallback constants | No breakage for sessions created before this change |
| Character limit | None | Trusted authoring tool; LLM context window is the real boundary |
| UI placement | New collapsible "AI 配置" section above 高级配置; default expanded | Visible enough to be discovered; collapsible for teachers who don't care |
| Greeting input | Multi-line textarea, ~3-4 rows tall | Lets teachers write multi-sentence openers |
| System prompt input | Multi-line textarea, ~10 rows tall | The defaults are several paragraphs long |
| Empty-string handling | Treated as "not set" → backend falls back to default | Forgiving; matches teacher intent ("I cleared it to reset") |
store/speaking.ts — extend config:
config: {
// ... existing
videoScene: {
enabled: boolean
systemPrompt: string // NEW
greeting: string // NEW
},
nonVideoScene: { // NEW node
systemPrompt: string
greeting: string
}
}
Add hydrateDefaults(payload) action that fills empty slots from the backend /defaults payload (does not overwrite teacher edits).
services/speaking.ts — new fetch helper:
export async function getDialogueDefaults(): Promise<DialogueDefaults>
TopicDiscussionConfig.vue — new section:
"AI 配置" (default expanded)
├── 会话开场白 (textarea, 3-4 rows)
│ └── helper text: "这句话会逐字作为 AI 第一句话, 不会再走模型生成"
└── 系统提示词 (textarea, ~10 rows)
└── helper text (only when toggle ON): "可用占位符: {video_description}"
Bindings:
videoScene.enabled === true → bind to videoScene.systemPrompt + videoScene.greetingvideoScene.enabled === false → bind to nonVideoScene.systemPrompt + nonVideoScene.greetingonMounted hook calls getDialogueDefaults() once, passes payload to store.hydrateDefaults().
preview/TopicDiscussionPreview.vue — createSession payload pulls active mode's pair and sends them as systemPrompt + greeting.
app/service/speaking/dialogue_service.py — restructure constants:
| Old constant | New constant | Notes |
|---|---|---|
SYSTEM_PROMPT_TEMPLATE |
DEFAULT_NORMAL_SYSTEM_PROMPT |
Long grade-adaptive teacher template |
VIDEO_SCENE_CONTEXT_TEMPLATE |
(folded into next) | Removed as standalone |
| (new) | DEFAULT_VIDEO_SCENE_SYSTEM_PROMPT |
Long teacher template + scene context, includes {video_description} placeholder |
IMAGE_RESPONSE_GREETING |
DEFAULT_NORMAL_GREETING |
Same value, renamed |
| (new) | DEFAULT_VIDEO_SCENE_GREETING |
Short multi-round opener, e.g. "Hi! Let's talk about the shape you picked." |
app/api/dialogue.py — extend CreateSessionRequest:
class CreateSessionRequest(BaseModel):
# ... existing
systemPrompt: str | None = None
greeting: str | None = None
New endpoint GET /api/speaking/dialogue/defaults:
@router.get("/defaults")
async def get_dialogue_defaults():
return {
"videoScene": {
"systemPrompt": DEFAULT_VIDEO_SCENE_SYSTEM_PROMPT,
"greeting": DEFAULT_VIDEO_SCENE_GREETING,
},
"nonVideoScene": {
"systemPrompt": DEFAULT_NORMAL_SYSTEM_PROMPT,
"greeting": DEFAULT_NORMAL_GREETING,
},
}
create_session_only flow:
def _normalise(value: str | None) -> str | None:
"""Treat empty / whitespace-only strings as not-set."""
if value is None or not value.strip():
return None
return value
# system prompt
custom_system_prompt = _normalise(request.systemPrompt)
if custom_system_prompt is not None:
video_desc = (selected_video or {}).get("description", "") or ""
final_system_prompt = custom_system_prompt.replace(
"{video_description}", video_desc,
)
else:
# backwards-compat fallback: existing default-assembly logic
final_system_prompt = _build_default_system_prompt(mode, ...)
session.system_prompt = final_system_prompt
session.custom_greeting = _normalise(request.greeting) # None if empty
generate_greeting flow:
session.custom_greeting is not None?
├── yes → write round=1 AI message = custom_greeting, skip LLM
└── no → existing branch:
- mode='normal' → DEFAULT_NORMAL_GREETING
- mode='video_scene' → call LLM with session.system_prompt
Idempotency cache for generate_greeting (turn-level + round-level) continues to work because the cached row is checked before the custom-greeting branch.
Migration: migrations/2026-05-16-add-custom-greeting-to-dialogue-session.sql
ALTER TABLE dialogue_session
ADD COLUMN custom_greeting TEXT NULL;
init.sql table definition gets the same custom_greeting TEXT NULL line so fresh installs include it.
app/models/dialogue.py — add custom_greeting: Mapped[Optional[str]] = mapped_column(Text, nullable=True) on DialogueSession.
[Config page mount]
│
├── GET /api/speaking/dialogue/defaults
│ └── returns {videoScene:{sp,g}, nonVideoScene:{sp,g}}
│
├── store.hydrateDefaults(payload) ← fills empty slots only
│
└── UI binds active mode's pair to two textareas
[Teacher edits a textarea]
│
└── store mutation persists to the matching mode slot (no cross-mode bleed)
[Teacher clicks 开始对话]
│
├── frontend reads active-mode pair
│
├── POST /api/speaking/dialogue/session
│ body: { ..., systemPrompt, greeting }
│
├── backend:
│ - substitutes {video_description} in systemPrompt
│ - stores session.system_prompt + session.custom_greeting
│
└── frontend → POST /session/{id}/greeting
backend:
- if custom_greeting set → return it (no LLM)
- else → existing fallback (LLM or hardcoded)
| Case | Behavior |
|---|---|
Backend /defaults endpoint fails (500/network) |
Frontend shows an inline error in the AI 配置 section ("默认值加载失败,请刷新"). Inputs stay empty until retry succeeds. No baked-in fallback in the frontend — backend is the single source of truth. |
| Teacher clears the system prompt textarea (empty string) | Frontend sends empty string. Backend request.systemPrompt.strip() == "" is normalised to None → fallback to legacy default assembly. Same outcome as a legacy client. |
| Teacher clears the greeting textarea (empty string) | Same normalisation. request.greeting.strip() == "" → treated as None → generate_greeting runs its original logic (LLM for video_scene, hardcoded for normal). |
{video_description} placeholder used in normal mode |
Substituted with empty string (no video selected). Teacher should not include the placeholder in nonVideoScene.systemPrompt but if they do, it is emptied. UI hint suppresses the placeholder helper text in non-video mode to discourage this. |
.replace() substitution finds no occurrence of {video_description} |
No-op. Custom prompt stored as-is. |
Old client (no systemPrompt / greeting in payload) |
request.systemPrompt is None → fallback to legacy default assembly. Full backwards compat. |
New file tests/api/test_dialogue_defaults.py:
test_get_defaults_returns_four_fields — endpoint returns the four expected non-empty stringstest_get_defaults_response_shape — validates {videoScene:{systemPrompt, greeting}, nonVideoScene:{systemPrompt, greeting}}Extend tests/service/speaking/test_dialogue_service_video_scene.py:
test_create_session_persists_custom_system_prompt_with_substitution — systemPrompt="...选择:{video_description}" + selected_video.description="heart" → session.system_prompt contains "选择: heart"test_create_session_custom_system_prompt_normal_mode_substitutes_empty — same prompt in normal mode → placeholder replaced with ""test_create_session_without_systemprompt_falls_back_to_default — legacy client omits field → fallback assembly usedtest_create_session_empty_systemprompt_treated_as_unset — systemPrompt=" " → same fallback as Nonetest_create_session_persists_custom_greeting — greeting="Hello kids" → session.custom_greeting == "Hello kids"test_create_session_empty_greeting_stored_as_null — greeting="" → session.custom_greeting is NoneExtend tests/service/speaking/test_dialogue_service_greeting.py:
test_generate_greeting_uses_custom_greeting_skips_llm — custom_greeting set → returned verbatim, service.llm.chat.assert_not_called(), round=1 AI message persistedtest_generate_greeting_falls_back_to_llm_when_no_custom_in_video_scene — video_scene + no custom_greeting → LLM calledtest_generate_greeting_falls_back_to_hardcoded_when_no_custom_in_normal — normal + no custom_greeting → returns DEFAULT_NORMAL_GREETING, LLM not calledtest_generate_greeting_idempotent_with_custom_greeting — repeated calls with same turnId return identical content, do not re-insert AI messageMigration coverage:
init.sql includes custom_greeting TEXT NULL — verified by existing test that runs Base.metadata.create_all against in-memory SQLite (column appears in ORM-introspected schema)No vitest suite exists for EnglishSpeaking. Verification:
npm run type-check — EnglishSpeaking module zero new type errorsvideoScene greeting → toggle off → values preserved per mode → toggle on → values still theresystem_prompt and custom_greeting insertionsystemPrompt to enforce a behavior (e.g., "Always reply in Chinese") → verify next turn obeysGET /defaults callable via curl returns the documented shapesystemPrompt / greeting in payload) produces identical behavior to before this change — verified by leaving at least one existing test untouched{video_description})locale (defaults are currently single-language)src/store/speaking.ts — extend config shape, add hydrateDefaults actionsrc/services/speaking.ts — add getDialogueDefaultssrc/views/Editor/EnglishSpeaking/configs/TopicDiscussionConfig.vue — new AI 配置 sectionsrc/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue — createSession payload extensionsrc/views/Editor/EnglishSpeaking/services/llmService.ts — createSession body adds systemPrompt + greetingsrc/types/englishSpeaking.ts — extend SessionConfig interfacesrc/views/lang/{cn,en,hk}.json — labels for new section + helper textapp/api/dialogue.py — new GET /defaults route, extend CreateSessionRequest, plumb fields to serviceapp/service/speaking/dialogue_service.py — rename + add 4 default constants, accept systemPrompt/greeting in create_session_only, branch in generate_greetingapp/models/dialogue.py — custom_greeting column on DialogueSessioninit.sql — add columnmigrations/2026-05-16-add-custom-greeting-to-dialogue-session.sql — new filetests/api/test_dialogue_defaults.py — new filetests/service/speaking/test_dialogue_service_video_scene.py — 4 new teststests/service/speaking/test_dialogue_service_greeting.py — 4 new tests