2026-05-16-custom-prompt-and-greeting-design.md 15 KB

Custom System Prompt & Opening Greeting — Design Spec

Date: 2026-05-16 Branch: feat/word-pronounce-and-img-response Repos: PPT (frontend) + cococlass-english-speaking-api (backend)

Summary

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

Motivation

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 template
  • dialogue_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:

  • Override the persona ("你是一名友好的英语口语助手...") and conversation logic (e.g., a strict 2-question quiz format)
  • Pin down the greeting so the first turn is deterministic and not randomised by the LLM
  • Inject the selected video description ({video_description} placeholder) into a custom prompt

Decisions

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

Architecture

Frontend (PPT)

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.greeting
  • videoScene.enabled === false → bind to nonVideoScene.systemPrompt + nonVideoScene.greeting

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

Backend (cococlass-english-speaking-api)

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.

Database

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.

Data Flow

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

Error Handling

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.

Testing Strategy

Backend

New file tests/api/test_dialogue_defaults.py:

  • test_get_defaults_returns_four_fields — endpoint returns the four expected non-empty strings
  • test_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 used
  • test_create_session_empty_systemprompt_treated_as_unset — systemPrompt=" " → same fallback as None
  • test_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 None

Extend 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 persisted
  • test_generate_greeting_falls_back_to_llm_when_no_custom_in_video_scene — video_scene + no custom_greeting → LLM called
  • test_generate_greeting_falls_back_to_hardcoded_when_no_custom_in_normal — normal + no custom_greeting → returns DEFAULT_NORMAL_GREETING, LLM not called
  • test_generate_greeting_idempotent_with_custom_greeting — repeated calls with same turnId return identical content, do not re-insert AI message

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

Frontend

No vitest suite exists for EnglishSpeaking. Verification:

  • npm run type-check — EnglishSpeaking module zero new type errors
  • Manual smoke:
    1. Open TopicDiscussionConfig → AI 配置 section expanded with defaults pre-filled
    2. Edit videoScene greeting → toggle off → values preserved per mode → toggle on → values still there
    3. Create session, inspect server logs for system_prompt and custom_greeting insertion
    4. Open the session as a student → AI's first utterance equals the configured greeting verbatim
    5. Change systemPrompt to enforce a behavior (e.g., "Always reply in Chinese") → verify next turn obeys

Acceptance Criteria

  • Backend test suite: existing 155 tests + ~10 new tests, all passing
  • GET /defaults callable via curl returns the documented shape
  • Legacy session creation (no systemPrompt / greeting in payload) produces identical behavior to before this change — verified by leaving at least one existing test untouched
  • Migration applies cleanly on a fresh and a populated DB

Out of Scope

  • Per-session override (only per-config / per-mode)
  • Multiple placeholder names (just {video_description})
  • System prompt versioning / history
  • Localized defaults per locale (defaults are currently single-language)
  • UI for previewing the substituted final prompt
  • Validation that the custom prompt actually instructs the LLM in English (the prompt is sent to the LLM as-is)

File Manifest

Frontend changes

  • src/store/speaking.ts — extend config shape, add hydrateDefaults action
  • src/services/speaking.ts — add getDialogueDefaults
  • src/views/Editor/EnglishSpeaking/configs/TopicDiscussionConfig.vue — new AI 配置 section
  • src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue — createSession payload extension
  • src/views/Editor/EnglishSpeaking/services/llmService.ts — createSession body adds systemPrompt + greeting
  • src/types/englishSpeaking.ts — extend SessionConfig interface
  • src/views/lang/{cn,en,hk}.json — labels for new section + helper text

Backend changes

  • app/api/dialogue.py — new GET /defaults route, extend CreateSessionRequest, plumb fields to service
  • app/service/speaking/dialogue_service.py — rename + add 4 default constants, accept systemPrompt/greeting in create_session_only, branch in generate_greeting
  • app/models/dialogue.py — custom_greeting column on DialogueSession
  • init.sql — add column
  • migrations/2026-05-16-add-custom-greeting-to-dialogue-session.sql — new file
  • tests/api/test_dialogue_defaults.py — new file
  • tests/service/speaking/test_dialogue_service_video_scene.py — 4 new tests
  • tests/service/speaking/test_dialogue_service_greeting.py — 4 new tests