Jelajahi Sumber

docs(speaking): spec for custom system prompt + opening greeting

Adds two configurable text inputs in TopicDiscussionConfig — per-mode
storage so video_scene and non-video-scene each keep their own pair.
Backend hosts defaults via a new GET /defaults endpoint; legacy clients
that omit the new fields keep current behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee 1 bulan lalu
induk
melakukan
848ad4488b

+ 297 - 0
docs/superpowers/specs/2026-05-16-custom-prompt-and-greeting-design.md

@@ -0,0 +1,297 @@
+# 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`:
+
+```ts
+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:
+
+```ts
+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`:
+
+```python
+class CreateSessionRequest(BaseModel):
+    # ... existing
+    systemPrompt: str | None = None
+    greeting: str | None = None
+```
+
+**New endpoint** `GET /api/speaking/dialogue/defaults`:
+
+```python
+@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:**
+
+```python
+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`
+
+```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