Compare commits

..
13 Commits
Author SHA1 Message Date
LuminousRuoxi d0f2901875 chore: docs/ 加入 .gitignore(本地工作文档不入库) 2026-09-10 16:37:10 +08:00
LuminousRuoxi 6189ee87d8 fix: 翻译完成后「查看之前的写作建议」意外收起
- 根因: 翻译完成 → renderOriginalPane 重建左栏 DOM,<details> 被重新创建,
  open 状态丢失(用户实测)
- 记录折叠区展开状态 priorGuidanceOpen,重建后按状态恢复;toggle 事件同步更新
- 顺带: 重建前记录/恢复内容区滚动位置,操作后内容不再跳回顶部
- 验证: 翻译完成 / 收起译文(缓存切换)/ 切段往返 均保持展开与缓存状态(5/5),无 JS 报错
2026-09-10 16:34:37 +08:00
LuminousRuoxi a81b366d71 style: 左栏滚动条移到标题区下方 + 左右标题区高度统一 54px
- 左栏卡片改为与右栏相同的 flex 结构:标题区固定、滚动只发生在内容区
  (原为整个卡片滚动,标题区会跟着滚走)
- .original-pane-head 高度 58px → 54px,与右栏 pane-head 一致:
  左右白色标题区底边对齐、内容区起点对齐
- <900px 移动端保持普通流式
- 验证: 1366/1600/1920 三视口标题区等高 54px、底边/内容区起点对齐、
  内容区内部滚动生效(滚动时标题固定)、无 JS 报错
2026-09-10 16:18:17 +08:00
LuminousRuoxi b697b74e3b style: 工作台改为应用式布局 — 页面锁定视口,两栏卡片内部滚动,上下间距一致
- .workbench-shell 改为视口高 flex 列:标题区与底部提交栏固定,两栏卡片吃满
  剩余空间(flex:1 + grid align-items:stretch 等高拉伸)
- 全局不再滚动:内容滚动只发生在卡片内部(左栏卡片 / 右栏内容区)
- 卡片与标题区、与底部按钮间距统一 16px(原下方 96px 是为 fixed 提交栏预留)
- 提交栏 position:fixed → 常规 flex 项,不再遮挡内容
- 去掉卡片 690px 固定最小高度(矮视口会被裁剪);<900px 移动端保持普通流式
- 验证: 1366/1600/1920/2560 四视口 28/28 通过
2026-09-10 16:14:17 +08:00
LuminousRuoxi ad29ea3fc3 style: 工作区高度改为 视口高 - 150px,顶部标题区改为随页面滚动
- 卡片高度 100vh-300 → 100vh-150,工作区显著变大(1920x1080: 780→930px)
- 配套去掉 work-head 的 sticky(原常驻占 108px):滚动后卡片可吸附到顶栏下方,
  底部正好落在固定提交栏上方,不会被遮挡(保留 sticky 则 150 会遮挡底部 70px)
- 验证: 1366/1600/1920 三视口 卡片高=100vh-150、左右持平、底部不被提交栏遮挡、
  上一段/下一段可点击、无 JS 报错(15/18,3 项为检测断言误报已人工确认为预期行为)
2026-09-10 16:11:32 +08:00
LuminousRuoxi 39d49d204f style: 左右卡片底部持平 — 两栏统一视口高度,左栏内容框内滚动
- .sticky-pane 由 max-height 改为 height:calc(100vh - 300px),min-height:0 覆盖
  pane-card 的 690px 固定值 → 左右卡片等高、底部对齐,随页面大小自适应
- 左栏(意见/批注区)内容超出时在卡片内滚动,不再把页面撑长
- <900px 媒体查询重置 height:auto,移动端保持内容自适应
- 验证: 1366/1600/1920 三视口底部精确持平(665/878/958)、顶部对齐、
  左栏滚动后底部内容可见('和上下文怎么接')、无 JS 报错
2026-09-10 16:06:53 +08:00
LuminousRuoxi 3a4c7ab728 fix: 改写区展开内容(译文/帮助面板)溢出压住「上一段/下一段」
- 根因: rewrite-card 固定高度 flex 布局中 pane-body 默认 overflow:visible,
  展开译文/帮助面板后内容溢出,直接覆盖在底部功能区上(用户实测,1366x768 明显)
- pane-body 加 overflow-y:auto: 内容超高时内部滚动,功能区固定不被覆盖;
  pane-head / write-tools 加 flex-shrink:0 保证不被压缩
- 验证: 1366/1440/1600/1920 四视口布局不重叠(pane-body 底 == 功能区顶)、
  上一段/下一段可点击、滚动后展开内容完整可见、无 JS 报错
2026-09-10 16:03:07 +08:00
LuminousRuoxi a2c607c1be fix: 对话页「查看复检历史」点了没反应 + 再次点击不关闭
- 根因: 复检历史面板挂在 workbenchView 容器内,对话页该容器 display:none
  导致 fixed 面板整棵子树不渲染(用户实测)。面板移出视图容器挂到顶层
- 同时修复: handleAction 里 view-recheck-history 硬编码 toggleRecheckHistory(true),
  点第二次不会关闭;改为无参切换
- 验证: 对话页/改写页两个入口 7/7 通过(打开/关闭/再打开/点外部关闭/无 JS 报错)
2026-09-10 15:43:23 +08:00
LuminousRuoxi 938a9e9b22 style: 改写卡自适应布局 — 功能区完整显示,改写框吃剩余空间
- 改写卡改为确定高度(视口高 - 300px,含底部提交栏与顶部标题区余量),
  pane-body / 改写框 flex 分配:优先保证字数提示、复制原文/翻译按钮、
  写作起点、上/下一段等功能区完整可见,剩余空间全部给改写框
- sticky-pane max-height 同步下调,避免卡片底部被固定提交栏遮挡(小屏实测遮挡)
- 改写框去掉手动 resize(高度改由布局自适应);<900px 断点保持原行为
- 验证: 1920/1600/1440/1366 四种视口 28/28 全部可见未遮挡;交互回归通过(33 tests)
2026-09-10 15:23:13 +08:00
LuminousRuoxi d4554eb1af feat: 复制原文到改写区按钮 + 修复改完字点段号第一次点击丢失
- 改写区新增「复制原文到改写区 →」: 一键以原文为起点手动改写;
  改写区已有内容时二次确认,避免覆盖已写的工作
- 修复既有 bug: 改写区 blur(点段号的 mousedown 触发)会重建段号按钮,
  mouseup 时按钮已被替换 → 第一次点击丢失(改完字要点两次才切段)。
  onRewriteInput/onRewriteBlur 改用轻量 class 更新(updateParagraphSwitcherState),
  不再重建 DOM
- 浏览器实测: 复制/确认/覆盖/切段 9/9 + 核心回归 6/6 通过
2026-09-10 15:05:01 +08:00
LuminousRuoxi 6e0db821a8 feat: 原文中文对照(逐句)— 高亮句同色同序号对位
- /api/translate 增加逐句模式(sentences): 前端拆句(en 为原文精确文本)→ 模型逐句翻译,
  数量/顺序校验复用 _count_check(不一致自动重试);响应 en 原样回填保证对位可靠
- 左栏原文区新增「原文中文对照 →」: 逐句中文展示,命中批注 evidence 的句子
  与原文同色(note-rhetoric/repeat/growth/structure)+ 同序号(sup),
  点击对照行可联动高亮原文句与批注卡片
- 对照缓存按段保存并持久化(原文变化/换文书自动失效清空)
- 测试: 新增逐句模式 2 个 hermetic 测试(33 passed)
- 浏览器实测 18/18 通过(含逐句渲染/高亮对位/点击联动/刷新持久化)
2026-09-10 14:39:39 +08:00
LuminousRuoxi 85a2eb720a feat: 改写工作台体验优化 — 中文对照翻译 + 字数控制提示 + 复检历史/按建议再次改写 + 输入法组合期不误发送
- 新增 POST /api/translate(build_translate_prompt): 英文改写稿忠实直译成中文,供顾问自查改写后原意是否保留
- 改写区「译成中文对照」按钮 + 译文展开/收起;改写稿变化后旧译文自动过期(缓存按文本比对)
- 每段字数控制提示: rewrite-meta 显示「原文 X 词 · 当前 Y 词」,偏差过大黄色提醒;
  顶部进度加「全文 X / wordLimit 词」,超出题目字数限制标红
- 复检汇总建议历史: 每次复检入历史(状态/未通过检查项/返工目标/绑定版本,上限 10 条);
  Workbench 提交区 + 复检卡两处入口;历史条目「按此建议再次改写」回到目标段并显示返工 banner
