From 85a2eb720a5f43980c1bc7fd6f5badb0ceb39ccd Mon Sep 17 00:00:00 2001
From: LuminousRuoxi <2494735589@qq.com>
Date: Thu, 10 Sep 2026 14:09:31 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B9=E5=86=99=E5=B7=A5=E4=BD=9C?=
=?UTF-8?q?=E5=8F=B0=E4=BD=93=E9=AA=8C=E4=BC=98=E5=8C=96=20=E2=80=94=20?=
=?UTF-8?q?=E4=B8=AD=E6=96=87=E5=AF=B9=E7=85=A7=E7=BF=BB=E8=AF=91=20+=20?=
=?UTF-8?q?=E5=AD=97=E6=95=B0=E6=8E=A7=E5=88=B6=E6=8F=90=E7=A4=BA=20+=20?=
=?UTF-8?q?=E5=A4=8D=E6=A3=80=E5=8E=86=E5=8F=B2/=E6=8C=89=E5=BB=BA?=
=?UTF-8?q?=E8=AE=AE=E5=86=8D=E6=AC=A1=E6=94=B9=E5=86=99=20+=20=E8=BE=93?=
=?UTF-8?q?=E5=85=A5=E6=B3=95=E7=BB=84=E5=90=88=E6=9C=9F=E4=B8=8D=E8=AF=AF?=
=?UTF-8?q?=E5=8F=91=E9=80=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 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 通过
---
index.html | 208 ++++++++++++++++++++++++++++++++++++++++++++++++---
main.py | 19 +++++
prompts.py | 15 ++++
schemas.py | 12 +++
test_demo.py | 26 +++++++
5 files changed, 269 insertions(+), 11 deletions(-)
diff --git a/index.html b/index.html
index e0c5b59..a5582d0 100644
--- a/index.html
+++ b/index.html
@@ -54,7 +54,7 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
.work-head{background:#fff;border-bottom:1px solid var(--line);padding:15px 24px;position:sticky;top:54px;z-index:30}
.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}
@@ -82,8 +82,8 @@ 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: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}.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}.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{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}.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)}}
@@ -192,7 +192,9 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
基于左侧原意重新表达这一整段,不需要逐句对应原文。
-
自动保存0 characters
+
自动保存0 characters
+
+
需要一点帮助?
@@ -210,7 +212,8 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
-
+
+
@@ -253,6 +256,13 @@ let scaffoldsShown = [];
let currentRewriteVersion = 1;
let lastRecheckSnapshot = null;
let lastRecheckResult = null;
+// 体验优化:复检汇总建议历史(最新在前,上限 10 条)——支持回看历次结论 +
+// 按历史建议再次进入改写(结合该条建议修改)。
+let recheckHistory = [];
+// 体验优化:改写稿中文对照翻译缓存(按段下标)。rec.text 与当前改写稿不一致
+// 时视为过期,需重新翻译(保证译文对应当前版本)。
+let translations = [];
+let translateOpen = [];
let analysisVersion = 1;
let helpState = {};
let dirtyDuringRecheck = false;
@@ -367,6 +377,10 @@ function ensureParaState(){
rewriteStatus = rewriteStatus.slice(0,n);
saveStatusByPara = saveStatusByPara.slice(0,n);
saveTimers = saveTimers.slice(0,n);
+ while(translations.lengthb.paragraph_id===pid)||null;}
function patternsFor(pid){return (diagnosis&&diagnosis.patterns||[]).filter(p=>(p.affected_paragraphs||[]).includes(pid));}
@@ -447,8 +461,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 = [];
ensureParaState();
closeEditor();
renderEntryEssay();
@@ -460,6 +475,7 @@ function loadSample(){
wordLimit = 650;
paragraphs = SAMPLE_ESSAY.slice();
essayVersions = []; viewedVersion = null;
+ recheckHistory = []; translations = []; translateOpen = [];
ensureParaState();
closeEditor();
renderEntryEssay();
@@ -474,7 +490,7 @@ function persistNow(){
current, originalView, revisionMode, revisionTarget,
referencesShown, scaffoldsShown, currentRewriteVersion,
lastRecheckSnapshot, lastRecheckResult, analysisVersion, agentStage,
- chatLog, essayVersions, viewedVersion
+ chatLog, essayVersions, viewedVersion, recheckHistory, translations, translateOpen
}));
return true;
}catch(e){ return false; }
@@ -531,6 +547,9 @@ 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 : [];
ensureParaState();
if(essayText !== SAMPLE_ESSAY.join('\n\n')) openEditor();
else renderEntryEssay();
@@ -558,6 +577,7 @@ function restoreSession(){
}
function restoreChatHistory(){
+ renderRecheckHistory();
if(!chatLog || !chatLog.length) return;
chat.innerHTML = '';
// silent 重放:只建 DOM 不重复入日志;按钮由 bindQuickActions 统一重绑,
@@ -713,6 +733,9 @@ function disableCompletedActions(){
});
}
function handleAction(action){
+ // 复检卡上的「查看复检历史」:两处(通过卡/返工卡)共用,面板 fixed 定位,
+ // 对话页与 Workbench 都能显示。
+ if(action==='view-recheck-history'){ renderRecheckHistory(); toggleRecheckHistory(true); return; }
if(action==='confirm-understanding'){
// 防重入:诊断请求进行中忽略连点(用户实测连点——修复前两次并发请求 +
// 两个确认气泡 + 两张诊断卡,见 Deviations #23);runDiagnose 内另有同守卫兜底
@@ -987,7 +1010,12 @@ function renderOriginalPane(){
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 +1053,76 @@ 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 = `中文对照(用于自查改写后原意是否保留)${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 resetHelpPanels(){
const key = pidOf(current);
const st = helpState[key] || {scaffold:false, reference:false};
@@ -1046,8 +1144,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 +1224,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{
@@ -1259,6 +1364,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 +1374,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 = `复检历史(最新在前,点建议可再次进入改写)
` + 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 ? '✓ 通过' : `需返工 ${tIdx>=0?paragraphLabel(tIdx):''}`;
+ 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 `
+
${time}${badge}${idx===0?'最新':''}
+ ${meta?`
${meta}
`:''}
+ ${rec.target&&rec.target.single_revision_goal?`
建议:${escapeHtml(rec.target.single_revision_goal)}
`:''}
+ ${rec.target?`
`:''}
+
`;
+ }).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 +1464,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(`Human Voice Recheck
本轮 Human Voice Rewrite 已完成。
${ver}我把 ${paragraphs.length} 段放回完整文章重新读了一遍。本次只做最终验收,没有重新开启一轮问题盘点。
可以结束这一轮 Human Voice Rewrite。剩余问题主要属于个人风格选择,不建议继续为了“更不像 AI”大面积修改。
本次改写后的全文已生成 版本 ${nextVersion}。点击下方按钮回文书页,可在文书页顶部切换「原版 / 版本 ${nextVersion}」对比。
`);
+ addAgent(`Human Voice Recheck
本轮 Human Voice Rewrite 已完成。
${ver}我把 ${paragraphs.length} 段放回完整文章重新读了一遍。本次只做最终验收,没有重新开启一轮问题盘点。
可以结束这一轮 Human Voice Rewrite。剩余问题主要属于个人风格选择,不建议继续为了“更不像 AI”大面积修改。
本次改写后的全文已生成 版本 ${nextVersion}。点击下方按钮回文书页,可在文书页顶部切换「原版 / 版本 ${nextVersion}」对比。
`);
bindQuickActions();
disableCompletedActions(); // 通过后收紧:历史返工卡按钮按 revisionTarget=null 置灰(用户实测 2)
}else{
@@ -1303,10 +1476,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?`Evidence:
${t.evidence.map(x=>'• '+escapeHtml(x)).join('
')}
`:'';
track('recheck_revision_required', {paragraph_id: t.paragraph_id});
- addAgent(`Human Voice Recheck
全文复检:${escapeHtml(paragraphLabel(idx))} 还需要返工一次
${ver}这次不重复首轮的 Pattern 教学,只看本轮重写有没有真正生效、上下文是否统一。
这次只返工 ${escapeHtml(paragraphLabel(idx))}
${escapeHtml(t.blocking_issue)}
${ev}本次只返工一件事:${escapeHtml(t.single_revision_goal)}
`);
+ addAgent(`Human Voice Recheck
全文复检:${escapeHtml(paragraphLabel(idx))} 还需要返工一次
${ver}这次不重复首轮的 Pattern 教学,只看本轮重写有没有真正生效、上下文是否统一。
这次只返工 ${escapeHtml(paragraphLabel(idx))}
${escapeHtml(t.blocking_issue)}
${ev}本次只返工一件事:${escapeHtml(t.single_revision_goal)}
`);
bindQuickActions();
disableCompletedActions();
}
+ renderRecheckHistory();
}
const MOCKS=[
@@ -1345,7 +1519,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 +1529,16 @@ $('#rewriteArea').addEventListener('input', onRewriteInput);
$('#rewriteArea').addEventListener('blur', onRewriteBlur);
$('#showScaffold').onclick=toggleScaffold;
$('#showReference').onclick=toggleReference;
+$('#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 ReferenceResponse:
return resp
+@app.post("/api/translate", response_model=TranslateResponse)
+def translate(req: TranslateRequest) -> TranslateResponse:
+ """体验优化:改写区「译成中文对照」——顾问自查改写后原意/细节是否保留。"""
+ 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
+
+
@app.get("/")
def index() -> FileResponse:
return FileResponse(HERE / "index.html", media_type="text/html")
diff --git a/prompts.py b/prompts.py
index aed29f8..34dccfc 100644
--- a/prompts.py
+++ b/prompts.py
@@ -307,6 +307,21 @@ 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 _as_text(value, sep=";") -> str:
"""模型偶发把字符串字段返回成数组(如 ai_focus 给了 1–3 类问题的列表,
2026-08-21 用户实测 4 段全中)——统一规整为字符串,避免 pydantic
diff --git a/schemas.py b/schemas.py
index aabc547..24c99b8 100644
--- a/schemas.py
+++ b/schemas.py
@@ -167,3 +167,15 @@ class ReferenceResponse(BaseModel):
paragraph_id: str = ""
starter: str = "" # 1-2 句英文局部参考,非整段
reference_snippet: str = ""
+
+
+# ------------------------------------------------------- 体验优化:中文对照翻译
+class TranslateRequest(BaseModel):
+ text: str = Field(min_length=1)
+ # 段落上下文(可选):帮助模型稳定语气,不参与翻译输出
+ paragraph_id: str = ""
+
+
+class TranslateResponse(BaseModel):
+ paragraph_id: str = ""
+ translation: str = ""
diff --git a/test_demo.py b/test_demo.py
index 518ed65..87ea1db 100644
--- a/test_demo.py
+++ b/test_demo.py
@@ -510,6 +510,32 @@ 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_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 段"),