diff --git a/docs/README.md b/docs/README.md index 77fa1c3..5555664 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,7 @@ docs/ | 位置 | 内容 | 维护约定 | |------|------|---------| | `docs/requirements/*.md` | **待办/实施中需求**的问题、目标、方案权衡、checklist、验收与进度 | 接到需求先记录;完成后同步 design/业务/changelog 并更新状态 | -| `docs/业务/*.md` | **当前已实现**行为的分模块文档(错题本与复习、用户反馈、自由录入、历史与分享、主题设置、账户资料) | 每次行为变更必须同步更新 | +| `docs/业务/*.md` | **当前已实现**行为的分模块文档(错题本与复习、用户反馈、自由录入、历史与分享、主题设置、结果页与发音评测、账户资料、渐进式场景练习) | 每次行为变更必须同步更新 | | `docs/design/schema.md` | MongoDB 集合字段(数据模型事实源) | 字段变更必须同步 | | `docs/design/*.md` | 已确定的当前数据结构、架构与交互设计(schema/ids/storage/spec) | 设计变化时同步,不记录实施进度 | | `docs/design/场景练习/*.md` | 场景模式总览 / 公共题分类 / 混合练习 / 题目评测 | 场景链路变化时同步 | diff --git "a/docs/changelog/20260827-083034-feat-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\346\217\220\347\244\272-\345\256\236\347\216\260\344\270\216\350\257\225\347\202\271\345\260\261\347\273\252.md" "b/docs/changelog/20260827-083034-feat-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\346\217\220\347\244\272-\345\256\236\347\216\260\344\270\216\350\257\225\347\202\271\345\260\261\347\273\252.md" new file mode 100644 index 0000000..880bb3c --- /dev/null +++ "b/docs/changelog/20260827-083034-feat-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\346\217\220\347\244\272-\345\256\236\347\216\260\344\270\216\350\257\225\347\202\271\345\260\261\347\273\252.md" @@ -0,0 +1,30 @@ +# feat: 渐进式场景提示——实现与试点就绪(未激活) + +`docs/requirements/20260826-渐进式场景提示.md`(规格 PR #198)的实施。代码与内容资产就绪,生产试点激活(两批 ×5 题)待 Operator 执行(规格 11 节 PA 清单)。 + +## 后端 +- `interactionType`(`standard|progressive_hints`):读取归一化、写入校验(`services/interaction_types.py`);旧题缺失/未知一律 standard,不迁移旧数据。 +- `GET /api/scenarios/by-slug/{slug}`:精确取题,只返回 active 且当前用户可访问;不存在/已归档/非法 slug/无权统一 404,不回退随机题。 +- `/api/scenarios/next` 与 by-slug 统一返回 `difficulty` / `interactionType` / `hints`(standard 缺失 hints 返回空数组)。 +- Session 创建幂等:新增 `requestId`,`(userId, creationRequestId)` 唯一部分索引(只覆盖非空);同 requestId 重复提交返回首次 Session(200),参数冲突 409。写前服务端重校验题目(active + 归属),不信任客户端。 +- Session 快照新增 interactionType/hints/difficulty;Session 持久化 `revealedHintCount` 与 `hintReveals`。 +- `POST /api/practice-sessions/{pid}/hints/next`:原子领取下一条提示,同 requestId 幂等重放,多标签页并发不重复不越界,耗尽不增计数返回 `exhausted`;standard/free 会话 409,非本人会话 404。 +- Attempt 落 `hintCount`(服务端从 Session 复制,客户端传值无效)。评分隔离:hints/hintCount 永不进入纠错/标准答案 prompt(单测证明:仅计数不同的同题同答构造出完全一致的模型请求)。 +- 确定性 grader 按 interactionType 分流,standard 规则与输出不变;试点走 `server/data/progressive_hint_pilot.yaml`(10 题审核稿)+ `scripts/generate_progressive_hint_pilot.py`(dry-run / ≤5 题一批 execute / archive 回滚)。 + +## 前端 +- `/practice?scenario=`:精确选题,真实 Session 在开始动作才创建(同 requestId 幂等),成功后 `replace` 到规范 Session URL;题目不可用时明确错误态,不静默换随机题。 +- 渐进题首次作答前只展示宽泛 mission(不显示 points);提示区只在已创建的渐进式 Session 出现,逐条领取、累计保留、刷新按服务端计数恢复;提示全中文,不显示英文,结果页不标注提示使用。 +- 埋点扩展:`practice_recording_started` / `practice_result` 新增 `practiceId`、`sourceType`、`interactionType`、`kind`、`difficulty`、`attemptId`(result)、`hintCount`,不含提示正文。 +- 500 行门禁:评估 SSE 抽到 `pages/practiceEvaluation.js`,渐进/待选状态抽到 `pages/useProgressiveScenario.js`,不可用态抽到 `components/practice/PracticeUnavailable.jsx`。 + +## 验证证据(本机实测) +- server:`uv run pytest tests/ -q` 383 passed(新增 35 条单元/集成);`uv run ruff check .`、`scripts/check_code_quality.py` 通过。 +- web:`pnpm test` 323 passed;`pnpm run lint` 0 errors(28 既有 warnings);`pnpm run build` 通过;`pnpm run test:e2e` 55 passed(chromium/firefox/webkit/iphone/pixel)。 +- 试点内容:`uv run python scripts/generate_progressive_hint_pilot.py --dry-run` 10/10 硬规则通过;逐题审核表由该命令输出,投放前人工复核。 +- 旧行为回归:既有后端/前端测试全部通过;standard 题接口输出仅新增归一化字段,旧字段原样。 + +## 后续(Operator) +- PA-01/02:第一批 5 题 `--execute`(先 dry-run 审核)→ 生产按 slug 走全流程 → 第二批 5 题。 +- PA-03:决定是否扩量;`--archive` 一键回滚(题目下线,历史会话保留)。 +- PA-04/05:两周试点观察(Umami 漏斗 + practiceAttempts.hintCount 分层)后决定保留/调优/下线。 diff --git a/docs/changelog/README.md b/docs/changelog/README.md index bc2a39b..fba745c 100644 --- a/docs/changelog/README.md +++ b/docs/changelog/README.md @@ -6,6 +6,7 @@ | 时间 | 文件 | 摘要 | |---|---|---| +| 2026-08-27 08:30:34 | [20260827-083034-feat-渐进式场景提示-实现与试点就绪.md](20260827-083034-feat-渐进式场景提示-实现与试点就绪.md) | 渐进式场景提示代码与试点内容就绪:slug 精确入口、按需提示、幂等创建、评分隔离、10 题审核稿;生产激活待分批投放。 | | 2026-08-26 15:27:01 | [20260826-152701-chore-渐进式场景提示实施规格.md](20260826-152701-chore-渐进式场景提示实施规格.md) | 新增 agent-ready 渐进提示规格,分开代码交付、生产激活与产品试点验收。 | | 2026-08-24 22:18:31 | [20260824-221831-feat-独立Attempt与反馈图片.md](20260824-221831-feat-独立Attempt与反馈图片.md) | 独立 Attempt、反馈多图原件和隔离的标准答案重点讲解。 | | 2026-08-24 22:18:31 | [20260824-221831-fix-结果页交互与发音模块撤下.md](20260824-221831-fix-结果页交互与发音模块撤下.md) | 稳定结果页、移动端笔记、精确分享,并撤下错误发音模块。 | diff --git a/docs/design/schema.md b/docs/design/schema.md index 9ecada3..c285de8 100644 --- a/docs/design/schema.md +++ b/docs/design/schema.md @@ -46,7 +46,7 @@ ```json { "_id": "sc_1781276...", - "slug": "coffee-wrong-order", // 幂等键;定制题为 custom-{userId}-{ts} + "slug": "coffee-wrong-order", // 幂等键与指定题目入口的稳定标识(小写 kebab-case);定制题为 custom-{userId}-{ts};渐进式试点题为稳定语义名 "kind": "task", // task办事 / chat日常问答 / describe描述 / opinion观点 / explain讲解(对齐雅思P1/2/3+实用) "title": "咖啡店给错咖啡", // 中文短标题,历史列表用 "where": "☕️ 咖啡店 · 西雅图", @@ -63,6 +63,8 @@ "sourceType": "human | ai_test", // 仅定制题写入,从 owner 用户冗余;公共题可缺省 "category": { "domain": "travel", "subId": "travel.airport_checkin" }, // 公共题:从 server/data/scenario_taxonomy.yaml 落 (domainShort, subId);定制题不写 "targetWords": ["could you take a look"], // 定制题:必须逼用户用上的弱点表达 + "interactionType": "standard | progressive_hints", // 可缺省;读取侧缺失/未知归一为 standard,写入侧校验 + "hints": ["我点的是热拿铁,但这杯是冰的。"], // 渐进式题 2-3 条有序中文提示;永不进纠错/标准答案请求;standard 缺省 "status": "active | archived", "createdAt": datetime } @@ -81,7 +83,10 @@ "kind": "task", "title": "咖啡店给错咖啡", // 历史列表标题 "topic": "☕️ 咖啡店 · 西雅图", // = scenario.where - "scenario": { "kind": "...", "title": "...", "where": "...", "story": "...", "mission": "...", "targetWords": [] }, // 快照,题目改动不影响历史 + "scenario": { "kind": "...", "title": "...", "where": "...", "story": "...", "mission": "...", "targetWords": [], "interactionType": "standard", "hints": [], "difficulty": 1 }, // 快照,题目改动不影响历史;渐进题 hints 只展示不进模型请求 + "revealedHintCount": 0, // 已显示的不同提示数;服务端持久化,始终 0..len(hints);渐进式会话必有,standard/free 缺省按 0 + "hintReveals": [{ "requestId": "uuid", "hintIndex": 0, "at": datetime }], // 已处理的提示领取,同 requestId 幂等重放 + "creationRequestId": "uuid", // 开始动作幂等键;仅非空写入;按 (userId, creationRequestId) 唯一 "imageKey": "scenarios/sc_.../cover.jpg", // 从题目复制的场景图 key,读取时现签 "videoKey": "scenarios/sc_.../cover.mp4", // 从题目复制的场景视频 key,读取时现签;前端视频优先、图片兜底 "attemptSeq": 3, // 原子分配下一轮 round;身份不用这个序号 @@ -101,6 +106,7 @@ "userId": "u_1781276...", "sourceType": "human | ai_test", "round": 1, // 只用于排序和显示,不是关联主键 + "hintCount": 0, // 作答当时已显示的提示数;服务端从 Session 复制,客户端不能伪造;仅用于分层分析 "status": "evaluating | completed", "mode": "scenario | free", "freeTopic": "", @@ -183,6 +189,7 @@ - `reviewItems`: `{userId, expression, kind}` 唯一索引(同一类型内去重;迁移前应先把历史缺失 `kind` 的记录补为 `mistake`) - `scenarios`: `{slug}` 唯一索引(脚本幂等) - `practiceSessions`: `{userId, createdAt}` 复合索引(历史列表) +- `practiceSessions`: `{userId, creationRequestId}` 唯一部分索引(仅字段存在的文档;开始动作创建幂等) - `practiceAttempts`: `{practiceId, round}` 唯一复合索引 - `practiceAttempts`: `{userId, createdAt}` 复合索引(历史查询) diff --git "a/docs/requirements/20260826-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\346\217\220\347\244\272.md" "b/docs/requirements/20260826-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\346\217\220\347\244\272.md" index b0fc2d9..553309a 100644 --- "a/docs/requirements/20260826-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\346\217\220\347\244\272.md" +++ "b/docs/requirements/20260826-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\346\217\220\347\244\272.md" @@ -631,3 +631,11 @@ PA 门禁全部通过才叫“生产激活成功”。生产凭据、部署权 - `web/src/pages/practiceTelemetry.js` - `web/src/api/client.js` - `web/e2e/journey.spec.js` + + +## 实施进度(2026-08-27 更新) + +- 状态:第 1-4 部分完成(代码、测试、内容资产、文档);生产未激活,试点未开始。 +- 代码:后端契约、前端交互、试点生成入口已实现;验证证据见 `docs/changelog/20260827-083034-feat-渐进式场景提示-实现与试点就绪.md`。 +- 内容:10 道试点题在 `server/data/progressive_hint_pilot.yaml`,`--dry-run` 硬规则 10/10 通过;逐题审核表由该命令输出。 +- 待办:第 11 节 PA-01~PA-05(Operator 分批投放、生产验证、两周观察与保留决策)。 diff --git "a/docs/\344\270\232\345\212\241/8-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\347\273\203\344\271\240.md" "b/docs/\344\270\232\345\212\241/8-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\347\273\203\344\271\240.md" new file mode 100644 index 0000000..63f004a --- /dev/null +++ "b/docs/\344\270\232\345\212\241/8-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\347\273\203\344\271\240.md" @@ -0,0 +1,37 @@ +# 8. 渐进式场景练习(按需中文提示) + +> 规格:`docs/requirements/20260826-渐进式场景提示.md`;契约事实源:`docs/design/schema.md`。 +> 当前状态:代码已实现,10 道试点题内容已审核就绪,生产尚未激活(待 Operator 分批投放)。 + +## 是什么、解决什么旧问题 +旧场景题把「应说到的内容」一次性全给出来,用户容易照读;自由说则完全没有支架。渐进式场景题把支架改为**按需逐条领取的中文提示**:首次作答前只给宽泛任务,卡住时点一次多给一条,最多 2-3 条。目标是把「照读答案」变成「自己想不出来再要辅助」。 + +## 题目形态(当前实现) +- `interactionType: progressive_hints` 的场景题:`hints` 为 2-3 条自然中文句(无英文、无「可以说」答案清单、无空泛指令)。 +- **task 渐进题**:`points` 保留为必要信息点(进评分),数量与 `hints` 一一对应;题卡不显示 points,改显示宽泛 `mission`。 +- **非 task 渐进题**(chat/describe/opinion/explain):`points` 为空(没有应说要点可判,避免假完成判定),提示只作展开支架;评分只按该 kind 既有标准。 +- 旧题不受影响:缺 `interactionType` 或未知值一律按 `standard`,行为完全不变。 + +## 交互行为(当前实现) +- **入口**:`/practice?scenario=` 精确选题。题目不存在/已归档/无权访问 → 明确「不可用」提示,不回退随机题;不产生 Session。 +- **会话创建**:点录音开始才创建真实 Practice Session(同一次开始动作带同一 `requestId`,网络重试/重复提交服务端幂等,只产生一个 Session);创建成功后进入规范 Session URL,刷新恢复同一会话。 +- **提示区**:仅已创建的渐进式 Session 显示;「给我一点提示」→「再给一点提示」→ 用尽后显示「已显示全部提示」。一次一条,已显示提示累计保留(重说不清空);刷新后按服务端计数恢复前缀;不自动弹出、不自动显示。 +- **作答与结果**:提示不影响任务完成判定与评分口径——hints 文本和提示计数永不进入纠错/标准答案的模型请求;结果页不标注「使用了提示」。Attempt 记录作答当时的 `hintCount`(服务端复制,客户端不可伪造),仅用于分层分析。 +- **standard/free 会话**:无提示入口;请求提示接口返回 409。 +- **回滚**:把题目置为 `archived` 即下线(by-slug 404、不再被取到);历史会话与提示记录保留。 + +## 埋点(当前实现) +`practice_recording_started` 与 `practice_result` 额外携带:`practiceId`、`sourceType`、`interactionType`、`kind`、`difficulty`、`attemptId`(仅 result)、`hintCount`。不上传提示正文、不上传作答文本(隐私边界同既有事件)。 + +## API +| 端点 | 方法 | 说明 | +|------|------|------| +| `/api/scenarios/by-slug/{slug}` | GET | 按稳定小写 kebab-case slug 精确取题;只返回 active 且当前用户可访问;失败统一 404 | +| `/api/practice-sessions` | POST | 新增可选 `requestId`(非空时按 `(userId, requestId)` 幂等);渐进题快照含 interactionType/hints/difficulty;返回 `revealedHintCount` | +| `/api/practice-sessions/{practiceId}/hints/next` | POST | body `{requestId}`;原子领取下一条提示;同 `requestId` 幂等;耗尽返回 `exhausted`;standard/free 返回 409;非本人会话 404 | +| `/api/correct` | POST | 无契约变化;attempt 自动记录 `hintCount` | + +## 试点与生成(当前实现) +- 试点题唯一事实源:`server/data/progressive_hint_pilot.yaml`(10 题:5 种 kind 各 2 道,难度 1×2 / 2×6 / 3×2)。 +- `server/scripts/generate_progressive_hint_pilot.py`:`--dry-run`(校验+逐题审核表,不花钱不写库)/ `--execute --ids …`(≤5 题一批,按 slug 幂等 upsert)/ `--archive --ids …`(回滚下线)。不改动 standard 自动补题链路。 +- 确定性硬规则(`evals/scenario_quality.py`)按 `interactionType` 分流:渐进题校验 points/hints 数量与对齐、中文提示、无空泛指令;standard 题规则与输出完全不变。 diff --git "a/docs/\344\270\232\345\212\241/\346\225\260\346\215\256\345\237\213\347\202\271.md" "b/docs/\344\270\232\345\212\241/\346\225\260\346\215\256\345\237\213\347\202\271.md" index e1cd57a..1d6b04a 100644 --- "a/docs/\344\270\232\345\212\241/\346\225\260\346\215\256\345\237\213\347\202\271.md" +++ "b/docs/\344\270\232\345\212\241/\346\225\260\346\215\256\345\237\213\347\202\271.md" @@ -7,13 +7,15 @@ SpeakUp 使用自建 [Umami](https://umami.is) 做产品统计(无 cookie、 | 事件 | 触发时机 | 属性 | |---|---|---| | (pageview,自动) | 首次加载与 SPA 路由变化 | umami 自动 | -| `practice_recording_started` | 录音成功开始(麦克风已打开) | `mode`(scenario/free)、`userId` | -| `practice_result` | 评估完成且反馈可用 | `mode`、`score`、`gaps` 数、`round`、`userId` | +| `practice_recording_started` | 录音成功开始(麦克风已打开) | `mode`(scenario/free)、`userId`、`practiceId`、`sourceType`、`interactionType`、`kind`、`difficulty`、`hintCount` | +| `practice_result` | 评估完成且反馈可用 | `mode`、`score`、`gaps` 数、`round`、`userId`、`attemptId`、`practiceId`、`sourceType`、`interactionType`、`kind`、`difficulty`、`hintCount` | | `result_shared` | 结果页分享成功(系统分享或复制链接) | `method`(shared/copied)、`userId` | | `gap_saved` | 差距卡加入错题本成功 | `userId` | | `note_added` | 结果文字手动摘录保存成功(重复保存不记) | `userId` | | `followup_asked` | 追问教练发出一个问题 | `userId` | +> 渐进式场景提示(见 `8-渐进式场景练习.md`):提示正文与录音、转写一样不上传;事件只带分层标量——`interactionType`(缺失/未知归一为 `standard`)、`hintCount`(事件时刻已显示的提示数,standard/free 恒为 0)、`practiceId`、`attemptId`、`sourceType`、`kind`、`difficulty`。`practiceId`/`attemptId` 用于与 `practiceAttempts.hintCount` 对齐。 + ## 隐私边界 - 只使用不透明内部 `userId`;不上传手机号、邮箱、Bearer Token、分享 token。 diff --git "a/docs/\350\204\232\346\234\254\350\257\264\346\230\216.md" "b/docs/\350\204\232\346\234\254\350\257\264\346\230\216.md" index a0c3227..7203ed4 100644 --- "a/docs/\350\204\232\346\234\254\350\257\264\346\230\216.md" +++ "b/docs/\350\204\232\346\234\254\350\257\264\346\230\216.md" @@ -17,6 +17,7 @@ | `generate_scenarios.py` | 13 个手写种子场景(slug 幂等),场景文案事实源 | | `generate_public_scenarios.py` | 按 `data/scenario_taxonomy.yaml` 坐标手动补公共题(生产自动补题后为可选) | | `sync_public_scenarios.py` | dev→prod 同步公共题 + OSS 图(默认 dry-run,`--execute` 才真写) | +| `generate_progressive_hint_pilot.py` | 渐进式试点题:只读已审核 manifest(`data/progressive_hint_pilot.yaml`)。`--dry-run` 校验+逐题审核表(不花钱不写库);`--execute --ids` 按 slug 幂等入库(≤5 题/批);`--archive --ids` 回滚下线 | | `migrate_storage_layout.py` | 历史 v2 路径迁移工具;保留用于测试和旧迁移记录,新生产迁移不再调用 | | `migrate_attempt_entities.py` | 将嵌入 Attempt、录音和朗读迁移到 `practiceAttempts` 与 Attempt v3 业务路径;默认审计,`--execute` 迁移,额外加 `--delete-source` 才复验并清理 | | `storage_orphan_cleanup.py` | 为 Attempt v3 迁移提供受管 OSS 前缀的引用扫描、路径分类、一小时安全窗和无引用对象清理逻辑,不单独执行 | diff --git a/server/data/progressive_hint_pilot.yaml b/server/data/progressive_hint_pilot.yaml new file mode 100644 index 0000000..90847ef --- /dev/null +++ b/server/data/progressive_hint_pilot.yaml @@ -0,0 +1,255 @@ +# 渐进式场景提示试点题 manifest +# +# 10 道试点题的最终审核文案(规格见 docs/requirements/20260826-渐进式场景提示.md)。 +# scripts/generate_progressive_hint_pilot.py 只读取本文件,不会在执行时随机改写已审核文案: +# - slug 是稳定主键,重复执行幂等 upsert,不会造出第二道同 slug 题 +# - 坐标(subId/kind/difficulty)必须与 data/scenario_taxonomy.yaml 一致 +# - dry-run 校验并打印,不调图片/视频服务、不写库;execute 只处理显式指定题, +# 每次最多 5 道(10 道分两批,每批先 dry-run 人工审核再花钱) +# +# 字段:slug / kind / category(domain+subId) / difficulty / title / where / story / +# mission / points(task 必要信息;其他 kind 为空)/ hints / imagePrompt / videoPrompt + +scenarios: + + - slug: delivery-missing-dish-claim + kind: task + category: + domain: food + subId: food.delivery_complaint + difficulty: 2 + title: 外卖少送了一份菜 + where: 家里 · 晚餐时间 + story: 你点了两份主菜的外卖,骑手刚走你就发现少送了一份,而商家电话一直没人接。 + mission: 找平台客服解决少送的菜 + points: + - 说明订单里有一份菜没有送到 + - 要求补送这份菜或者退掉它的钱 + hints: + - 我点的两份菜只送到了一份。 + - 请帮我把缺的那份补送来,不行的话就退那一份的钱。 + imagePrompt: >- + Evening apartment dinner table with two takeout bags opened, + one empty spot where a missing dish should be, a smartphone lying + face down beside the bags, warm indoor light, overhead angle, + no readable text or captions + videoPrompt: >- + 5-second silent video: hands opening a food delivery bag on a home + dinner table, pausing as one container is missing, side angle + close-up of the table, warm evening light, gentle pan, no text + + - slug: airport-checkin-overweight-bag + kind: task + category: + domain: travel + subId: travel.airport_checkin + difficulty: 2 + title: 值机柜台行李超重 + where: 机场值机柜台 · 早上 + story: 你在值机柜台托运时被告知行李超了两公斤,要么付超重费,要么当场拿出点东西。 + mission: 不花超重费把行李托运了 + points: + - 说明自己只超了两公斤而且赶时间 + - 提出把一件厚外套穿在身上来减重 + hints: + - 我的行李只超了两公斤,而且我快赶不上安检了。 + - 我把这件厚外套直接穿上,这样应该就不超重了吧? + imagePrompt: >- + Airport check-in counter in the morning, a suitcase standing on the + luggage scale with the traveler gesturing at it, airline staff behind + the desk, wide shot from the side, no close-up faces, no text + videoPrompt: >- + 5-second silent video: a suitcase resting on a check-in scale, the + traveler opening it slightly and holding up a jacket, wide side angle + at the counter, gentle camera push-in, no text + + - slug: pantry-weekend-smalltalk + kind: chat + category: + domain: work + subId: work.tea_room_chat + difficulty: 1 + title: 茶水间聊周末 + where: 公司茶水间 · 周一上午 + story: 周一早上你在茶水间碰到不太熟的外国同事,他一边接咖啡一边随口问你周末过得怎么样。 + mission: 自然地聊几句你的周末 + points: [] + hints: + - 上个周末我终于有空去了趟郊外,天气特别好。 + - 不过周日晚上在家收拾屋子,累得够呛。 + - 你呢,周末一般喜欢做点什么? + imagePrompt: >- + Office pantry on a Monday morning, two colleagues standing at the + coffee machine, one holding a mug mid-conversation, bright window + light, medium shot from behind at an angle, no close-up faces, no text + videoPrompt: >- + 5-second silent video: an office pantry scene, steam rising from a + coffee machine as a person leans against the counter chatting, soft + morning light, slow lateral camera move, no text + + - slug: reunion-old-classmate-catchup + kind: chat + category: + domain: social + subId: social.reunion_reconnect + difficulty: 2 + title: 同学会重逢叙旧 + where: 餐馆包厢 · 晚上 + story: 同学会上你碰到了十年没见的老同桌,他端着杯子坐到你旁边,问起你这些年过得怎么样。 + mission: 跟他聊聊这些年的近况 + points: [] + hints: + - 我毕业之后去了南方工作,现在在一家互联网公司做产品。 + - 前几年忙得很少回家,今年开始尽量多回去陪陪父母。 + - 你还是老样子,一点都没变。 + imagePrompt: >- + Private room of a Chinese restaurant at night, round dinner table + with a lazy susan, an old classmate raising a glass while pulling up + a chair, warm ambient light, wide shot, no close-up faces, no text + videoPrompt: >- + 5-second silent video: a banquet table in a restaurant private room, + someone lifting a glass and sitting down beside an old friend, warm + low light, slow dolly around the table edge, no text + + - slug: hometown-riverside-city + kind: describe + category: + domain: social + subId: social.hometown + difficulty: 1 + title: 聊聊我的家乡 + where: 朋友家客厅 · 闲聊 + story: 外国朋友看着你手机里的照片,好奇地问你家乡是个什么样的地方,离江边是不是很近。 + mission: 给他介绍你的家乡 + points: [] + hints: + - 我家乡在南方的一条江边,夏天特别热,冬天也不怎么下雪。 + - 那边最出名的是早餐粉,一条街上能有十几家粉店。 + - 现在变化很大,江边修了漂亮的步道,晚上全是散步的人。 + imagePrompt: >- + A riverside southern Chinese city at dusk, illuminated walking + promenade along the river with strolling people, distant bridges and + buildings, wide landscape shot, no text or captions + videoPrompt: >- + 5-second silent video: an aerial view slowly gliding over a riverside + city promenade at dusk, lights turning on, people walking far below, + no text + + - slug: memorable-trip-mountain-village + kind: describe + category: + domain: travel + subId: travel.memorable_trip + difficulty: 2 + title: 一次难忘的山村旅行 + where: 朋友聚会 · 闲聊 + story: 朋友问起你印象最深的一次旅行,你想起前年去西南一个山村,差点没赶上回程的班车。 + mission: 讲讲这次难忘的旅行 + points: [] + hints: + - 那个村子在山里,客车一天只有两班,我们进去的时候就没想好怎么出来。 + - 临走那天我们一路小跑到村口,司机居然专门等了我们几分钟。 + - 现在想起来还后怕,但也多亏了那趟车,不然就得在山里多住一晚。 + imagePrompt: >- + A small mountain village in southwest China with terraced fields and + low houses, a lone bus on a winding road below, late afternoon light, + wide scenic shot, no text + videoPrompt: >- + 5-second silent video: a bus rounding a mountain bend toward a small + village, mist over terraced hills, slow drone-like camera glide, no text + + - slug: short-video-addiction-debate + kind: opinion + category: + domain: hobby + subId: hobby.short_video_addiction + difficulty: 2 + title: 刷短视频算不算上瘾 + where: 咖啡馆 · 下午 + story: 外国朋友注意到你等咖啡时一直在刷短视频,笑着问你是不是有点上瘾了,你们顺势聊了起来。 + mission: 说说你对刷短视频的看法 + points: [] + hints: + - 我觉得自己算不上上瘾,只是等车排队的空当顺手看看。 + - 不过确实有一次刷到凌晨,第二天特别后悔。 + - 它推荐得太准了,越看越停不下来,这点挺吓人的。 + imagePrompt: >- + Afternoon cafe table with a latte and a phone propped up, two friends + seated across the table mid-conversation, window light, over-the-shoulder + shot without visible faces, no readable text + videoPrompt: >- + 5-second silent video: close-up of a phone propped on a cafe table + next to a coffee cup, two blurred people talking in the background, + slow push-in, no text + + - slug: young-people-fading-festivals + kind: opinion + category: + domain: festival + subId: festival.tradition_decline + difficulty: 3 + title: 年轻人不爱过传统节日了? + where: 朋友聚餐 · 晚上 + story: 聚餐时外国朋友说感觉中国年轻人过节越来越敷衍,只有放假没有仪式感,问你是不是也这样。 + mission: 谈谈你怎么看这个现象 + points: [] + hints: + - 我承认我自己也是,春节基本只剩吃年夜饭和抢红包。 + - 但我觉得不是不在乎,是很多仪式在城里没条件做了。 + - 像中秋我们还是会买月饼,只是改成寄给朋友了。 + imagePrompt: >- + Evening dinner gathering with hot pot on the table, red festival + lantern decorations hanging above, young people chatting and raising + glasses, warm restaurant light, wide shot, no close-up faces, no text + videoPrompt: >- + 5-second silent video: friends around a dinner table laughing, one + gesturing while speaking, a festival lantern decoration hanging above, + warm light, gentle camera arc, no text + + - slug: spring-festival-for-foreigner + kind: explain + category: + domain: festival + subId: festival.spring_festival + difficulty: 2 + title: 给老外讲春节 + where: 办公室休息区 · 年前 + story: 春节前外国同事看你抢着回家的高铁票,好奇地问中国人过年到底都忙些什么。 + mission: 给他讲讲春节怎么过 + points: [] + hints: + - 最重要的是除夕那顿团圆饭,再远也要赶回家吃。 + - 长辈会给小孩发红包,现在大家都直接在手机上发了。 + - 初一开始就走亲戚拜年,一直热闹到元宵。 + imagePrompt: >- + Chinese New Year eve dinner table full of dishes with a family + gathered around, red lanterns and couplets decorating the room, warm + festive light, wide shot, no close-up faces, no readable text + videoPrompt: >- + 5-second silent video: a family raising glasses around a round dinner + table decorated in red, steam rising from dishes, warm festive light, + slow zoom out, no text + + - slug: explain-xiaohongshu-to-foreigner + kind: explain + category: + domain: tech + subId: tech.explain_cn_apps + difficulty: 3 + title: 给老外解释小红书 + where: 地铁车厢 · 通勤路上 + story: 通勤路上外国同事看到你刷小红书,问这是什么软件,跟国外的图片社区有什么不一样。 + mission: 给他解释小红书是干嘛的 + points: [] + hints: + - 它是一个生活方式社区,大家把探店、旅行攻略写成图文笔记。 + - 它最厉害的是搜索,很多人把它当搜索引擎用。 + - 跟图片社区不一样,它上面大部分内容是中文的实用经验帖。 + imagePrompt: >- + Commute subway carriage, a commuter browsing a lifestyle community + app on a phone, another person leaning over curiously, handrails and + seats visible, medium shot from the side, no close-up faces, no readable text + videoPrompt: >- + 5-second silent video: inside a moving subway carriage, one commuter + showing a phone screen to a curious colleague, both seen from the side, + gentle sway of the carriage, no text diff --git a/server/db/connection.py b/server/db/connection.py index 7e3e939..1043a31 100644 --- a/server/db/connection.py +++ b/server/db/connection.py @@ -16,6 +16,13 @@ async def connect_db(): await db.practiceAttempts.create_index( [("userId", 1), ("createdAt", -1)], name="user_attempt_history" ) + # 开始动作幂等键:只覆盖非空 creationRequestId,旧客户端缺省该字段不受影响 + await db.practiceSessions.create_index( + [("userId", 1), ("creationRequestId", 1)], + unique=True, + name="session_creation_idempotent", + partialFilterExpression={"creationRequestId": {"$exists": True}}, + ) print("MongoDB connected") diff --git a/server/evals/scenario_quality.py b/server/evals/scenario_quality.py index fc1d4e9..a18cdba 100644 --- a/server/evals/scenario_quality.py +++ b/server/evals/scenario_quality.py @@ -2,6 +2,10 @@ 开放性的“有趣、真实、难度合适”由独立 LLM judge + 人工校准处理, 见 docs/scenario-evaluation.md。 + +规则按 interactionType 分流:standard 题完全沿用既有规则;渐进式试点题按 +task/非 task 的 points-hints 契约替换 points 规则(规格见 +docs/requirements/20260826-渐进式场景提示.md)。 """ from __future__ import annotations @@ -10,6 +14,9 @@ from dataclasses import dataclass from difflib import SequenceMatcher +from services.interaction_types import PROGRESSIVE_HINTS, normalize_interaction_type + +KINDS = {"task", "chat", "describe", "opinion", "explain"} EXAM_SMELL = re.compile( r"考官|考场|口语考试|语言考试|分别从.{0,12}阐述|讨论.{0,12}利弊|" @@ -19,9 +26,12 @@ r"假装|摇头|深呼吸|语气.{0,6}(礼貌|坚定)|表达诚意|" r"指出.{0,8}不合理|说明情况|进行沟通" ) +# 渐进提示除 abstract point 外,还不许出现“展开讨论”这类无法直接帮助开口的空泛指令 +HINT_SMELL = re.compile(ABSTRACT_POINT.pattern + r"|展开.{0,4}讨论") EMOJI = re.compile( "[\U0001F1E6-\U0001F1FF\U0001F300-\U0001FAFF\u2600-\u27BF]" ) +ENGLISH_LETTER = re.compile(r"[A-Za-z]") @dataclass(frozen=True) @@ -44,6 +54,50 @@ def scenario_similarity(left: dict, right: dict) -> float: return SequenceMatcher(None, a, b).ratio() +def _clean_strs(value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [v for v in value if isinstance(v, str) and v.strip()] + + +def _progressive_checks(candidate: dict) -> list[Check]: + """渐进式试点题契约(规格第 6.1 / 10.4 节)。 + + task:2-3 条必要 points + 数量一致的 2-3 条中文 hints; + 其他 kind:points 必须为空,2-3 条中文 hints 只作展开支架。 + """ + kind = candidate.get("kind") + points = candidate.get("points") + hints = candidate.get("hints") + clean_points = _clean_strs(points) + clean_hints = _clean_strs(hints) + is_task = kind == "task" + points_ok = ( + 2 <= len(clean_points) <= 3 if is_task + else isinstance(points, list) and not clean_points + ) + return [ + Check("known_kind", kind in KINDS, f"kind={kind!r}"), + Check("progressive_points", points_ok, f"kind={kind!r}, points={points!r}"), + Check("progressive_hints", 2 <= len(clean_hints) <= 3, f"hints={hints!r}"), + Check( + "points_hints_aligned", + not is_task or len(clean_points) == len(clean_hints), + f"points={len(clean_points)}, hints={len(clean_hints)}", + ), + Check( + "hints_chinese_only", + all(not ENGLISH_LETTER.search(h) for h in clean_hints), + f"hints={hints!r}", + ), + Check( + "speakable_hints", + not any(HINT_SMELL.search(h) for h in clean_hints), + f"hints={hints!r}", + ), + ] + + def grade_scenario(candidate: dict, references: list[dict] | None = None) -> list[Check]: references = references or [] required = ("title", "where", "story", "mission") @@ -62,28 +116,37 @@ def grade_scenario(candidate: dict, references: list[dict] | None = None) -> lis for item in references ) - return [ - Check("required_fields", not missing, f"missing={missing}"), - Check( + progressive = normalize_interaction_type(candidate.get("interactionType")) == PROGRESSIVE_HINTS + + checks = [Check("required_fields", not missing, f"missing={missing}")] + if progressive: + checks.extend(_progressive_checks(candidate)) + else: + checks.append(Check( "exactly_two_points", isinstance(points, list) and len(points) == 2 and all(str(p).strip() for p in points), f"points={points!r}", - ), + )) + checks.extend([ Check("story_length", 15 <= len(story) <= 70, f"chars={len(story)}, want=15..70"), Check("mission_length", 6 <= len(mission) <= 30, f"chars={len(mission)}, want=6..30"), Check("no_exam_smell", not EXAM_SMELL.search(text), f"text={text!r}"), - Check( + ]) + if not progressive: + checks.append(Check( "speakable_points", isinstance(points, list) and not any(ABSTRACT_POINT.search(str(p)) for p in points), f"points={points!r}", - ), + )) + checks.extend([ Check("no_emoji_in_label", not EMOJI.search(title + where), f"title={title!r}, where={where!r}"), Check( "not_near_duplicate", not duplicate_title and max_similarity < 0.78, f"duplicate_title={duplicate_title}, max_similarity={max_similarity:.3f}", ), - ] + ]) + return checks def hard_pass(candidate: dict, references: list[dict] | None = None) -> bool: diff --git a/server/routes/practice_sessions.py b/server/routes/practice_sessions.py index eeddbcb..465cad2 100644 --- a/server/routes/practice_sessions.py +++ b/server/routes/practice_sessions.py @@ -4,7 +4,9 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile -from pydantic import BaseModel +from pydantic import BaseModel, Field +from pymongo import ReturnDocument +from pymongo.errors import DuplicateKeyError from db.connection import get_db from services.auth_tokens import assert_same_user, current_user_id @@ -13,6 +15,11 @@ get_url as oss_signed_url, upload_bytes_async, ) +from services.interaction_types import ( + PROGRESSIVE_HINTS, + normalize_interaction_type, + scenario_hints, +) from services.practice_attempts import hydrate_practice, resolve_attempt, update_attempt from services.storage_paths import PracticeAssetContext, recording_original_key from utils.data_source import normalize_source_type @@ -46,6 +53,7 @@ class CreatePracticeRequest(BaseModel): mode: str = "scenario" # scenario 场景题 / free 自由说(历史缺省按场景题) freeTopicId: str = "" # 自由说话题 id(无话题自由说为空) freeTopic: str = "" # 自由说话题文本快照(无话题自由说为空) + requestId: str = "" # 开始动作幂等键;新版必填,旧客户端缺省不获得幂等保证 class RecordingTarget: @@ -60,23 +68,150 @@ def __init__( self.attempt_index = attemptIndex +def _same_create_params(session: dict, req: CreatePracticeRequest) -> bool: + """重复 requestId 是否携带相同创建参数(幂等重放放行,参数冲突 409)。""" + return ( + session.get("mode", "scenario") == req.mode + and session.get("scenarioId", "") == (req.scenarioId or "") + and session.get("freeTopicId", "") == (req.freeTopicId or "") + and session.get("freeTopic", "") == (req.freeTopic or "").strip() + ) + + @router.post("") async def create_practice(req: CreatePracticeRequest, token_user_id: str = Depends(current_user_id)): assert_same_user(req.userId, token_user_id) user = await get_db().users.find_one(id_filter(token_user_id), {"sourceType": 1}) source_type = normalize_source_type((user or {}).get("sourceType")) + request_id = (req.requestId or "").strip() if req.mode == "free": doc = _build_free_doc(req, source_type) else: + # 写入前服务端重新校验:题目必须 active 且当前用户可访问,不信任客户端此前的查询结果 scenario = await get_db().scenarios.find_one({"_id": req.scenarioId}) - if not scenario: - raise HTTPException(404, "场景不存在") + if ( + not scenario + or scenario.get("status") != "active" + or scenario.get("ownerUserId") not in (None, token_user_id) + ): + raise HTTPException(404, "场景不存在或不可用") + if request_id: + # 幂等:同 requestId 重复提交返回首次创建的会话(仍 200),不再写一条历史 + existing = await get_db().practiceSessions.find_one( + {"userId": req.userId, "creationRequestId": request_id} + ) + if existing: + if not _same_create_params(existing, req): + raise HTTPException(409, "相同 requestId 与已有会话的创建参数冲突") + return _sign(await hydrate_practice(existing)) doc = _build_scenario_doc(req, scenario, source_type) - await get_db().practiceSessions.insert_one(doc) + + if request_id: + doc["creationRequestId"] = request_id + try: + await get_db().practiceSessions.insert_one(doc) + except DuplicateKeyError: + # (userId, creationRequestId) 唯一部分索引拦住并发重复创建:回落到幂等重放 + existing = await get_db().practiceSessions.find_one( + {"userId": req.userId, "creationRequestId": request_id} + ) + if existing and _same_create_params(existing, req): + return _sign(await hydrate_practice(existing)) + raise HTTPException(409, "相同 requestId 与已有会话的创建参数冲突") return _sign({**doc, "attempts": []}) +class RevealHintRequest(BaseModel): + requestId: str = Field(min_length=1) + + +def _hint_response(request_id: str, hints: list[str], idx: int | None, count: int) -> dict: + return { + "requestId": request_id, + "revealedHintCount": count, + "hintIndex": idx, + "hint": hints[idx] if idx is not None else None, + "exhausted": count >= len(hints), + } + + +def _hint_replayed(practice: dict, request_id: str, hints: list[str]) -> dict | None: + for reveal in practice.get("hintReveals", []): + if reveal.get("requestId") == request_id: + idx = int(reveal["hintIndex"]) + return _hint_response(request_id, hints, idx, idx + 1) + return None + + +async def _claim_next_hint( + pid: str, token_user_id: str, request_id: str, hints: list[str] +) -> dict: + """原子领取:多标签页并发不重复不越界;同 requestId 已在库里则幂等重放。""" + current = 0 + while True: + if current >= len(hints): + return _hint_response(request_id, hints, None, current) + updated = await get_db().practiceSessions.find_one_and_update( + { + **id_filter(pid), + "userId": token_user_id, + "revealedHintCount": current, + "hintReveals.requestId": {"$ne": request_id}, + }, + { + "$inc": {"revealedHintCount": 1}, + "$push": { + "hintReveals": { + "requestId": request_id, + "hintIndex": current, + "at": datetime.now(timezone.utc), + } + }, + }, + return_document=ReturnDocument.AFTER, + ) + if updated: + return _hint_response(request_id, hints, current, current + 1) + # 并发冲突或状态变化:重读再判 + practice = await get_db().practiceSessions.find_one( + {**id_filter(pid), "userId": token_user_id} + ) + if not practice: + raise HTTPException(404, "练习不存在") + replay = _hint_replayed(practice, request_id, hints) + if replay: + return replay + current = int(practice.get("revealedHintCount") or 0) + + +@router.post("/{pid}/hints/next") +async def reveal_next_hint( + pid: str, + req: RevealHintRequest, + token_user_id: str = Depends(current_user_id), +): + """原子领取下一条提示:服务端持久化,同 requestId 幂等,多标签页并发不重复不越界。 + + 已耗尽时不增加计数,返回 200 + exhausted;standard/free 会话返回 409; + 会话不存在或不属于当前用户返回 404。 + """ + request_id = req.requestId.strip() + if not request_id: + raise HTTPException(422, "requestId 必填") + practice = await get_db().practiceSessions.find_one( + {**id_filter(pid), "userId": token_user_id} + ) + if not practice: + raise HTTPException(404, "练习不存在") + scenario = practice.get("scenario") or {} + if normalize_interaction_type(scenario.get("interactionType")) != PROGRESSIVE_HINTS: + raise HTTPException(409, "该练习不支持渐进提示") + hints = scenario_hints(scenario) + replay = _hint_replayed(practice, request_id, hints) + return replay or await _claim_next_hint(pid, token_user_id, request_id, hints) + + def _build_free_doc(req: CreatePracticeRequest, source_type: str) -> dict: """自由说会话:无场景,快照里带 kind=free + 话题(可空),corrector 据此走自由说反馈。""" topic = (req.freeTopic or "").strip() @@ -130,7 +265,11 @@ def _build_scenario_doc(req: CreatePracticeRequest, scenario: dict, source_type: "mission": scenario.get("mission", ""), "points": scenario.get("points", []), "targetWords": scenario.get("targetWords", []), + "interactionType": normalize_interaction_type(scenario.get("interactionType")), + "hints": scenario_hints(scenario), + "difficulty": scenario.get("difficulty"), }, + "revealedHintCount": 0, # 已显示的不同提示数;服务端维护,始终 0..len(hints) # 只存 OSS key,签名 URL 一律读取时现签 "imageKey": scenario.get("imageKey", ""), "videoKey": scenario.get("videoKey", ""), diff --git a/server/routes/scenarios.py b/server/routes/scenarios.py index 5d051d6..bf6fcb5 100644 --- a/server/routes/scenarios.py +++ b/server/routes/scenarios.py @@ -1,11 +1,14 @@ import asyncio import logging +import re from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from db.connection import get_db from services.auth_tokens import assert_same_user, current_user_id +from services.interaction_types import normalize_interaction_type, scenario_hints +from services.oss_storage import get_url as oss_signed_url from services.scenario_service import ( FRESH_THRESHOLD, fresh_scenario_count, @@ -21,6 +24,36 @@ logger = logging.getLogger(__name__) +# 指定题目入口的 slug:小写 kebab-case,区分大小写,不做猜测性改写 +SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + +SCENARIO_NOT_AVAILABLE = "场景不存在或不可用" + + +def _scenario_payload(scenario: dict) -> dict: + """/next 与按 slug 精确读取共用的场景响应契约。 + + interactionType 在出口统一归一化(缺失/未知按 standard), + 前端不在多处重复猜测;standard 缺失 hints 时返回空数组。 + """ + return { + "scenarioId": scenario["_id"], + "kind": scenario.get("kind", "task"), + "title": scenario.get("title", ""), + "where": scenario.get("where", ""), + "story": scenario.get("story", ""), + "mission": scenario.get("mission", ""), + "points": scenario.get("points", []), + "imageUrl": scenario.get("imageUrl", ""), + "videoUrl": scenario.get("videoUrl", ""), + "isCustom": scenario.get("isCustom", False), + "preferenceMatch": scenario.get("preferenceMatch", "exact"), + "targetWords": scenario.get("targetWords", []), + "difficulty": scenario.get("difficulty"), + "interactionType": normalize_interaction_type(scenario.get("interactionType")), + "hints": scenario_hints(scenario), + } + def _maybe_topup( user_id: str, @@ -73,20 +106,26 @@ async def get_next( purpose=purpose, source_type=normalize_source_type((user or {}).get("sourceType")), ) - return { - "scenarioId": scenario["_id"], - "kind": scenario.get("kind", "task"), - "title": scenario.get("title", ""), - "where": scenario.get("where", ""), - "story": scenario.get("story", ""), - "mission": scenario.get("mission", ""), - "points": scenario.get("points", []), - "imageUrl": scenario.get("imageUrl", ""), - "videoUrl": scenario.get("videoUrl", ""), - "isCustom": scenario.get("isCustom", False), - "preferenceMatch": scenario.get("preferenceMatch", "exact"), - "targetWords": scenario.get("targetWords", []), - } + return _scenario_payload(scenario) + + +@router.get("/by-slug/{slug}") +async def get_scenario_by_slug(slug: str, token_user_id: str = Depends(current_user_id)): + """按 slug 精确取题:只返回 active 且当前用户可访问的题。 + + 身份只取 Bearer token。不存在、已归档、slug 非法、无权访问统一 404, + 不回退随机题,也不暴露其他用户定制题的存在。 + """ + if not SLUG_PATTERN.match(slug): + raise HTTPException(404, SCENARIO_NOT_AVAILABLE) + scenario = await get_db().scenarios.find_one({"slug": slug, "status": "active"}) + if not scenario or scenario.get("ownerUserId") not in (None, token_user_id): + raise HTTPException(404, SCENARIO_NOT_AVAILABLE) + scenario["isCustom"] = scenario.get("ownerUserId") is not None + scenario["preferenceMatch"] = "exact" + scenario["imageUrl"] = oss_signed_url(scenario["imageKey"]) if scenario.get("imageKey") else "" + scenario["videoUrl"] = oss_signed_url(scenario["videoKey"]) if scenario.get("videoKey") else "" + return _scenario_payload(scenario) class PracticeWordRequest(BaseModel): diff --git a/server/scripts/generate_progressive_hint_pilot.py b/server/scripts/generate_progressive_hint_pilot.py new file mode 100644 index 0000000..1451712 --- /dev/null +++ b/server/scripts/generate_progressive_hint_pilot.py @@ -0,0 +1,222 @@ +"""渐进式提示试点题生成入口(独立于 standard 公共题自动补题)。只读已审核文案,幂等可复跑。""" + +from __future__ import annotations + +import argparse +import asyncio +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import yaml +from motor.motor_asyncio import AsyncIOMotorClient + +from config import MONGO_URI +from evals.scenario_quality import grade_scenario +from services.interaction_types import PROGRESSIVE_HINTS, validate_new_interaction_type +from services.public_scenario_service import load_taxonomy +from services.scenario_images import maybe_gen_image +from services.scenario_videos import maybe_gen_video +from utils.id_generator import scenario_id + +MANIFEST_PATH = Path(__file__).parent.parent / "data" / "progressive_hint_pilot.yaml" +SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +MAX_EXECUTE_BATCH = 5 + + +def load_manifest() -> list[dict]: + with open(MANIFEST_PATH, encoding="utf-8") as f: + data = yaml.safe_load(f) + scenarios = data.get("scenarios") or [] + slugs = [str(s.get("slug", "")) for s in scenarios] + if len(slugs) != len(set(slugs)): + raise SystemExit("manifest 存在重复 slug") + if not scenarios: + raise SystemExit("manifest 为空") + return scenarios + + +def _taxonomy_index() -> dict[str, dict]: + index = {} + for domain in load_taxonomy()["domains"]: + for sub in domain["subs"]: + index[sub["id"]] = { + "domainShort": domain["short"], + "kind": sub["kind"], + "difficulty": sub["difficulty"], + } + return index + + +def validate_entry(entry: dict, taxonomy: dict[str, dict]) -> list[str]: + """确定性校验:返回错误列表(空=通过)。含 manifest 结构与 grader 硬规则。""" + errors: list[str] = [] + slug = str(entry.get("slug", "")) + if not SLUG_PATTERN.match(slug): + errors.append(f"slug 非法(小写 kebab-case):{slug!r}") + category = entry.get("category") or {} + sub_id = category.get("subId", "") + coord = taxonomy.get(sub_id) + if not coord: + errors.append(f"subId 不在 taxonomy:{sub_id!r}") + else: + if coord["domainShort"] != category.get("domain"): + errors.append(f"category.domain 与 taxonomy 不符:{category.get('domain')!r}") + if coord["kind"] != entry.get("kind"): + errors.append(f"kind 与 taxonomy 不符:manifest={entry.get('kind')!r} taxonomy={coord['kind']!r}") + if coord["difficulty"] != entry.get("difficulty"): + errors.append( + f"difficulty 与 taxonomy 不符:" + f"manifest={entry.get('difficulty')!r} " + f"taxonomy={coord['difficulty']!r}" + ) + try: + validate_new_interaction_type(PROGRESSIVE_HINTS) + except ValueError as exc: + errors.append(str(exc)) + candidate = {"interactionType": PROGRESSIVE_HINTS} + for key in ("kind", "title", "where", "story", "mission", "points", "hints"): + candidate[key] = entry.get(key) + for check in grade_scenario(candidate): + if not check.passed: + errors.append(f"{check.name}: {check.reason}") + return errors + + +def _print_entry(entry: dict, errors: list[str]) -> None: + """逐题审核表:投放前人工对照本表逐题确认(规格 10.5)。""" + category = entry.get("category") or {} + print(f"\n== {entry.get('slug')} [{entry.get('kind')}/{entry.get('difficulty')}] {category.get('subId', '')} ==") + print(f" title : {entry.get('title', '')}") + print(f" where : {entry.get('where', '')}") + print(f" story : {entry.get('story', '')}") + print(f" mission : {entry.get('mission', '')}") + print(f" points : {entry.get('points') or []}") + for i, hint in enumerate(entry.get("hints") or [], 1): + print(f" hint{i} : {hint}") + print(f" image : {entry.get('imagePrompt', '')}") + if errors: + for error in errors: + print(f" X {error}") + else: + print(" OK 硬规则全部通过") + + +async def cmd_execute(entries: list[dict]) -> None: + db = AsyncIOMotorClient(MONGO_URI).get_default_database() + now = datetime.now(timezone.utc) + for entry in entries: + slug = str(entry["slug"]) + existing = await db.scenarios.find_one({"slug": slug}) + sid = existing["_id"] if existing else scenario_id() + link = {"scenarioId": sid, "subId": (entry.get("category") or {}).get("subId", "")} + image_key = (existing or {}).get("imageKey") or "" + if not image_key: + image_key = await maybe_gen_image(sid, entry.get("imagePrompt", ""), link) + video_key = (existing or {}).get("videoKey") or "" + if not video_key: + video_key = await maybe_gen_video(sid, entry.get("videoPrompt") or entry.get("imagePrompt", ""), link) + doc = { + "_id": sid, + "slug": slug, + "interactionType": PROGRESSIVE_HINTS, + "category": entry.get("category") or {}, + "kind": entry["kind"], + "title": entry.get("title", ""), + "where": entry.get("where", ""), + "story": entry.get("story", ""), + "mission": entry.get("mission", ""), + "points": entry.get("points") or [], + "hints": entry.get("hints") or [], + "difficulty": entry.get("difficulty"), + "imageKey": image_key, + "imagePrompt": entry.get("imagePrompt", ""), + "videoKey": video_key, + "videoPrompt": entry.get("videoPrompt", ""), + "videoStatus": "ready" if video_key else "skipped", + "ownerUserId": None, + "status": "active", + "createdAt": (existing or {}).get("createdAt") or now, + } + await db.scenarios.replace_one({"_id": sid}, doc, upsert=True) + note = "upsert 复用" if existing else "新建" + image_note = "有" if image_key else "无" + video_note = "有" if video_key else "无" + print(f" OK {slug} -> scenarioId={sid}({note},image={image_note},video={video_note})") + + +async def cmd_archive(entries: list[dict]) -> None: + db = AsyncIOMotorClient(MONGO_URI).get_default_database() + for entry in entries: + slug = str(entry["slug"]) + before = await db.scenarios.find_one({"slug": slug}, {"_id": 1, "status": 1}) + if not before: + print(f" !! {slug} 不在库里,跳过") + continue + await db.scenarios.update_one({"_id": before["_id"]}, {"$set": {"status": "archived"}}) + print(f" OK {slug} ({before['_id']}): {before.get('status')!r} -> 'archived'") + + +def _run_dry_run(manifest: list[dict], by_slug: dict[str, dict], ids: list[str]) -> None: + missing = [i for i in ids if i not in by_slug] + if missing: + raise SystemExit(f"manifest 没有这些 slug:{missing}") + targets = manifest if not ids else [by_slug[i] for i in ids] + taxonomy = _taxonomy_index() + failed = 0 + for entry in targets: + errors = validate_entry(entry, taxonomy) + failed += bool(errors) + _print_entry(entry, errors) + print(f"\n共 {len(targets)} 道,硬规则通过 {len(targets) - failed} 道,失败 {failed} 道。") + if failed: + raise SystemExit(1) + + + +def main() -> None: + parser = argparse.ArgumentParser(description="渐进式提示试点题:校验 / 入库 / 归档") + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--dry-run", action="store_true", help="只校验并打印,不花钱不写库") + mode.add_argument("--execute", action="store_true", help="对 --ids 指定题幂等 upsert(每次最多 5 道)") + mode.add_argument("--archive", action="store_true", help="把 --ids 指定题改为 archived") + parser.add_argument("--ids", type=str, default="", help="逗号分隔的 slug;dry-run 缺省=全部") + args = parser.parse_args() + + manifest = load_manifest() + by_slug = {str(e.get("slug", "")): e for e in manifest} + ids = [s.strip() for s in args.ids.split(",") if s.strip()] + + if args.dry_run: + _run_dry_run(manifest, by_slug, ids) + return + + if not ids: + raise SystemExit("--execute/--archive 必须用 --ids 显式指定题目") + unknown = [i for i in ids if i not in by_slug] + if unknown: + raise SystemExit(f"manifest 没有这些 slug:{unknown}") + if args.execute and len(ids) > MAX_EXECUTE_BATCH: + raise SystemExit(f"每次 --execute 最多 {MAX_EXECUTE_BATCH} 道,10 道试点分两批") + + taxonomy = _taxonomy_index() + entries = [by_slug[i] for i in ids] + for entry in entries: + errors = validate_entry(entry, taxonomy) + if errors: + print(f"FAIL {entry.get('slug')} 校验失败:") + for error in errors: + print(f" - {error}") + raise SystemExit(1) + + if args.execute: + asyncio.run(cmd_execute(entries)) + else: + asyncio.run(cmd_archive(entries)) + + +if __name__ == "__main__": + main() diff --git a/server/services/interaction_types.py b/server/services/interaction_types.py new file mode 100644 index 0000000..2cfe325 --- /dev/null +++ b/server/services/interaction_types.py @@ -0,0 +1,31 @@ +"""交互类型归一化与写入校验。 + +interactionType 区分场景题的两种交互:standard(旧题,可缺省)与 +progressive_hints(渐进式按需提示)。读取侧缺失/未知一律归一为 standard, +旧题不会误入渐进交互;写入侧必须是合法值,拼写错误直接拒绝。 +""" + +STANDARD = "standard" +PROGRESSIVE_HINTS = "progressive_hints" + +_VALID = {STANDARD, PROGRESSIVE_HINTS} + + +def normalize_interaction_type(value: object) -> str: + """读取归一化:缺失/未知/非字符串都按 standard。""" + return value if value in _VALID else STANDARD + + +def validate_new_interaction_type(value: object) -> str: + """新题写入校验:非法值直接抛错,不能把拼写错误写进生产。""" + if value in _VALID: + return value + raise ValueError(f"unknown interactionType: {value!r}") + + +def scenario_hints(scenario: dict | None) -> list[str]: + """展示用有序提示列表;standard/缺失一律返回空数组。""" + scenario = scenario or {} + if normalize_interaction_type(scenario.get("interactionType")) != PROGRESSIVE_HINTS: + return [] + return [h for h in scenario.get("hints") or [] if isinstance(h, str) and h.strip()] diff --git a/server/services/practice_attempts.py b/server/services/practice_attempts.py index cd0f60d..1b33982 100644 --- a/server/services/practice_attempts.py +++ b/server/services/practice_attempts.py @@ -129,6 +129,9 @@ async def reserve_attempt( "userId": practice["userId"], "sourceType": normalize_source_type(practice.get("sourceType")), "round": round_no, + # 作答当时已显示的提示数:服务端从 Session 复制,客户端不能伪造; + # 只用于分层分析,不进 corrector / 评分 / 反馈文案 + "hintCount": int(practice.get("revealedHintCount") or 0), "mode": mode, "freeTopic": free_topic if mode == "free" else "", "transcript": transcript, diff --git a/server/tests/integration/test_progressive_hints.py b/server/tests/integration/test_progressive_hints.py new file mode 100644 index 0000000..5877814 --- /dev/null +++ b/server/tests/integration/test_progressive_hints.py @@ -0,0 +1,272 @@ +"""渐进式场景提示:slug 精确取题、创建会话幂等/重校验、提示原子领取、Attempt 计数。""" + +from copy import deepcopy +from unittest.mock import AsyncMock, patch + +from pymongo import MongoClient + +from tests.conftest import TEST_DB_NAME, login_headers + +PROGRESSIVE_SCENARIO = { + "_id": "sc_prog_coffee", + "slug": "prog-coffee-remake", + "kind": "task", + "interactionType": "progressive_hints", + "title": "咖啡店重做饮品", + "where": "咖啡店 · 西雅图", + "story": "店员把你的热拿铁做成了冰拿铁,你赶时间想尽快解决。", + "mission": "礼貌说明问题,请店员重做。", + "points": ["说明饮品做错了", "要求重做一杯热的"], + "hints": ["我点的是热拿铁,但这杯是冰的。", "能麻烦你重新做一杯热的吗?"], + "difficulty": 2, + "imageKey": "", + "videoKey": "", + "ownerUserId": None, + "status": "active", +} + + +def _db(): + return MongoClient("mongodb://localhost:27017/")[TEST_DB_NAME] + + +def _seed(doc): + db = _db() + db.scenarios.insert_one(deepcopy(doc)) + + +def _create_session(client, auth_headers, user_id, scenario_id, request_id=None): + body = {"userId": user_id, "scenarioId": scenario_id} + if request_id is not None: + body["requestId"] = request_id + return client.post("/api/practice-sessions", json=body, headers=auth_headers) + + +# ---------- GET /api/scenarios/by-slug/{slug} ---------- + +def test_by_slug_returns_progressive_scenario(client, auth_headers): + _seed(PROGRESSIVE_SCENARIO) + resp = client.get("/api/scenarios/by-slug/prog-coffee-remake", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["scenarioId"] == "sc_prog_coffee" + assert data["interactionType"] == "progressive_hints" + assert data["hints"] == PROGRESSIVE_SCENARIO["hints"] + assert data["difficulty"] == 2 + assert data["points"] == PROGRESSIVE_SCENARIO["points"] + assert data["mission"] == PROGRESSIVE_SCENARIO["mission"] + + +def test_by_slug_normalizes_legacy_scenario(client, auth_headers, scenario_id): + """旧题缺 interactionType:归一为 standard + hints 空数组。""" + resp = client.get("/api/scenarios/by-slug/test-coffee", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["interactionType"] == "standard" + assert data["hints"] == [] + + +def test_by_slug_missing_archived_invalid_all_404(client, auth_headers): + _seed(PROGRESSIVE_SCENARIO) + assert client.get("/api/scenarios/by-slug/no-such-slug", headers=auth_headers).status_code == 404 + assert client.get("/api/scenarios/by-slug/UPPER-CASE", headers=auth_headers).status_code == 404 + _db().scenarios.update_one({"_id": "sc_prog_coffee"}, {"$set": {"status": "archived"}}) + assert client.get("/api/scenarios/by-slug/prog-coffee-remake", headers=auth_headers).status_code == 404 + + +def test_by_slug_cannot_read_other_users_custom(client, auth_headers): + custom = { + **PROGRESSIVE_SCENARIO, + "_id": "sc_prog_custom", + "slug": "prog-custom-other", + "ownerUserId": "u_someone_else", + } + _seed(custom) + resp = client.get("/api/scenarios/by-slug/prog-custom-other", headers=auth_headers) + assert resp.status_code == 404 + + +# ---------- POST /api/practice-sessions(重校验 + 幂等) ---------- + +def test_create_session_snapshots_progressive_fields(client, user_id, auth_headers): + _seed(PROGRESSIVE_SCENARIO) + resp = _create_session(client, auth_headers, user_id, "sc_prog_coffee", "req-start-1") + assert resp.status_code == 200 + p = resp.json() + assert p["scenario"]["interactionType"] == "progressive_hints" + assert p["scenario"]["hints"] == PROGRESSIVE_SCENARIO["hints"] + assert p["scenario"]["difficulty"] == 2 + assert p["revealedHintCount"] == 0 + assert p["creationRequestId"] == "req-start-1" + + +def test_create_session_revalidates_archived_scenario(client, user_id, auth_headers): + _seed(PROGRESSIVE_SCENARIO) + _db().scenarios.update_one({"_id": "sc_prog_coffee"}, {"$set": {"status": "archived"}}) + resp = _create_session(client, auth_headers, user_id, "sc_prog_coffee") + assert resp.status_code == 404 + assert _db().practiceSessions.count_documents({"userId": user_id}) == 0 + + +def test_create_session_rejects_other_users_scenario(client, user_id, auth_headers): + custom = {**PROGRESSIVE_SCENARIO, "_id": "sc_prog_custom2", "ownerUserId": "u_someone_else"} + _seed(custom) + assert _create_session(client, auth_headers, user_id, "sc_prog_custom2").status_code == 404 + + +def test_create_session_same_request_id_is_idempotent(client, user_id, auth_headers): + _seed(PROGRESSIVE_SCENARIO) + first = _create_session(client, auth_headers, user_id, "sc_prog_coffee", "req-dup") + second = _create_session(client, auth_headers, user_id, "sc_prog_coffee", "req-dup") + assert first.status_code == 200 and second.status_code == 200 + assert first.json()["_id"] == second.json()["_id"] + assert _db().practiceSessions.count_documents({"userId": user_id}) == 1 + + +def test_create_session_same_request_id_conflicting_params_409(client, user_id, auth_headers, scenario_id): + _seed(PROGRESSIVE_SCENARIO) + first = _create_session(client, auth_headers, user_id, "sc_prog_coffee", "req-conflict") + assert first.status_code == 200 + other = _create_session(client, auth_headers, user_id, scenario_id, "req-conflict") + assert other.status_code == 409 + + +def test_create_session_request_id_scoped_per_user(client, user_id, auth_headers): + _seed(PROGRESSIVE_SCENARIO) + _create_session(client, auth_headers, user_id, "sc_prog_coffee", "req-shared") + other_uid, other_headers = login_headers(client, "13900009999") + resp = _create_session(client, other_headers, other_uid, "sc_prog_coffee", "req-shared") + assert resp.status_code == 200 + assert resp.json()["userId"] == other_uid + + +def test_create_session_without_request_id_keeps_legacy_shape(client, user_id, auth_headers): + _seed(PROGRESSIVE_SCENARIO) + resp = _create_session(client, auth_headers, user_id, "sc_prog_coffee") + assert resp.status_code == 200 + assert "creationRequestId" not in resp.json() + + +# ---------- POST /api/practice-sessions/{pid}/hints/next ---------- + +def _progressive_session(client, user_id, auth_headers): + _seed(PROGRESSIVE_SCENARIO) + resp = _create_session(client, auth_headers, user_id, "sc_prog_coffee") + return resp.json()["_id"] + + +def _reveal(client, auth_headers, pid, request_id): + return client.post( + f"/api/practice-sessions/{pid}/hints/next", + json={"requestId": request_id}, + headers=auth_headers, + ) + + +def test_reveal_walks_hints_in_order_then_exhausts(client, user_id, auth_headers): + pid = _progressive_session(client, user_id, auth_headers) + first = _reveal(client, auth_headers, pid, "req-h1") + assert first.status_code == 200 + assert first.json() == { + "requestId": "req-h1", + "revealedHintCount": 1, + "hintIndex": 0, + "hint": PROGRESSIVE_SCENARIO["hints"][0], + "exhausted": False, + } + second = _reveal(client, auth_headers, pid, "req-h2") + assert second.json()["revealedHintCount"] == 2 + assert second.json()["hint"] == PROGRESSIVE_SCENARIO["hints"][1] + assert second.json()["exhausted"] is True + third = _reveal(client, auth_headers, pid, "req-h3") + assert third.status_code == 200 + assert third.json() == { + "requestId": "req-h3", + "revealedHintCount": 2, + "hintIndex": None, + "hint": None, + "exhausted": True, + } + assert _db().practiceSessions.find_one({"_id": pid})["revealedHintCount"] == 2 + + +def test_reveal_same_request_id_replays_without_increment(client, user_id, auth_headers): + pid = _progressive_session(client, user_id, auth_headers) + first = _reveal(client, auth_headers, pid, "req-retry") + retry = _reveal(client, auth_headers, pid, "req-retry") + assert first.json() == retry.json() + session = _db().practiceSessions.find_one({"_id": pid}) + assert session["revealedHintCount"] == 1 + assert len(session["hintReveals"]) == 1 + + +def test_reveal_state_restores_on_session_read(client, user_id, auth_headers): + """刷新恢复:GET 会话返回服务端计数与快照提示,前端据此渲染前缀。""" + pid = _progressive_session(client, user_id, auth_headers) + _reveal(client, auth_headers, pid, "req-a") + session = client.get(f"/api/practice-sessions/{pid}", headers=auth_headers).json() + assert session["revealedHintCount"] == 1 + assert session["scenario"]["hints"] == PROGRESSIVE_SCENARIO["hints"] + assert session["scenario"]["interactionType"] == "progressive_hints" + + +def test_reveal_on_standard_session_is_409(client, user_id, auth_headers, practice_id): + resp = _reveal(client, auth_headers, practice_id, "req-x") + assert resp.status_code == 409 + + +def test_reveal_on_missing_or_foreign_session_is_404(client, user_id, auth_headers): + pid = _progressive_session(client, user_id, auth_headers) + assert _reveal(client, auth_headers, "ps_no_such", "req-x").status_code == 404 + _, other_headers = login_headers(client, "13900008888") + assert _reveal(client, other_headers, pid, "req-x").status_code == 404 + + +def test_reveal_rejects_empty_request_id(client, user_id, auth_headers): + pid = _progressive_session(client, user_id, auth_headers) + assert _reveal(client, auth_headers, pid, "").status_code == 422 + assert client.post( + f"/api/practice-sessions/{pid}/hints/next", json={}, headers=auth_headers + ).status_code == 422 + + +# ---------- Attempt hintCount ---------- + +FAKE_AI_RESULT = { + "summary": "请求语气可以更礼貌。", + "standardAnswer": "Excuse me, my latte was iced but I ordered it hot.", + "standardAnswerNotes": [], + "gaps": [], + "progress": None, +} + + +def test_attempt_copies_hint_count_and_ignores_client_value(client, user_id, auth_headers): + pid = _progressive_session(client, user_id, auth_headers) + _reveal(client, auth_headers, pid, "req-h1") + with patch("routes.correct.correct_text", new=AsyncMock(return_value=deepcopy(FAKE_AI_RESULT))): + resp = client.post( + "/api/correct", + json={ + "userId": user_id, + "practiceId": pid, + "text": "My latte is iced but I ordered it hot.", + "hintCount": 99, + }, + headers=auth_headers, + ) + assert resp.status_code == 200 + attempt = _db().practiceAttempts.find_one({"practiceId": pid}) + assert attempt["hintCount"] == 1 + + +def test_standard_attempt_hint_count_defaults_zero(client, user_id, auth_headers, practice_id): + with patch("routes.correct.correct_text", new=AsyncMock(return_value=deepcopy(FAKE_AI_RESULT))): + resp = client.post( + "/api/correct", + json={"userId": user_id, "practiceId": practice_id, "text": "Could you remake it, please?"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + attempt = _db().practiceAttempts.find_one({"practiceId": practice_id}) + assert attempt["hintCount"] == 0 diff --git a/server/tests/unit/test_interaction_types.py b/server/tests/unit/test_interaction_types.py new file mode 100644 index 0000000..5ba9b16 --- /dev/null +++ b/server/tests/unit/test_interaction_types.py @@ -0,0 +1,41 @@ +import pytest + +from services.interaction_types import ( + PROGRESSIVE_HINTS, + STANDARD, + normalize_interaction_type, + scenario_hints, + validate_new_interaction_type, +) + + +def test_missing_and_unknown_normalize_to_standard(): + """旧题缺字段或出现未知值时一律按 standard,绝不触发渐进交互。""" + assert normalize_interaction_type(None) == STANDARD + assert normalize_interaction_type("") == STANDARD + assert normalize_interaction_type("progressive") == STANDARD + assert normalize_interaction_type("PROGRESSIVE_HINTS") == STANDARD + assert normalize_interaction_type(123) == STANDARD + + +def test_known_values_pass_through(): + assert normalize_interaction_type(STANDARD) == STANDARD + assert normalize_interaction_type(PROGRESSIVE_HINTS) == PROGRESSIVE_HINTS + + +def test_validate_new_rejects_unknown(): + """新写入必须是合法值,拼写错误不能进生产。""" + assert validate_new_interaction_type(STANDARD) == STANDARD + assert validate_new_interaction_type(PROGRESSIVE_HINTS) == PROGRESSIVE_HINTS + with pytest.raises(ValueError): + validate_new_interaction_type("progressive-hints") + with pytest.raises(ValueError): + validate_new_interaction_type(None) + + +def test_scenario_hints_only_for_progressive(): + progressive = {"interactionType": PROGRESSIVE_HINTS, "hints": ["甲", "", " ", "乙", 3]} + assert scenario_hints(progressive) == ["甲", "乙"] + assert scenario_hints({"hints": ["甲"]}) == [] + assert scenario_hints(None) == [] + assert scenario_hints({"interactionType": "weird", "hints": ["甲"]}) == [] diff --git a/server/tests/unit/test_progressive_prompt_isolation.py b/server/tests/unit/test_progressive_prompt_isolation.py new file mode 100644 index 0000000..7605767 --- /dev/null +++ b/server/tests/unit/test_progressive_prompt_isolation.py @@ -0,0 +1,73 @@ +"""评分隔离:hints 文本与 hintCount 永不进入纠错 / 标准答案请求(AC-05)。 + +hints 只是 Session 快照里的展示字段;corrector 与标准答案的消息构造函数都走 +字段白名单。这里直接对构造出的模型消息做断言:提示不在 prompt 里,且只有 +hintCount 不同时,构造出的请求完全一致。 +""" + +import json + +from services.corrector import _build_messages +from services.standard_answer import _question_snapshot, build_standard_answer_messages + +SCENARIO_WITH_HINTS = { + "kind": "task", + "title": "外卖少送了一份菜", + "where": "家里 · 晚餐时间", + "story": "你点了两份主菜的外卖,骑手刚走你就发现少送了一份。", + "mission": "找平台客服解决少送的菜", + "points": ["说明订单里有一份菜没有送到", "要求补送或者退钱"], + "interactionType": "progressive_hints", + "hints": ["我点的两份菜只送到了一份。", "请帮我把缺的那份补送来。"], + "difficulty": 2, +} + + +def _all_content(messages) -> str: + return "\n".join(m.content for m in messages) + + +def test_hints_do_not_enter_corrector_messages(): + messages = _build_messages("Excuse me, one dish is missing.", SCENARIO_WITH_HINTS, None, 1, "scenario") + text = _all_content(messages) + for hint in SCENARIO_WITH_HINTS["hints"]: + assert hint not in text + assert "hints" not in text + + +def test_task_points_still_checked_but_only_points(): + text = _all_content(_build_messages("text", SCENARIO_WITH_HINTS, None, 1, "scenario")) + for point in SCENARIO_WITH_HINTS["points"]: + assert point in text + + +def test_only_hint_count_differs_model_request_identical(): + """hintCount 只存在 Session/Attempt 上;相同题目相同作答、仅提示计数不同的两个会话,构造出的模型请求完全一致。""" + a = dict(SCENARIO_WITH_HINTS) + b = dict(SCENARIO_WITH_HINTS) + text = "I ordered two dishes but only got one." + assert _build_messages(text, a, None, 1, "scenario") == _build_messages(text, b, None, 1, "scenario") + + +def test_progressive_non_task_has_no_required_points_block(): + scenario = {**SCENARIO_WITH_HINTS, "kind": "chat", "points": []} + text = _all_content(_build_messages("hello", scenario, None, 1, "scenario")) + assert "应说到的内容" not in text + + +def test_standard_answer_snapshot_excludes_hints(): + snapshot = _question_snapshot(SCENARIO_WITH_HINTS) + assert "hints" not in snapshot + serialized = json.dumps(snapshot, ensure_ascii=False) + for hint in SCENARIO_WITH_HINTS["hints"]: + assert hint not in serialized + assert snapshot.get("points") == SCENARIO_WITH_HINTS["points"] + + +def test_standard_answer_messages_identical_with_or_without_hints(): + with_hints = dict(SCENARIO_WITH_HINTS) + without = {k: v for k, v in SCENARIO_WITH_HINTS.items() if k not in ("hints", "interactionType")} + assert ( + build_standard_answer_messages(with_hints)[1].content + == build_standard_answer_messages(without)[1].content + ) diff --git a/server/tests/unit/test_scenario_quality_eval.py b/server/tests/unit/test_scenario_quality_eval.py index 1434c6e..295d3f6 100644 --- a/server/tests/unit/test_scenario_quality_eval.py +++ b/server/tests/unit/test_scenario_quality_eval.py @@ -39,3 +39,62 @@ def test_similarity_distinguishes_unrelated_real_life_tasks(): "mission": "请前台立即维修或提供替代方案", } assert scenario_similarity(GOOD, unrelated) < 0.5 + + +# --- 渐进式试点题分流(interactionType=progressive_hints)--- + +def _progressive(**overrides): + base = { + "interactionType": "progressive_hints", + "kind": "chat", + "title": "茶水间聊周末", + "where": "公司茶水间 · 周一上午", + "story": "周一早上你在茶水间碰到不太熟的外国同事,他随口问你周末过得怎么样。", + "mission": "自然地聊几句你的周末", + "points": [], + "hints": ["上周末我去了趟郊外,天气特别好。", "你呢,周末一般喜欢做点什么?"], + } + base.update(overrides) + return base + + +def test_progressive_non_task_passes_with_empty_points_and_hints(): + assert hard_pass(_progressive()) + + +def test_progressive_task_requires_aligned_points_and_hints(): + task = _progressive( + kind="task", + points=["说明订单少送了一份", "要求补送或退款"], + hints=["我点的两份菜只送到了一份。", "请帮我补送缺的那份。"], + ) + assert hard_pass(task) + failed = {c.name for c in grade_scenario({**task, "hints": ["只有一条提示"]}) if not c.passed} + assert "points_hints_aligned" in failed + assert "progressive_hints" in failed + + +def test_progressive_non_task_rejects_points(): + failed = {c.name for c in grade_scenario(_progressive(points=["不该出现的要点"])) if not c.passed} + assert "progressive_points" in failed + + +def test_progressive_hints_must_be_chinese_and_speakable(): + english = _progressive(hints=["I went out last weekend.", "你呢,周末喜欢做什么?"]) + assert "hints_chinese_only" in {c.name for c in grade_scenario(english) if not c.passed} + vague = _progressive(hints=["语气坚定但礼貌地回应", "围绕这个话题展开讨论"]) + assert "speakable_hints" in {c.name for c in grade_scenario(vague) if not c.passed} + + +def test_progressive_unknown_kind_fails(): + failed = {c.name for c in grade_scenario(_progressive(kind="debate")) if not c.passed} + assert "known_kind" in failed + + +def test_standard_check_sequence_unchanged(): + """旧题不迁移:没有 interactionType 时输出与改造前完全一致。""" + names = [c.name for c in grade_scenario(GOOD)] + assert names == [ + "required_fields", "exactly_two_points", "story_length", "mission_length", + "no_exam_smell", "speakable_points", "no_emoji_in_label", "not_near_duplicate", + ] diff --git a/web/e2e/progressive.spec.js b/web/e2e/progressive.spec.js new file mode 100644 index 0000000..dd190e3 --- /dev/null +++ b/web/e2e/progressive.spec.js @@ -0,0 +1,150 @@ +import { expect, test } from "@playwright/test"; + +// 录音有 "http 仅限 localhost" 守卫(生产走 https),e2e 走 localhost 入口 +test.use({ baseURL: "http://localhost:4173" }); + +// 渐进式场景提示 journey:指定题目 URL → 开始建 Session → 逐条提示 → 刷新恢复 → 不可用态。 +// 全链路 mock /api/**,与 journey.spec.js 同套路,多引擎/移动视口矩阵下跑。 +const USER = { + userId: "u_progressive", + phone: "13800002222", + nickname: "progressive", + sourceType: "ai_test", + token: "tok_progressive", +}; + +const PROGRESSIVE = { + scenarioId: "sc_prog_e2e", + kind: "task", + title: "咖啡店重做饮品", + where: "咖啡店 · 西雅图", + story: "店员把你的热拿铁做成了冰拿铁。", + mission: "礼貌说明问题,请店员重做。", + points: ["说明饮品做错了", "要求重做一杯热的"], + imageUrl: "", + videoUrl: "", + isCustom: false, + preferenceMatch: "exact", + targetWords: [], + difficulty: 2, + interactionType: "progressive_hints", + hints: ["我点的是热拿铁,但这杯是冰的。", "能麻烦你重新做一杯热的吗?"], +}; + +const SESSION = { + _id: "sess_prog_e2e", + userId: USER.userId, + scenarioId: "sc_prog_e2e", + mode: "scenario", + sourceType: "ai_test", + title: "咖啡店重做饮品", + topic: "咖啡店 · 西雅图", + scenario: { + kind: "task", + title: "咖啡店重做饮品", + where: "咖啡店 · 西雅图", + story: "店员把你的热拿铁做成了冰拿铁。", + mission: "礼貌说明问题,请店员重做。", + points: ["说明饮品做错了", "要求重做一杯热的"], + targetWords: [], + interactionType: "progressive_hints", + hints: ["我点的是热拿铁,但这杯是冰的。", "能麻烦你重新做一杯热的吗?"], + difficulty: 2, + }, + revealedHintCount: 0, + attempts: [], +}; + +async function setup(page, { slugOk = true, revealed = 0 } = {}) { + const calls = { createSession: 0, reveals: [] }; + await page.route("https://fonts.googleapis.com/**", (route) => route.fulfill({ body: "", contentType: "text/css" })); + await page.route("https://fonts.gstatic.com/**", (route) => route.abort()); + await page.addInitScript((user) => { + localStorage.setItem("english-speak-user", JSON.stringify(user)); + localStorage.setItem("speakup_lang", "en"); + localStorage.setItem( + `speakup-practice-preferences:${user.userId}`, + JSON.stringify({ level: "daily", purpose: "travel" }), + ); + }, USER); + page.on("dialog", (dialog) => dialog.dismiss()); + await page.route("**/api/**", async (route) => { + const { pathname } = new URL(route.request().url()); + const method = route.request().method(); + if (pathname === "/api/scenarios/by-slug/prog-e2e") { + if (!slugOk) { + return route.fulfill({ status: 404, json: { detail: "场景不存在或不可用" } }); + } + return route.fulfill({ json: PROGRESSIVE }); + } + if (pathname === "/api/practice-sessions" && method === "POST") { + calls.createSession += 1; + return route.fulfill({ json: { ...SESSION, revealedHintCount: revealed } }); + } + if (pathname === "/api/practice-sessions/sess_prog_e2e") { + return route.fulfill({ json: { ...SESSION, revealedHintCount: revealed } }); + } + if (pathname === "/api/practice-sessions/sess_prog_e2e/hints/next") { + const body = route.request().postDataJSON(); + calls.reveals.push(body.requestId); + const count = revealed + calls.reveals.length; + const exhausted = count >= PROGRESSIVE.hints.length; + return route.fulfill({ + json: { + requestId: body.requestId, + revealedHintCount: Math.min(count, PROGRESSIVE.hints.length), + hintIndex: exhausted ? null : count - 1, + hint: exhausted ? null : PROGRESSIVE.hints[count - 1], + exhausted, + }, + }); + } + return route.fulfill({ json: {} }); + }); + return calls; +} + +test("指定题目 URL 精确练习:开始后逐条提示、用尽后明确状态", async ({ page }) => { + const calls = await setup(page); + await page.goto("/practice?scenario=prog-e2e", { waitUntil: "domcontentloaded" }); + + // 待开始:只有宽泛 mission,不显示 points,也没有提示按钮(会话未创建) + await expect(page.getByText("店员把你的热拿铁做成了冰拿铁。").first()).toBeVisible(); + await expect(page.getByText("礼貌说明问题,请店员重做。").first()).toBeVisible(); + await expect(page.getByText("说明饮品做错了")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Give me a hint" })).toHaveCount(0); + expect(calls.createSession).toBe(0); + + // 开始动作创建真实 Session 并进入规范 Session URL + await page.locator(".su-rec").first().click(); + await expect(page).toHaveURL(/\/practice\/sess_prog_e2e/); + + // 逐条领取:一次一条,累计保留,用尽后不再增加 + await page.getByRole("button", { name: "Give me a hint" }).click(); + await expect(page.getByText("我点的是热拿铁,但这杯是冰的。")).toBeVisible(); + await expect(page.getByText("能麻烦你重新做一杯热的吗?")).toHaveCount(0); + + await page.getByRole("button", { name: "Give me another hint" }).click(); + await expect(page.getByText("能麻烦你重新做一杯热的吗?")).toBeVisible(); + await expect(page.getByText("All hints shown")).toBeVisible(); + await expect(page.getByRole("button", { name: "Give me another hint" })).toHaveCount(0); + expect(calls.reveals.length).toBe(2); +}); + +test("不可用 slug:明确错误,不创建 Session 不换随机题", async ({ page }) => { + const calls = await setup(page, { slugOk: false }); + await page.goto("/practice?scenario=prog-e2e", { waitUntil: "domcontentloaded" }); + + await expect(page.getByText("This scenario is not available.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Back to practice" })).toBeVisible(); + expect(calls.createSession).toBe(0); +}); + +test("刷新后按服务端计数恢复已显示提示", async ({ page }) => { + await setup(page, { revealed: 1 }); + await page.goto("/practice/sess_prog_e2e", { waitUntil: "domcontentloaded" }); + + await expect(page.getByText("我点的是热拿铁,但这杯是冰的。")).toBeVisible(); + await expect(page.getByText("能麻烦你重新做一杯热的吗?")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Give me another hint" })).toBeVisible(); +}); diff --git a/web/src/api/client.js b/web/src/api/client.js index 564aa6d..fda56be 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -190,6 +190,9 @@ export const api = { }, removeAvatar: () => request("/auth/profile/avatar", { method: "DELETE" }), + // 指定题目入口:按 slug 精确取题(/practice?scenario= 用),失败由调用方展示不可用态 + scenarioBySlug: (slug) => request(`/scenarios/by-slug/${encodeURIComponent(slug)}`), + nextScenario: (userId, exclude = [], prefs = {}) => { const params = new URLSearchParams({ userId }); for (const id of exclude) params.append("exclude", id); @@ -210,6 +213,10 @@ export const api = { nextFreeTopic: (userId) => request(`/free-topics/next?userId=${userId}`), createPractice: (data) => request("/practice-sessions", { method: "POST", body: data }), + + // 渐进式提示:原子领取下一条;同 requestId 网络重试服务端幂等 + revealNextHint: (practiceId, requestId) => + request(`/practice-sessions/${practiceId}/hints/next`, { method: "POST", body: { requestId } }), getPractice: (id) => request(`/practice-sessions/${id}`), listPractices: (userId, skip = 0) => request(`/practice-sessions?userId=${userId}&skip=${skip}`), diff --git a/web/src/api/client.test.js b/web/src/api/client.test.js index 78a6e3c..c7ab9c4 100644 --- a/web/src/api/client.test.js +++ b/web/src/api/client.test.js @@ -412,3 +412,40 @@ describe("api/client SSE 流(correctStream / chatStream)", () => { expect(onError.mock.calls[0][0].message).toBe("boom"); }); }); + +describe("渐进式提示与指定题目接口", () => { + let fetchMock; + + beforeEach(() => { + localStorage.clear(); + storeToken(); + fetchMock = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + localStorage.clear(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("scenarioBySlug 按路径参数精确取题", async () => { + await api.scenarioBySlug("prog-coffee-remake"); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/scenarios/by-slug/prog-coffee-remake"); + expect(opts.headers.Authorization).toBe("Bearer tok_test"); + }); + + it("scenarioBySlug 对 slug 做 URL 编码", async () => { + await api.scenarioBySlug("a b"); + expect(fetchMock.mock.calls[0][0]).toBe("/api/scenarios/by-slug/a%20b"); + }); + + it("revealNextHint 带 requestId POST", async () => { + await api.revealNextHint("ps_1", "req-uuid"); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/practice-sessions/ps_1/hints/next"); + expect(opts.method).toBe("POST"); + expect(JSON.parse(opts.body)).toEqual({ requestId: "req-uuid" }); + }); +}); diff --git a/web/src/components/practice/PracticeActiveView.jsx b/web/src/components/practice/PracticeActiveView.jsx index 523eef8..ca52db9 100644 --- a/web/src/components/practice/PracticeActiveView.jsx +++ b/web/src/components/practice/PracticeActiveView.jsx @@ -48,6 +48,9 @@ export default function PracticeActiveView({ handleRecordPressStart, hintGaps, hintAttemptId, + hintBusy, + hintCount, + hintError, mode, modeSwitch, onChangeTopic, @@ -55,7 +58,9 @@ export default function PracticeActiveView({ paused, pauseResumeRecording, pauseSupported, + pendingScenario, phase, + revealNextHint, round, scenario, session, @@ -70,6 +75,16 @@ export default function PracticeActiveView({ }) { const isFree = mode === "free"; const prompts = buildPrompts(mode, t); + // 渐进式提示:按钮只在已创建的渐进式 Session 中出现;待开始预览不出现。 + // 已显示前缀按服务端 revealedHintCount 恢复,累计保留,不替换。 + const scenarioHints = session?.scenario?.hints ?? []; + const revealedHints = scenarioHints.slice(0, Math.max(0, hintCount ?? 0)); + const hintsExhausted = scenarioHints.length > 0 && revealedHints.length >= scenarioHints.length; + const showProgressiveHints = + !isFree + && session + && session.scenario?.interactionType === "progressive_hints" + && ["ready", "recording", "transcribing", "review", "evaluating"].includes(phase); // 话题展示:优先当前抽到的话题;刷新后从会话快照还原(zh 不在快照里,可空) const freeInfo = freeTopic || (isFree && session?.freeTopic @@ -86,8 +101,8 @@ export default function PracticeActiveView({ )} {phase !== "loading" && (isFree @@ -117,6 +132,33 @@ export default function PracticeActiveView({ )} + {showProgressiveHints && ( +
+ {revealedHints.length > 0 && ( +
    + {revealedHints.map((h, i) =>
  • {h}
  • )} +
+ )} + {hintsExhausted ? ( +
{t("practice.hintExhausted")}
+ ) : ( + + )} + {hintError &&
{hintError}
} +
+ )} +

