# 单轮内容评语 MVP 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:** 在现有每轮 Azure 发音四分之外,补一条 LLM 内容评语链路,产出 `{highlights, corrections, suggestions}` 挂在每轮评估上,只在结果页展示。 **Architecture:** 每轮 `/speak` 后台任务里,Azure PA 完成后串联一次 OpenAI JSON-mode 调用;失败降级到 `content_feedback=null` 不影响发音分;`/report` 返回时带 `contentFeedback` 字段。 **Tech Stack:** Python 3.13 · FastAPI · SQLAlchemy 2.x async · MySQL · OpenAI SDK (via onehub base_url) · pytest · uv · Vue 3 · TypeScript **Repos:** - Backend: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api` - Frontend: `/Users/buoy/Development/gitrepo/PPT` **Spec:** `/Users/buoy/Development/gitrepo/PPT/doc/ContentEvaluationDesign.md` --- ## File Structure ### Backend (cococlass-english-speaking-api) **Create:** - `app/service/speaking/content_evaluator.py` — 单一职责:把 (4 发音分 + AI 上一句 + 学生转录) 丢给 LLM 出 JSON 评语 - `tests/conftest.py` — pytest 异步 + mock 夹具 - `tests/service/__init__.py` - `tests/service/speaking/__init__.py` - `tests/service/speaking/test_content_evaluator.py` — evaluator 的单元测试 - `tests/service/speaking/test_dialogue_service_content.py` — 串联逻辑的单元测试 - `migrations/001_add_content_feedback.sql` — 对已有 DB 的增量 SQL **Modify:** - `init.sql` — 对新 DB 的建表语句同步加列 - `app/models/dialogue.py` — `PronunciationEvaluation` 增加 `content_feedback` 列 - `app/service/speaking/dialogue_service.py` — `_evaluate_pronunciation` 成功分支后追加 content 评估;`get_report` 返回 `contentFeedback` ### Frontend (PPT) **Modify:** - `src/views/Editor/EnglishSpeaking/services/llmService.ts` — `getReport` 的响应转换(把后端 `rounds[i].evaluation.contentFeedback` 映射到 `sentenceEvaluations[i].feedback`) 不改:`DetailedReport.vue` 已经按 `sentence.feedback.{highlights, corrections, suggestions}` 形状渲染;`englishSpeaking.ts` 的 `SentenceEvaluation.feedback` 类型也已经对齐。 --- ## Task 1: [backend] 加 `content_feedback` 列 **Files:** - Modify: `cococlass-english-speaking-api/init.sql`(新建表语句) - Create: `cococlass-english-speaking-api/migrations/001_add_content_feedback.sql` - Modify: `cococlass-english-speaking-api/app/models/dialogue.py`(SQLAlchemy 模型) - [ ] **Step 1: 更新 `init.sql` 的 `pronunciation_evaluation` 建表语句** 在 `pronunciation_evaluation` 表定义里,`completed_at` 之前插入 `content_feedback` 列: 打开 `cococlass-english-speaking-api/init.sql`,把: ```sql word_analysis JSON NULL, error_message TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME NULL, ``` 改为: ```sql word_analysis JSON NULL, content_feedback JSON NULL, error_message TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME NULL, ``` - [ ] **Step 2: 创建 `migrations/` 目录并写入增量 SQL** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api mkdir -p migrations ``` 创建 `migrations/001_add_content_feedback.sql`,内容: ```sql -- Add content_feedback column to existing pronunciation_evaluation table. -- Apply once against an existing database (new DBs use updated init.sql). ALTER TABLE pronunciation_evaluation ADD COLUMN content_feedback JSON NULL AFTER word_analysis; ``` - [ ] **Step 3: 更新 SQLAlchemy 模型** 打开 `cococlass-english-speaking-api/app/models/dialogue.py`。 定位: ```python word_analysis: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) ``` 改为(在中间插入 `content_feedback`): ```python word_analysis: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) content_feedback: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) ``` - [ ] **Step 4: Commit** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api git add init.sql migrations/001_add_content_feedback.sql app/models/dialogue.py git commit -m "feat(db): 为 pronunciation_evaluation 增加 content_feedback 列" ``` --- ## Task 2: [backend] 搭 pytest 目录骨架 + conftest 本仓库 `tests/` 目前只有空 `__init__.py`。先建立可运行的单测基础。 **Files:** - Create: `cococlass-english-speaking-api/tests/conftest.py` - Create: `cococlass-english-speaking-api/tests/service/__init__.py` - Create: `cococlass-english-speaking-api/tests/service/speaking/__init__.py` - Create: `cococlass-english-speaking-api/tests/service/speaking/test_smoke.py` - [ ] **Step 1: 创建 `tests/conftest.py`** ```python """Pytest global fixtures & asyncio config.""" import pytest @pytest.fixture def anyio_backend() -> str: """Force asyncio backend for anyio tests (not trio).""" return "asyncio" ``` - [ ] **Step 2: 创建空 `__init__.py` 使 pytest 能发现嵌套目录** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api mkdir -p tests/service/speaking touch tests/service/__init__.py tests/service/speaking/__init__.py ``` - [ ] **Step 3: 写冒烟测试确认 pytest 跑得起来** 创建 `tests/service/speaking/test_smoke.py`: ```python def test_pytest_works() -> None: assert 1 + 1 == 2 ``` - [ ] **Step 4: 运行冒烟测试** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api uv run pytest tests/service/speaking/test_smoke.py -v ``` Expected: `1 passed`. 如果 `uv run pytest` 报 "pytest: command not found",先 `uv sync --group dev` 装开发依赖再重跑。 - [ ] **Step 5: Commit** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api git add tests/ git commit -m "chore(test): 搭建 pytest 目录骨架和 conftest" ``` --- ## Task 3: [backend] 写 `content_evaluator` 模块(TDD) **Files:** - Create: `cococlass-english-speaking-api/app/service/speaking/content_evaluator.py` - Modify: `cococlass-english-speaking-api/tests/service/speaking/test_content_evaluator.py`(上一任务 smoke 测试文件所在目录,新建另一个文件) ContentEvaluator 直接实例化 `AsyncOpenAI`(和 `OneHubLLM` 一样用 `settings.ONEHUB_BASE_URL` + `settings.ONEHUB_API_KEY`),因为需要 `response_format` 参数,现有 `LLMProvider.chat()` 接口不暴露它。 - [ ] **Step 1: 写 evaluator 的失败测试(happy path)** 创建 `cococlass-english-speaking-api/tests/service/speaking/test_content_evaluator.py`: ```python """Unit tests for ContentEvaluator.""" import json from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.service.speaking.content_evaluator import ContentEvaluator def _mock_openai_response(content: str) -> MagicMock: """Construct a fake AsyncOpenAI chat completion response.""" choice = MagicMock() choice.message.content = content resp = MagicMock() resp.choices = [choice] return resp @pytest.mark.asyncio async def test_evaluate_happy_path() -> None: fake_json = json.dumps( { "highlights": ["发音清晰", "句子完整"], "corrections": [ { "original": "I go to park yesterday", "corrected": "I went to the park yesterday", "explanation": "过去式应用 went,park 前加 the", } ], "suggestions": ["可增加连接词"], } ) with patch( "app.service.speaking.content_evaluator.AsyncOpenAI" ) as MockClient: instance = MockClient.return_value instance.chat.completions.create = AsyncMock( return_value=_mock_openai_response(fake_json) ) evaluator = ContentEvaluator() result = await evaluator.evaluate( transcript="I go to park yesterday", prior_ai_turn="What did you do last weekend?", pron_scores={"accuracy": 72, "fluency": 85, "completeness": 90, "prosody": 60}, ) assert result is not None assert result["highlights"] == ["发音清晰", "句子完整"] assert len(result["corrections"]) == 1 assert result["corrections"][0]["corrected"] == "I went to the park yesterday" assert result["suggestions"] == ["可增加连接词"] ``` - [ ] **Step 2: 运行,确认 fail(模块还不存在)** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api uv run pytest tests/service/speaking/test_content_evaluator.py -v ``` Expected: `ModuleNotFoundError: No module named 'app.service.speaking.content_evaluator'` 或类似 import 错误。 - [ ] **Step 3: 实现最小 evaluator 让 happy path 通过** 创建 `cococlass-english-speaking-api/app/service/speaking/content_evaluator.py`: ```python """Per-turn content evaluation via LLM (JSON mode).""" import asyncio import json from openai import AsyncOpenAI from app.config import settings from app.logging import get_logger logger = get_logger(__name__) SYSTEM_PROMPT = """You are an English tutor evaluating a student's single spoken turn in an open dialogue. You receive: - Azure pronunciation scores (accuracy/fluency/completeness/prosody, 0-100) - The immediate prior AI turn (context) - The student's transcript Return JSON with exactly these keys: - highlights: 1-2 Chinese sentences praising specific strengths. Reference a pronunciation dimension if that score is >= 85. <= 30 chars each. - corrections: array of grammar/word-choice fixes. Each item has keys: original (EN), corrected (EN), explanation (ZH, <= 30 chars). - suggestions: 1-2 Chinese actionable improvements. Reference a pronunciation dimension if that score is < 70. <= 30 chars each. Rules: - Empty arrays are valid. Do not invent errors to fill quota. - If the student only said a filler ("yes", "ok", "hmm"), return empty corrections and suggestions plus one encouragement in highlights. - Never include raw score numbers in output text; describe qualitatively ("发音准确度很高" not "accuracy 92"). - Output MUST be a single JSON object with keys highlights, corrections, suggestions. """ class ContentEvaluator: """Generates per-turn content feedback via LLM in JSON mode.""" def __init__(self, timeout_seconds: float = 10.0): self.client = AsyncOpenAI( base_url=settings.ONEHUB_BASE_URL, api_key=settings.ONEHUB_API_KEY, ) self.model = settings.ONEHUB_MODEL self.timeout_seconds = timeout_seconds async def evaluate( self, transcript: str, prior_ai_turn: str, pron_scores: dict, ) -> dict | None: """Return {highlights, corrections, suggestions} or None on failure.""" user_payload = json.dumps( { "pronunciation": pron_scores, "ai_said": prior_ai_turn, "student_said": transcript, }, ensure_ascii=False, ) try: resp = await asyncio.wait_for( self.client.chat.completions.create( model=self.model, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_payload}, ], response_format={"type": "json_object"}, temperature=0, ), timeout=self.timeout_seconds, ) except asyncio.TimeoutError: logger.warning("ContentEvaluator LLM timeout") return None except Exception as e: logger.error(f"ContentEvaluator LLM error: {e}") return None raw = resp.choices[0].message.content or "" try: parsed = json.loads(raw) except json.JSONDecodeError: logger.warning(f"ContentEvaluator got non-JSON: {raw[:200]}") return None if not self._has_required_shape(parsed): logger.warning(f"ContentEvaluator got invalid shape: {parsed}") return None return { "highlights": parsed.get("highlights", []), "corrections": parsed.get("corrections", []), "suggestions": parsed.get("suggestions", []), } @staticmethod def _has_required_shape(obj: object) -> bool: if not isinstance(obj, dict): return False for key in ("highlights", "corrections", "suggestions"): if key not in obj or not isinstance(obj[key], list): return False return True ``` - [ ] **Step 4: 运行 happy path 测试,确认 pass** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api uv run pytest tests/service/speaking/test_content_evaluator.py::test_evaluate_happy_path -v ``` Expected: `1 passed`. 如果报错 `pytest-asyncio plugin not installed`,在 `pyproject.toml` 的 `[dependency-groups].dev` 里追加 `"pytest-asyncio>=0.26.0"`,并在 `tests/conftest.py` 顶部加: ```python import pytest pytest_plugins = ["pytest_asyncio"] ``` 再 `uv sync --group dev` 重跑。 - [ ] **Step 5: 加失败分支测试 — JSON 解析失败** 在 `test_content_evaluator.py` 追加: ```python @pytest.mark.asyncio async def test_evaluate_returns_none_on_invalid_json() -> None: with patch( "app.service.speaking.content_evaluator.AsyncOpenAI" ) as MockClient: instance = MockClient.return_value instance.chat.completions.create = AsyncMock( return_value=_mock_openai_response("not a json") ) evaluator = ContentEvaluator() result = await evaluator.evaluate( transcript="Hi", prior_ai_turn="Hello", pron_scores={"accuracy": 80, "fluency": 80, "completeness": 80, "prosody": 80}, ) assert result is None ``` - [ ] **Step 6: 加失败分支测试 — 超时** 追加: ```python @pytest.mark.asyncio async def test_evaluate_returns_none_on_timeout() -> None: async def never_returns(**kwargs): await asyncio.sleep(5) with patch( "app.service.speaking.content_evaluator.AsyncOpenAI" ) as MockClient: instance = MockClient.return_value instance.chat.completions.create = never_returns evaluator = ContentEvaluator(timeout_seconds=0.05) result = await evaluator.evaluate( transcript="Hi", prior_ai_turn="Hello", pron_scores={"accuracy": 80, "fluency": 80, "completeness": 80, "prosody": 80}, ) assert result is None ``` 同时在文件顶部 import 里加 `import asyncio`(如果还没有)。 - [ ] **Step 7: 加失败分支测试 — 非法 shape** 追加: ```python @pytest.mark.asyncio async def test_evaluate_returns_none_on_wrong_shape() -> None: # LLM 返回 JSON 但少字段 bad = json.dumps({"highlights": ["ok"]}) with patch( "app.service.speaking.content_evaluator.AsyncOpenAI" ) as MockClient: instance = MockClient.return_value instance.chat.completions.create = AsyncMock( return_value=_mock_openai_response(bad) ) evaluator = ContentEvaluator() result = await evaluator.evaluate( transcript="Hi", prior_ai_turn="Hello", pron_scores={"accuracy": 80, "fluency": 80, "completeness": 80, "prosody": 80}, ) assert result is None ``` - [ ] **Step 8: 运行全部 evaluator 测试** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api uv run pytest tests/service/speaking/test_content_evaluator.py -v ``` Expected: `4 passed`. - [ ] **Step 9: Commit** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api git add app/service/speaking/content_evaluator.py tests/service/speaking/test_content_evaluator.py # 如果改了 pyproject.toml / conftest.py git add pyproject.toml tests/conftest.py uv.lock 2>/dev/null || true git commit -m "feat(speaking): 新增 content_evaluator(LLM JSON 模式生成单轮评语)" ``` --- ## Task 4: [backend] 把 ContentEvaluator 串进 `_evaluate_pronunciation`(TDD) 这是核心集成点:Azure 成功后追加一次 content 评估;Azure 失败则不调用;content 失败不影响 status。 **Files:** - Modify: `cococlass-english-speaking-api/app/service/speaking/dialogue_service.py` - Create: `cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_content.py` 注意:原 `_evaluate_pronunciation` 通过 `self.assessor` 依赖注入。为了让 content evaluator 可被测替换,下面把它也作为依赖挂到 `DialogueService` 上。 - [ ] **Step 1: 写测试 — Azure 成功 + content 成功 → 两个字段都写入** 创建 `cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_content.py`: ```python """Integration-ish tests for content evaluation wired into DialogueService._evaluate_pronunciation.""" from unittest.mock import AsyncMock, MagicMock import pytest from app.service.speaking.dialogue_service import DialogueService class _StubDB: """Minimal stand-in for AsyncSession that supports get() + commit().""" def __init__(self, evaluation): self._evaluation = evaluation self.commit = AsyncMock() async def __aenter__(self): return self async def __aexit__(self, *args): return False async def get(self, _cls, _id): return self._evaluation def _fake_evaluation() -> MagicMock: ev = MagicMock() ev.status = "pending" ev.accuracy_score = None ev.fluency_score = None ev.completeness_score = None ev.prosody_score = None ev.word_analysis = None ev.content_feedback = None ev.completed_at = None ev.error_message = None return ev def _build_service(assessor, evaluator) -> DialogueService: return DialogueService( asr=MagicMock(), llm=MagicMock(), assessor=assessor, storage=MagicMock(), content_evaluator=evaluator, ) @pytest.mark.asyncio async def test_azure_success_then_content_success_writes_both(monkeypatch) -> None: ev = _fake_evaluation() stub_db = _StubDB(ev) monkeypatch.setattr( "app.service.speaking.dialogue_service.async_session", lambda: stub_db ) assessor = MagicMock() assessor.assess = AsyncMock( return_value={ "accuracy_score": 80, "fluency_score": 85, "completeness_score": 90, "prosody_score": 75, "word_analysis": [], } ) evaluator = MagicMock() evaluator.evaluate = AsyncMock( return_value={ "highlights": ["nice"], "corrections": [], "suggestions": [], } ) service = _build_service(assessor, evaluator) await service._evaluate_pronunciation( evaluation_id=1, audio_bytes=b"", reference_text="hi", prior_ai_turn="hello", ) assert ev.status == "completed" assert ev.accuracy_score == 80 assert ev.content_feedback == {"highlights": ["nice"], "corrections": [], "suggestions": []} evaluator.evaluate.assert_awaited_once() ``` - [ ] **Step 2: 写测试 — Azure 成功 + content 失败 → content_feedback None,status 仍 completed** 追加: ```python @pytest.mark.asyncio async def test_azure_success_content_failure_keeps_status_completed(monkeypatch) -> None: ev = _fake_evaluation() stub_db = _StubDB(ev) monkeypatch.setattr( "app.service.speaking.dialogue_service.async_session", lambda: stub_db ) assessor = MagicMock() assessor.assess = AsyncMock( return_value={ "accuracy_score": 80, "fluency_score": 85, "completeness_score": 90, "prosody_score": 75, "word_analysis": [], } ) evaluator = MagicMock() evaluator.evaluate = AsyncMock(return_value=None) # LLM failed service = _build_service(assessor, evaluator) await service._evaluate_pronunciation( evaluation_id=1, audio_bytes=b"", reference_text="hi", prior_ai_turn="hello", ) assert ev.status == "completed" assert ev.accuracy_score == 80 assert ev.content_feedback is None ``` - [ ] **Step 3: 写测试 — Azure 失败 → ContentEvaluator 不被调用** 追加: ```python @pytest.mark.asyncio async def test_azure_failure_skips_content_evaluator(monkeypatch) -> None: ev = _fake_evaluation() stub_db = _StubDB(ev) monkeypatch.setattr( "app.service.speaking.dialogue_service.async_session", lambda: stub_db ) assessor = MagicMock() assessor.assess = AsyncMock(side_effect=RuntimeError("azure exploded")) evaluator = MagicMock() evaluator.evaluate = AsyncMock() service = _build_service(assessor, evaluator) await service._evaluate_pronunciation( evaluation_id=1, audio_bytes=b"", reference_text="hi", prior_ai_turn="hello", ) assert ev.status == "failed" assert ev.content_feedback is None evaluator.evaluate.assert_not_awaited() ``` - [ ] **Step 4: 运行测试,确认 fail** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api uv run pytest tests/service/speaking/test_dialogue_service_content.py -v ``` Expected: 3 tests fail(`DialogueService.__init__` 还没有 `content_evaluator` 参数,`_evaluate_pronunciation` 也没有 `prior_ai_turn` 参数)。 - [ ] **Step 5: 修改 `DialogueService.__init__` 接受 content_evaluator** 打开 `cococlass-english-speaking-api/app/service/speaking/dialogue_service.py`。 在文件顶部 import 追加: ```python from app.service.speaking.content_evaluator import ContentEvaluator ``` 定位 `__init__`: ```python def __init__( self, asr: ASRProvider, llm: LLMProvider, assessor: PronunciationAssessor, storage: AudioStorage, ): self.asr = asr self.llm = llm self.assessor = assessor self.storage = storage ``` 改为: ```python def __init__( self, asr: ASRProvider, llm: LLMProvider, assessor: PronunciationAssessor, storage: AudioStorage, content_evaluator: ContentEvaluator | None = None, ): self.asr = asr self.llm = llm self.assessor = assessor self.storage = storage self.content_evaluator = content_evaluator or ContentEvaluator() ``` - [ ] **Step 6: 改 `_evaluate_pronunciation` 签名和逻辑** 定位现有实现(`dialogue_service.py:321` 左右): ```python async def _evaluate_pronunciation( self, evaluation_id: int, audio_bytes: bytes, reference_text: str, content_type: str = "audio/webm;codecs=opus", ): """后台静默发音评估""" from app.models.database import async_session async with async_session() as db: evaluation = await db.get(PronunciationEvaluation, evaluation_id) if not evaluation: logger.error(f"Evaluation record not found: id={evaluation_id}") return try: result = await self.assessor.assess(audio_bytes, reference_text, content_type) logger.info(f"Pronunciation assessment done: eval={evaluation_id}, accuracy={result['accuracy_score']}") evaluation.status = "completed" evaluation.accuracy_score = result["accuracy_score"] evaluation.fluency_score = result["fluency_score"] evaluation.completeness_score = result["completeness_score"] evaluation.prosody_score = result["prosody_score"] evaluation.word_analysis = result.get("word_analysis") evaluation.completed_at = datetime.now() except Exception as e: logger.error(f"Pronunciation assessment failed: eval={evaluation_id}, error={e}") evaluation.status = "failed" evaluation.error_message = str(e) await db.commit() ``` 改为: ```python async def _evaluate_pronunciation( self, evaluation_id: int, audio_bytes: bytes, reference_text: str, prior_ai_turn: str = "", content_type: str = "audio/webm;codecs=opus", ): """后台静默发音评估 + 内容评语""" from app.models.database import async_session async with async_session() as db: evaluation = await db.get(PronunciationEvaluation, evaluation_id) if not evaluation: logger.error(f"Evaluation record not found: id={evaluation_id}") return try: result = await self.assessor.assess(audio_bytes, reference_text, content_type) logger.info(f"Pronunciation assessment done: eval={evaluation_id}, accuracy={result['accuracy_score']}") evaluation.status = "completed" evaluation.accuracy_score = result["accuracy_score"] evaluation.fluency_score = result["fluency_score"] evaluation.completeness_score = result["completeness_score"] evaluation.prosody_score = result["prosody_score"] evaluation.word_analysis = result.get("word_analysis") evaluation.completed_at = datetime.now() # Content evaluation: 仅在 Azure 成功时触发;失败不影响 status。 try: content_feedback = await self.content_evaluator.evaluate( transcript=reference_text, prior_ai_turn=prior_ai_turn, pron_scores={ "accuracy": result["accuracy_score"], "fluency": result["fluency_score"], "completeness": result["completeness_score"], "prosody": result["prosody_score"], }, ) evaluation.content_feedback = content_feedback logger.info( f"Content evaluation done: eval={evaluation_id}, " f"has_feedback={content_feedback is not None}" ) except Exception as e: logger.error(f"Content evaluation error (soft-fail): eval={evaluation_id}, error={e}") evaluation.content_feedback = None except Exception as e: logger.error(f"Pronunciation assessment failed: eval={evaluation_id}, error={e}") evaluation.status = "failed" evaluation.error_message = str(e) await db.commit() ``` - [ ] **Step 7: 更新 `speak()` 里的 `asyncio.create_task` 传入 `prior_ai_turn`** 定位 `speak()` 方法内现有的 `create_task` 调用(`dialogue_service.py:189` 左右): ```python asyncio.create_task( self._evaluate_pronunciation( evaluation_id=evaluation.id, audio_bytes=audio_bytes, reference_text=transcript, content_type=content_type, ) ) ``` 在 create_task 之前,计算 `prior_ai_turn`。新增变量(放在 "⑩ 后台发音评估" 之前): ```python # 找到本轮 student 消息之前最近的一条 AI 消息作为 content 评估的上下文 prior_ai_turn = "" for msg in reversed(history): if msg.role == "ai": prior_ai_turn = msg.content break ``` 然后把 create_task 改为: ```python asyncio.create_task( self._evaluate_pronunciation( evaluation_id=evaluation.id, audio_bytes=audio_bytes, reference_text=transcript, prior_ai_turn=prior_ai_turn, content_type=content_type, ) ) ``` - [ ] **Step 8: 运行测试,确认全部通过** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api uv run pytest tests/service/speaking/test_dialogue_service_content.py -v ``` Expected: `3 passed`. 如果报 `ImportError: cannot import name 'ContentEvaluator' from partial init`(循环引用),把 `ContentEvaluator` 的 import 放到 `dialogue_service.py` 的 `__init__` 方法内的首行(延迟导入)而不是文件顶部。 - [ ] **Step 9: Commit** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api git add app/service/speaking/dialogue_service.py tests/service/speaking/test_dialogue_service_content.py git commit -m "feat(speaking): 在 _evaluate_pronunciation 串联 content_evaluator" ``` --- ## Task 5: [backend] `/report` 返回 `contentFeedback` **Files:** - Modify: `cococlass-english-speaking-api/app/service/speaking/dialogue_service.py`(`get_report` 方法) - Create: `cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_report.py` - [ ] **Step 1: 写测试 — evaluation 带 content_feedback 时,report entry 也带** 创建 `cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_report.py`: ```python """Tests for get_report including content_feedback pass-through.""" from unittest.mock import MagicMock import pytest def _stub_message(role: str, content: str, round_: int, evaluation=None): msg = MagicMock() msg.role = role msg.content = content msg.round = round_ msg.audio_url = None msg.evaluation = evaluation return msg def _stub_evaluation(content_feedback=None, status="completed"): ev = MagicMock() ev.status = status ev.accuracy_score = 80 ev.fluency_score = 80 ev.completeness_score = 80 ev.prosody_score = 80 ev.word_analysis = None ev.content_feedback = content_feedback return ev def _build_report_entry(msg) -> dict: """Replicates the entry construction in DialogueService.get_report. We only exercise the dict-shaping step in isolation — the full get_report path hits DB/LLM summary and is not needed for this contract check. """ entry = { "round": msg.round, "role": msg.role, "content": msg.content, "audioUrl": msg.audio_url, } if msg.role == "student" and msg.evaluation: ev = msg.evaluation entry["evaluation"] = { "status": ev.status, "accuracyScore": ev.accuracy_score, "fluencyScore": ev.fluency_score, "completenessScore": ev.completeness_score, "prosodyScore": ev.prosody_score, "wordAnalysis": ev.word_analysis, "contentFeedback": ev.content_feedback, } return entry def test_report_entry_includes_content_feedback_when_present() -> None: feedback = {"highlights": ["good"], "corrections": [], "suggestions": []} ev = _stub_evaluation(content_feedback=feedback) msg = _stub_message("student", "hi", 1, evaluation=ev) entry = _build_report_entry(msg) assert entry["evaluation"]["contentFeedback"] == feedback def test_report_entry_content_feedback_is_null_when_absent() -> None: ev = _stub_evaluation(content_feedback=None) msg = _stub_message("student", "hi", 1, evaluation=ev) entry = _build_report_entry(msg) assert entry["evaluation"]["contentFeedback"] is None def test_ai_message_has_no_evaluation_key() -> None: msg = _stub_message("ai", "hello", 1, evaluation=None) entry = _build_report_entry(msg) assert "evaluation" not in entry ``` 这里我们测试的是 entry-shaping 的契约(独立 helper)。真实 `get_report` 里我们要修改同样的 entry 构造块保持一致。 - [ ] **Step 2: 运行测试,应该全过(独立 helper 不依赖还没修改的代码)** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api uv run pytest tests/service/speaking/test_dialogue_service_report.py -v ``` Expected: `3 passed`. 这一步验证的是契约,下一步把它应用到真实代码。 - [ ] **Step 3: 修改 `get_report` 的 entry 构造块** 打开 `cococlass-english-speaking-api/app/service/speaking/dialogue_service.py`,定位: ```python if msg.role == "student" and msg.evaluation: ev = msg.evaluation entry["evaluation"] = { "status": ev.status, "accuracyScore": ev.accuracy_score, "fluencyScore": ev.fluency_score, "completenessScore": ev.completeness_score, "prosodyScore": ev.prosody_score, "wordAnalysis": ev.word_analysis, } ``` 改为: ```python if msg.role == "student" and msg.evaluation: ev = msg.evaluation entry["evaluation"] = { "status": ev.status, "accuracyScore": ev.accuracy_score, "fluencyScore": ev.fluency_score, "completenessScore": ev.completeness_score, "prosodyScore": ev.prosody_score, "wordAnalysis": ev.word_analysis, "contentFeedback": ev.content_feedback, } ``` - [ ] **Step 4: 重跑 report 相关测试** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api uv run pytest tests/service/speaking/ -v ``` Expected: 全部 pass(smoke 1 + evaluator 4 + content 3 + report 3 = 11 passed)。 - [ ] **Step 5: Commit** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api git add app/service/speaking/dialogue_service.py tests/service/speaking/test_dialogue_service_report.py git commit -m "feat(speaking): /report 返回每轮 contentFeedback" ``` --- ## Task 6: [frontend] 把 `contentFeedback` 透传到 `sentence.feedback` `DetailedReport.vue` 已经按 `sentence.feedback.{highlights, corrections, suggestions}` 渲染(`PPT/src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue:94-116`),所以前端只需要在 `getReport` 响应转 `OverallEvaluation` 的地方加一个 field pass-through。 **Files:** - Modify: `PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts` - [ ] **Step 1: 定位后端→前端形状转换位置** 运行: ```bash grep -n "rounds\|sentenceEvaluations\|evaluation" /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts ``` 后端 `/report` 返回 `{ sessionId, topic, status, rounds[], summary }`,前端 `DialogueReport` 期望 `{ evaluation: OverallEvaluation }`(`sentenceEvaluations[]` 里每项的 `feedback` 字段)。当前 `RealDialogueAPI.getReport()`(`llmService.ts:86-92`)直接 `return res.json()`,不做形状转换。 这意味着:**当前前端要么通过其他层做 shape adaption,要么 `DetailedReport.vue` 从别处拿数据**。先跑一次 grep 找适配位置: ```bash grep -rn "sentenceEvaluations\|rounds" /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking --include="*.ts" --include="*.vue" | head -30 ``` - [ ] **Step 2: 根据 Step 1 结果选一条支路** **支路 A(理想情况):如果已经有一个 `mapReportToEvaluation(backendRes)` 之类的函数** - 在那个函数里给每个 sentence 加 `feedback: round.evaluation?.contentFeedback ?? undefined` - 继续 Step 3 **支路 B(没有转换层):如果 `getReport` 的返回值直接裸传给组件** - 在 `RealDialogueAPI.getReport()` 里把 `rounds[]` 转成 `OverallEvaluation.sentenceEvaluations[]`,其中每个 `student` 角色的轮次 emit 一个 `SentenceEvaluation` 带 `feedback: r.evaluation?.contentFeedback ?? undefined` - 继续 Step 3 **支路 C(Mock API 已经长成 `{ evaluation: OverallEvaluation }` 但真实后端没适配):** 这是当前最可能的状态。此时必须在 `RealDialogueAPI.getReport()` 里写一个显式 adapter。按支路 B 实现。 - [ ] **Step 3: 在 `RealDialogueAPI.getReport` 里加 adapter(假设走支路 B/C)** 打开 `PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts`。 把: ```typescript async getReport(sessionId: string): Promise { const res = await fetch(`${API_BASE}/report?sessionId=${encodeURIComponent(sessionId)}`, { credentials: 'include', }) if (!res.ok) throw new Error(`getReport failed: ${res.status}`) return res.json() } ``` 改为: ```typescript async getReport(sessionId: string): Promise { const res = await fetch(`${API_BASE}/report?sessionId=${encodeURIComponent(sessionId)}`, { credentials: 'include', }) if (!res.ok) throw new Error(`getReport failed: ${res.status}`) const raw = await res.json() as BackendReportResponse return adaptReport(raw) } ``` 在 `RealDialogueAPI` 类定义**之前**加: ```typescript interface BackendEvaluation { status: 'pending' | 'completed' | 'failed' accuracyScore: number | null fluencyScore: number | null completenessScore: number | null prosodyScore: number | null wordAnalysis: unknown contentFeedback: { highlights: string[] corrections: { original: string; corrected: string; explanation: string }[] suggestions: string[] } | null } interface BackendRound { round: number role: 'ai' | 'student' content: string audioUrl: string | null evaluation?: BackendEvaluation } interface BackendReportResponse { sessionId: string topic: string status: 'evaluating' | 'ready' rounds: BackendRound[] summary: string | null } function adaptReport(raw: BackendReportResponse): DialogueReport { const sentenceEvaluations: SentenceEvaluation[] = raw.rounds.map((r, idx) => ({ id: `${raw.sessionId}-${idx}`, round: r.round, role: r.role, content: r.content, audioUrl: r.audioUrl ?? undefined, pronunciation: r.evaluation && r.role === 'student' ? { accuracy: r.evaluation.accuracyScore ?? 0, fluency: r.evaluation.fluencyScore ?? 0, // enspeak 原型用 intonation/stress 做 UI label;把 Azure 的 prosody/completeness 分别 // 映射到这两格(prosody → intonation 表示语调、completeness → stress 表示完整读出)。 // 这是一个 UI 贴合性决定,如未来 UI 统一改用 Azure 四维,再把 key 改回来。 intonation: r.evaluation.prosodyScore ?? 0, stress: r.evaluation.completenessScore ?? 0, } : undefined, feedback: r.evaluation?.contentFeedback ?? undefined, })) // overallScore 先用平均分作为 MVP 占位;其他字段留空/安全默认。 const studentEvals = sentenceEvaluations.filter(s => s.role === 'student' && s.pronunciation) const avg = studentEvals.length > 0 ? Math.round( studentEvals.reduce( (sum, s) => sum + (s.pronunciation!.accuracy + s.pronunciation!.fluency + s.pronunciation!.intonation + s.pronunciation!.stress) / 4, 0, ) / studentEvals.length, ) : 0 return { evaluation: { overallScore: avg, scoreLevel: avg >= 85 ? 'excellent' : avg >= 70 ? 'good' : avg >= 60 ? 'fair' : 'needsWork', percentile: 0, dimensions: { fluency: 0, interaction: 0, vocabulary: 0, grammar: 0 }, aiComment: raw.summary ?? '', highlights: [], improvements: [], nextChallenge: {}, statistics: { totalRounds: Math.max(...sentenceEvaluations.map(s => s.round), 0), averageScore: avg, highestScore: 0, highestRound: 0, grammarErrors: 0, excellentExpressions: 0, totalDuration: 0, }, sentenceEvaluations, }, } } ``` 然后在顶部 import 里追加 `SentenceEvaluation`: ```typescript import type { DialogueAPI, DialogueReport, SessionConfig, SessionInfo, SSEEvent, SentenceEvaluation, } from '@/types/englishSpeaking' ``` (如果 `SentenceEvaluation` 未从 `englishSpeaking.ts` 导出,先去那个文件确认 `export interface SentenceEvaluation` 已加 `export` 关键字。) **注意**:如果 Step 1 的 grep 显示已经有现成的 adapter 函数,**以现有适配层为准**——只在那里追加 `feedback` 字段、不新建 adapter。跳过这里的 `adaptReport` 整段代码,改为找到现有函数加一行 pass-through。 - [ ] **Step 4: 类型检查** ```bash cd /Users/buoy/Development/gitrepo/PPT npm run type-check ``` (如果项目用 `pnpm` / `yarn`,相应调整。若没有 `type-check` script,跑 `npx vue-tsc --noEmit`。) Expected: 无 type error。 - [ ] **Step 5: 手动 smoke 验证** 1. 启动后端: ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api uv run uvicorn app.main:app --reload ``` 2. 启动前端: ```bash cd /Users/buoy/Development/gitrepo/PPT npm run dev ``` 3. 浏览器进入 EnglishSpeaking 组件,完成一轮对话。 4. 打开结果页(DetailedReport),确认每轮学生句子下面能看到"亮点 / 改正 / 建议"三段内容。 5. 同时在后端 DB 查: ```sql SELECT round, status, accuracy_score, content_feedback FROM pronunciation_evaluation WHERE session_id = (SELECT id FROM dialogue_session ORDER BY id DESC LIMIT 1); ``` 确认 `content_feedback` 是 `{highlights, corrections, suggestions}` 结构(或 `NULL` 如果 LLM 失败)。 任意一项不通过,回到对应 Task 定位 bug。 - [ ] **Step 6: Commit** ```bash cd /Users/buoy/Development/gitrepo/PPT git add src/views/Editor/EnglishSpeaking/services/llmService.ts git commit -m "feat(english-speaking): 结果页透传 contentFeedback 到 SentenceCard" ``` --- ## Task 7: 回归校验全部测试和现有流程 - [ ] **Step 1: 跑后端全测试** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api uv run pytest -v ``` Expected: 所有 test 通过(包含本次新增的 smoke 1 + evaluator 4 + content-dispatch 3 + report 3 = 11 个)。 - [ ] **Step 2: 跑前端类型检查** ```bash cd /Users/buoy/Development/gitrepo/PPT npm run type-check ``` Expected: 无 type error。 - [ ] **Step 3: 把两个 repo 的 HEAD 记下来,作为本次实施的完成标记** ```bash echo "backend: $(git -C /Users/buoy/Development/gitrepo/cococlass-english-speaking-api rev-parse --short HEAD)" echo "frontend: $(git -C /Users/buoy/Development/gitrepo/PPT rev-parse --short HEAD)" ``` 把输出贴到本 plan 文件底部的"完成记录"栏。 --- ## 完成记录 - 计划完成日期:2026-04-23 - 后端 HEAD:`7d192be` (branch `feat/content-evaluator`, 基线 `aa5e1a7`,5 个 commit) - `99e64fa` Task 1 DB 列 - `1492ebe` Task 2 pytest 骨架 - `dee45e6` Task 3 content_evaluator 模块 - `a1f1b91` Task 4 接线 _evaluate_pronunciation - `7d192be` Task 5 /report 返回 contentFeedback - 前端 HEAD:`7c4d1a9` (branch `feat/english-speaking`, 基线 `4523862`,1 个 commit) - `7c4d1a9` Task 6 结果页 contentFeedback 透传 - 最终回归:后端 `uv run pytest` 11/11 passed;前端 `vue-tsc --noEmit` exit=0 - 偏差与说明: - Task 2 触发了 plan Step 2a 的条件分支(pytest-asyncio 缺失,按 plan 指令自动追加依赖 + conftest.py 插入 `pytest_plugins`) - Task 4 把 `async_session` 从方法内延迟 import 提升到模块顶层(为了让 `monkeypatch.setattr` 能打到;无循环依赖风险) - Task 6 实际走 plan 的 Branch C(实现完整 adapter),投资成本略高于 Branch A 的一行 pass-through。发现:`TopicDiscussionPreview.vue` 当前展示的是 `mockEvaluation` 硬编码数据,真实 `getReport()` 并未被 UI 消费。adapter 是结构性就位,真正"打开结果页看到 LLM 评语"需要后续把 UI 切换到走 `DialogueAPI.getReport`——该切换不在本 MVP 范围,留给后续任务 - 未做端到端 smoke(真实后端 + 真实浏览器操作),仅静态验证(单测 + 类型检查)。进入真实联调时需先在后端启动 `.env` 里配好 `AZURE_SPEECH_KEY` 和 `ONEHUB_API_KEY` - 评审中提出的几个 non-blocking 改进项(content_evaluator 的 AsyncOpenAI 生命周期、`_StubDB` 断言说明、`pron_scores` TypedDict、adapter 错误容忍)均标记为后续迭代,未纳入本次 MVP --- ## 2026-04-24 补充:UI 接入 + 跨仓全量 Code Review 发现 本轮继续完成了"UI 切换到真实 getReport"(原遗留项),并对后端/前端分别跑了一次完整 code review。对话主流程尚未跑通到结果页,下次回到本 MVP 前先让对话链路能走完 N 轮进入 completed 态,再基于真实数据验证下列修复。 **新增 commit**: - 前端 `d1186cb` — `DialogueChatView` emit `complete` 携带 `DialogueReport | null`;`TopicDiscussionPreview` 用 `displayEvaluation` 优先真实数据、mock 作为 fallback - 前端最新 HEAD:`d1186cb`;后端 HEAD 未变(仍 `7d192be`) - vue-tsc 通过 ### 下次回来必须先修(Critical + Important) - **[BACKEND CRITICAL] `/speak-stream` WebSocket 路径完全绕过 ContentEvaluator** - `app/api/dialogue.py:159-184` 的 `_background_evaluate_pronunciation` 只跑 Azure,从未调用 content evaluator - 前端录音主路径是 WebSocket(`useDialogueEngine.ts:256+ beginStudentStream`),HTTP `/speak` 只是 fallback - 后果:真实用户的 `content_feedback` 永远 NULL - 修复方向:让 `/speak-stream` 统一走 `DialogueService._evaluate_pronunciation`,或把 evaluator 调用复制进去 - **[FRONTEND CRITICAL] `getReport` 轮询不识别 `status === 'evaluating'`** - `useDialogueEngine.ts:190-202` 的 poll 只对 reject 重试;后端 200 返 `status='evaluating'` 且部分 round `contentFeedback=null` 时直接 resolve - 设计文档 §2.6 已预告此情况但实现未处理 - `BackendReportResponse.status` 类型已声明却从未被读取 - 修复方向:把 `status === 'evaluating'` 视为"未完成"继续 poll - **[FRONTEND IMPORTANT] `getReport` 失败悄悄回落到 `mockEvaluation`(I2)** - `fetchReportSafe → null → displayEvaluation → mockEvaluation`(熊猫/竹子那份假数据) - 用户会把虚构报告当作自己的 - 修复方向:真实模式下失败必须显示错误态 UI,不回落 mock - **[FRONTEND IMPORTANT] "结束并查看报告" 阻塞最多 30s(I3)** - `handleExitConfirm` 里 `await fetchReportSafe()` 串在 modal 关闭后,期间 chat view 冻结 - 修复方向:先切到 `completed` stage 显示 loading,getReport 后台拉 - **[FRONTEND IMPORTANT] `pending/failed` 轮次 0 分污染 `overallScore`(I5)** - `llmService.ts:90-97` 用 `?? 0` 把未完成轮次填 0,`.filter(s.pronunciation)` 仍会保留 - 修复方向:adapter 里只有 `status === 'completed'` 且 score 非 null 才填 `pronunciation` - **[FRONTEND IMPORTANT] Axis 映射语义不对(I4)** - `prosody → intonation`、`completeness → stress`:completeness 是"读完整度",stress 是"重音" - 修复方向:MVP 先加注释标记为债,后续扩展 `SentenceEvaluation.pronunciation` 为 Azure 四维 ### 下下轮改进(Non-blocking) - Backend I1:`DialogueService.__init__` 每请求 new `AsyncOpenAI`(`get_dialogue_service` 在 `Depends` 里),改 module-level 单例 - Backend I2:`prior_ai_turn` 依赖"学生消息已 flush 但 AI 未写入"的时序脆弱,加 `role='ai' AND round < current_round` 显式过滤 - Backend I4:`pron_scores: dict` 缺 TypedDict - Backend I5/I6:`test_content_evaluator` 缺 prompt payload 断言;`test_dialogue_service_report` 契约镜像嫌疑 - Backend M1-6:corrections 内部结构未校验、prompt 注入、migration 非幂等、model 无专用配置、f-string 日志 - Frontend M1-5:非空断言可避免、`Math.max(...arr)` 栈风险、`dimensions/statistics/aiComment` 占位零未标 TODO、id 方案不统一、`status` 类型不含 `'evaluating'`