日期: 2026-05-15 作者: jimmylee 状态: 待实施
英语口语工具(TopicDiscussion)目前只支持纯文本多轮对话。本次新增两块能力:
app/providers/azure_speech.py 已使用 granularity=Word, enable_miscue=True,并对每个 word 返回 error_type(None/Mispronunciation/Omission/Insertion)与 accuracy_score,完整存于 pronunciation_evaluation.word_analysis JSON 列。正确/错误/漏读完全可做到,无需调研。/report 当前未透传 wordAnalysis:类型上前端 BackendEvaluation.wordAnalysis 已经存在但 adaptReport 丢弃,后端 serializer 也需调整。images.generate:参考 application_center_api/utils/utils.py:aget_dalle_image,模型 doubao-seedream-3-0-t2i-250415,响应 b64_json,解码后上传 S3 拿公网 URL。dialogue_message 表无 image_url 列:需 ALTER。/speak-stream (WebSocket) 已具备转录、PronunciationEvaluation、S3 上传、turn-id 幂等:全部复用,只在 LLM 输出环节按 session mode 分支。在 "展示学习报告" toggle 下方新增 "影片场景" toggle。
Store 改动:TopicDiscussionConfig 新增顶层字段(与 practice、evaluation 平级):
videoScene: {
enabled: boolean // 默认 false
}
理由:它改变运行时语义(轮次=1、AI 返图),不属于"评估配置"。
DEFAULT_CONFIG 与 EMPTY_CONFIG 都加这一字段(默认 false)。新增 store action toggleVideoScene()。
src/views/Editor/EnglishSpeaking/data/topicDiscussionVideos.json:
[
{
"id": "v1",
"title": "<placeholder title>",
"videoUrl": "<placeholder mp4 url>",
"description": "<placeholder English description for system prompt>"
},
... 共 8 条占位
]
description 字段是 AI system prompt 的关键输入。资源由用户后续提供,先用占位顶住开发。
仅当 speakingStore.config.videoScene.enabled === true 时,在 ready 阶段标题下方插入一个 2×4 网格 影片卡片栏:
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│标题1│ │标题2│ │标题3│ │标题4│
│○ ▷│ │○ ▷│ │○ ▷│ │○ ▷│
└──────┘ └──────┘ └──────┘ └──────┘
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│标题5│ │标题6│ │标题7│ │标题8│
│○ ▷│ │○ ▷│ │○ ▷│ │○ ▷│
└──────┘ └──────┘ └──────┘ └──────┘
CSS:grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(2, auto); gap: 12px。无横向滚动。
每卡:
交互:
VideoPlayerModal(<video controls> + 右上角 ✕,占预览区域 ~80% 宽,maxWidth 720px,16:9)"开始对话" 按钮 disabled 条件追加:videoScene.enabled && !selectedVideo,提示文案:请先选择一个影片。
新增组件:
preview/VideoSceneGrid.vue(影片卡片网格)preview/VideoPlayerModal.vue(播放弹窗)TopicDiscussionPreview.vue:startDialogue() → api.createSession() 的 body 增加:
selectedVideo?: {
id: string
title: string
videoUrl: string
description: string
}
后端入库到 DialogueSession.role_config.selected_video(JSON 列已存在,无需 schema 改动)。同时 session 的 mode 字段也存到 role_config.mode,值为 'normal' | 'video_scene'。
mode === 'video_scene' 时:
total_rounds 强制 1(不论 config 里 practice.rounds/duration 是什么)system_prompt 模板尾部追加 video 场景上下文段:
─ Video Scene Context ─
The student has chosen a video scene to discuss:
Title: {selected_video.title}
Description: {selected_video.description}
Your single question must reference this scene specifically and invite
the student to describe / react to it.
app/providers/image_gen.pyclass ImageGenProvider(Protocol):
async def generate(self, prompt: str) -> str: ... # returns image URL
class DoubaoImageGen(ImageGenProvider):
def __init__(self, storage: AudioStorage):
self.client = AsyncOpenAI(
base_url=settings.ONEHUB_BASE_URL,
api_key=settings.ONEHUB_API_KEY,
)
self.storage = storage
async def generate(self, prompt: str) -> str:
response = await self.client.images.generate(
model="doubao-seedream-3-0-t2i-250415",
prompt=prompt,
n=1,
quality="hd",
size="1024x1024",
response_format="b64_json",
)
img_bytes = base64.b64decode(response.data[0].b64_json)
timestamp = int(time.time())
url = await self.storage.upload(img_bytes, f"images/{timestamp}.png")
return url
S3 复用 S3AudioStorage(upload(bytes, filename) -> url),最终 key 为 speaking/images/{timestamp}.png。失败时(任何 exception)→ 返回占位 URL settings.IMAGE_GEN_PLACEHOLDER_URL + logger.warning,不阻塞用户流程。
get_dialogue_service() 注入 image_gen=DoubaoImageGen(storage=S3AudioStorage())。
app/service/speaking/prompts.py(或现有 prompts 常量文件)新增:
VIDEO_SCENE_IMAGE_PROMPT_TEMPLATE = """\
<placeholder — 后续由 jimmylee 调整>
Generate a vivid photorealistic image based on:
- Video scene title: {video_title}
- Video scene description: {video_description}
- AI question to student: {ai_question}
- Student response: {student_transcript}
The image should depict what the student is describing, in the context of the video scene.
"""
后续真实改文案就改这一处。
dialogue_message.image_url 新列ALTER TABLE dialogue_message ADD COLUMN image_url VARCHAR(512) NULL;
models/dialogue.py 同步加 image_url: Mapped[Optional[str]] 字段。
init.sql 同步更新(项目无 alembic 迁移,与现有 schema 演进方式一致)。
AI 消息:content 仍存 image gen prompt 文本(便于审计/重生成),image_url 存生成的 URL。
/speak-stream 分支(WebSocket)在收到 transcript、写完 student msg + evaluation 占位后:
mode = (session.role_config or {}).get("mode", "normal")
if mode == "video_scene":
# 取上一条 AI 消息作为 question
ai_question = next((m.content for m in reversed(history) if m.role == "ai"), "")
selected = (session.role_config or {}).get("selected_video", {})
image_prompt = VIDEO_SCENE_IMAGE_PROMPT_TEMPLATE.format(
video_title=selected.get("title", ""),
video_description=selected.get("description", ""),
ai_question=ai_question,
student_transcript=transcript,
)
image_url = await image_gen.generate(image_prompt)
await websocket.send_json({"type": "image", "url": image_url, "round": current_round})
full_response = image_prompt # 持久化到 content 便于审计
ai_image_url = image_url
else:
# 现有 LLM 流式分支
full_response = ""
async for token in llm.chat_stream(...):
full_response += token
await websocket.send_json({"type": "token", "content": token})
ai_image_url = None
之后照常保存 AI 消息(带 image_url=ai_image_url)、推进轮次。total_rounds=1 的情况下,is_complete = True,session 自动 mark completed。后台仍跑 pronunciation_evaluation。
types/englishSpeaking.ts:SSEEvent union 加入:
| { type: 'image'; url: string; round: number }
composables/useDialogueEngine.ts 收到 image 事件:把当前 AI message 的 imageUrl 字段填好,并视为 AI turn 完成(等价于 done,但 done 事件后端仍会发,engine 需幂等处理)。
DialogueChatView.vue 渲染 AI 消息时:
<template v-if="message.imageUrl">
<img :src="message.imageUrl" class="ai-image-bubble" />
</template>
<template v-else>
<!-- 现有 voice-bar + text bubble -->
</template>
Message 类型加可选 imageUrl?: string。
后端 /report round response 增加 wordAnalysis 字段:
# dialogue_service.py:get_report() 中构建 round entry
entry["evaluation"]["wordAnalysis"] = ev.word_analysis # 直接透传 JSON
entry["imageUrl"] = msg.image_url # AI 消息新加
前端 services/llmService.ts:
BackendEvaluation.wordAnalysis 类型从 unknown 改为 WordAnalysisItem[](新类型)。BackendRound 新增 imageUrl?: string | null。adaptReport 把 wordAnalysis 透传到 SentenceEvaluation.wordAnalysis,imageUrl 透传到 SentenceEvaluation.imageUrl。类型 types/englishSpeaking.ts:
export interface WordAnalysisItem {
word: string
accuracyScore: number
errorType: 'None' | 'Mispronunciation' | 'Omission' | 'Insertion' | 'UnexpectedBreak' | 'MissingBreak'
}
export interface SentenceEvaluation {
// ...existing
wordAnalysis?: WordAnalysisItem[]
imageUrl?: string
}
preview/WordPronunciationDisplay.vue位置:DetailedReport.vue 的 feedback-section 内,直接放在 "进阶表达" feedback-block 之后(同一展开面板内,与已有 feedback-block 共享布局)。
展示规则:
word_analysis 顺序渲染(包含 Omission 占位词,否则漏读不可见)。<span>,文字色不变(#1f2937),只设 border-bottom: 2px solid <color>(下划线)。颜色映射:
| Azure error_type | 含义 | 下划线色 |
|---|---|---|
None |
正确 | #16a34a 绿 |
Mispronunciation |
错误 | #ef4444 红 |
Omission |
漏读 | #9ca3af 灰 |
Insertion |
多读(归"错误") | #ef4444 红 |
UnexpectedBreak / MissingBreak |
韵律(忽略) | 无下划线 |
布局:display: inline; line-height: 2.0(为下划线留空间),word 之间空格用普通 。新增 block label 🎤 发音详情(沿用现有 .feedback-block-label 风格)。
imageUrl 存在 → DetailedReport 中 AI 卡片渲染 <img> 而非纯文本(替换 card-text 块)。改动 1 — dialogue_service.py:_build_overall_history() 在 student 项里增加 wordAnalysis:
if msg.role == "student" and msg.evaluation:
ev = msg.evaluation
item["pronunciation"] = {
"accuracy": ev.accuracy_score,
"fluency": ev.fluency_score,
"completeness": ev.completeness_score,
"prosody": ev.prosody_score,
}
item["wordAnalysis"] = ev.word_analysis # ← 新增
if ev.content_feedback:
item["contentFeedback"] = ev.content_feedback
改动 2 — overall_report_evaluator.py:SYSTEM_PROMPT 修订:
error_type 与 accuracy_score)" 列为 LLM 第 2 类输入(原文含糊地说"Speech-API的每条语音评分")。although 多次发音准确度偏低,可重点练 /ɔː/" / "banana 出现漏读")。OverallReport.vue 不动。
改动 1(持久化全量):pronunciation_evaluation.word_analysis JSON 列直接存 Azure 完整返回 dict(本来已经是 JSON 列),便于后续 DB 查询/分析。当下 azure_speech.py 已经返回所有字段,只需 dialogue_service._evaluate_pronunciation 把整个 dict 写入。
改动 2(实时日志):每次 _evaluate_pronunciation 完成后,以 INFO 级别打印 Azure 完整返回:
logger.info(
"[pronunciation:azure-result] session=%s round=%s msg_id=%s evaluation_id=%s\n"
" accuracy=%s fluency=%s completeness=%s prosody=%s\n"
" word_analysis=%s",
session_uuid, round_no, message_id, evaluation_id,
result.get("accuracy_score"), result.get("fluency_score"),
result.get("completeness_score"), result.get("prosody_score"),
json.dumps(result.get("word_analysis", []), ensure_ascii=False, indent=2),
)
INFO 级别确保上线后不靠 DEBUG 也能 grep。
[老师] Config 开 videoScene.enabled
└─→ updateSpeakingConfig(POST → 后端 speaking_config 表)
[学生] 打开练习页
└─→ TopicDiscussionPreview 拉取 config
└─→ 渲染 VideoSceneGrid (8 张卡)
└─→ 学生选 v3,点 [开始对话]
└─→ createSession(body 含 selectedVideo + mode='video_scene')
└─→ 后端建 session: total_rounds=1, role_config={mode, selected_video}
system_prompt 含 Video Scene Context
└─→ generateGreeting → AI 提一个针对 v3 场景的问题
└─→ 学生录音 → /speak-stream
├─ 转录 → "send transcript event"
├─ DoubaoImageGen.generate(prompt) → image_url
├─ "send image event"
├─ 保存 AI msg (image_url=...)
├─ session.status = completed
└─ 后台 pronunciation_evaluation
[学生] DialogueChatView 渲染 AI 消息为 <img>
└─→ 自动跳到 report 阶段
└─→ getReport poll 直到 ready → OverallReport + DetailedReport
DetailedReport 展示 1 条 AI(图片) + 1 条 student(带 WordPronunciationDisplay)
[学生] 完整多轮对话(现有逻辑) → completed
[报告] /report 返回 wordAnalysis 数组
└─→ DetailedReport 每条学生卡片展开后,在"进阶表达"下方
渲染 WordPronunciationDisplay(各 word 带颜色下划线)
└─→ OverallReport 的 aiComment / highlights / suggestions
由 LLM 基于 word_analysis 生成更具体的反馈
| # | 决策 | 备选/理由 |
|---|---|---|
| 1 | videoScene.enabled 在 config 顶层(平级 practice/evaluation) |
改变运行时语义,不属评估 |
| 2 | selectedVideo 在 session.role_config(每学生不同) |
不能存到老师配的全局 config |
| 3 | 影片资源本地 topicDiscussionVideos.json,8 条含 description |
用户后续提供资源 |
| 4 | 复用 /speak-stream + 新增 image 事件类型 |
不开新端点,复用幂等机制 |
| 5 | dialogue_message.image_url 新列(migration) |
语义清晰,避免与 audio_url 混 |
| 6 | Image gen = doubao-seedream-3-0-t2i-250415,沿用 aget_dalle_image 模式 |
与现网生产一致 |
| 7 | 失败回退占位图 + log warning | 不阻塞用户流程 |
| 8 | 词级展示按 Azure word_analysis 顺序(含 Omission 占位) | 漏读必须能展示 |
| 9 | Insertion 归红色"错误" | 用户只列 3 类色 |
| 10 | 影片场景下也跑发音评估 | 词级展示与是否影片场景无关 |
| 11 | system_prompt 在 video_scene 模式追加 Video Scene Context 段 | 让 AI 提问围绕场景 |
| 12 | OverallReport 生成时 LLM 输入加 word_analysis,SYSTEM_PROMPT 要求点名问题词 | 无 UI 改动 |
| 13 | pronunciation_evaluation.word_analysis 存 Azure 完整返回 + INFO 日志 |
便于调试与离线分析 |
| 14 | 预览层 2×4 网格 | 用户指定 |
| 15 | 影片播放弹窗:80% 宽 / maxWidth 720px / 16:9 | 默认值,可调 |
| 风险 | 缓解 |
|---|---|
| Doubao image gen 慢(>10s)拖延整个 turn | WS 超时设 30s,失败回退占位图 |
| word_analysis JSON 体积大,影响 OverallReport LLM 上下文 | 当前 16k+ 上下文够用,如果触发 token 限制再做截断 |
enable_miscue 在不同 Azure region 行为差异 |
已经在生产路径,假设稳定 |
| 影片资源 mp4 跨域 / CORS | 由资源提供方保证,弹窗内 <video> 标签直接加载 |
| 同一 session 多次刷新页面图片重复请求生成 | turn-id 幂等机制已覆盖,缓存的 AI msg 直接回放 |
src/views/Editor/EnglishSpeaking/configs/TopicDiscussionConfig.vue — 加 togglesrc/store/speaking.ts — 加 videoScene 字段 + actionsrc/types/englishSpeaking.ts — 加 WordAnalysisItem、videoScene、SentenceEvaluation.wordAnalysis/imageUrl、SSEEvent image 变体src/views/Editor/EnglishSpeaking/data/topicDiscussionVideos.json — 新文件,8 条占位src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue — 渲染 VideoSceneGrid + 传 selectedVideosrc/views/Editor/EnglishSpeaking/preview/VideoSceneGrid.vue — 新组件src/views/Editor/EnglishSpeaking/preview/VideoPlayerModal.vue — 新组件src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue — AI 消息分支渲染图片src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue — AI 图片渲染 + 插入 WordPronunciationDisplaysrc/views/Editor/EnglishSpeaking/preview/WordPronunciationDisplay.vue — 新组件src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts — 处理 image 事件src/views/Editor/EnglishSpeaking/services/llmService.ts — 类型扩展、adaptReport 透传 wordAnalysis/imageUrl、createSession 传 selectedVideoapp/models/dialogue.py — DialogueMessage.image_url 新字段init.sql — 同步加 image_url 列app/api/dialogue.py — CreateSessionRequest 加 selectedVideo / mode,/speak-stream 加 image gen 分支与 image 事件app/service/speaking/dialogue_service.py —
create_session_only 接收 mode/selected_video,total_rounds=1 强制,system_prompt 加 Video Scene Context 段_evaluate_pronunciation 持久化 azure raw + INFO 日志_build_overall_history 加 wordAnalysisget_report 透传 wordAnalysis 与 imageUrlapp/service/speaking/overall_report_evaluator.py — SYSTEM_PROMPT 修订app/service/speaking/prompts.py(或现有 prompts 常量位置)— VIDEO_SCENE_IMAGE_PROMPT_TEMPLATE、Video Scene Context 模板段app/providers/image_gen.py — 新文件,ImageGenProvider + DoubaoImageGenapp/providers/protocols.py — 加 ImageGenProviderapp/config.py — 新 setting IMAGE_GEN_PLACEHOLDER_URL(占位回退 URL)WordPronunciationDisplay,展示带正确(绿)/错误(红)/漏读(灰)下划线的单词。although、banana),证明 LLM 看到了 word_analysis。[pronunciation:azure-result] 行,内含 word_analysis 完整 JSON。dialogue_message.image_url 列存在,影片场景模式下的 AI 消息行 image_url 非空。