{prompts[phase]}

{(phase === "recording" || phase === "transcribing" || phase === "evaluating") && ( diff --git a/web/src/components/practice/PracticeScenarioCard.jsx b/web/src/components/practice/PracticeScenarioCard.jsx index e72d3a4..382b775 100644 --- a/web/src/components/practice/PracticeScenarioCard.jsx +++ b/web/src/components/practice/PracticeScenarioCard.jsx @@ -10,7 +10,10 @@ const stripEmoji = (s = "") => export default function PracticeScenarioCard({ scenario, topic, t }) { const points = scenario?.points ?? []; + // 渐进式题首次作答前只给宽泛 mission,不展示可照读的 points;旧题行为不变 + const progressive = scenario?.interactionType === "progressive_hints"; const where = stripEmoji(scenario?.where || topic || t("practice.scene_default")); + const showPoints = !progressive && points.length > 0; return (
@@ -24,7 +27,7 @@ export default function PracticeScenarioCard({ scenario, topic, t }) {
{t("practice.goal")}
- {points.length > 0 ? ( + {showPoints ? (
    {points.map((p, i) =>
  • {stripEmoji(p)}
  • )}
diff --git a/web/src/components/practice/PracticeScenarioCard.test.jsx b/web/src/components/practice/PracticeScenarioCard.test.jsx new file mode 100644 index 0000000..4c32d4e --- /dev/null +++ b/web/src/components/practice/PracticeScenarioCard.test.jsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; + +import PracticeScenarioCard from "./PracticeScenarioCard.jsx"; + +const t = (key) => ({ + "practice.place": "地点", + "practice.scene": "情境", + "practice.goal": "任务", + "practice.scene_default": "场景", +}[key] || key); + +const STANDARD = { + where: "咖啡店 · 西雅图", + story: "店员把你的热拿铁做成了冰拿铁。", + mission: "礼貌说明问题,请店员重做。", + points: ["说明饮品做错了", "要求重做一杯热的"], +}; + +describe("PracticeScenarioCard", () => { + it("standard 题沿用 points 优先展示", () => { + render(); + expect(screen.getByText("说明饮品做错了")).toBeInTheDocument(); + expect(screen.getByText("要求重做一杯热的")).toBeInTheDocument(); + expect(screen.queryByText("礼貌说明问题,请店员重做。")).not.toBeInTheDocument(); + }); + + it("standard 题无 points 时展示 mission", () => { + render(); + expect(screen.getByText("礼貌说明问题,请店员重做。")).toBeInTheDocument(); + }); + + it("渐进式题即使带 points 也只展示宽泛 mission", () => { + render( + , + ); + expect(screen.getByText("礼貌说明问题,请店员重做。")).toBeInTheDocument(); + expect(screen.queryByText("说明饮品做错了")).not.toBeInTheDocument(); + expect(screen.queryByText("提示一")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/practice/PracticeUnavailable.jsx b/web/src/components/practice/PracticeUnavailable.jsx new file mode 100644 index 0000000..f40ca7e --- /dev/null +++ b/web/src/components/practice/PracticeUnavailable.jsx @@ -0,0 +1,14 @@ +// 指定题目 URL 不可用(不存在/已归档/无权):明确提示并可返回,不静默换随机题。 +export default function PracticeUnavailable({ modeSwitch, onBack, t }) { + return ( +
+ {modeSwitch} +
+

{t("practice.scenarioUnavailable")}

+ +
+
+ ); +} diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 3e89f8b..a89f7bc 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -138,7 +138,14 @@ "emptyFeedback": "AI did not return usable feedback. Please try again.", "micFailed": "Can't access microphone: {msg}", "removeFailed": "Failed to remove: {msg}", - "addFailed": "Failed to add: {msg}" + "addFailed": "Failed to add: {msg}", + "hintFirst": "Give me a hint", + "hintMore": "Give me another hint", + "hintExhausted": "All hints shown", + "hintLoading": "Loading…", + "hintFailed": "Could not load the hint: {msg}", + "scenarioUnavailable": "This scenario is not available.", + "backToPractice": "Back to practice" }, "practicePrefs": { "settingsSub": "Changes apply immediately. The next scenario will use this preference.", diff --git a/web/src/i18n/zh-CN.json b/web/src/i18n/zh-CN.json index e2b969f..d560a74 100644 --- a/web/src/i18n/zh-CN.json +++ b/web/src/i18n/zh-CN.json @@ -138,7 +138,14 @@ "emptyFeedback": "AI 没有返回可用反馈,请重试。", "micFailed": "无法访问麦克风:{msg}", "removeFailed": "取消收录失败:{msg}", - "addFailed": "加入复习失败:{msg}" + "addFailed": "加入复习失败:{msg}", + "hintFirst": "给我一点提示", + "hintMore": "再给一点提示", + "hintExhausted": "已显示全部提示", + "hintLoading": "正在加载提示…", + "hintFailed": "提示获取失败:{msg}", + "scenarioUnavailable": "该场景不存在或不可用。", + "backToPractice": "返回练习" }, "practicePrefs": { "settingsSub": "修改后立即生效,下一道场景会按新的偏好匹配。", diff --git a/web/src/pages/PracticePage.jsx b/web/src/pages/PracticePage.jsx index ff91e29..80dfe70 100644 --- a/web/src/pages/PracticePage.jsx +++ b/web/src/pages/PracticePage.jsx @@ -2,11 +2,12 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useParams, useNavigate, useSearchParams } from "react-router-dom"; import { useUser } from "../context/useUser.js"; import { useT } from "../i18n/useI18n.js"; -import { api, correctStream } from "../api/client.js"; +import { api } from "../api/client.js"; import PracticeActiveView from "../components/practice/PracticeActiveView.jsx"; import PracticeFeedbackView from "../components/practice/PracticeFeedbackView.jsx"; import PracticeModeSwitch from "../components/practice/PracticeModeSwitch.jsx"; import PracticePrefsWelcome from "../components/practice/PracticePrefsWelcome.jsx"; +import PracticeUnavailable from "../components/practice/PracticeUnavailable.jsx"; import { getPracticePreferences, hasPracticePreferences, @@ -17,12 +18,12 @@ import useFreeTopic from "./useFreeTopic.js"; import useFollowupChat from "./useFollowupChat.js"; import usePressGuard from "./usePressGuard.js"; import usePracticeRecorder from "./usePracticeRecorder.js"; +import useProgressiveScenario from "./useProgressiveScenario.js"; import { resolveRequestedAttempt } from "./practiceAttemptRouting.js"; import { readSkippedScenarios, writeSkippedScenarios } from "./practiceSkippedScenarios.js"; -import { trackPracticeRecordingStarted, trackPracticeResult } from "./practiceTelemetry.js"; -import { - EMPTY_FEEDBACK, hasUsableFeedback, resultFromAttempt, reviewMapFromGaps, -} from "./practiceFeedbackState.js"; +import { startEvaluation } from "./practiceEvaluation.js"; +import { trackPracticeRecordingStarted } from "./practiceTelemetry.js"; +import { resultFromAttempt, reviewMapFromGaps } from "./practiceFeedbackState.js"; import useResultShare from "./useResultShare.js"; export default function PracticePage() { @@ -40,9 +41,13 @@ export default function PracticePage() { const [session, setSession] = useState(null); const [phase, setPhase] = useState("loading"); + // 渐进式提示与指定题目待选题:计数/幂等以服务端为准(useProgressiveScenario) + const progressive = useProgressiveScenario(t); + const { pendingScenario, hintCount, hintBusy, hintError } = progressive; const [practicePrefs, setPracticePrefs] = useState(() => getPracticePreferences(user.userId)); const [needsPrefs, setNeedsPrefs] = useState( - () => !practiceId && mode !== "free" && !hasPracticePreferences(user.userId), + () => !practiceId && mode !== "free" && !searchParams.get("scenario") + && !hasPracticePreferences(user.userId), ); const [transcript, setTranscript] = useState(""); const [transcriptionError, setTranscriptionError] = useState(false); @@ -91,6 +96,7 @@ export default function PracticePage() { resetReviewCollection(); resetCapture(); setSession(null); + progressive.reset(); }; const startNewRound = async (extraSkip = null, overridePrefs = null) => { @@ -108,6 +114,7 @@ export default function PracticePage() { const sess = await api.createPractice({ userId: user.userId, scenarioId: scenario.scenarioId, + requestId: crypto.randomUUID(), }); setSession({ ...sess, @@ -169,6 +176,7 @@ export default function PracticePage() { if (practiceId) { api.getPractice(practiceId).then((s) => { setSession(s); + progressive.restoreHintCount(s); // 会话模式决定顶部切换器高亮与「下一个」行为(旧数据无 mode 按场景题) setMode(s?.mode === "free" ? "free" : "scenario"); const attempts = s.attempts ?? []; @@ -193,6 +201,11 @@ export default function PracticePage() { }).catch(console.error); return; } + const slug = searchParams.get("scenario"); + if (slug) { + progressive.loadBySlug(slug, setPhase); + return; + } if (mode === "free") { // 自由说:抽一个没说过的话题。回流去重:已有话题/正在抽就不重复请求。 // 微任务延迟同场景题分支:不在 effect 里同步 setState @@ -255,76 +268,12 @@ export default function PracticePage() { const active = sessOverride || session; const text = (textOverride ?? transcript).trim(); if (!text || !active) return; - setResult(EMPTY_FEEDBACK); - setFeedbackLoading(true); - setPhase("feedback"); - setEvalElapsed(0); - setStreamingLen(0); - evalTimerRef.current = setInterval(() => setEvalElapsed((s) => s + 1), 1000); - - sseControllerRef.current = correctStream( - { - userId: user.userId, - practiceId: active._id, - text, - // 自由说:不判任务完成度,后端据此走 FREE prompt;话题一并落 attempt - mode: active.mode === "free" ? "free" : "scenario", - freeTopic: active.freeTopic || "", - }, - { - onStarted: ({ attemptId, round: startedRound }) => { - setActiveAttemptId(attemptId); - if (startedRound) setRound(startedRound); - navigate(`/practice/${active._id}?attempt=${attemptId}`, { replace: true }); - }, - onChunk: (chunk) => setStreamingLen((n) => n + chunk.length), - onDone: ({ result: res, attemptId, round: r }) => { - clearInterval(evalTimerRef.current); - if (!hasUsableFeedback(res)) { - alert(t("practice.feedbackFailed", { msg: res?.summary || t("practice.emptyFeedback") })); - setFeedbackLoading(false); - setResult(null); - setPhase("review"); - navigate(`/practice/${active._id}`, { replace: true }); - return; - } - setResult(res); - setFeedbackLoading(false); - trackPracticeResult(active, res, r ?? round, user.userId); - // AI 自动收录的 gap 回传了 reviewItemId,用它初始化收录态(这样「已在错题本」可直接取消) - setSavedMap(reviewMapFromGaps(res.gaps)); - if (r) { - setRound(r); - } - if (attemptId) { - setActiveAttemptId(attemptId); - navigate(`/practice/${active._id}?attempt=${attemptId}`, { replace: true }); - } - resetChat(); - // 结果页首帧在绘制前回到顶部;后续流式完成和媒体加载不再重复滚动。 - setFeedbackActionsDisabled(true); - setTimeout(() => setFeedbackActionsDisabled(false), 1500); - // URL 标记具体轮次,刷新能恢复到同一个 attempt。 - // 必须用 navigate 显式带 pathname:setSearchParams 在当前 react-router 版本下会丢掉 - // pathname 使 useParams 的 practiceId 变空,触发 useEffect 走"无 practiceId"分支自动跳下一题 - // 评估完成后异步上传完整原声,供历史回听;当前不再触发发音评测。 - const audioBlob = takeAudioBlob(); - if (audioBlob && active?._id && attemptId) { - api.uploadRecording(active._id, user.userId, audioBlob, attemptId) - .catch((error) => console.warn("Recording upload unavailable:", error)); - } - }, - onError: (err) => { - clearInterval(evalTimerRef.current); - alert(t("practice.feedbackFailed", { msg: err.message })); - setFeedbackLoading(false); - setResult(null); - setActiveAttemptId(""); - setPhase("review"); - navigate(`/practice/${active._id}`, { replace: true }); - }, - } - ); + startEvaluation({ + text, active, userId: user.userId, hintCount, round, t, navigate, + evalTimerRef, sseControllerRef, takeAudioBlob, resetChat, + setResult, setFeedbackLoading, setPhase, setEvalElapsed, setStreamingLen, + setActiveAttemptId, setRound, setSavedMap, setFeedbackActionsDisabled, + }); } // 录完一段的后续流转:转写 → 有文本就评估,否则回到可手动输入态 @@ -372,6 +321,17 @@ export default function PracticePage() { return; } } + if (mode === "scenario" && !sess && pendingScenario) { + // 指定题目:开始动作才建真实 Session;同 requestId 服务端幂等去重 + try { + sess = await progressive.createPendingSession(user.userId); + } catch (err) { + alert(t("practice.loadScenarioFailed", { msg: err.message })); + return; + } + setSession(sess); + navigate(`/practice/${sess._id}`, { replace: true }); + } setTranscript(""); setTranscriptionError(false); @@ -385,9 +345,9 @@ export default function PracticePage() { setPhase("ready"); }, }); - if (started) trackPracticeRecordingStarted(mode, user.userId); + if (started) trackPracticeRecordingStarted(sess || session, user.userId, hintCount); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user, t, session, mode, freeTopic, startCapture]); + }, [user, t, session, mode, freeTopic, startCapture, pendingScenario, hintCount]); // 重录:丢弃本次录音回到 ready,不转写不评估(onstop 里由钩子的 discardRef 短路) const discardRecording = () => { @@ -412,7 +372,7 @@ export default function PracticePage() { else startNewRound(skipId); }; - const scenario = session?.scenario; + const scenario = session?.scenario ?? pendingScenario; const modeSwitch = ; if (needsPrefs) { @@ -427,6 +387,10 @@ export default function PracticePage() { ); } + if (phase === "scenarioUnavailable") { + return navigate("/practice")} t={t} />; + } + if (phase === "feedback" && result) { return ( progressive.revealNextHint(session?._id)} round={round} scenario={scenario} session={session} diff --git a/web/src/pages/PracticePage.progressive.test.jsx b/web/src/pages/PracticePage.progressive.test.jsx new file mode 100644 index 0000000..047e397 --- /dev/null +++ b/web/src/pages/PracticePage.progressive.test.jsx @@ -0,0 +1,178 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { setup, installMediaStubs, USER } from "./PracticePage.feedback.helpers.jsx"; + +vi.mock("../api/client.js", () => ({ + api: { + nextScenario: vi.fn(), + scenarioBySlug: vi.fn(), + createPractice: vi.fn(), + getPractice: vi.fn(), + revealNextHint: vi.fn(), + transcribeAudio: vi.fn(), + uploadRecording: vi.fn(), + addReviewItems: vi.fn(), + deleteReviewItem: vi.fn(), + tts: vi.fn(), + }, + correctStream: vi.fn(), + chatStream: vi.fn(), +})); + +vi.mock("../utils/tts.js", () => ({ + speak: vi.fn().mockResolvedValue(null), + stop: vi.fn(), + isCached: vi.fn().mockReturnValue(false), +})); + +// by-slug 响应:与 /next 同形 + 归一化 interactionType/hints/difficulty +const PROGRESSIVE_SCENARIO = { + scenarioId: "sc_prog", + kind: "task", + title: "咖啡店重做饮品", + where: "咖啡店 · 西雅图", + story: "店员把你的热拿铁做成了冰拿铁。", + mission: "礼貌说明问题,请店员重做。", + points: ["说明饮品做错了", "要求重做一杯热的"], + imageUrl: "", + videoUrl: "", + isCustom: false, + preferenceMatch: "exact", + targetWords: [], + difficulty: 2, + interactionType: "progressive_hints", + hints: ["我点的是热拿铁,但这杯是冰的。", "能麻烦你重新做一杯热的吗?"], +}; + +const PROGRESSIVE_SESSION = { + _id: "sess_prog", + userId: USER.userId, + scenarioId: "sc_prog", + mode: "scenario", + sourceType: "human", + title: "咖啡店重做饮品", + topic: "咖啡店 · 西雅图", + scenario: { + kind: "task", + title: "咖啡店重做饮品", + where: "咖啡店 · 西雅图", + story: "店员把你的热拿铁做成了冰拿铁。", + mission: "礼貌说明问题,请店员重做。", + points: ["说明饮品做错了", "要求重做一杯热的"], + targetWords: [], + interactionType: "progressive_hints", + hints: ["我点的是热拿铁,但这杯是冰的。", "能麻烦你重新做一杯热的吗?"], + difficulty: 2, + }, + revealedHintCount: 0, + attempts: [], +}; + +describe("PracticePage 渐进式提示 / 指定题目 URL", () => { + beforeEach(async () => { + localStorage.clear(); + sessionStorage.clear(); + vi.clearAllMocks(); + const { api } = await import("../api/client.js"); + api.scenarioBySlug.mockResolvedValue(PROGRESSIVE_SCENARIO); + api.createPractice.mockResolvedValue(PROGRESSIVE_SESSION); + api.getPractice.mockResolvedValue(PROGRESSIVE_SESSION); + api.revealNextHint.mockResolvedValue({ + requestId: "r1", revealedHintCount: 1, hintIndex: 0, + hint: "我点的是热拿铁,但这杯是冰的。", exhausted: false, + }); + api.transcribeAudio.mockResolvedValue({ text: "My latte is iced" }); + api.uploadRecording.mockResolvedValue({}); + installMediaStubs(); + }); + + it("指定 URL 精确取题:展示 mission、隐藏 points,会话创建前无提示按钮", async () => { + const { api } = await import("../api/client.js"); + setup("/practice?scenario=prog-coffee-remake"); + await waitFor(() => expect(api.scenarioBySlug).toHaveBeenCalledWith("prog-coffee-remake")); + await waitFor(() => + expect(screen.getByText("店员把你的热拿铁做成了冰拿铁。")).toBeInTheDocument(), + ); + expect(screen.getByText("礼貌说明问题,请店员重做。")).toBeInTheDocument(); + expect(screen.queryByText("说明饮品做错了")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Give me a hint" })).not.toBeInTheDocument(); + expect(api.createPractice).not.toHaveBeenCalled(); + }); + + it("开始动作创建会话并逐条领取提示,用尽后不再显示按钮", async () => { + const { api } = await import("../api/client.js"); + api.revealNextHint + .mockResolvedValueOnce({ + requestId: "r1", revealedHintCount: 1, hintIndex: 0, + hint: "我点的是热拿铁,但这杯是冰的。", exhausted: false, + }) + .mockResolvedValueOnce({ + requestId: "r2", revealedHintCount: 2, hintIndex: 1, + hint: "能麻烦你重新做一杯热的吗?", exhausted: true, + }); + setup("/practice?scenario=prog-coffee-remake"); + await waitFor(() => + expect(screen.getByText("礼貌说明问题,请店员重做。")).toBeInTheDocument(), + ); + + await userEvent.click(document.querySelector(".su-rec")); + await waitFor(() => + expect(api.createPractice).toHaveBeenCalledWith(expect.objectContaining({ + userId: USER.userId, + scenarioId: "sc_prog", + requestId: expect.any(String), + })), + ); + + const first = await screen.findByRole("button", { name: "Give me a hint" }); + await userEvent.click(first); + await waitFor(() => + expect(screen.getByText("我点的是热拿铁,但这杯是冰的。")).toBeInTheDocument(), + ); + expect(screen.queryByText("能麻烦你重新做一杯热的吗?")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Give me another hint" })); + await waitFor(() => + expect(screen.getByText("能麻烦你重新做一杯热的吗?")).toBeInTheDocument(), + ); + expect(screen.getByText("All hints shown")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Give me another hint" })).not.toBeInTheDocument(); + expect(api.revealNextHint).toHaveBeenCalledTimes(2); + }); + + it("刷新后按服务端计数恢复已显示提示前缀", async () => { + const { api } = await import("../api/client.js"); + api.getPractice.mockResolvedValue({ ...PROGRESSIVE_SESSION, revealedHintCount: 1 }); + setup("/practice/sess_prog"); + await waitFor(() => + expect(screen.getByText("我点的是热拿铁,但这杯是冰的。")).toBeInTheDocument(), + ); + expect(screen.queryByText("能麻烦你重新做一杯热的吗?")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Give me another hint" })).toBeInTheDocument(); + }); + + it("不可用 slug:明确错误提示,不创建会话", async () => { + const { api } = await import("../api/client.js"); + api.scenarioBySlug.mockRejectedValue(new Error("场景不存在或不可用")); + setup("/practice?scenario=no-such"); + await waitFor(() => + expect(screen.getByText("This scenario is not available.")).toBeInTheDocument(), + ); + expect(api.createPractice).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Back to practice" })).toBeInTheDocument(); + }); + + it("standard 会话不出现提示按钮", async () => { + const { api } = await import("../api/client.js"); + api.getPractice.mockResolvedValue({ + ...PROGRESSIVE_SESSION, + scenario: { ...PROGRESSIVE_SESSION.scenario, interactionType: "standard", hints: [] }, + }); + setup("/practice/sess_prog"); + await waitFor(() => + expect(screen.getByText("店员把你的热拿铁做成了冰拿铁。")).toBeInTheDocument(), + ); + expect(screen.queryByRole("button", { name: "Give me a hint" })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/PracticePage.test.jsx b/web/src/pages/PracticePage.test.jsx index 25eda03..31874b2 100644 --- a/web/src/pages/PracticePage.test.jsx +++ b/web/src/pages/PracticePage.test.jsx @@ -183,10 +183,10 @@ describe("PracticePage", () => { await userEvent.click(screen.getByTitle("Try another scenario")); await waitFor(() => - expect(api.createPractice).toHaveBeenCalledWith({ + expect(api.createPractice).toHaveBeenCalledWith(expect.objectContaining({ userId: USER.userId, scenarioId: SCENARIO_B.scenarioId, - }), + })), ); }); diff --git a/web/src/pages/practiceEvaluation.js b/web/src/pages/practiceEvaluation.js new file mode 100644 index 0000000..9336ab0 --- /dev/null +++ b/web/src/pages/practiceEvaluation.js @@ -0,0 +1,86 @@ +// 评估 SSE 流:把已提交文本送去流式评估,并把结果写回页面状态。 +// 页面只传状态 setter 与会话上下文(PracticePage.evaluate 的薄封装)。 +import { api, correctStream } from "../api/client.js"; + +import { EMPTY_FEEDBACK, hasUsableFeedback, reviewMapFromGaps } from "./practiceFeedbackState.js"; +import { trackPracticeResult } from "./practiceTelemetry.js"; + +export function startEvaluation(ctx) { + const { + text, active, userId, hintCount, round, t, navigate, + evalTimerRef, sseControllerRef, takeAudioBlob, resetChat, + setResult, setFeedbackLoading, setPhase, setEvalElapsed, setStreamingLen, + setActiveAttemptId, setRound, setSavedMap, setFeedbackActionsDisabled, + } = ctx; + + setResult(EMPTY_FEEDBACK); + setFeedbackLoading(true); + setPhase("feedback"); + setEvalElapsed(0); + setStreamingLen(0); + evalTimerRef.current = setInterval(() => setEvalElapsed((sec) => sec + 1), 1000); + + sseControllerRef.current = correctStream( + { + userId, + practiceId: active._id, + text, + // 自由说:不判任务完成度,后端据此走 FREE prompt;话题一并落 attempt + mode: active.mode === "free" ? "free" : "scenario", + freeTopic: active.freeTopic || "", + }, + { + onStarted: ({ attemptId, round: startedRound }) => { + setActiveAttemptId(attemptId); + if (startedRound) setRound(startedRound); + navigate(`/practice/${active._id}?attempt=${attemptId}`, { replace: true }); + }, + onChunk: (chunk) => setStreamingLen((n) => n + chunk.length), + onDone: ({ result: res, attemptId, round: r }) => { + clearInterval(evalTimerRef.current); + if (!hasUsableFeedback(res)) { + alert(t("practice.feedbackFailed", { msg: res?.summary || t("practice.emptyFeedback") })); + setFeedbackLoading(false); + setResult(null); + setPhase("review"); + navigate(`/practice/${active._id}`, { replace: true }); + return; + } + setResult(res); + setFeedbackLoading(false); + trackPracticeResult({ active, result: res, round: r ?? round, userId, attemptId, hintCount }); + // AI 自动收录的 gap 回传了 reviewItemId,用它初始化收录态(这样「已在错题本」可直接取消) + setSavedMap(reviewMapFromGaps(res.gaps)); + if (r) { + setRound(r); + } + if (attemptId) { + setActiveAttemptId(attemptId); + navigate(`/practice/${active._id}?attempt=${attemptId}`, { replace: true }); + } + resetChat(); + // 结果页首帧在绘制前回到顶部;后续流式完成和媒体加载不再重复滚动。 + setFeedbackActionsDisabled(true); + setTimeout(() => setFeedbackActionsDisabled(false), 1500); + // URL 标记具体轮次,刷新能恢复到同一个 attempt。 + // 必须用 navigate 显式带 pathname:setSearchParams 在当前 react-router 版本下会丢掉 + // pathname 使 useParams 的 practiceId 变空,触发 useEffect 走"无 practiceId"分支自动跳下一题。 + // 评估完成后异步上传完整原声,供历史回听;当前不再触发发音评测。 + const audioBlob = takeAudioBlob(); + if (audioBlob && active?._id && attemptId) { + api.uploadRecording(active._id, userId, audioBlob, attemptId) + .catch((error) => console.warn("Recording upload unavailable:", error)); + } + }, + onError: (err) => { + clearInterval(evalTimerRef.current); + alert(t("practice.feedbackFailed", { msg: err.message })); + setFeedbackLoading(false); + setResult(null); + setActiveAttemptId(""); + setPhase("review"); + navigate(`/practice/${active._id}`, { replace: true }); + }, + } + ); +} diff --git a/web/src/pages/practiceTelemetry.js b/web/src/pages/practiceTelemetry.js index 3bc1115..e266e51 100644 --- a/web/src/pages/practiceTelemetry.js +++ b/web/src/pages/practiceTelemetry.js @@ -1,15 +1,35 @@ import { track } from "../lib/analytics.js"; -export function trackPracticeResult(active, result, round, userId) { +// 场景标量:只上报不透明 ID 与分层标量,不带提示正文/作答正文(隐私边界见 docs/业务/数据埋点.md) +function scenarioProps(active) { + const scenario = active?.scenario || {}; + return { + practiceId: active?._id || "", + sourceType: active?.sourceType || "", + interactionType: scenario.interactionType || "standard", + kind: scenario.kind || "", + difficulty: scenario.difficulty ?? null, + }; +} + +export function trackPracticeResult({ active, result, round, userId, attemptId = '', hintCount = 0 }) { track("practice_result", { mode: active.mode === "free" ? "free" : "scenario", score: result.score ?? null, gaps: (result.gaps ?? []).length, round, userId, + attemptId, + hintCount: hintCount || 0, + ...scenarioProps(active), }); } -export function trackPracticeRecordingStarted(mode, userId) { - track("practice_recording_started", { mode, userId }); +export function trackPracticeRecordingStarted(active, userId, hintCount = 0) { + track("practice_recording_started", { + mode: active?.mode === "free" ? "free" : "scenario", + userId, + hintCount: hintCount || 0, + ...scenarioProps(active), + }); } diff --git a/web/src/pages/practiceTelemetry.test.js b/web/src/pages/practiceTelemetry.test.js new file mode 100644 index 0000000..d5d8aa8 --- /dev/null +++ b/web/src/pages/practiceTelemetry.test.js @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { trackPracticeRecordingStarted, trackPracticeResult } from "./practiceTelemetry.js"; + +vi.mock("../lib/analytics.js", () => ({ track: vi.fn() })); + +const SESSION = { + _id: "sess_p1", + sourceType: "human", + mode: "scenario", + scenario: { + kind: "task", + interactionType: "progressive_hints", + difficulty: 2, + }, +}; + +describe("practiceTelemetry", () => { + beforeEach(async () => { + vi.clearAllMocks(); + }); + + it("录音开始事件带去重 ID、交互类型、难度与提示计数", async () => { + const { track } = await import("../lib/analytics.js"); + trackPracticeRecordingStarted(SESSION, "u1", 1); + expect(track).toHaveBeenCalledWith("practice_recording_started", expect.objectContaining({ + mode: "scenario", + userId: "u1", + practiceId: "sess_p1", + sourceType: "human", + interactionType: "progressive_hints", + kind: "task", + difficulty: 2, + hintCount: 1, + })); + }); + + it("standard/free 缺省归一:interactionType=standard、hintCount=0", async () => { + const { track } = await import("../lib/analytics.js"); + trackPracticeRecordingStarted({ _id: "sess_f", mode: "free", scenario: { kind: "free" } }, "u1"); + expect(track).toHaveBeenCalledWith("practice_recording_started", expect.objectContaining({ + mode: "free", + interactionType: "standard", + hintCount: 0, + })); + }); + + it("结果事件带 attemptId 与提示计数,且不含提示正文", async () => { + const { track } = await import("../lib/analytics.js"); + trackPracticeResult({ + active: SESSION, result: { score: 6.5, gaps: [1, 2] }, round: 2, userId: "u1", attemptId: "pa_9", hintCount: 2, + }); + const [event, props] = track.mock.calls[0]; + expect(event).toBe("practice_result"); + expect(props).toMatchObject({ + attemptId: "pa_9", + practiceId: "sess_p1", + interactionType: "progressive_hints", + hintCount: 2, + score: 6.5, + gaps: 2, + round: 2, + }); + // 隐私边界:只有计数/标量,没有提示正文键 + expect(props).not.toHaveProperty("hints"); + expect(props).not.toHaveProperty("story"); + expect(props).not.toHaveProperty("transcript"); + }); +}); diff --git a/web/src/pages/useProgressiveScenario.js b/web/src/pages/useProgressiveScenario.js new file mode 100644 index 0000000..5b5a17d --- /dev/null +++ b/web/src/pages/useProgressiveScenario.js @@ -0,0 +1,69 @@ +import { useRef, useState } from "react"; + +import { api } from "../api/client.js"; + +// 渐进式场景提示 + 指定题目(?scenario=)待选题状态。 +// 规格:docs/requirements/20260826-渐进式场景提示.md;计数与幂等以服务端为准。 +export default function useProgressiveScenario(t) { + const [pendingScenario, setPendingScenario] = useState(null); + const [hintCount, setHintCount] = useState(0); + const [hintBusy, setHintBusy] = useState(false); + const [hintError, setHintError] = useState(""); + const startRequestIdRef = useRef(""); + + const reset = () => { + setPendingScenario(null); + setHintCount(0); + setHintBusy(false); + setHintError(""); + startRequestIdRef.current = ""; + }; + + // 指定题目入口:精确取题;失败给 unavailable 态,绝不回退随机题 + const loadBySlug = async (slug, onPhase) => { + onPhase("loading"); + try { + const scenario = await api.scenarioBySlug(slug); + setPendingScenario(scenario); + onPhase("ready"); + } catch { + setPendingScenario(null); + onPhase("scenarioUnavailable"); + } + }; + + // 开始动作才创建真实 Session;重试复用同一 requestId,服务端幂等去重, + // 一个开始动作只产生一个 Session;创建成功后由调用方 replace 到规范 Session URL + const createPendingSession = async (userId) => { + if (!startRequestIdRef.current) startRequestIdRef.current = crypto.randomUUID(); + const session = await api.createPractice({ + userId, + scenarioId: pendingScenario.scenarioId, + requestId: startRequestIdRef.current, + }); + setHintCount(session.revealedHintCount ?? 0); + return session; + }; + + const restoreHintCount = (session) => setHintCount(session?.revealedHintCount ?? 0); + + // 提示按服务端计数逐条领取;失败不提前显示提示,只给可重试错误 + const revealNextHint = async (practiceId) => { + if (!practiceId || hintBusy) return; + setHintBusy(true); + setHintError(""); + try { + const res = await api.revealNextHint(practiceId, crypto.randomUUID()); + setHintCount(res.revealedHintCount); + } catch (err) { + setHintError(t("practice.hintFailed", { msg: err.message })); + } finally { + setHintBusy(false); + } + }; + + return { + pendingScenario, hintCount, hintBusy, hintError, + reset, loadBySlug, createPendingSession, restoreHintCount, revealNextHint, + }; +} diff --git a/web/src/styles/app/07-scenario-sharing.css b/web/src/styles/app/07-scenario-sharing.css index 30b416e..2424e00 100644 --- a/web/src/styles/app/07-scenario-sharing.css +++ b/web/src/styles/app/07-scenario-sharing.css @@ -406,3 +406,65 @@ .share-date { font-family: var(--ff-mono); font-size: 11px; color: var(--ink-3); } .share-ops { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex: 0 0 auto; } .share-ops .su-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; font-size: 12px; } + +/* 渐进式场景提示:按需逐条出现的中文提示区。 + 固定区域不挤出录音主操作,不触发整页滚动;长文本换行不横向溢出 */ +.sc-hints { + font-family: var(--ff-cn); + background: var(--hint-bg); + border: 1px solid var(--hint-rule); + border-radius: 12px; + padding: 10px 14px; + margin-bottom: 12px; + min-width: 0; + overflow-wrap: anywhere; +} +.sc-hints-list { + list-style: none; + margin: 0 0 10px; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.sc-hints-list li { + position: relative; + padding-left: 20px; + font-size: 16px; + color: var(--ink); + line-height: 1.7; +} +.sc-hints-list li::before { + content: "•"; + position: absolute; + left: 2px; + color: var(--accent); +} +.sc-hints-btn { + width: 100%; +} +.sc-hints-done { + font-size: 14px; + color: var(--ink-3); + text-align: center; + padding: 6px 0 2px; +} +.sc-hints-error { + margin-top: 8px; + font-size: 14px; + line-height: 1.5; + color: var(--warn); +} + +/* 指定题目不可用(不存在/已归档/无权):明确提示 + 返回,不静默换随机题 */ +.sc-unavailable { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + text-align: center; + padding: 48px 24px; + font-family: var(--ff-cn); + font-size: 16px; + color: var(--ink-2); +}