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.
Frontend repo: /Users/buoy/Development/gitrepo/PPT
src/types/englishSpeaking.ts: add TaskHint and extend DialogueAPI.src/views/Editor/EnglishSpeaking/services/llmService.ts: add real/mock generateTaskHint().src/views/Editor/EnglishSpeaking/preview/TaskHintModal.vue: presentational modal with loading/error/content states.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
app/models/dialogue.py: add DialogueSession.task_hint.init.sql: add task_hint JSON NULL.migrations/002_add_task_hint.sql: one-shot migration for existing DBs.app/service/speaking/task_hint.py: prompt, JSON parsing, validation, normalization, fallback, and generation service helpers.app/service/speaking/dialogue_service.py: add idempotent generate_task_hint().app/api/dialogue.py: add POST /session/{session_id}/task-hint.tests/service/test_task_hint.py: pure helper tests.tests/api/test_dialogue_greeting.py or create tests/api/test_dialogue_task_hint.py: endpoint and cache tests.Files:
/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/models/dialogue.py/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/init.sqlCreate: /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)
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,
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;
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.
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"
Files:
/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/task_hint.pyCreate: /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
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'.
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],
}
Run:
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/service/test_task_hint.py -q
Expected: 3 passed.
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"
Files:
/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/dialogue_service.py/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/api/dialogue.pyCreate: /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
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.
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
role_configIn 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,
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
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.
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.
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"
Files:
/Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.tsModify: /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>
In llmService.ts, include TaskHint in the type import:
TaskHint,
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()
}
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: '栖息地' },
],
}
}
Run:
cd /Users/buoy/Development/gitrepo/PPT
pnpm run type-check
Expected: PASS.
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"
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>
Run:
cd /Users/buoy/Development/gitrepo/PPT
pnpm run type-check
Expected: PASS, because the component is not imported yet.
cd /Users/buoy/Development/gitrepo/PPT
git add src/views/Editor/EnglishSpeaking/preview/TaskHintModal.vue
git commit -m "feat: add task hint modal component"
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'
Change:
<button class="hint-btn" @click="showHintModal = true">
to:
<button class="hint-btn" @click="openTaskHint">
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"
/>
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: '栖息地' },
]
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)
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
}
}
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.
Run:
cd /Users/buoy/Development/gitrepo/PPT
pnpm run type-check
Expected: PASS.
Run:
cd /Users/buoy/Development/gitrepo/PPT
pnpm run build
Expected: PASS.
cd /Users/buoy/Development/gitrepo/PPT
git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
git commit -m "feat: lazy load dialogue task hints"
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.
cd /Users/buoy/Development/gitrepo/PPT
pnpm run type-check
pnpm run build
Expected: both commands pass.
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.
No commit is needed if verification does not change files.