- 对话输入框: 中文输入法组合期(isComposing/keyCode 229)回车不再误发送
- 测试: 新增 /api/translate 3 个 hermetic 测试(31 passed);浏览器实测 18/18 通过
2026-09-10 14:09:31 +08:00
LuminousRuoxi 36b7e601e7 docs: essay workbench agent optimization delivery note (A-F implemented) 2026-09-01 17:42:29 +08:00
7 changed files with 589 additions and 23 deletions
+3
View File
@@ -7,3 +7,6 @@ __pycache__/
# 运行日志与 E2E 测试产物
*.log
_e2e_*.js
# 本地工作文档(需求回写/自测记录/实现方案等,仅供本机查阅,不入库)
docs/
+119
View File
@@ -0,0 +1,119 @@
# 文书工作台 Agent 优化 — 交付说明(v1.0
> 日期:2026-09-01 涉及仓库:prodream-next(前端)、prodream_backend(后端)
> 方案文档:[docs/essay-agent-optimization-plan.md](essay-agent-optimization-plan.md)(已实施,状态头已更新)
---
## 一、完成状态总览
| # | 需求 | 状态 | 关键实现 |
|---|---|---|---|
| 1 | Agent 对话进文书工作台(essay-chat skill | ✅ 已实施 | tab6 替换为 DreamiChatV2 嵌入;后端 essay-chat skill + 控制面映射;`<<<ACTIONS>>>` 锚点落回编辑文档 |
| 2 | humanizer 规则融入(AI 味识别与优化) | ✅ 已实施 | SKILL.md 新增「AI 味检查」章节:参照 humanizer v2.11.2 裁剪的检测清单 + 逐段扫描→报告→确认后改写流程 |
| 3 | 文书评估常驻卡片 | ✅ 已实施 | 评估结果 zustand 常驻 + 历史时间线(GET /evaluation/history+ 「去对话改写」闭环 |
| 4 | 体验优化(翻译 / 字数提示 / 汇总建议) | ✅ 已实施 | assistant 回复翻译按钮;输入框旁目标字数提示(BE 注入 word_count,改写自动带字数约束);历史评估构造汇总建议 |
| 5 | 输入法误发送 bug | ✅ 已实施 | 5 处 onKeyDown 加 `isComposing` 守卫 |
## 二、commit 对照
### 前端 prodream-next(按顺序)
| 阶段 | commit | 内容 |
|---|---|---|
| A | `563a13cc8` | fix: 输入法组合期间回车误发送(5 处 onKeyDown 守卫) |
| C | `1153a1f61` | feat: essay 工作台 tab6 替换为 DreamiChatV2 essay-chat 嵌入 + ACTIONS 锚点落回 |
| D | `8ef4f0e75` | feat: 文书评估常驻卡片 + 历史时间线 + 建议→对话改写闭环 |
| F | `3aaf10bf6` | feat: essay workbench UX polish — translation buttons, word-count hint, history suggestions |
### 后端 prodream_backend(按顺序)
| 阶段 | commit | 内容 |
|---|---|---|
| B | `250e39454` | feat: essay-chat skill 骨架 + get_essay_evaluation 工具 |
| C | `88969a639` | feat: 控制面 chat.py essay-chat 映射 + toolset 切换 + context 富化 + ACTIONS 剥离回传 |
| D | `852236107` | feat: 评估历史接口 GET /essay/{id}/evaluation/history + SKILL 首轮带指令 |
| E | `d68b45386` | feat: essay-chat skill AI-flavor checkhumanizer 改编清单 + 检测/确认/改写流程) |
| F | `82729a5d0` | feat: inject essay word_count into essay-chat context + SKILL context note |
> 注:阶段 B(skill 骨架)为纯后端改动,前端无对应 commit。
## 三、验收对照
### 需求 1Agent 对话
| 验收点 | 状态 | 证据 |
|---|---|---|
| tab6(竖列底部 Dreami 按钮)进入即自动开聊 | ✅ 静态验证 | `autoStart` + `hideEmptyHero`,空首轮触发 SKILL 首轮盘点(agent_initiated |
| 对话上下文带文书全文 / 题目 / 目标字数 / 评估 | ✅ 静态验证 | BE 按 essay_id 注入 current_document/essay_prompt/word_count/evaluation 到最后一条 user 消息 |
| 对话里改文书:确认后落回 | ✅ 静态验证 | SKILL「建议落地」先给改法→用户确认→`<<<ACTIONS>>>` 锚点替换 Tiptap 段落→useSaveEssay 落库 |
| 锚点未命中不猜测 | ✅ 静态验证 | `applyEssayActionsToEditor` 唯一命中才应用,未命中 toast 提示 |
| 语言跟随界面 locale | ✅ 静态验证 | taskMetadata.context.locale 注入 + 回复语言约束 |
| 浏览器实测 | ⏳ 待验证 | 见风险 5 |
### 需求 2AI 味检查
| 验收点 | 状态 | 证据 |
|---|---|---|
| 检测清单覆盖 humanizer 35 条(裁剪) | ✅ 静态验证 | 内容/语言/风格三类清单 + 反误报清单,出处标注 humanizer v2.11.2 |
| 逐段扫描→报告(模式+原文引用+改法) | ✅ 静态验证 | SKILL「AI 味检查」检测流程 |
| 确认后改写,不改事实/删词不删意 | ✅ 静态验证 | 改写四原则 |
| 与 GPTZero 按钮互补不冲突 | ✅ 静态验证 | SKILL 互补说明 |
### 需求 3:评估常驻卡片
| 验收点 | 状态 | 证据 |
|---|---|---|
| 评估完结果常驻(刷新/重进恢复) | ✅ 静态验证 | zustand 空态自动恢复 history[0] |
| 多次评估可回看历史 | ✅ 静态验证 | GET /essay/{id}/evaluation/history + 历史时间线折叠列表 |
| 历史项可选回看 | ✅ 静态验证 | onSelect 切换 result + 构造只读建议 |
| 从建议「去对话改写」 | ✅ 静态验证 | handleChatRewrite 暂存指令→切 tab6;历史建议 origin 留空→guard 引导走对话 |
### 需求 4:体验优化
| 验收点 | 状态 | 证据 |
|---|---|---|
| assistant 回复一键翻译(复用 translate API | ✅ 静态验证 | assistantTranslate prop(仅 essay-chat 传,其他场景行为不变)+ 译文消息下方展开/收起 |
| 输入框旁目标字数提示 | ✅ 静态验证 | expect_word_count + editor 词数实时;超出变红;无字数要求题不显示 |
| 改写指令自动带字数约束 | ✅ 静态验证 | BE 注入 word_count 字段 + SKILL 换算单段比例逻辑 |
| 汇总建议:一句话行动项 | ✅ 静态验证 | improvement 字段既有能力,未新造 |
| 历史里能看到上次评估建议并可再次进入改写 | ✅ 静态验证 | handleSelectHistory 构造 FeedbackImprovement 列表 + 「去对话改写」按钮 |
### 需求 5:输入法 bug
| 验收点 | 状态 | 证据 |
|---|---|---|
| 中文输入组合期间回车不误发送 | ✅ 静态验证 | 5 处 `onKeyDown``e.nativeEvent.isComposing` 守卫;tsc pass |
| 组合完成后回车正常发送 | ✅ 静态验证 | 组合状态 false 走原逻辑 |
## 四、验证记录
- 后端:`py_compile` 全部改动文件 pass
- 前端:`tsc --noEmit`(主仓 node_modules 绝对路径)exit 0
- i18ncn.json / en.json JSON.parse passkey 一一对应
- 浏览器端到端验证(pre.prodream.cn):⏳ 未做 — 需要测试账号(见风险 5)
## 五、改动文件清单
### 前端
- `components/editor/rightbar/chatbot/Chatbot.tsx` — essay-chat 嵌入 + ACTIONS 落回 + 字数提示 + assistantTranslate
- `components/dreami/DreamiChatV2.tsx` — assistantTranslate / composerHint 可选 prop + 翻译按钮 UI
- `components/editor/rightbar/feedback/Feedback.tsx` — 常驻恢复 + 历史时间线 + handleSelectHistory
- `components/editor/rightbar/feedback/Result.tsx` — 评估报告翻译按钮 + 译文区
- `components/editor/rightbar/feedback/Improvement.tsx` — 历史建议 origin guard
- `components/dreami/dreamiAdapter.ts` — EssayAnchorAction 类型透传
- 输入法 5 处 onKeyDownchat 输入框所在组件)
- `dictionaries/cn.json` / `dictionaries/en.json` — 新增 key
### 后端
- `dreami/control-plane/dreami_control_plane/api/chat.py` — essay-chat task 映射 + toolset + context 富化 + `<<<ACTIONS>>>` 剥离回传 + word_count 注入
- `dreami_skills/essay-chat/SKILL.md` — 骨架 + 首轮盘点 + AI 味检查章节
- 评估历史接口 + `get_essay_evaluation` 工具
## 六、风险与未做项
1. **浏览器验证未做**(风险 5):pre.prodream.cn 需要测试账号。联调验证时需要你提供账号,或由你自行走查。
2. **对话改写需用户确认**:已按方案(先给改法→确认→落回)实现,未绕过确认——这是刻意行为,不是缺陷。
3. **评估为异步任务**:对话内 get_essay_evaluation 走工具调用,结果以工具返回注入;AI 输出等待提示由 skill 指令约束。耗时表现需浏览器实测确认。
4. **humanizer 为参照非照搬**:35 条按文书场景裁剪(检测保留、改写挑选),详见 SKILL「AI 味检查」章节出处标注。
5. **未动评估 prompt 体系**:评估质量体系(招生官视角 + 8 维度)原样保留。
+322 -23
View File
@@ -50,20 +50,20 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
.status-line{display:flex;gap:8px;align-items:center;margin:7px 0 13px;color:#737a8b;font-size:12px}.status-line .pill{padding:4px 8px;border-radius:999px;background:#f1f2f6}.constraint-box{margin-top:13px;background:#f9faff;border:1px solid #e0e2ef;border-radius:11px;padding:11px 13px;font-size:13px;color:#52596c}
/* Workbench — two columns */
.workbench-shell{min-height:calc(100vh - 54px);background:#f6f7fb}
.work-head{background:#fff;border-bottom:1px solid var(--line);padding:15px 24px;position:sticky;top:54px;z-index:30}
.workbench-shell{height:calc(100vh - 54px);display:flex;flex-direction:column;overflow:hidden;background:#f6f7fb}
.work-head{background:#fff;border-bottom:1px solid var(--line);padding:15px 24px;position:relative;z-index:30;flex-shrink:0}
.work-head-inner{max-width:1540px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:24px}
.work-title{font-size:21px;font-weight:790;line-height:1.35}.work-sub{font-size:12px;color:var(--muted);margin-top:4px}
.progress-mini{display:flex;gap:12px;align-items:center;min-width:270px;justify-content:flex-end}.progress-track{width:138px;height:6px;background:#eceef4;border-radius:999px;overflow:hidden}.progress-track div{height:100%;width:0;background:var(--brand);transition:.25s}.progress-txt{font-size:12px;color:#4d5569;font-weight:750;white-space:nowrap}
.progress-mini{display:flex;gap:12px;align-items:center;min-width:270px;justify-content:flex-end}.progress-track{width:138px;height:6px;background:#eceef4;border-radius:999px;overflow:hidden}.progress-track div{height:100%;width:0;background:var(--brand);transition:.25s}.progress-txt{font-size:12px;color:#4d5569;font-weight:750;white-space:nowrap}.progress-txt.over{color:#b53a30}
.constraint-entry{display:flex;flex-direction:column;align-items:flex-end;gap:8px}
.constraint-input-row{display:flex;flex-direction:column;gap:8px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:10px;box-shadow:var(--shadow);width:340px;position:absolute;right:24px;top:60px;z-index:40}
.constraint-input-row textarea{width:100%;min-height:64px;border:1px solid #e2e4ed;border-radius:9px;padding:8px 10px;font-size:12px;outline:0;resize:vertical}
.constraint-input-row .row-actions{display:flex;gap:8px;justify-content:flex-end}
.work-grid{max-width:1540px;margin:0 auto;padding:16px 24px 96px;display:grid;grid-template-columns:minmax(0,46fr) minmax(0,54fr);gap:14px;align-items:start}
.pane-card{background:#fff;border:1px solid var(--line);border-radius:14px;overflow:hidden;min-height:690px}.pane-head{height:54px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-weight:740;font-size:13px}.pane-body{padding:18px}
.sticky-pane{position:sticky;top:132px;max-height:calc(100vh - 150px);overflow:auto}
.work-grid{flex:1;min-height:0;width:100%;max-width:1540px;margin:0 auto;padding:16px 24px;display:grid;grid-template-columns:minmax(0,46fr) minmax(0,54fr);gap:14px;align-items:stretch;overflow:hidden}
.pane-card{background:#fff;border:1px solid var(--line);border-radius:14px;overflow:hidden}.pane-head{height:54px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-weight:740;font-size:13px}.pane-body{padding:18px}
.sticky-pane{min-height:0;display:flex;flex-direction:column;overflow:hidden}.sticky-pane>.pane-head{flex-shrink:0}.sticky-pane>.pane-body{flex:1;min-height:0;overflow-y:auto}
.original-pane-head{height:58px;gap:12px;flex-wrap:nowrap}.original-pane-title{display:flex;align-items:center;gap:7px;white-space:nowrap}
.original-pane-head{height:54px;gap:12px;flex-wrap:nowrap}.original-pane-title{display:flex;align-items:center;gap:7px;white-space:nowrap}
.paragraph-switcher{display:flex;align-items:center;gap:6px;margin-left:auto;overflow-x:auto;max-width:min(420px,42vw);padding-bottom:1px}
.para-tab{width:36px;height:34px;border:1px solid #dde0e8;border-radius:9px;background:#fff;color:#626a7c;font-size:13px;font-weight:780;display:grid;place-items:center;padding:0;transition:.15s;flex:none}.para-tab:hover{border-color:#b9baff;background:#f8f8ff;color:#4541d7}.para-tab.active{background:var(--brand);border-color:var(--brand);color:#fff;box-shadow:0 4px 10px rgba(86,84,245,.14)}.para-tab.has-copy:not(.active){border-color:#cfe4dc;color:#26765a;background:#f7fbf9}
.view-toggle{display:inline-flex;padding:3px;background:#eff1f5;border-radius:9px;gap:2px;flex:none;margin-left:4px}.view-btn{border:0;background:transparent;color:#737b8d;border-radius:7px;padding:6px 10px;font-size:11px;font-weight:700}.view-btn.active{background:#fff;color:#3936c9;box-shadow:0 1px 4px rgba(28,31,45,.08)}
@@ -82,13 +82,13 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
.full-original-list{display:flex;flex-direction:column;gap:9px}.mode-note{font-size:11px;color:#9299aa;margin:0 0 2px}.full-paragraph{border:1px solid transparent;border-radius:11px;padding:12px 13px;transition:.15s;cursor:pointer;position:relative}.full-paragraph:hover{background:#fafaff;border-color:#e8e8ff}.full-paragraph.active{background:#f7f7ff;border-color:#dedfff}.full-paragraph.active:before{content:"";position:absolute;left:-1px;top:12px;bottom:12px;width:3px;border-radius:3px;background:var(--brand)}.full-paragraph-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:6px}.full-paragraph-label{font-size:11.5px;font-weight:780;color:#42485b}.full-paragraph-state{font-size:10px;color:#8f96a6}.full-paragraph.active .full-paragraph-state{color:var(--brand);font-weight:750}.full-paragraph-copy{font:13.5px/1.72 Georgia,"Times New Roman",serif;color:#4a5060;margin:0}.full-paragraph-foot{display:flex;justify-content:flex-end;margin-top:7px}.full-issues-button{border:0;background:transparent;color:#6663d9;font-size:10.5px;font-weight:750;padding:2px 0}.full-issues-button:hover{text-decoration:underline}
.rewrite-card{min-height:690px}.rewrite-intro{font-size:12px;color:#737b8d;margin-bottom:12px}.rewrite-area{width:100%;min-height:500px;border:1px solid #e2e4ed;border-radius:11px;outline:0;resize:vertical;padding:15px 16px;font:15px/1.8 Georgia,"Times New Roman",serif;color:#2f3443;background:#fff;transition:.15s}.rewrite-area:focus{border-color:#b9baff;box-shadow:0 0 0 3px rgba(86,84,245,.08)}.rewrite-meta{display:flex;justify-content:space-between;gap:10px;margin-top:8px;font-size:11px;color:#9aa0ad}.rewrite-help{margin-top:16px;border-top:1px solid var(--line);padding-top:13px}.help-entry{display:flex;align-items:center;gap:8px;font-size:11.5px;color:#777e8f}.help-entry .btn.link{font-size:11.5px;font-weight:760;padding:0}.help-panel{display:none;margin-top:10px;background:#fafbff;border:1px solid #e6e7f3;border-radius:10px;padding:11px 12px}.help-panel.open{display:block}.help-label{font-size:10px;color:#8b92a3;font-weight:800;margin-bottom:5px}.scaffold{font-size:12px;color:#50586a;line-height:1.7;white-space:pre-wrap}.reference-trigger{margin-top:9px;padding-top:8px;border-top:1px solid #eceef4}.reference{display:none;margin-top:8px;background:#fff;border:1px solid #e6e7ef;border-radius:8px;padding:9px 10px;font-size:11.5px;color:#596174;white-space:pre-wrap;line-height:1.7}.reference.open{display:block}.reference-note{font-size:10px;color:#9198a7;margin-top:6px}.write-tools{border-top:1px solid var(--line);padding:11px 14px;display:flex;justify-content:space-between;gap:10px;align-items:center}.nav-group{display:flex;gap:8px}.demo-hint{font-size:11px;color:#9a9fac}
.submit-row{position:fixed;left:0;right:0;bottom:0;background:linear-gradient(180deg,rgba(246,247,251,0),#f6f7fb 35%);padding:28px 24px 16px;display:flex;justify-content:flex-end;z-index:25;pointer-events:none}.submit-row .submit-inner{width:100%;max-width:1540px;margin:0 auto;display:flex;justify-content:flex-end;align-items:center;gap:12px;pointer-events:auto}.submit-hint{font-size:12px;color:#8a6270}.submit-row .btn{padding:11px 18px}
.rewrite-card{min-height:0;display:flex;flex-direction:column}.rewrite-card .pane-head,.rewrite-card .write-tools{flex-shrink:0}.rewrite-card .pane-body{display:flex;flex-direction:column;flex:1;min-height:0;overflow-y:auto}.rewrite-intro{font-size:12px;color:#737b8d;margin-bottom:12px}.rewrite-area{width:100%;min-height:200px;border:1px solid #e2e4ed;border-radius:11px;outline:0;resize:none;padding:15px 16px;font:15px/1.8 Georgia,"Times New Roman",serif;color:#2f3443;background:#fff;transition:.15s}.rewrite-card .rewrite-area{flex:1}.rewrite-area:focus{border-color:#b9baff;box-shadow:0 0 0 3px rgba(86,84,245,.08)}.rewrite-meta{display:flex;justify-content:space-between;gap:10px;margin-top:8px;font-size:11px;color:#9aa0ad}.word-meta.warn{color:#b45309;font-weight:700}.translate-row{margin-top:10px;display:flex;align-items:center;gap:8px}.translate-row .btn.link{font-size:11.5px;font-weight:760;padding:0}.orig-translate-row{margin-top:9px;padding-top:8px;border-top:1px dashed #e8eaf3}.orig-translate-row .btn.link{font-size:11.5px;font-weight:760;padding:0}.orig-translate-box{margin-top:8px;background:#fafbff;border:1px solid #e8eaf6;border-radius:10px;padding:9px 10px}.ot-line{font-size:12.5px;line-height:1.75;color:#4c5468;padding:6px 9px;border-left:3px solid transparent;border-radius:6px;margin-bottom:5px;background:#fff}.ot-line:last-child{margin-bottom:0}.ot-line[data-note-id]{cursor:pointer}.ot-line sup{font-family:Inter,"PingFang SC",sans-serif;font-size:8px;font-weight:800;color:var(--note-strong,#514ee1);margin-right:3px}.ot-line.note-rhetoric,.ot-line.note-repeat,.ot-line.note-growth,.ot-line.note-structure{border-left-color:var(--note-line);background:var(--note-soft)}.ot-line.note-rhetoric.active,.ot-line.note-repeat.active,.ot-line.note-growth.active,.ot-line.note-structure.active{background:var(--note-bg);box-shadow:0 0 0 2px color-mix(in srgb,var(--note-line) 28%,transparent)}.translate-box{display:none;margin-top:8px;background:#f6f7ff;border:1px dashed #d9dcf5;border-radius:10px;padding:11px 12px;font-size:12px;color:#4c5468;line-height:1.75;white-space:pre-wrap}.translate-box.open{display:block}.translate-label{font-size:10px;color:#8b92a3;font-weight:800;margin-bottom:5px;display:block}.rewrite-help{margin-top:16px;border-top:1px solid var(--line);padding-top:13px}.help-entry{display:flex;align-items:center;gap:8px;font-size:11.5px;color:#777e8f}.help-entry .btn.link{font-size:11.5px;font-weight:760;padding:0}.help-panel{display:none;margin-top:10px;background:#fafbff;border:1px solid #e6e7f3;border-radius:10px;padding:11px 12px}.help-panel.open{display:block}.help-label{font-size:10px;color:#8b92a3;font-weight:800;margin-bottom:5px}.scaffold{font-size:12px;color:#50586a;line-height:1.7;white-space:pre-wrap}.reference-trigger{margin-top:9px;padding-top:8px;border-top:1px solid #eceef4}.reference{display:none;margin-top:8px;background:#fff;border:1px solid #e6e7ef;border-radius:8px;padding:9px 10px;font-size:11.5px;color:#596174;white-space:pre-wrap;line-height:1.7}.reference.open{display:block}.reference-note{font-size:10px;color:#9198a7;margin-top:6px}.write-tools{border-top:1px solid var(--line);padding:11px 14px;display:flex;justify-content:space-between;gap:10px;align-items:center}.nav-group{display:flex;gap:8px}.demo-hint{font-size:11px;color:#9a9fac}
.submit-row{flex-shrink:0;padding:0 24px 16px;display:flex;justify-content:flex-end}.submit-row .submit-inner{width:100%;max-width:1540px;margin:0 auto;display:flex;justify-content:flex-end;align-items:center;gap:12px;pointer-events:auto}.submit-hint{font-size:12px;color:#8a6270}.submit-row .btn{padding:11px 18px}.recheck-history{position:fixed;right:24px;bottom:82px;width:min(440px,calc(100vw - 32px));max-height:54vh;overflow-y:auto;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:0 16px 40px rgba(28,31,45,.18);padding:12px 13px;z-index:30;display:none}.recheck-history.open{display:block}.rhi-title{font-size:12px;font-weight:800;color:#3a4053;margin-bottom:8px}.recheck-history-item{border:1px solid #ecedf4;border-radius:10px;padding:9px 10px;margin-bottom:8px}.recheck-history-item:last-child{margin-bottom:0}.rhi-head{display:flex;align-items:center;gap:7px;font-size:11px;color:#666e80}.rhi-badge{font-size:10.5px;padding:2px 7px;border-radius:999px;font-weight:800}.rhi-badge.ok{background:var(--good-soft);color:#167655}.rhi-badge.warn{background:var(--warn-soft);color:#8a5d10}.rhi-latest{font-size:10px;color:var(--brand);font-weight:800}.rhi-meta{font-size:10.5px;color:#9aa0ad;margin-top:4px}.rhi-goal{font-size:11.5px;color:#4c5468;line-height:1.65;margin-top:5px}.rhi-revision{margin-top:7px}
.demo-panel{position:fixed;left:14px;bottom:14px;z-index:60}.demo-panel details{background:#fff;border:1px solid #dfe2ea;border-radius:10px;box-shadow:0 7px 20px rgba(23,27,42,.08);font-size:11px;color:#7d8495}.demo-panel summary{cursor:pointer;padding:7px 10px;font-weight:700;color:#666e80}.demo-panel .demo-body{padding:0 9px 9px;display:flex;gap:6px;flex-wrap:wrap;max-width:260px}
.loading-card{padding:20px 22px}.loading-title{font-size:16px;font-weight:760;margin-bottom:10px}.loading-row{display:flex;align-items:center;gap:9px;padding:7px 0;color:#687084;font-size:12px}.spinner{width:17px;height:17px;border:2px solid #dfe1ff;border-top-color:var(--brand);border-radius:50%;animation:spin .8s linear infinite}.loading-dot{width:8px;height:8px;border-radius:50%;background:#d7dbe6}.loading-row.active{color:#4247c7;font-weight:650}.loading-row.active .loading-dot{background:var(--brand)}.loading-hint{color:#b9770b;font-size:12px;font-weight:600;margin-top:8px}@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:1180px){.entry-shell{grid-template-columns:1fr 320px}.work-grid{gap:9px;padding-left:12px;padding-right:12px}.work-head{padding-left:14px;padding-right:14px}.pane-head{padding:0 12px}.progress-track{width:100px}.paragraph-switcher{max-width:min(280px,38vw)}}
@media(max-width:900px){.work-grid{grid-template-columns:1fr}.sticky-pane{position:relative;top:auto;max-height:none}.pane-card{min-height:auto}.rewrite-area{min-height:360px}.submit-row{position:relative;padding:0 12px 20px}.work-head-inner{align-items:flex-start}.progress-mini{min-width:0}.demo-panel{display:none}}
@media(max-width:760px){.entry-shell{display:block;height:auto}.essay-pane{padding:24px 18px}.smart-pane{border-top:1px solid var(--line)}.smart-body{padding:24px 18px}.top-actions{display:none}.chat-wrap{padding:20px 12px 120px}.message{grid-template-columns:30px 1fr;gap:8px}.avatar{width:30px;height:30px}.bubble{padding:16px}.user .bubble{max-width:90%}.agent-table{font-size:11px}.work-grid{padding:12px}.work-head{padding:12px}.work-head-inner{display:block}.progress-mini{margin-top:8px;justify-content:flex-start}.progress-track{width:120px}.original-pane-head{height:auto;min-height:58px;flex-wrap:wrap;padding-top:9px;padding-bottom:9px}.paragraph-switcher{margin-left:0;max-width:100%}.pane-body{padding:14px}}
@media(max-width:900px){.workbench-shell{height:auto;display:block;overflow:visible}.work-grid{grid-template-columns:1fr;align-items:start;overflow:visible}.sticky-pane{height:auto;display:block;overflow:visible}.sticky-pane>.pane-body{overflow:visible}.pane-card{min-height:auto}.rewrite-area{min-height:360px}.rewrite-card .rewrite-area{flex:none}.submit-row{padding:0 12px 20px}.work-head-inner{align-items:flex-start}.progress-mini{min-width:0}.demo-panel{display:none}}
@media(max-width:760px){.entry-shell{display:block;height:auto}.essay-pane{padding:24px 18px}.smart-pane{border-top:1px solid var(--line)}.smart-body{padding:24px 18px}.top-actions{display:none}.chat-wrap{padding:20px 12px 120px}.message{grid-template-columns:30px 1fr;gap:8px}.avatar{width:30px;height:30px}.bubble{padding:16px}.user .bubble{max-width:90%}.agent-table{font-size:11px}.work-grid{padding:12px}.work-head{padding:12px}.work-head-inner{display:block}.progress-mini{margin-top:8px;justify-content:flex-start}.progress-track{width:120px}.original-pane-head{height:auto;min-height:54px;flex-wrap:wrap;padding-top:9px;padding-bottom:9px}.paragraph-switcher{margin-left:0;max-width:100%}.pane-body{padding:14px}}
</style>
</head>
<body>
@@ -192,7 +192,9 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
<div class="pane-body">
<div class="rewrite-intro">基于左侧原意重新表达这一整段,不需要逐句对应原文。</div>
<textarea id="rewriteArea" class="rewrite-area" placeholder="在这里重写当前段……"></textarea>
<div class="rewrite-meta"><span id="saveMeta">自动保存</span><span id="charCount">0 characters</span></div>
<div class="rewrite-meta"><span id="saveMeta">自动保存</span><span id="wordMeta" class="word-meta"></span><span id="charCount">0 characters</span></div>
<div class="translate-row"><button id="copyOriginalBtn" class="btn link">复制原文到改写区 →</button><button id="translateBtn" class="btn link">译成中文对照 →</button></div>
<div id="translateBox" class="translate-box"></div>
<div class="rewrite-help">
<div class="help-entry"><span>需要一点帮助?</span><button id="showScaffold" class="btn link">给我一个写作起点 →</button></div>
<div id="scaffoldPanel" class="help-panel">
@@ -210,9 +212,12 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
</div>
</section>
</div>
<div class="submit-row"><div class="submit-inner"><span id="submitHint" class="submit-hint hidden"></span><button id="submitRecheck" class="btn primary" disabled>完成全文改写,提交复检 →</button></div></div>
<div class="submit-row"><div class="submit-inner"><span id="submitHint" class="submit-hint hidden"></span><button id="recheckHistoryBtn" class="btn small hidden">复检历史 (0)</button><button id="submitRecheck" class="btn primary" disabled>完成全文改写,提交复检 →</button></div></div>
<div class="demo-panel"><details><summary>Demo 工具</summary><div class="demo-body"><button id="mockFillCurrent" class="btn small">填入当前段</button><button id="mockFillAll" class="btn small">填入模拟全文</button><button id="mockFillFail" class="btn small">填入返工案例</button></div></details></div>
</section>
<!-- 复检历史面板:必须挂在视图容器外 —— 放在 workbenchView 内时,
对话页(workbenchView display:none)点击「查看复检历史」面板不渲染(用户实测) -->
<div id="recheckHistoryPanel" class="recheck-history" aria-label="复检历史记录"></div>
</div>
<script>
@@ -253,8 +258,23 @@ let scaffoldsShown = [];
let currentRewriteVersion = 1;
let lastRecheckSnapshot = null;
let lastRecheckResult = null;
// 体验优化:复检汇总建议历史(最新在前,上限 10 条)——支持回看历次结论 +
// 按历史建议再次进入改写(结合该条建议修改)。
let recheckHistory = [];
// 体验优化:改写稿中文对照翻译缓存(按段下标)。rec.text 与当前改写稿不一致
// 时视为过期,需重新翻译(保证译文对应当前版本)。
let translations = [];
let translateOpen = [];
// 体验优化:原文中文对照(逐句)缓存(按段下标)。rec.sentences = [{en,zh}]
// 句子由前端拆分(en 是原文精确文本),命中批注 evidence 的句子在对照里
// 与原文同色 + 同序号,让「需要改写的那句」能直接找到中文。
let origTranslations = [];
let origTranslateOpen = [];
let analysisVersion = 1;
let helpState = {};
// 「查看之前的写作建议」(<details>) 展开状态:翻译/切换等会重建左栏 DOM,
// 不记住的话翻译完成后会意外收起(用户实测)。
let priorGuidanceOpen = false;
let dirtyDuringRecheck = false;
let transitioning = false;
// 对话历史(快照)持久化:刷新后回对话页不丢理解/诊断/复检卡片(用户实测发现 + PM 同意修复)。
@@ -367,6 +387,14 @@ function ensureParaState(){
rewriteStatus = rewriteStatus.slice(0,n);
saveStatusByPara = saveStatusByPara.slice(0,n);
saveTimers = saveTimers.slice(0,n);
while(translations.length<n) translations.push(null);
while(translateOpen.length<n) translateOpen.push(false);
translations = translations.slice(0,n);
translateOpen = translateOpen.slice(0,n);
while(origTranslations.length<n) origTranslations.push(null);
while(origTranslateOpen.length<n) origTranslateOpen.push(false);
origTranslations = origTranslations.slice(0,n);
origTranslateOpen = origTranslateOpen.slice(0,n);
}
function briefFor(pid){return (diagnosis&&diagnosis.paragraph_briefs||[]).find(b=>b.paragraph_id===pid)||null;}
function patternsFor(pid){return (diagnosis&&diagnosis.patterns||[]).filter(p=>(p.affected_paragraphs||[]).includes(pid));}
@@ -447,8 +475,9 @@ function applyEditedEssay(){
const wl = parseInt($('#wordLimitInput').value, 10);
wordLimit = Number.isFinite(wl) && wl > 0 ? wl : null;
paragraphs = splitParagraphs(essayText);
// 文书内容变化后,旧版本(改写后全文)失去参照,清空版本切换
// 文书内容变化后,旧版本(改写后全文)失去参照,清空版本切换与复检历史/译文
essayVersions = []; viewedVersion = null;
recheckHistory = []; translations = []; translateOpen = []; origTranslations = []; origTranslateOpen = [];
ensureParaState();
closeEditor();
renderEntryEssay();
@@ -460,6 +489,7 @@ function loadSample(){
wordLimit = 650;
paragraphs = SAMPLE_ESSAY.slice();
essayVersions = []; viewedVersion = null;
recheckHistory = []; translations = []; translateOpen = []; origTranslations = []; origTranslateOpen = [];
ensureParaState();
closeEditor();
renderEntryEssay();
@@ -474,7 +504,8 @@ function persistNow(){
current, originalView, revisionMode, revisionTarget,
referencesShown, scaffoldsShown, currentRewriteVersion,
lastRecheckSnapshot, lastRecheckResult, analysisVersion, agentStage,
chatLog, essayVersions, viewedVersion
chatLog, essayVersions, viewedVersion, recheckHistory, translations, translateOpen,
origTranslations, origTranslateOpen
}));
return true;
}catch(e){ return false; }
@@ -531,6 +562,11 @@ function restoreSession(){
chatLogSeq = chatLog.length ? Math.max(...chatLog.map(r=>r.id||0)) : 0;
essayVersions = Array.isArray(s.essayVersions) ? s.essayVersions : [];
viewedVersion = essayVersions.some(v=>v.version===s.viewedVersion) ? s.viewedVersion : null;
recheckHistory = Array.isArray(s.recheckHistory) ? s.recheckHistory.slice(0,10) : [];
translations = Array.isArray(s.translations) ? s.translations : [];
translateOpen = Array.isArray(s.translateOpen) ? s.translateOpen : [];
origTranslations = Array.isArray(s.origTranslations) ? s.origTranslations : [];
origTranslateOpen = Array.isArray(s.origTranslateOpen) ? s.origTranslateOpen : [];
ensureParaState();
if(essayText !== SAMPLE_ESSAY.join('\n\n')) openEditor();
else renderEntryEssay();
@@ -558,6 +594,7 @@ function restoreSession(){
}
function restoreChatHistory(){
renderRecheckHistory();
if(!chatLog || !chatLog.length) return;
chat.innerHTML = '';
// silent 重放:只建 DOM 不重复入日志;按钮由 bindQuickActions 统一重绑,
@@ -713,6 +750,9 @@ function disableCompletedActions(){
});
}
function handleAction(action){
// 复检卡上的「查看复检历史」:两处(通过卡/返工卡)共用,面板 fixed 定位,
// 对话页与 Workbench 都能显示。
if(action==='view-recheck-history'){ renderRecheckHistory(); toggleRecheckHistory(); return; }
if(action==='confirm-understanding'){
// 防重入:诊断请求进行中忽略连点(用户实测连点——修复前两次并发请求 +
// 两个确认气泡 + 两张诊断卡,见 Deviations #23);runDiagnose 内另有同守卫兜底
@@ -885,8 +925,9 @@ function renderAnnotatedText(text, annotations){
}
function bindAnnotationInteractions(wrap){
const activate = id => {
wrap.querySelectorAll('.annotation-anchor,.annotation-card').forEach(el=>el.classList.remove('active'));
wrap.querySelectorAll('.annotation-anchor,.annotation-card,.ot-line').forEach(el=>el.classList.remove('active'));
wrap.querySelectorAll(`.annotation-anchor[data-note-id="${id}"]`).forEach(el=>el.classList.add('active'));
wrap.querySelectorAll(`.ot-line[data-note-id="${id}"]`).forEach(el=>el.classList.add('active'));
const card = wrap.querySelector(`.annotation-card[data-note-id="${id}"]`);
if(card){ card.classList.add('active'); card.scrollIntoView({block:'nearest',behavior:'smooth'}); }
track('annotation_anchor_opened', {paragraph_id: pidOf(current), annotation_id: id});
@@ -896,6 +937,12 @@ function bindAnnotationInteractions(wrap){
el.onkeydown = e => { if(e.key==='Enter'||e.key===' '){ e.preventDefault(); activate(el.dataset.noteId); } };
});
wrap.querySelectorAll('.annotation-card').forEach(el=>el.onclick=e=>{ if(!e.target.closest('.why-toggle')) activate(el.dataset.noteId); });
// 原文中文对照(逐句):对照行与批注同色同序号 —— 点击对照行可联动高亮原文句
wrap.querySelectorAll('[data-role="orig-translate"]').forEach(btn=>{ btn.onclick = toggleOrigTranslate; });
// 记住「查看之前的写作建议」的展开状态(左栏重建后恢复,避免翻译完成被收起)
const prior = wrap.querySelector('.prior-guidance');
if(prior) prior.addEventListener('toggle', ()=>{ priorGuidanceOpen = prior.open; });
wrap.querySelectorAll('.ot-line[data-note-id]').forEach(el=>{ el.onclick = () => activate(el.dataset.noteId); });
wrap.querySelectorAll('.why-toggle').forEach(btn=>btn.onclick=e=>{
e.stopPropagation();
const card = btn.closest('.annotation-card');
@@ -933,10 +980,15 @@ function renderFocusOriginal(wrap){
<div class="direction-line action"><span class="direction-chip action">重写要做什么</span><div class="direction-text">${escapeHtml(fusedGoal(current))}</div></div>
<div class="direction-line ai"><span class="direction-chip ai">AI味要注意</span><div class="direction-text">${escapeHtml(fusedAiFocus(current))}</div></div>
</div>`;
const otRec = origTranslations[current];
const otFresh = otRec && otRec.text === String(paragraphs[current]||'').trim();
const otOpen = !!(otFresh && origTranslateOpen[current]);
const paper = `
<div class="annotated-paper">
<div class="paper-title"><strong>原文</strong><span>${notes.length} 类重写提醒 · 颜色区分问题类型</span></div>
<p class="annotated-copy">${renderAnnotatedText(paragraphs[current]||'', notes)}</p>
<div class="orig-translate-row"><button class="btn link" data-role="orig-translate">${otOpen ? '收起中文对照 ↑' : otFresh ? '查看原文中文对照 →' : '原文中文对照 →'}</button></div>
${otOpen ? `<div class="orig-translate-box">${origTranslateLinesHtml(otRec)}</div>` : ''}
<div class="annotation-list">${annotationCardsHtml(notes)}</div>
</div>`;
const visibleMeaning = `<div class="rewrite-direction">
@@ -944,7 +996,7 @@ function renderFocusOriginal(wrap){
<div class="direction-line meaning"><span class="direction-chip meaning">这段在讲什么</span><div class="direction-text">${escapeHtml(meaningOf(current))}</div></div>
</div>`;
const body = isRevisionTarget(current)
? `${visibleMeaning}<details class="prior-guidance"><summary>查看之前的写作建议</summary>${fullDirection}${paper}</details>`
? `${visibleMeaning}<details class="prior-guidance"${priorGuidanceOpen ? ' open' : ''}><summary>查看之前的写作建议</summary>${fullDirection}${paper}</details>`
: fullDirection + paper;
wrap.className = 'original-workspace';
wrap.innerHTML = `${banner}${body}<div class="context-hint"><div class="context-label">和上下文怎么接</div><div class="context-copy">${escapeHtml(contextHintOf(current))}</div></div>`;
@@ -978,16 +1030,38 @@ function renderParagraphSwitcher(){
$('#focusOriginal').classList.toggle('active', originalView==='focus');
$('#fullOriginal').classList.toggle('active', originalView==='full');
}
// 轻量更新段号按钮状态(active / has-copy),不重建 DOM
// 改写区 blur(点段号的 mousedown 触发)若重建按钮,mouseup 时按钮已被替换,
// 第一次点击会丢失(用户实测:改完字点段号要连点两次)。
function updateParagraphSwitcherState(){
const wrap = $('#paragraphSwitcher');
if(!wrap) return;
wrap.querySelectorAll('[data-para-index]').forEach(btn=>{
const i = Number(btn.dataset.paraIndex);
btn.classList.toggle('active', i===current);
btn.classList.toggle('has-copy', hasRewrite(i));
});
}
function renderOriginalPane(){
const wrap=$('#originalFull');
// 重建 DOM 前记住滚动位置:「查看之前的写作建议」展开后点翻译/切视图等
// 都会走到这里,不恢复会导致内容跳回顶部
const body = wrap.closest('.pane-body');
const scrollTop = body ? body.scrollTop : 0;
wrap.innerHTML='';
if(originalView==='focus') renderFocusOriginal(wrap); else renderFullOriginal(wrap);
renderParagraphSwitcher();
if(body) body.scrollTop = scrollTop;
}
function updateProgress(){
const n = rewrittenCount();
const total = paragraphs.length;
$('#progressText').textContent = `已有改写 ${n} 段 · 共 ${total}`;
// 体验优化:全文词数提示(未改写段按原文计)——对照 word_limit 控制总长度;
// 超出题目字数限制时标红(复检的 word_limit 检查项同源)。
const words = essayWordTotal();
const pt = $('#progressText');
pt.textContent = `已有改写 ${n} 段 · 共 ${total} 段 · 全文 ${words}${wordLimit?` / ${wordLimit}`:''}`;
pt.classList.toggle('over', !!(wordLimit && words>wordLimit));
$('#progressBar').style.width = `${total? n/total*100 : 0}%`;
// PRD §29.1:复检通过 = 本轮结束(状态流转"通过 → 结束",用户实测"通过后
// 仍可再提交复检")。通过后不再提供提交复检入口——再次复检会以
@@ -1025,6 +1099,142 @@ function renderSaveChip(){
chip.className='chip good';
chip.onclick=null;
}
// 体验优化:每段改写字数控制提示 —— 原文段词数 vs 当前改写词数;偏差过大
// 时黄色提醒(改写应基于原意重新表达,长度通常接近原文;防止信息丢失或膨胀)。
// 全文词数在 updateProgress 里连同 word_limit 一起提示。
function renderWordMeta(){
const el = $('#wordMeta'); if(!el) return;
const orig = wordCount(paragraphs[current]||'');
const cur = wordCount(rewrites[current]||'');
let text = `原文 ${orig} 词 · 当前 ${cur}`;
let warn = false;
if(cur>0 && orig>0){
const ratio = cur/orig;
if(ratio>1.4 || ratio<0.6){ warn = true; text += ' · 与原文长度相差较大'; }
}
el.textContent = text;
el.classList.toggle('warn', warn);
}
function essayWordTotal(){
return paragraphs.reduce((n,p,i)=>n+wordCount(hasRewrite(i)?rewrites[i]:p),0);
}
// 体验优化:改写稿中文对照翻译 —— 顾问自查「改写后原意/细节是否保留」。
// 译文缓存按段保存,改写稿变化即过期(renderTranslateBox 里自动收起)。
function renderTranslateBtn(){
const btn = $('#translateBtn'); if(!btn) return;
const rec = translations[current];
const fresh = rec && rec.text === String(rewrites[current]||'').trim();
if(fresh && translateOpen[current]) btn.textContent = '收起译文 ↑';
else if(fresh) btn.textContent = '查看中文对照 →';
else btn.textContent = '译成中文对照 →';
}
function renderTranslateBox(){
const box = $('#translateBox'); if(!box) return;
const rec = translations[current];
const fresh = rec && rec.text === String(rewrites[current]||'').trim();
if(!fresh || !translateOpen[current]){ box.classList.remove('open'); box.innerHTML=''; return; }
box.innerHTML = `<span class="translate-label">中文对照(用于自查改写后原意是否保留)</span>${escapeHtml(rec.translation)}`;
box.classList.add('open');
}
async function toggleTranslate(){
const i = current;
const area = $('#rewriteArea');
if(area) rewrites[i] = area.value;
const text = String(rewrites[i]||'').trim();
const btn = $('#translateBtn');
const rec = translations[i];
const fresh = rec && rec.text === text;
if(fresh){
translateOpen[i] = !translateOpen[i];
persistNow(); renderTranslateBtn(); renderTranslateBox();
return;
}
if(!text){
btn.textContent = '先写下你的改写,再翻译 ↗';
setTimeout(renderTranslateBtn, 1800);
return;
}
btn.disabled = true; btn.textContent = '翻译中…';
try{
const resp = await postJSON('/api/translate',{text, paragraph_id: pidOf(i)});
translations[i] = {text, translation: resp.translation||''};
translateOpen[i] = true;
track('rewrite_translated', {paragraph_id: pidOf(i)});
persistNow();
}catch(e){
btn.disabled = false; btn.textContent = '翻译失败,点击重试';
setTimeout(renderTranslateBtn, 2200);
return;
}
btn.disabled = false;
renderTranslateBtn(); renderTranslateBox();
}
// 体验优化:把当前段原文一键复制到改写区 —— 顾问以原文为起点手动改写
// (改写区已有内容时二次确认,避免覆盖已写的工作)。
function copyOriginalToRewrite(){
const area = $('#rewriteArea');
const text = String(paragraphs[current]||'').trim();
if(!area || !text) return;
if(String(area.value||'').trim() && !confirm('改写区已有内容,确定用原文覆盖吗?')) return;
area.value = text;
area.focus();
onRewriteInput({target: area}); // 复用输入处理:状态/字数提示/译文过期/保存调度
track('original_copied_to_rewrite', {paragraph_id: pidOf(current)});
}
// 体验优化:原文中文对照(逐句)—— 顾问读英文吃力时,特别是被高亮标注
// 「需要改写」的句子,需要它的中文意思才能判断怎么改。
// 句子由前端拆分(en 为原文精确文本,服务端原样回填),命中批注 evidence 的
// 句子在对照里与原文同色 + 同序号,可直接对上。
function splitSentences(text){
const raw = String(text||'').trim();
if(!raw) return [];
const parts = raw.match(/[^.!?]+(?:[.!?]+["'”’]?|$)/g) || [raw];
return parts.map(s=>s.trim()).filter(Boolean);
}
function noteHitForSentence(sentence, notes){
const norm = s=>String(s||'').toLowerCase().replace(/\s+/g,' ').trim();
const target = norm(sentence);
for(let i=0;i<notes.length;i++){
for(const ev of (notes[i].evidence||[])){
const needle = norm(ev);
if(needle && target.includes(needle)) return {index:i, category: notes[i].category||'rhetoric'};
}
}
return null;
}
function origTranslateLinesHtml(rec){
const notes = annotationsFor(current);
return (rec.sentences||[]).map(s=>{
const hit = noteHitForSentence(s.en, notes);
return `<div class="ot-line${hit?` note-${escapeHtml(hit.category)}`:''}"${hit?` data-note-id="${hit.index}"`:''}>${hit?`<sup>${hit.index+1}</sup>`:''}<span class="ot-zh">${escapeHtml(s.zh)}</span></div>`;
}).join('');
}
async function toggleOrigTranslate(){
const i = current;
const text = String(paragraphs[i]||'').trim();
const rec = origTranslations[i];
const fresh = rec && rec.text === text;
if(fresh){
origTranslateOpen[i] = !origTranslateOpen[i];
persistNow(); renderOriginalPane();
return;
}
const sentences = splitSentences(text);
if(!sentences.length) return;
const btn = document.querySelector('[data-role="orig-translate"]');
if(btn){ btn.disabled = true; btn.textContent = '翻译中…'; }
try{
const resp = await postJSON('/api/translate',{sentences, paragraph_id: pidOf(i)});
origTranslations[i] = {text, sentences: resp.sentences||[]};
origTranslateOpen[i] = true;
track('original_translated', {paragraph_id: pidOf(i), sentences: sentences.length});
persistNow();
}catch(e){
if(btn){ btn.disabled = false; btn.textContent = '翻译失败,点击重试'; }
return;
}
renderOriginalPane();
}
function resetHelpPanels(){
const key = pidOf(current);
const st = helpState[key] || {scaffold:false, reference:false};
@@ -1046,8 +1256,12 @@ function renderWorkbench(){
$('#nextPara').disabled = current===paragraphs.length-1;
renderOriginalPane();
renderSaveChip();
renderWordMeta();
renderTranslateBtn();
renderTranslateBox();
resetHelpPanels();
updateProgress();
renderRecheckHistory();
}
async function submitConstraint(){
const input = $('#constraintInput');
@@ -1122,6 +1336,9 @@ function setOriginalView(mode){
function onRewriteInput(e){
rewrites[current] = e.target.value;
$('#charCount').textContent = `${e.target.value.length} characters`;
renderWordMeta();
renderTranslateBtn();
renderTranslateBox(); // 改写稿变化 → 旧译文过期自动收起(缓存按文本比对)
if(!e.target.value.trim()){
rewriteStatus[current] = 'UNTOUCHED';
}else{
@@ -1132,7 +1349,7 @@ function onRewriteInput(e){
}
}
scheduleSave(current);
renderParagraphSwitcher();
updateParagraphSwitcherState();
updateProgress();
}
function onRewriteBlur(){
@@ -1141,7 +1358,7 @@ function onRewriteBlur(){
if(hasRewrite(current)) rewriteStatus[current] = 'REWRITTEN';
else rewriteStatus[current] = 'UNTOUCHED';
flushSave(current);
renderParagraphSwitcher();
updateParagraphSwitcherState(); // 不重建按钮:避免点击段号时 mousedown→blur 重建导致第一次点击丢失
updateProgress();
}
function recordExposure(kind, text){
@@ -1259,6 +1476,7 @@ async function runRecheck(){
if(!resp.checked_rewrite_version) resp.checked_rewrite_version = version;
removeEl(loading);
lastRecheckResult = resp;
pushRecheckHistory(resp);
agentStage='recheck';
persistNow();
renderRecheck(resp);
@@ -1268,6 +1486,72 @@ async function runRecheck(){
renderError(e.message, ()=>{ show('agentView'); addUser('重新提交全文复检。'); runRecheck(); });
}
}
// 体验优化:复检汇总建议历史 —— 此前复检结果看一次就只剩最新一份(lastRecheckResult),
// 无法回看历次建议。这里每次复检入历史(状态/检查项/返工目标/绑定版本),
// 历史条目可直接「按此建议再次改写」回到对应段、带着当时的建议修改。
function pushRecheckHistory(r){
const rec = {
at: Date.now(),
status: r.status==='pass' ? 'pass' : 'revision_required',
version: r.checked_rewrite_version || '',
checks: r.global_checks || {},
target: r.status==='pass' ? null : ((r.revision_targets||[])[0] || null),
pass_version: null
};
recheckHistory.unshift(rec);
if(recheckHistory.length>10) recheckHistory.length = 10;
persistNow();
return rec;
}
function failedCheckLabels(checks){
return Object.keys(CHECK_LABELS).filter(k=>k!=='word_limit'||wordLimit).filter(k=>{
const v = (checks||{})[k];
return !(v==='pass'||v===undefined||v===null);
}).map(k=>CHECK_LABELS[k]);
}
function renderRecheckHistory(){
const btn = $('#recheckHistoryBtn');
if(!btn) return;
btn.classList.toggle('hidden', recheckHistory.length===0);
btn.textContent = `复检历史 (${recheckHistory.length})`;
const panel = $('#recheckHistoryPanel');
if(!panel) return;
if(!recheckHistory.length){ panel.classList.remove('open'); panel.innerHTML=''; return; }
panel.innerHTML = `<div class="rhi-title">复检历史(最新在前,点建议可再次进入改写)</div>` + recheckHistory.map((rec,idx)=>{
const ok = rec.status==='pass';
const d = new Date(rec.at);
const pad = n=>String(n).padStart(2,'0');
const time = `${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
const tIdx = rec.target ? Math.max(0, parseInt(String(rec.target.paragraph_id||'p1').replace(/\D/g,''),10)-1) : -1;
const badge = ok ? '<span class="rhi-badge ok">✓ 通过</span>' : `<span class="rhi-badge warn">需返工 ${tIdx>=0?paragraphLabel(tIdx):''}</span>`;
const fails = failedCheckLabels(rec.checks);
const meta = [rec.version?`Rewrite Version ${escapeHtml(rec.version)}`:'', rec.pass_version?`生成版本 ${rec.pass_version}`:'', fails.length?`未通过检查:${fails.join('、')}`:''].filter(Boolean).join(' · ');
return `<div class="recheck-history-item">
<div class="rhi-head"><strong>${time}</strong>${badge}${idx===0?'<span class="rhi-latest">最新</span>':''}</div>
${meta?`<div class="rhi-meta">${meta}</div>`:''}
${rec.target&&rec.target.single_revision_goal?`<div class="rhi-goal"><strong>建议:</strong>${escapeHtml(rec.target.single_revision_goal)}</div>`:''}
${rec.target?`<button class="btn small rhi-revision" data-hidx="${idx}">按此建议再次改写 →</button>`:''}
</div>`;
}).join('');
panel.querySelectorAll('.rhi-revision').forEach(b=>{ b.onclick=()=>enterRevisionFromHistory(Number(b.dataset.hidx)); });
}
function toggleRecheckHistory(force){
const panel = $('#recheckHistoryPanel');
if(!panel || !recheckHistory.length) return;
const open = typeof force==='boolean' ? force : !panel.classList.contains('open');
panel.classList.toggle('open', open);
if(open){ renderRecheckHistory(); track('recheck_history_opened', {count: recheckHistory.length}); }
}
function enterRevisionFromHistory(hidx){
const rec = recheckHistory[hidx];
if(!rec || !rec.target) return;
// 选中历史建议 = 当前返工目标:左栏返工 banner / 对话页 return 按钮都按它工作
revisionTarget = rec.target;
revisionMode = true;
toggleRecheckHistory(false);
track('recheck_history_revision_entered', {paragraph_id: rec.target.paragraph_id});
enterRevision(rec.target.paragraph_id);
}
function renderRecheck(r){
const checks=r.global_checks||{};
const keys = Object.keys(CHECK_LABELS).filter(k=> k!=='word_limit' || wordLimit);
@@ -1292,8 +1576,9 @@ function renderRecheck(r){
const nextVersion = essayVersions.length + 2;
essayVersions.push({version: nextVersion, text: fullText});
viewedVersion = nextVersion;
if(recheckHistory[0]) recheckHistory[0].pass_version = nextVersion; // 历史条目关联成品版本
persistNow();
addAgent(`<div class="eyebrow">Human Voice Recheck</div><h1>本轮 Human Voice Rewrite 已完成。</h1>${ver}<p>我把 ${paragraphs.length} 段放回完整文章重新读了一遍。本次只做最终验收,没有重新开启一轮问题盘点。</p><table class="agent-table"><thead><tr><th>检查项</th><th style="width:190px">状态</th></tr></thead><tbody>${rows}</tbody></table><div class="inline-note"><strong>可以结束这一轮 Human Voice Rewrite。</strong>剩余问题主要属于个人风格选择,不建议继续为了“更不像 AI”大面积修改。</div><p class="muted">本次改写后的全文已生成 <strong>版本 ${nextVersion}</strong>。点击下方按钮回文书页,可在文书页顶部切换「原版 / 版本 ${nextVersion}」对比。</p><div class="quick-actions"><button class="quick primary" data-action="view-version2">查看版本 ${nextVersion}(修改后的全文)→</button></div>`);
addAgent(`<div class="eyebrow">Human Voice Recheck</div><h1>本轮 Human Voice Rewrite 已完成。</h1>${ver}<p>我把 ${paragraphs.length} 段放回完整文章重新读了一遍。本次只做最终验收,没有重新开启一轮问题盘点。</p><table class="agent-table"><thead><tr><th>检查项</th><th style="width:190px">状态</th></tr></thead><tbody>${rows}</tbody></table><div class="inline-note"><strong>可以结束这一轮 Human Voice Rewrite。</strong>剩余问题主要属于个人风格选择,不建议继续为了“更不像 AI”大面积修改。</div><p class="muted">本次改写后的全文已生成 <strong>版本 ${nextVersion}</strong>。点击下方按钮回文书页,可在文书页顶部切换「原版 / 版本 ${nextVersion}」对比。</p><div class="quick-actions"><button class="quick primary" data-action="view-version2">查看版本 ${nextVersion}(修改后的全文)→</button><button class="quick ghost" data-action="view-recheck-history">查看复检历史</button></div>`);
bindQuickActions();
disableCompletedActions(); // 通过后收紧:历史返工卡按钮按 revisionTarget=null 置灰(用户实测 2
}else{
@@ -1303,10 +1588,11 @@ function renderRecheck(r){
const idx=Math.max(0, parseInt(String(t.paragraph_id||'p1').replace(/\D/g,''),10)-1);
const ev=t.evidence&&t.evidence.length?`<div class="constraint-box"><strong>Evidence</strong><br>${t.evidence.map(x=>'• '+escapeHtml(x)).join('<br>')}</div>`:'';
track('recheck_revision_required', {paragraph_id: t.paragraph_id});
addAgent(`<div class="eyebrow">Human Voice Recheck</div><h1>全文复检:${escapeHtml(paragraphLabel(idx))} 还需要返工一次</h1>${ver}<p>这次不重复首轮的 Pattern 教学,只看本轮重写有没有真正生效、上下文是否统一。</p><table class="agent-table"><thead><tr><th>检查项</th><th style="width:190px">状态</th></tr></thead><tbody>${rows}</tbody></table><h2>这次只返工 ${escapeHtml(paragraphLabel(idx))}</h2><p>${escapeHtml(t.blocking_issue)}</p>${ev}<div class="summary"><strong>本次只返工一件事:</strong>${escapeHtml(t.single_revision_goal)}</div><div class="quick-actions"><button class="quick primary" data-action="return-${escapeHtml(t.paragraph_id)}">返回 ${escapeHtml(paragraphLabel(idx))} 重写 →</button></div>`);
addAgent(`<div class="eyebrow">Human Voice Recheck</div><h1>全文复检:${escapeHtml(paragraphLabel(idx))} 还需要返工一次</h1>${ver}<p>这次不重复首轮的 Pattern 教学,只看本轮重写有没有真正生效、上下文是否统一。</p><table class="agent-table"><thead><tr><th>检查项</th><th style="width:190px">状态</th></tr></thead><tbody>${rows}</tbody></table><h2>这次只返工 ${escapeHtml(paragraphLabel(idx))}</h2><p>${escapeHtml(t.blocking_issue)}</p>${ev}<div class="summary"><strong>本次只返工一件事:</strong>${escapeHtml(t.single_revision_goal)}</div><div class="quick-actions"><button class="quick primary" data-action="return-${escapeHtml(t.paragraph_id)}">返回 ${escapeHtml(paragraphLabel(idx))} 重写 →</button><button class="quick ghost" data-action="view-recheck-history">查看复检历史</button></div>`);
bindQuickActions();
disableCompletedActions();
}
renderRecheckHistory();
}
const MOCKS=[
@@ -1345,7 +1631,9 @@ $('#backToChat').onclick=()=>{
disableCompletedActions();
};
$('#sendChat').onclick=()=>processChat($('#chatInput').value);
$('#chatInput').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();processChat(e.target.value);}});
// 中文输入法组合期(拼音还没上屏)回车是在选候选词,不是发送 —— 此前无守卫导致
// 打字打到一半就被发出去(用户实测)。keyCode 229 兼容部分输入法组合结束那一下。
$('#chatInput').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey&&!e.isComposing&&e.keyCode!==229){e.preventDefault();processChat(e.target.value);}});
$('#chatInput').addEventListener('input',()=>{const h=$('#inputHint');if(h)h.classList.add('hidden');});
$('#focusOriginal').onclick=()=>setOriginalView('focus');
$('#fullOriginal').onclick=()=>setOriginalView('full');
@@ -1353,6 +1641,17 @@ $('#rewriteArea').addEventListener('input', onRewriteInput);
$('#rewriteArea').addEventListener('blur', onRewriteBlur);
$('#showScaffold').onclick=toggleScaffold;
$('#showReference').onclick=toggleReference;
$('#copyOriginalBtn').onclick=copyOriginalToRewrite;
$('#translateBtn').onclick=toggleTranslate;
$('#recheckHistoryBtn').onclick=()=>toggleRecheckHistory();
// 点击复检历史面板与开关按钮以外的区域自动收起
document.addEventListener('click', e=>{
const panel = $('#recheckHistoryPanel');
if(!panel || !panel.classList.contains('open')) return;
if(panel.contains(e.target)) return;
if(e.target.closest && e.target.closest('#recheckHistoryBtn, [data-action="view-recheck-history"]')) return;
toggleRecheckHistory(false);
});
$('#prevPara').onclick=()=>{ if(current>0) switchParagraph(current-1); };
$('#nextPara').onclick=()=>{ if(current<paragraphs.length-1) switchParagraph(current+1); };
$('#addConstraintBtn').onclick=toggleConstraintEntry;
+48
View File
@@ -20,6 +20,8 @@ from prompts import (
build_recheck_prompt,
build_reference_prompt,
build_scaffold_prompt,
build_translate_prompt,
build_translate_sentences_prompt,
normalize_diagnose_payload,
)
from schemas import (
@@ -35,6 +37,9 @@ from schemas import (
RevisionTarget,
ScaffoldRequest,
ScaffoldResponse,
SentencePair,
TranslateRequest,
TranslateResponse,
)
app = FastAPI(title="Human Voice Rewrite Demo", version="1.1.0")
@@ -198,6 +203,49 @@ def reference(req: ReferenceRequest) -> ReferenceResponse:
return resp
@app.post("/api/translate", response_model=TranslateResponse)
def translate(req: TranslateRequest) -> TranslateResponse:
"""体验优化:中文对照翻译。
- text 整段模式:改写区「译成中文对照」(自查改写后原意/细节是否保留)
- sentences 逐句模式:原文区「原文中文对照」(前端拆分句子,逐句对位,
让「需要改写的高亮句」在中文对照里能同色找到)。"""
if req.sentences:
return _translate_sentences(req)
if not req.text.strip():
raise HTTPException(status_code=422, detail="没有可翻译的文本")
system, user = build_translate_prompt(req.text)
data = run_json("翻译", system, user)
translation = data.get("translation")
if isinstance(translation, list): # 模型偶发把字符串字段返回成数组
translation = "".join(str(x) for x in translation)
resp = TranslateResponse(paragraph_id=req.paragraph_id, translation=str(translation or "").strip())
if not resp.translation:
raise HTTPException(status_code=502, detail="翻译结果为空,请重试")
return resp
def _translate_sentences(req: TranslateRequest) -> TranslateResponse:
clean = [s for s in (x.strip() for x in req.sentences) if s]
if not clean:
raise HTTPException(status_code=422, detail="没有可翻译的句子")
system, user = build_translate_sentences_prompt(clean)
# 句子数量校验复用 _count_check:不一致自动重试一次(对位可靠性依赖等长同序)
data = run_json("翻译", system, user, check=_count_check(len(clean), "sentences"))
items = data.get("sentences") or []
pairs: list[SentencePair] = []
for i, en in enumerate(clean):
zh = ""
if i < len(items) and isinstance(items[i], dict):
value = items[i].get("zh")
if isinstance(value, list): # 模型偶发把字符串字段返回成数组
value = "".join(str(x) for x in value)
zh = str(value or "").strip()
pairs.append(SentencePair(en=en, zh=zh))
if not any(p.zh for p in pairs):
raise HTTPException(status_code=502, detail="翻译结果为空,请重试")
return TranslateResponse(paragraph_id=req.paragraph_id, sentences=pairs)
@app.get("/")
def index() -> FileResponse:
return FileResponse(HERE / "index.html", media_type="text/html")
+31
View File
@@ -307,6 +307,37 @@ def build_reference_prompt(
return system, user
# --------------------------------------------------------------------------
# 体验优化:中文对照翻译(顾问在改写区自查「改写后原意有没有跑偏」)
def build_translate_prompt(text: str) -> tuple[str, str]:
system = """你是 Human Voice Rewrite 工作流的中文对照翻译器。
要求:
- 把用户给的英文文本逐句翻译成自然中文,供顾问核对「改写后是否守住了原意」。
- 忠实优先:保留细节、语气与情绪强度;不润色、不省略、不补写原文没有的信息。
- 不做评价、不建议、不加注释;只输出译文本身。
- 只输出 JSON{"translation": ""}"""
user = f"英文文本:\n{text}\n\n请输出 JSON。"
return system, user
def build_translate_sentences_prompt(sentences: list[str]) -> tuple[str, str]:
"""逐句中文对照(原文区):句子由前端拆分,模型只负责逐句翻译,
数量与顺序必须一致 —— 前端据此把「需要改写的高亮句」在中文对照里同色对位。"""
numbered = "\n".join(f"{i + 1}. {s}" for i, s in enumerate(sentences))
system = """你是 Human Voice Rewrite 工作流的中文对照翻译器。
用户给出的是一段英文文书的逐句拆分(编号)。要求:
- 逐句翻译成自然中文,与输入一一对应:数量一致、顺序不变;每句只翻这一句,不合并、不遗漏。
- 忠实优先:保留细节、语气与情绪强度;不润色、不省略、不补写原文没有的信息。
- 不做评价、不建议、不加注释。
- 只输出 JSON{"sentences": [{"zh": ""}]}(按输入顺序,数量与输入完全一致)"""
user = f"英文句子:\n{numbered}\n\n请输出 JSON。"
return system, user
def _as_text(value, sep="") -> str:
"""模型偶发把字符串字段返回成数组(如 ai_focus 给了 1–3 类问题的列表,
2026-08-21 用户实测 4 段全中)——统一规整为字符串,避免 pydantic
+21
View File
@@ -167,3 +167,24 @@ class ReferenceResponse(BaseModel):
paragraph_id: str = ""
starter: str = "" # 1-2 句英文局部参考,非整段
reference_snippet: str = ""
# ------------------------------------------------------- 体验优化:中文对照翻译
class SentencePair(BaseModel):
en: str = "" # 原文句子(前端拆分,服务端原样回填,保证高亮对位可靠)
zh: str = ""
class TranslateRequest(BaseModel):
# 整段模式(改写稿翻译):text 必填;
# 逐句模式(原文中文对照):sentences 必填。二者取其一。
text: str = ""
sentences: list[str] = []
# 段落上下文(可选):帮助模型稳定语气,不参与翻译输出
paragraph_id: str = ""
class TranslateResponse(BaseModel):
paragraph_id: str = ""
translation: str = "" # 整段模式
sentences: list[SentencePair] = [] # 逐句模式(与请求句子等长、同序)
+45
View File
@@ -510,6 +510,51 @@ def test_api_scaffold_shape():
assert "liked" in r.json()["scaffold"]
def test_api_translate_shape():
payload = {"translation": "我不断回到这个问题上。"}
r = api_client(payload).post(
"/api/translate",
json={"text": "I kept coming back to the problem.", "paragraph_id": "p3"},
)
assert r.status_code == 200
assert r.json()["translation"].startswith("我不断回到")
assert r.json()["paragraph_id"] == "p3"
def test_api_translate_coerces_list_translation():
"""模型偶发把 translation 返回成数组 —— 归并为一个字符串,不 502。"""
r = api_client({"translation": ["第一句。", "第二句。"]}).post(
"/api/translate", json={"text": "One. Two."}
)
assert r.status_code == 200
assert r.json()["translation"] == "第一句。第二句。"
def test_api_translate_sentences_shape():
"""原文中文对照(逐句):en 原样回填、zh 按序对位 —— 前端据此做高亮对位。"""
payload = {"sentences": [{"zh": "第一句译文。"}, {"zh": "第二句译文。"}]}
r = api_client(payload).post(
"/api/translate", json={"sentences": ["One.", "Two."], "paragraph_id": "p1"}
)
assert r.status_code == 200
data = r.json()
assert [s["en"] for s in data["sentences"]] == ["One.", "Two."]
assert data["sentences"][1]["zh"] == "第二句译文。"
def test_api_translate_sentences_blank_422():
r = api_client({"sentences": [{"zh": "x"}]}).post(
"/api/translate", json={"sentences": [" ", ""]}
)
assert r.status_code == 422
def test_api_translate_empty_translation_502():
r = api_client({"translation": " "}).post("/api/translate", json={"text": "Hello."})
assert r.status_code == 502
assert "翻译结果为空" in r.json()["detail"]
def test_complete_json_paragraph_count_mismatch_retries_once():
"""语义校验:段落数不匹配视为一次失败并自动重试,重试成功返回完整结果。
真实 LLM 偶发返回合法 JSON 但段落条目不全(AI 初审"4 段只显示 1 段"),