Procházet zdrojové kódy

docs: plan english speaking task hint modal

jimmylee před 1 měsícem
rodič
revize
4430c70e33

+ 1282 - 0
docs/superpowers/plans/2026-04-25-english-speaking-task-hint-modal.md

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