|
@@ -0,0 +1,1282 @@
|
|
|
|
|
+# English Speaking Task Hint Modal 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:** Build a lazily generated, cached task-hint modal for English speaking dialogue sessions.
|
|
|
|
|
+
|
|
|
|
|
+**Architecture:** The frontend opens a dedicated `TaskHintModal.vue` immediately and asks `DialogueChatView.vue` to lazy-load `TaskHint` through the existing `DialogueAPI`. The backend adds a per-session `task_hint` JSON cache, returns cached hints idempotently, and performs a single LLM JSON generation only when no cached hint exists. LLM output is parsed, validated, normalized, and replaced by deterministic fallback on any parse/schema/provider failure.
|
|
|
|
|
+
|
|
|
|
|
+**Tech Stack:** Vue 3 `<script setup>` + TypeScript, Pinia-adjacent local component state, Fetch API, FastAPI, SQLAlchemy async ORM, Pydantic, pytest + httpx ASGITransport.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## File Structure
|
|
|
|
|
+
|
|
|
|
|
+Frontend repo: `/Users/buoy/Development/gitrepo/PPT`
|
|
|
|
|
+
|
|
|
|
|
+- Modify `src/types/englishSpeaking.ts`: add `TaskHint` and extend `DialogueAPI`.
|
|
|
|
|
+- Modify `src/views/Editor/EnglishSpeaking/services/llmService.ts`: add real/mock `generateTaskHint()`.
|
|
|
|
|
+- Create `src/views/Editor/EnglishSpeaking/preview/TaskHintModal.vue`: presentational modal with loading/error/content states.
|
|
|
|
|
+- Modify `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`: remove inline task-hint modal, wire lazy loading and local cache.
|
|
|
|
|
+
|
|
|
|
|
+Backend repo: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`
|
|
|
|
|
+
|
|
|
|
|
+- Modify `app/models/dialogue.py`: add `DialogueSession.task_hint`.
|
|
|
|
|
+- Modify `init.sql`: add `task_hint JSON NULL`.
|
|
|
|
|
+- Create `migrations/002_add_task_hint.sql`: one-shot migration for existing DBs.
|
|
|
|
|
+- Create `app/service/speaking/task_hint.py`: prompt, JSON parsing, validation, normalization, fallback, and generation service helpers.
|
|
|
|
|
+- Modify `app/service/speaking/dialogue_service.py`: add idempotent `generate_task_hint()`.
|
|
|
|
|
+- Modify `app/api/dialogue.py`: add `POST /session/{session_id}/task-hint`.
|
|
|
|
|
+- Create `tests/service/test_task_hint.py`: pure helper tests.
|
|
|
|
|
+- Extend `tests/api/test_dialogue_greeting.py` or create `tests/api/test_dialogue_task_hint.py`: endpoint and cache tests.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 1: Backend Storage Contract
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/models/dialogue.py`
|
|
|
|
|
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/init.sql`
|
|
|
|
|
+- Create: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/migrations/002_add_task_hint.sql`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Add model field**
|
|
|
|
|
+
|
|
|
|
|
+In `app/models/dialogue.py`, add this field after `system_prompt`:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ task_hint: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Update new-database schema**
|
|
|
|
|
+
|
|
|
|
|
+In `init.sql`, add `task_hint JSON NULL` after `system_prompt TEXT NULL`:
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+ system_prompt TEXT NULL,
|
|
|
|
|
+ task_hint JSON NULL,
|
|
|
|
|
+ expires_at DATETIME NULL,
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Add migration**
|
|
|
|
|
+
|
|
|
|
|
+Create `migrations/002_add_task_hint.sql`:
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+-- Add cached task hint JSON to existing dialogue sessions.
|
|
|
|
|
+-- Apply once against an existing database (new DBs use updated init.sql).
|
|
|
|
|
+ALTER TABLE dialogue_session
|
|
|
|
|
+ ADD COLUMN task_hint JSON NULL AFTER system_prompt;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Verify metadata creates the column in tests**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
|
|
|
|
|
+uv run pytest tests/api/test_dialogue_greeting.py::test_post_session_does_not_include_ai_message -q
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: `1 passed`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Commit backend storage**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
|
|
|
|
|
+git add app/models/dialogue.py init.sql migrations/002_add_task_hint.sql
|
|
|
|
|
+git commit -m "feat: add task hint session cache"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 2: Backend Task Hint Helper
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/task_hint.py`
|
|
|
|
|
+- Create: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/service/test_task_hint.py`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write helper tests**
|
|
|
|
|
+
|
|
|
|
|
+Create `tests/service/test_task_hint.py`:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+import pytest
|
|
|
|
|
+
|
|
|
|
|
+from app.service.speaking.task_hint import (
|
|
|
|
|
+ build_fallback_task_hint,
|
|
|
|
|
+ normalize_task_hint,
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_normalize_task_hint_accepts_valid_llm_json():
|
|
|
|
|
+ raw = {
|
|
|
|
|
+ "practice_level": "grade5-1",
|
|
|
|
|
+ "conversation_topic": "How I get to school",
|
|
|
|
|
+ "current_question": "和 Tom 聊一聊上学方式。",
|
|
|
|
|
+ "example_sentences": [
|
|
|
|
|
+ {"english": "I come to school by bus.", "chinese": "我坐公交车上学。"},
|
|
|
|
|
+ {"english": "I go to school on foot.", "chinese": "我步行上学。"},
|
|
|
|
|
+ {"english": "It takes ten minutes.", "chinese": "需要十分钟。"},
|
|
|
|
|
+ {"english": "Extra sentence.", "chinese": "多余句子。"},
|
|
|
|
|
+ ],
|
|
|
|
|
+ "key_vocabulary": [
|
|
|
|
|
+ {"word": "by bus", "meaning": "乘公交车"},
|
|
|
|
|
+ {"word": "on foot", "meaning": "步行"},
|
|
|
|
|
+ {"word": "near", "meaning": "附近的"},
|
|
|
|
|
+ {"word": "far from", "meaning": "离……远"},
|
|
|
|
|
+ {"word": "extra", "meaning": "多余"},
|
|
|
|
|
+ ],
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ hint = normalize_task_hint(
|
|
|
|
|
+ raw,
|
|
|
|
|
+ practice_level="grade5-1",
|
|
|
|
|
+ conversation_topic="How I get to school",
|
|
|
|
|
+ vocabulary=["by bus", "on foot", "near", "far from"],
|
|
|
|
|
+ sentences=["I come to school by bus."],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert hint["practice_level"] == "grade5-1"
|
|
|
|
|
+ assert hint["conversation_topic"] == "How I get to school"
|
|
|
|
|
+ assert len(hint["example_sentences"]) == 3
|
|
|
|
|
+ assert len(hint["key_vocabulary"]) == 4
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_normalize_task_hint_fills_missing_items_from_fallback():
|
|
|
|
|
+ raw = {
|
|
|
|
|
+ "practice_level": "wrong",
|
|
|
|
|
+ "conversation_topic": "wrong",
|
|
|
|
|
+ "current_question": " 和 Tom 聊一聊。 ",
|
|
|
|
|
+ "example_sentences": [
|
|
|
|
|
+ {"english": "I come to school by bus.", "chinese": "我坐公交车上学。"}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "key_vocabulary": [
|
|
|
|
|
+ {"word": "by bus", "meaning": "乘公交车"}
|
|
|
|
|
+ ],
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ hint = normalize_task_hint(
|
|
|
|
|
+ raw,
|
|
|
|
|
+ practice_level="grade5-1",
|
|
|
|
|
+ conversation_topic="How I get to school",
|
|
|
|
|
+ vocabulary=["by bus", "on foot"],
|
|
|
|
|
+ sentences=["I come to school by bus."],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert hint["practice_level"] == "grade5-1"
|
|
|
|
|
+ assert hint["conversation_topic"] == "How I get to school"
|
|
|
|
|
+ assert hint["current_question"] == "和 Tom 聊一聊。"
|
|
|
|
|
+ assert len(hint["example_sentences"]) == 3
|
|
|
|
|
+ assert all(item["chinese"] for item in hint["example_sentences"])
|
|
|
|
|
+ assert len(hint["key_vocabulary"]) == 4
|
|
|
|
|
+ assert all(item["meaning"] for item in hint["key_vocabulary"])
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_build_fallback_task_hint_is_contract_valid():
|
|
|
|
|
+ hint = build_fallback_task_hint(
|
|
|
|
|
+ practice_level="grade5-1",
|
|
|
|
|
+ conversation_topic="How I get to school",
|
|
|
|
|
+ vocabulary=["by bus"],
|
|
|
|
|
+ sentences=["I come to school by bus."],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert hint["practice_level"] == "grade5-1"
|
|
|
|
|
+ assert hint["conversation_topic"] == "How I get to school"
|
|
|
|
|
+ assert len(hint["example_sentences"]) == 3
|
|
|
|
|
+ assert len(hint["key_vocabulary"]) == 4
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run tests to verify failure**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
|
|
|
|
|
+uv run pytest tests/service/test_task_hint.py -q
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: FAIL with `ModuleNotFoundError: No module named 'app.service.speaking.task_hint'`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Implement helper module**
|
|
|
|
|
+
|
|
|
|
|
+Create `app/service/speaking/task_hint.py`:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+import json
|
|
|
|
|
+from typing import Any
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+TaskHint = dict[str, Any]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+TASK_HINT_PROMPT_TEMPLATE = """## 任务概述
|
|
|
|
|
+
|
|
|
|
|
+你是一个口语练习提示信息生成器。你的任务是根据用户提供的课程变量,生成一个结构化的、鼓励学生进行口语表达的 JSON 对象。
|
|
|
|
|
+
|
|
|
|
|
+## 输入变量
|
|
|
|
|
+
|
|
|
|
|
+1. practice_level: {practice_level}
|
|
|
|
|
+2. conversation_topic: {conversation_topic}
|
|
|
|
|
+3. key_vocabulary: {key_vocabulary}
|
|
|
|
|
+4. key_sentence_patterns: {key_sentence_patterns}
|
|
|
|
|
+
|
|
|
|
|
+## 生成规则
|
|
|
|
|
+
|
|
|
|
|
+1. current_question: 用 1-2 句中文简要概述任务和练习要点,语言自然、友好、鼓励。
|
|
|
|
|
+2. example_sentences: 基于重点句型生成 3 个完整英文例句,每个例句提供中文翻译。
|
|
|
|
|
+3. key_vocabulary: 基于重点词汇列举 4 个英文词汇,每个词汇提供简短中文释义;如果输入不足 4 个,可基于话题补足同难度词汇。
|
|
|
|
|
+
|
|
|
|
|
+## 输出格式
|
|
|
|
|
+
|
|
|
|
|
+必须且仅输出一个 JSON Object,不要 Markdown code block,不要解释。
|
|
|
|
|
+
|
|
|
|
|
+{{
|
|
|
|
|
+ "practice_level": "{practice_level}",
|
|
|
|
|
+ "conversation_topic": "{conversation_topic}",
|
|
|
|
|
+ "current_question": "和 Tom 聊一聊「{conversation_topic}」,试着用今天的重点词汇和句型表达你的想法。",
|
|
|
|
|
+ "example_sentences": [
|
|
|
|
|
+ {{"english": "I come to school by bus.", "chinese": "我乘公交车来学校。"}},
|
|
|
|
|
+ {{"english": "I live near my school.", "chinese": "我住得离学校很近。"}},
|
|
|
|
|
+ {{"english": "It takes me ten minutes to get there.", "chinese": "我到那里需要十分钟。"}}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "key_vocabulary": [
|
|
|
|
|
+ {{"word": "by bus", "meaning": "乘公交车"}},
|
|
|
|
|
+ {{"word": "on foot", "meaning": "步行"}},
|
|
|
|
|
+ {{"word": "near", "meaning": "附近的"}},
|
|
|
|
|
+ {{"word": "far from", "meaning": "离……远"}}
|
|
|
|
|
+ ]
|
|
|
|
|
+}}
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+DEFAULT_SENTENCES = [
|
|
|
|
|
+ {"english": "I can talk about this topic.", "chinese": "我可以谈论这个话题。"},
|
|
|
|
|
+ {"english": "I can use today's key words.", "chinese": "我可以使用今天的重点词汇。"},
|
|
|
|
|
+ {"english": "I can answer in complete sentences.", "chinese": "我可以用完整句子回答。"},
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+DEFAULT_VOCABULARY = [
|
|
|
|
|
+ {"word": "topic", "meaning": "话题"},
|
|
|
|
|
+ {"word": "answer", "meaning": "回答"},
|
|
|
|
|
+ {"word": "practice", "meaning": "练习"},
|
|
|
|
|
+ {"word": "sentence", "meaning": "句子"},
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_task_hint_prompt(
|
|
|
|
|
+ practice_level: str,
|
|
|
|
|
+ conversation_topic: str,
|
|
|
|
|
+ vocabulary: list[str],
|
|
|
|
|
+ sentences: list[str],
|
|
|
|
|
+) -> str:
|
|
|
|
|
+ return TASK_HINT_PROMPT_TEMPLATE.format(
|
|
|
|
|
+ practice_level=practice_level,
|
|
|
|
|
+ conversation_topic=conversation_topic,
|
|
|
|
|
+ key_vocabulary=json.dumps(vocabulary, ensure_ascii=False),
|
|
|
|
|
+ key_sentence_patterns=json.dumps(sentences, ensure_ascii=False),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def parse_task_hint_json(content: str) -> dict[str, Any]:
|
|
|
|
|
+ return json.loads(content)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_fallback_task_hint(
|
|
|
|
|
+ practice_level: str,
|
|
|
|
|
+ conversation_topic: str,
|
|
|
|
|
+ vocabulary: list[str],
|
|
|
|
|
+ sentences: list[str],
|
|
|
|
|
+) -> TaskHint:
|
|
|
|
|
+ sentence_items = [
|
|
|
|
|
+ {"english": sentence.strip(), "chinese": "参考译文待补充"}
|
|
|
|
|
+ for sentence in sentences
|
|
|
|
|
+ if sentence.strip()
|
|
|
|
|
+ ]
|
|
|
|
|
+ while len(sentence_items) < 3:
|
|
|
|
|
+ sentence_items.append(DEFAULT_SENTENCES[len(sentence_items)])
|
|
|
|
|
+
|
|
|
|
|
+ vocab_items = [
|
|
|
|
|
+ {"word": word.strip(), "meaning": "重点词汇"}
|
|
|
|
|
+ for word in vocabulary
|
|
|
|
|
+ if word.strip()
|
|
|
|
|
+ ]
|
|
|
|
|
+ for item in DEFAULT_VOCABULARY:
|
|
|
|
|
+ if len(vocab_items) >= 4:
|
|
|
|
|
+ break
|
|
|
|
|
+ if item["word"] not in {v["word"] for v in vocab_items}:
|
|
|
|
|
+ vocab_items.append(item)
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "practice_level": practice_level,
|
|
|
|
|
+ "conversation_topic": conversation_topic,
|
|
|
|
|
+ "current_question": f"和 Tom 聊一聊「{conversation_topic}」,试着用今天的重点词汇和句型表达你的想法。",
|
|
|
|
|
+ "example_sentences": sentence_items[:3],
|
|
|
|
|
+ "key_vocabulary": vocab_items[:4],
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _non_empty_str(value: Any) -> str | None:
|
|
|
|
|
+ if not isinstance(value, str):
|
|
|
|
|
+ return None
|
|
|
|
|
+ stripped = value.strip()
|
|
|
|
|
+ return stripped or None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def normalize_task_hint(
|
|
|
|
|
+ raw: dict[str, Any],
|
|
|
|
|
+ practice_level: str,
|
|
|
|
|
+ conversation_topic: str,
|
|
|
|
|
+ vocabulary: list[str],
|
|
|
|
|
+ sentences: list[str],
|
|
|
|
|
+) -> TaskHint:
|
|
|
|
|
+ fallback = build_fallback_task_hint(
|
|
|
|
|
+ practice_level=practice_level,
|
|
|
|
|
+ conversation_topic=conversation_topic,
|
|
|
|
|
+ vocabulary=vocabulary,
|
|
|
|
|
+ sentences=sentences,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if not isinstance(raw, dict):
|
|
|
|
|
+ return fallback
|
|
|
|
|
+
|
|
|
|
|
+ current_question = _non_empty_str(raw.get("current_question")) or fallback["current_question"]
|
|
|
|
|
+
|
|
|
|
|
+ example_sentences: list[dict[str, str]] = []
|
|
|
|
|
+ for item in raw.get("example_sentences", []):
|
|
|
|
|
+ if not isinstance(item, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ english = _non_empty_str(item.get("english"))
|
|
|
|
|
+ chinese = _non_empty_str(item.get("chinese"))
|
|
|
|
|
+ if english and chinese:
|
|
|
|
|
+ example_sentences.append({"english": english, "chinese": chinese})
|
|
|
|
|
+ if len(example_sentences) == 3:
|
|
|
|
|
+ break
|
|
|
|
|
+ for item in fallback["example_sentences"]:
|
|
|
|
|
+ if len(example_sentences) == 3:
|
|
|
|
|
+ break
|
|
|
|
|
+ example_sentences.append(item)
|
|
|
|
|
+
|
|
|
|
|
+ key_vocabulary: list[dict[str, str]] = []
|
|
|
|
|
+ for item in raw.get("key_vocabulary", []):
|
|
|
|
|
+ if not isinstance(item, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ word = _non_empty_str(item.get("word"))
|
|
|
|
|
+ meaning = _non_empty_str(item.get("meaning"))
|
|
|
|
|
+ if word and meaning:
|
|
|
|
|
+ key_vocabulary.append({"word": word, "meaning": meaning})
|
|
|
|
|
+ if len(key_vocabulary) == 4:
|
|
|
|
|
+ break
|
|
|
|
|
+ for item in fallback["key_vocabulary"]:
|
|
|
|
|
+ if len(key_vocabulary) == 4:
|
|
|
|
|
+ break
|
|
|
|
|
+ key_vocabulary.append(item)
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "practice_level": practice_level,
|
|
|
|
|
+ "conversation_topic": conversation_topic,
|
|
|
|
|
+ "current_question": current_question,
|
|
|
|
|
+ "example_sentences": example_sentences[:3],
|
|
|
|
|
+ "key_vocabulary": key_vocabulary[:4],
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Run helper tests**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
|
|
|
|
|
+uv run pytest tests/service/test_task_hint.py -q
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: `3 passed`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Commit helper**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
|
|
|
|
|
+git add app/service/speaking/task_hint.py tests/service/test_task_hint.py
|
|
|
|
|
+git commit -m "feat: add task hint normalization"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 3: Backend Service and API Endpoint
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/dialogue_service.py`
|
|
|
|
|
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/api/dialogue.py`
|
|
|
|
|
+- Create: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/test_dialogue_task_hint.py`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write endpoint tests**
|
|
|
|
|
+
|
|
|
|
|
+Create `tests/api/test_dialogue_task_hint.py`:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+import pytest_asyncio
|
|
|
|
|
+from httpx import ASGITransport, AsyncClient
|
|
|
|
|
+from sqlalchemy import select
|
|
|
|
|
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
|
+from sqlalchemy.pool import StaticPool
|
|
|
|
|
+
|
|
|
|
|
+from app.api.dialogue import get_dialogue_service
|
|
|
|
|
+from app.main import app
|
|
|
|
|
+from app.models.database import get_db
|
|
|
|
|
+from app.models.dialogue import Base, DialogueSession
|
|
|
|
|
+from app.service.speaking.dialogue_service import DialogueService
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest_asyncio.fixture
|
|
|
|
|
+async def test_env():
|
|
|
|
|
+ engine = create_async_engine(
|
|
|
|
|
+ "sqlite+aiosqlite:///:memory:",
|
|
|
|
|
+ poolclass=StaticPool,
|
|
|
|
|
+ connect_args={"check_same_thread": False},
|
|
|
|
|
+ )
|
|
|
|
|
+ async with engine.begin() as conn:
|
|
|
|
|
+ await conn.run_sync(Base.metadata.create_all)
|
|
|
|
|
+ SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
+
|
|
|
|
|
+ llm = MagicMock()
|
|
|
|
|
+ llm.chat = AsyncMock(return_value='{"practice_level":"grade5-1","conversation_topic":"How I get to school","current_question":"和 Tom 聊一聊上学方式。","example_sentences":[{"english":"I come to school by bus.","chinese":"我坐公交车上学。"},{"english":"I go to school on foot.","chinese":"我步行上学。"},{"english":"It takes ten minutes.","chinese":"需要十分钟。"}],"key_vocabulary":[{"word":"by bus","meaning":"乘公交车"},{"word":"on foot","meaning":"步行"},{"word":"near","meaning":"附近的"},{"word":"far from","meaning":"离……远"}]}')
|
|
|
|
|
+
|
|
|
|
|
+ async def _override_db():
|
|
|
|
|
+ async with SessionLocal() as s:
|
|
|
|
|
+ yield s
|
|
|
|
|
+
|
|
|
|
|
+ def _override_service():
|
|
|
|
|
+ return DialogueService(
|
|
|
|
|
+ asr=MagicMock(), llm=llm, assessor=MagicMock(), storage=MagicMock(),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ app.dependency_overrides[get_db] = _override_db
|
|
|
|
|
+ app.dependency_overrides[get_dialogue_service] = _override_service
|
|
|
|
|
+
|
|
|
|
|
+ transport = ASGITransport(app=app)
|
|
|
|
|
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
|
|
|
+ yield client, SessionLocal, llm
|
|
|
|
|
+
|
|
|
|
|
+ app.dependency_overrides.clear()
|
|
|
|
|
+ await engine.dispose()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _create_session(client: AsyncClient) -> str:
|
|
|
|
|
+ response = await client.post(
|
|
|
|
|
+ "/api/speaking/dialogue/session",
|
|
|
|
|
+ json={
|
|
|
|
|
+ "topic": "How I get to school",
|
|
|
|
|
+ "grade": "grade5-1",
|
|
|
|
|
+ "vocabulary": ["by bus", "on foot", "near", "far from"],
|
|
|
|
|
+ "sentences": ["I come to school by bus."],
|
|
|
|
|
+ "totalRounds": 3,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ assert response.status_code == 200
|
|
|
|
|
+ return response.json()["sessionId"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_post_task_hint_generates_and_persists(test_env):
|
|
|
|
|
+ client, SessionLocal, llm = test_env
|
|
|
|
|
+ session_id = await _create_session(client)
|
|
|
|
|
+
|
|
|
|
|
+ response = await client.post(f"/api/speaking/dialogue/session/{session_id}/task-hint")
|
|
|
|
|
+
|
|
|
|
|
+ assert response.status_code == 200
|
|
|
|
|
+ body = response.json()
|
|
|
|
|
+ assert body["practice_level"] == "grade5-1"
|
|
|
|
|
+ assert body["conversation_topic"] == "How I get to school"
|
|
|
|
|
+ assert len(body["example_sentences"]) == 3
|
|
|
|
|
+ assert len(body["key_vocabulary"]) == 4
|
|
|
|
|
+ assert llm.chat.await_count == 1
|
|
|
|
|
+
|
|
|
|
|
+ async with SessionLocal() as s:
|
|
|
|
|
+ row = (await s.execute(select(DialogueSession).where(DialogueSession.uuid == session_id))).scalar_one()
|
|
|
|
|
+ assert row.task_hint == body
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_post_task_hint_returns_cached_hint_without_llm(test_env):
|
|
|
|
|
+ client, _SessionLocal, llm = test_env
|
|
|
|
|
+ session_id = await _create_session(client)
|
|
|
|
|
+
|
|
|
|
|
+ first = await client.post(f"/api/speaking/dialogue/session/{session_id}/task-hint")
|
|
|
|
|
+ second = await client.post(f"/api/speaking/dialogue/session/{session_id}/task-hint")
|
|
|
|
|
+
|
|
|
|
|
+ assert first.status_code == 200
|
|
|
|
|
+ assert second.status_code == 200
|
|
|
|
|
+ assert second.json() == first.json()
|
|
|
|
|
+ assert llm.chat.await_count == 1
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_post_task_hint_falls_back_on_bad_json(test_env):
|
|
|
|
|
+ client, _SessionLocal, llm = test_env
|
|
|
|
|
+ llm.chat = AsyncMock(return_value="not json")
|
|
|
|
|
+ session_id = await _create_session(client)
|
|
|
|
|
+
|
|
|
|
|
+ response = await client.post(f"/api/speaking/dialogue/session/{session_id}/task-hint")
|
|
|
|
|
+
|
|
|
|
|
+ assert response.status_code == 200
|
|
|
|
|
+ body = response.json()
|
|
|
|
|
+ assert body["practice_level"] == "grade5-1"
|
|
|
|
|
+ assert len(body["example_sentences"]) == 3
|
|
|
|
|
+ assert len(body["key_vocabulary"]) == 4
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_post_task_hint_missing_session_returns_404(test_env):
|
|
|
|
|
+ client, _SessionLocal, _llm = test_env
|
|
|
|
|
+ response = await client.post(
|
|
|
|
|
+ "/api/speaking/dialogue/session/00000000-0000-0000-0000-000000000000/task-hint"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert response.status_code == 404
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run tests to verify failure**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
|
|
|
|
|
+uv run pytest tests/api/test_dialogue_task_hint.py -q
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: FAIL with `404 Not Found` for `/task-hint`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Add service imports and method**
|
|
|
|
|
+
|
|
|
|
|
+In `app/service/speaking/dialogue_service.py`, add imports:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+import json
|
|
|
|
|
+
|
|
|
|
|
+from app.service.speaking.task_hint import (
|
|
|
|
|
+ build_fallback_task_hint,
|
|
|
|
|
+ build_task_hint_prompt,
|
|
|
|
|
+ normalize_task_hint,
|
|
|
|
|
+ parse_task_hint_json,
|
|
|
|
|
+)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Add this method to `DialogueService` after `generate_greeting()`:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ async def generate_task_hint(
|
|
|
|
|
+ self,
|
|
|
|
|
+ db: AsyncSession,
|
|
|
|
|
+ session_uuid: str,
|
|
|
|
|
+ ) -> dict:
|
|
|
|
|
+ """Generate or return the cached task hint for a session."""
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(DialogueSession)
|
|
|
|
|
+ .where(DialogueSession.uuid == session_uuid)
|
|
|
|
|
+ .with_for_update()
|
|
|
|
|
+ )
|
|
|
|
|
+ session = result.scalar_one_or_none()
|
|
|
|
|
+ if session is None:
|
|
|
|
|
+ raise SessionNotFound(f"Session not found: {session_uuid}")
|
|
|
|
|
+
|
|
|
|
|
+ if session.task_hint:
|
|
|
|
|
+ cached_hint = session.task_hint
|
|
|
|
|
+ await db.rollback()
|
|
|
|
|
+ return cached_hint
|
|
|
|
|
+
|
|
|
|
|
+ role_config = session.role_config or {}
|
|
|
|
|
+ practice_level = str(role_config.get("grade") or "")
|
|
|
|
|
+ vocabulary = role_config.get("vocabulary") or []
|
|
|
|
|
+ sentences = role_config.get("sentences") or []
|
|
|
|
|
+ if not practice_level:
|
|
|
|
|
+ practice_level = "未指定年级"
|
|
|
|
|
+ if not isinstance(vocabulary, list):
|
|
|
|
|
+ vocabulary = []
|
|
|
|
|
+ if not isinstance(sentences, list):
|
|
|
|
|
+ sentences = []
|
|
|
|
|
+ vocabulary = [str(item) for item in vocabulary]
|
|
|
|
|
+ sentences = [str(item) for item in sentences]
|
|
|
|
|
+
|
|
|
|
|
+ prompt = build_task_hint_prompt(
|
|
|
|
|
+ practice_level=practice_level,
|
|
|
|
|
+ conversation_topic=session.topic,
|
|
|
|
|
+ vocabulary=vocabulary,
|
|
|
|
|
+ sentences=sentences,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ content = await self.llm.chat([{"role": "user", "content": prompt}], model="")
|
|
|
|
|
+ raw = parse_task_hint_json(content)
|
|
|
|
|
+ hint = normalize_task_hint(
|
|
|
|
|
+ raw,
|
|
|
|
|
+ practice_level=practice_level,
|
|
|
|
|
+ conversation_topic=session.topic,
|
|
|
|
|
+ vocabulary=vocabulary,
|
|
|
|
|
+ sentences=sentences,
|
|
|
|
|
+ )
|
|
|
|
|
+ except (json.JSONDecodeError, TypeError, ValueError) as exc:
|
|
|
|
|
+ logger.warning(f"Task hint generation fell back: session={session_uuid}, err={exc}")
|
|
|
|
|
+ hint = build_fallback_task_hint(
|
|
|
|
|
+ practice_level=practice_level,
|
|
|
|
|
+ conversation_topic=session.topic,
|
|
|
|
|
+ vocabulary=vocabulary,
|
|
|
|
|
+ sentences=sentences,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ logger.warning(f"Task hint provider failure fell back: session={session_uuid}, err={exc}")
|
|
|
|
|
+ hint = build_fallback_task_hint(
|
|
|
|
|
+ practice_level=practice_level,
|
|
|
|
|
+ conversation_topic=session.topic,
|
|
|
|
|
+ vocabulary=vocabulary,
|
|
|
|
|
+ sentences=sentences,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ session.task_hint = hint
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ return hint
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Persist source variables in `role_config`**
|
|
|
|
|
+
|
|
|
|
|
+In `create_session_only()`, before `session = DialogueSession(...)`, add:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ session_role_config = dict(role_config or {})
|
|
|
|
|
+ session_role_config.update({
|
|
|
|
|
+ "grade": grade,
|
|
|
|
|
+ "vocabulary": vocabulary,
|
|
|
|
|
+ "sentences": sentences,
|
|
|
|
|
+ })
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Then change the constructor argument:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ role_config=session_role_config,
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Add API route**
|
|
|
|
|
+
|
|
|
|
|
+In `app/api/dialogue.py`, add after `generate_greeting()`:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+@router.post("/session/{session_id}/task-hint")
|
|
|
|
|
+async def generate_task_hint(
|
|
|
|
|
+ session_id: UUID,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ service: DialogueService = Depends(get_dialogue_service),
|
|
|
|
|
+):
|
|
|
|
|
+ """Generate or return cached task hint for a session."""
|
|
|
|
|
+ logger.info(f"Generating task hint: session={session_id}")
|
|
|
|
|
+ try:
|
|
|
|
|
+ result = await service.generate_task_hint(db=db, session_uuid=str(session_id))
|
|
|
|
|
+ except SessionNotFound:
|
|
|
|
|
+ logger.warning(f"Task hint 404: session not found: {session_id}")
|
|
|
|
|
+ raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
|
+ logger.info(f"Task hint ready: session={session_id}")
|
|
|
|
|
+ return result
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Run endpoint tests**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
|
|
|
|
|
+uv run pytest tests/api/test_dialogue_task_hint.py tests/service/test_task_hint.py -q
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: all tests pass.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 7: Run backend regression tests**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
|
|
|
|
|
+uv run pytest tests/api/test_dialogue_greeting.py tests/api/test_dialogue_task_hint.py tests/service/test_task_hint.py -q
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: all tests pass.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 8: Commit backend endpoint**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
|
|
|
|
|
+git add app/api/dialogue.py app/service/speaking/dialogue_service.py tests/api/test_dialogue_task_hint.py
|
|
|
|
|
+git commit -m "feat: add task hint endpoint"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 4: Frontend API Contract
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts`
|
|
|
|
|
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Add frontend type**
|
|
|
|
|
+
|
|
|
|
|
+In `src/types/englishSpeaking.ts`, add before `DialogueAPI`:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+export interface TaskHint {
|
|
|
|
|
+ practice_level: string
|
|
|
|
|
+ conversation_topic: string
|
|
|
|
|
+ current_question: string
|
|
|
|
|
+ example_sentences: {
|
|
|
|
|
+ english: string
|
|
|
|
|
+ chinese: string
|
|
|
|
|
+ }[]
|
|
|
|
|
+ key_vocabulary: {
|
|
|
|
|
+ word: string
|
|
|
|
|
+ meaning: string
|
|
|
|
|
+ }[]
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Extend `DialogueAPI`:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+ generateTaskHint(sessionId: string): Promise<TaskHint>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Update service imports**
|
|
|
|
|
+
|
|
|
|
|
+In `llmService.ts`, include `TaskHint` in the type import:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+ TaskHint,
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Add real API method**
|
|
|
|
|
+
|
|
|
|
|
+In `RealDialogueAPI`, add after `generateGreeting()`:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+ async generateTaskHint(sessionId: string): Promise<TaskHint> {
|
|
|
|
|
+ const res = await fetch(`${API_BASE}/session/${sessionId}/task-hint`, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ credentials: 'include',
|
|
|
|
|
+ })
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ throw new DialogueApiError(`task hint failed: ${res.status}`, res.status)
|
|
|
|
|
+ }
|
|
|
|
|
+ return await res.json()
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Add mock API method**
|
|
|
|
|
+
|
|
|
|
|
+In `MockDialogueAPI`, add after `generateGreeting()`:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+ async generateTaskHint(_sessionId: string): Promise<TaskHint> {
|
|
|
|
|
+ await delay(250)
|
|
|
|
|
+ return {
|
|
|
|
|
+ practice_level: 'grade5-1',
|
|
|
|
|
+ conversation_topic: '我最喜欢的动物',
|
|
|
|
|
+ current_question: '和 Tom 聊一聊「我最喜欢的动物」,试着用今天的重点词汇和句型表达你的想法。',
|
|
|
|
|
+ example_sentences: [
|
|
|
|
|
+ { english: 'I like pandas best because they are very cute.', chinese: '我最喜欢大熊猫,因为它们非常可爱。' },
|
|
|
|
|
+ { english: "My favorite animal is the elephant. It's really smart!", chinese: '我最喜欢的动物是大象,它真的很聪明!' },
|
|
|
|
|
+ { english: 'I enjoy watching animals at the zoo with my family.', chinese: '我喜欢和家人一起在动物园看动物。' },
|
|
|
|
|
+ ],
|
|
|
|
|
+ key_vocabulary: [
|
|
|
|
|
+ { word: 'favorite', meaning: '最喜欢的' },
|
|
|
|
|
+ { word: 'adorable', meaning: '可爱的' },
|
|
|
|
|
+ { word: 'bamboo', meaning: '竹子' },
|
|
|
|
|
+ { word: 'habitat', meaning: '栖息地' },
|
|
|
|
|
+ ],
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Run type-check**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/PPT
|
|
|
|
|
+pnpm run type-check
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: PASS.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit frontend API**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/PPT
|
|
|
|
|
+git add src/types/englishSpeaking.ts src/views/Editor/EnglishSpeaking/services/llmService.ts
|
|
|
|
|
+git commit -m "feat: add task hint api contract"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 5: Extract TaskHintModal Component
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/TaskHintModal.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Create component**
|
|
|
|
|
+
|
|
|
|
|
+Create `TaskHintModal.vue`:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div v-if="visible" class="modal-mask" @click.self="$emit('close')">
|
|
|
|
|
+ <div class="modal hint-modal scale-in">
|
|
|
|
|
+ <div class="modal-head">
|
|
|
|
|
+ <h3 class="modal-title">
|
|
|
|
|
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#f97316"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
+ <path d="M9 18h6" /><path d="M10 22h4" />
|
|
|
|
|
+ <path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ 任务提示
|
|
|
|
|
+ </h3>
|
|
|
|
|
+ <button class="close-btn" @click="$emit('close')">
|
|
|
|
|
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="loading" class="hint-state">
|
|
|
|
|
+ <svg class="spinner" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round">
|
|
|
|
|
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <p>正在生成任务提示...</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-else-if="error" class="hint-state hint-state-error">
|
|
|
|
|
+ <p>{{ error }}</p>
|
|
|
|
|
+ <button class="retry-btn" @click="$emit('retry')">重试</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <template v-else-if="hint">
|
|
|
|
|
+ <div class="hint-context">
|
|
|
|
|
+ <p class="context-label">当前问题</p>
|
|
|
|
|
+ <p class="context-body">{{ hint.current_question }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="hint-section">
|
|
|
|
|
+ <p class="section-label">参考句子</p>
|
|
|
|
|
+ <div class="sentences">
|
|
|
|
|
+ <div v-for="(item, index) in hint.example_sentences" :key="index" class="sentence-card">
|
|
|
|
|
+ <div class="sentence-main">
|
|
|
|
|
+ <p class="sentence-en">{{ item.english }}</p>
|
|
|
|
|
+ <p class="sentence-zh">{{ item.chinese }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="hint-section">
|
|
|
|
|
+ <p class="section-label">关键词汇</p>
|
|
|
|
|
+ <div class="vocab-grid">
|
|
|
|
|
+ <div v-for="(item, index) in hint.key_vocabulary" :key="index" class="vocab-item">
|
|
|
|
|
+ <div class="vocab-info">
|
|
|
|
|
+ <p class="vocab-word">{{ item.word }}</p>
|
|
|
|
|
+ <p class="vocab-meta">{{ item.meaning }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <p class="hint-footer">用自己的话表达更棒哦</p>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script lang="ts" setup>
|
|
|
|
|
+import type { TaskHint } from '@/types/englishSpeaking'
|
|
|
|
|
+
|
|
|
|
|
+defineProps<{
|
|
|
|
|
+ visible: boolean
|
|
|
|
|
+ loading: boolean
|
|
|
|
|
+ error?: string | null
|
|
|
|
|
+ hint?: TaskHint | null
|
|
|
|
|
+ aiName?: string
|
|
|
|
|
+}>()
|
|
|
|
|
+
|
|
|
|
|
+defineEmits<{
|
|
|
|
|
+ close: []
|
|
|
|
|
+ retry: []
|
|
|
|
|
+}>()
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style lang="scss" scoped>
|
|
|
|
|
+.modal-mask {
|
|
|
|
|
+ position: fixed;
|
|
|
|
|
+ inset: 0;
|
|
|
|
|
+ background: rgba(0,0,0,0.3);
|
|
|
|
|
+ backdrop-filter: blur(2px);
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ z-index: 50;
|
|
|
|
|
+ padding: 16px;
|
|
|
|
|
+}
|
|
|
|
|
+.modal {
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border-radius: 16px;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ max-height: 80vh;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ box-shadow: 0 20px 60px rgba(0,0,0,0.15);
|
|
|
|
|
+}
|
|
|
|
|
+.hint-modal { max-width: 440px; padding: 20px; }
|
|
|
|
|
+.modal-head {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ margin-bottom: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+.modal-title {
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: #111827;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+}
|
|
|
|
|
+.close-btn {
|
|
|
|
|
+ width: 26px; height: 26px;
|
|
|
|
|
+ background: #f3f4f6;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ color: #6b7280;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
|
|
+ &:hover { background: #e5e7eb; }
|
|
|
|
|
+}
|
|
|
|
|
+.hint-state {
|
|
|
|
|
+ min-height: 180px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+ color: #6b7280;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+}
|
|
|
|
|
+.hint-state-error { color: #b91c1c; }
|
|
|
|
|
+.retry-btn {
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+ background: #f97316;
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ padding: 6px 14px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+}
|
|
|
|
|
+.hint-context {
|
|
|
|
|
+ background: #fff7ed;
|
|
|
|
|
+ border: 1px solid #fed7aa;
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ padding: 12px 14px;
|
|
|
|
|
+ margin-bottom: 16px;
|
|
|
|
|
+}
|
|
|
|
|
+.context-label, .section-label {
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ letter-spacing: 0.03em;
|
|
|
|
|
+ text-transform: uppercase;
|
|
|
|
|
+ margin: 0 0 8px;
|
|
|
|
|
+}
|
|
|
|
|
+.context-label { color: #f97316; margin-bottom: 4px; }
|
|
|
|
|
+.section-label { color: #9ca3af; }
|
|
|
|
|
+.context-body { font-size: 13px; color: #374151; line-height: 1.5; margin: 0; }
|
|
|
|
|
+.hint-section { margin-bottom: 16px; }
|
|
|
|
|
+.sentences { display: flex; flex-direction: column; gap: 8px; }
|
|
|
|
|
+.sentence-card {
|
|
|
|
|
+ padding: 10px 12px;
|
|
|
|
|
+ background: #f9fafb;
|
|
|
|
|
+ border: 1px solid #f3f4f6;
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+.sentence-en { font-size: 12px; color: #1f2937; margin: 0; line-height: 1.5; }
|
|
|
|
|
+.sentence-zh { font-size: 11px; color: #9ca3af; margin: 2px 0 0; }
|
|
|
|
|
+.vocab-grid {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+.vocab-item {
|
|
|
|
|
+ padding: 8px 10px;
|
|
|
|
|
+ background: #f9fafb;
|
|
|
|
|
+ border: 1px solid #f3f4f6;
|
|
|
|
|
+ border-radius: 10px;
|
|
|
|
|
+}
|
|
|
|
|
+.vocab-word { font-size: 12px; font-weight: 500; color: #1f2937; margin: 0; }
|
|
|
|
|
+.vocab-meta { font-size: 10px; color: #9ca3af; margin: 2px 0 0; }
|
|
|
|
|
+.hint-footer { text-align: center; font-size: 11px; color: #d1d5db; margin: 16px 0 0; }
|
|
|
|
|
+.spinner { animation: spin 0.8s linear infinite; }
|
|
|
|
|
+@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
|
+@keyframes scale-in-frames {
|
|
|
|
|
+ from { opacity: 0; transform: scale(0.96); }
|
|
|
|
|
+ to { opacity: 1; transform: scale(1); }
|
|
|
|
|
+}
|
|
|
|
|
+.scale-in { animation: scale-in-frames 0.22s ease-out; }
|
|
|
|
|
+</style>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run type-check**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/PPT
|
|
|
|
|
+pnpm run type-check
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: PASS, because the component is not imported yet.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Commit component**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/PPT
|
|
|
|
|
+git add src/views/Editor/EnglishSpeaking/preview/TaskHintModal.vue
|
|
|
|
|
+git commit -m "feat: add task hint modal component"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 6: Wire DialogueChatView Lazy Loading
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Update imports**
|
|
|
|
|
+
|
|
|
|
|
+Change imports near the top of the script:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import type { PreviewChatMessage, BadgeAchievement, DialogueReport, SessionStartInfo, TaskHint } from '@/types/englishSpeaking'
|
|
|
|
|
+import TaskHintModal from './TaskHintModal.vue'
|
|
|
|
|
+import { createDialogueApi } from '../services/llmService'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Replace hint button handler**
|
|
|
|
|
+
|
|
|
|
|
+Change:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<button class="hint-btn" @click="showHintModal = true">
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+to:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<button class="hint-btn" @click="openTaskHint">
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Replace inline hint modal template**
|
|
|
|
|
+
|
|
|
|
|
+Delete the whole inline block beginning with:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<!-- 任务提示弹窗 -->
|
|
|
|
|
+<div v-if="showHintModal" class="modal-mask" @click.self="showHintModal = false">
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+and ending just before:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<!-- 音素详情弹窗 -->
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Insert:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+ <TaskHintModal
|
|
|
|
|
+ :visible="showHintModal"
|
|
|
|
|
+ :loading="taskHintLoading"
|
|
|
|
|
+ :error="taskHintError"
|
|
|
|
|
+ :hint="taskHint"
|
|
|
|
|
+ :ai-name="aiName"
|
|
|
|
|
+ @close="showHintModal = false"
|
|
|
|
|
+ @retry="loadTaskHint"
|
|
|
|
|
+ />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Remove static hint arrays**
|
|
|
|
|
+
|
|
|
|
|
+Delete:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+const sentenceHints = [
|
|
|
|
|
+ { en: 'I like pandas best because they are very cute.', zh: '我最喜欢大熊猫,因为它们非常可爱。' },
|
|
|
|
|
+ { en: "My favorite animal is the elephant. It's really smart!", zh: '我最喜欢的动物是大象,它真的很聪明!' },
|
|
|
|
|
+ { en: 'I enjoy watching animals at the zoo with my family.', zh: '我喜欢和家人一起在动物园看动物。' },
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+const vocabHints = [
|
|
|
|
|
+ { word: 'favorite', phonetic: '/ˈfeɪvərɪt/', meaning: '最喜欢的' },
|
|
|
|
|
+ { word: 'adorable', phonetic: '/əˈdɔːrəbl/', meaning: '可爱的' },
|
|
|
|
|
+ { word: 'bamboo', phonetic: '/bæmˈbuː/', meaning: '竹子' },
|
|
|
|
|
+ { word: 'habitat', phonetic: '/ˈhæbɪtæt/', meaning: '栖息地' },
|
|
|
|
|
+]
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Add lazy-load state**
|
|
|
|
|
+
|
|
|
|
|
+After `const showHintModal = ref(false)`, add:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+const taskHint = ref<TaskHint | null>(null)
|
|
|
|
|
+const taskHintLoading = ref(false)
|
|
|
|
|
+const taskHintError = ref<string | null>(null)
|
|
|
|
|
+const taskHintApi = createDialogueApi(props.mode)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Add functions**
|
|
|
|
|
+
|
|
|
|
|
+Near other UI handlers, add:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+function openTaskHint() {
|
|
|
|
|
+ showHintModal.value = true
|
|
|
|
|
+ if (!taskHint.value && !taskHintLoading.value) {
|
|
|
|
|
+ loadTaskHint()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function loadTaskHint() {
|
|
|
|
|
+ if (!props.sessionInfo?.sessionId) {
|
|
|
|
|
+ taskHintError.value = '当前会话未准备好,请稍后重试'
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ taskHintLoading.value = true
|
|
|
|
|
+ taskHintError.value = null
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ taskHint.value = await taskHintApi.generateTaskHint(props.sessionInfo.sessionId)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ taskHintError.value = err instanceof Error ? '生成任务提示失败,请重试' : '生成任务提示失败,请重试'
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ taskHintLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 7: Remove task-hint styles from DialogueChatView**
|
|
|
|
|
+
|
|
|
|
|
+Delete only styles now owned by `TaskHintModal.vue`:
|
|
|
|
|
+
|
|
|
|
|
+```scss
|
|
|
|
|
+.hint-modal
|
|
|
|
|
+.hint-context
|
|
|
|
|
+.context-label
|
|
|
|
|
+.context-body
|
|
|
|
|
+.hint-section
|
|
|
|
|
+.section-label
|
|
|
|
|
+.sentences
|
|
|
|
|
+.sentence-card
|
|
|
|
|
+.sentence-main
|
|
|
|
|
+.sentence-en
|
|
|
|
|
+.sentence-zh
|
|
|
|
|
+.voice-icon-btn
|
|
|
|
|
+.vocab-grid
|
|
|
|
|
+.vocab-item
|
|
|
|
|
+.vocab-info
|
|
|
|
|
+.vocab-word
|
|
|
|
|
+.vocab-meta
|
|
|
|
|
+.vocab-play
|
|
|
|
|
+.hint-footer
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Keep shared `.modal-mask`, `.modal`, `.modal-head`, `.modal-title`, `.close-btn` because phoneme and exit modals still use them.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 8: Run type-check**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/PPT
|
|
|
|
|
+pnpm run type-check
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: PASS.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 9: Run build**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/PPT
|
|
|
|
|
+pnpm run build
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: PASS.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 10: Commit frontend wiring**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/PPT
|
|
|
|
|
+git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
|
|
|
|
|
+git commit -m "feat: lazy load dialogue task hints"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 7: End-to-End Verification
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Verify only; no planned edits.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Run backend focused tests**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
|
|
|
|
|
+uv run pytest tests/api/test_dialogue_greeting.py tests/api/test_dialogue_task_hint.py tests/service/test_task_hint.py -q
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: all tests pass.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run frontend verification**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/PPT
|
|
|
|
|
+pnpm run type-check
|
|
|
|
|
+pnpm run build
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: both commands pass.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Manual API cache check**
|
|
|
|
|
+
|
|
|
|
|
+Start backend in one terminal:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
|
|
|
|
|
+uv run uvicorn app.main:app --reload --port 8000
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Create a session:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+curl -s http://localhost:8000/api/speaking/dialogue/session \
|
|
|
|
|
+ -H 'Content-Type: application/json' \
|
|
|
|
|
+ -d '{"topic":"How I get to school","grade":"grade5-1","vocabulary":["by bus","on foot"],"sentences":["I come to school by bus."],"totalRounds":3}'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Call task hint twice with the returned `sessionId`:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+curl -s -X POST http://localhost:8000/api/speaking/dialogue/session/<sessionId>/task-hint
|
|
|
|
|
+curl -s -X POST http://localhost:8000/api/speaking/dialogue/session/<sessionId>/task-hint
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: both responses are valid `TaskHint` JSON with 3 `example_sentences` and 4 `key_vocabulary` items, and the second response equals the first response byte-for-byte after JSON parsing.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Confirm no verification changes**
|
|
|
|
|
+
|
|
|
|
|
+No commit is needed if verification does not change files.
|