2026-04-25-english-speaking-task-hint-modal.md 38 KB

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:

    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:

    system_prompt TEXT NULL,
    task_hint JSON NULL,
    expires_at DATETIME NULL,
  • Step 3: Add migration

Create migrations/002_add_task_hint.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:

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
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:

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:

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:

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:

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
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:

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:

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:

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():

    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:

        session_role_config = dict(role_config or {})
        session_role_config.update({
            "grade": grade,
            "vocabulary": vocabulary,
            "sentences": sentences,
        })

Then change the constructor argument:

            role_config=session_role_config,
  • Step 5: Add API route

In app/api/dialogue.py, add after generate_greeting():

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

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:

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
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:

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:

  generateTaskHint(sessionId: string): Promise<TaskHint>
  • Step 2: Update service imports

In llmService.ts, include TaskHint in the type import:

  TaskHint,
  • Step 3: Add real API method

In RealDialogueAPI, add after generateGreeting():

  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():

  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:

cd /Users/buoy/Development/gitrepo/PPT
pnpm run type-check

Expected: PASS.

  • Step 6: Commit frontend API
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:

<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:

cd /Users/buoy/Development/gitrepo/PPT
pnpm run type-check

Expected: PASS, because the component is not imported yet.

  • Step 3: Commit component
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:

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:

<button class="hint-btn" @click="showHintModal = true">

to:

<button class="hint-btn" @click="openTaskHint">
  • Step 3: Replace inline hint modal template

Delete the whole inline block beginning with:

<!-- 任务提示弹窗 -->
<div v-if="showHintModal" class="modal-mask" @click.self="showHintModal = false">

and ending just before:

<!-- 音素详情弹窗 -->

Insert:

    <TaskHintModal
      :visible="showHintModal"
      :loading="taskHintLoading"
      :error="taskHintError"
      :hint="taskHint"
      :ai-name="aiName"
      @close="showHintModal = false"
      @retry="loadTaskHint"
    />
  • Step 4: Remove static hint arrays

Delete:

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:

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:

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:

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

cd /Users/buoy/Development/gitrepo/PPT
pnpm run type-check

Expected: PASS.

  • Step 9: Run build

Run:

cd /Users/buoy/Development/gitrepo/PPT
pnpm run build

Expected: PASS.

  • Step 10: Commit frontend wiring
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

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
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:

cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run uvicorn app.main:app --reload --port 8000

Create a session:

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:

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.