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