feat: 原文中文对照(逐句)— 高亮句同色同序号对位
- /api/translate 增加逐句模式(sentences): 前端拆句(en 为原文精确文本)→ 模型逐句翻译, 数量/顺序校验复用 _count_check(不一致自动重试);响应 en 原样回填保证对位可靠 - 左栏原文区新增「原文中文对照 →」: 逐句中文展示,命中批注 evidence 的句子 与原文同色(note-rhetoric/repeat/growth/structure)+ 同序号(sup), 点击对照行可联动高亮原文句与批注卡片 - 对照缓存按段保存并持久化(原文变化/换文书自动失效清空) - 测试: 新增逐句模式 2 个 hermetic 测试(33 passed) - 浏览器实测 18/18 通过(含逐句渲染/高亮对位/点击联动/刷新持久化)
This commit is contained in:
+80
-5
@@ -82,7 +82,7 @@ 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}.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}
|
||||
.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}.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{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)}}
|
||||
@@ -263,6 +263,11 @@ let recheckHistory = [];
|
||||
// 时视为过期,需重新翻译(保证译文对应当前版本)。
|
||||
let translations = [];
|
||||
let translateOpen = [];
|
||||
// 体验优化:原文中文对照(逐句)缓存(按段下标)。rec.sentences = [{en,zh}],
|
||||
// 句子由前端拆分(en 是原文精确文本),命中批注 evidence 的句子在对照里
|
||||
// 与原文同色 + 同序号,让「需要改写的那句」能直接找到中文。
|
||||
let origTranslations = [];
|
||||
let origTranslateOpen = [];
|
||||
let analysisVersion = 1;
|
||||
let helpState = {};
|
||||
let dirtyDuringRecheck = false;
|
||||
@@ -381,6 +386,10 @@ function ensureParaState(){
|
||||
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));}
|
||||
@@ -463,7 +472,7 @@ function applyEditedEssay(){
|
||||
paragraphs = splitParagraphs(essayText);
|
||||
// 文书内容变化后,旧版本(改写后全文)失去参照,清空版本切换与复检历史/译文
|
||||
essayVersions = []; viewedVersion = null;
|
||||
recheckHistory = []; translations = []; translateOpen = [];
|
||||
recheckHistory = []; translations = []; translateOpen = []; origTranslations = []; origTranslateOpen = [];
|
||||
ensureParaState();
|
||||
closeEditor();
|
||||
renderEntryEssay();
|
||||
@@ -475,7 +484,7 @@ function loadSample(){
|
||||
wordLimit = 650;
|
||||
paragraphs = SAMPLE_ESSAY.slice();
|
||||
essayVersions = []; viewedVersion = null;
|
||||
recheckHistory = []; translations = []; translateOpen = [];
|
||||
recheckHistory = []; translations = []; translateOpen = []; origTranslations = []; origTranslateOpen = [];
|
||||
ensureParaState();
|
||||
closeEditor();
|
||||
renderEntryEssay();
|
||||
@@ -490,7 +499,8 @@ function persistNow(){
|
||||
current, originalView, revisionMode, revisionTarget,
|
||||
referencesShown, scaffoldsShown, currentRewriteVersion,
|
||||
lastRecheckSnapshot, lastRecheckResult, analysisVersion, agentStage,
|
||||
chatLog, essayVersions, viewedVersion, recheckHistory, translations, translateOpen
|
||||
chatLog, essayVersions, viewedVersion, recheckHistory, translations, translateOpen,
|
||||
origTranslations, origTranslateOpen
|
||||
}));
|
||||
return true;
|
||||
}catch(e){ return false; }
|
||||
@@ -550,6 +560,8 @@ function restoreSession(){
|
||||
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();
|
||||
@@ -908,8 +920,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});
|
||||
@@ -919,6 +932,9 @@ 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; });
|
||||
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');
|
||||
@@ -956,10 +972,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">
|
||||
@@ -1123,6 +1144,60 @@ async function toggleTranslate(){
|
||||
btn.disabled = false;
|
||||
renderTranslateBtn(); renderTranslateBox();
|
||||
}
|
||||
// 体验优化:原文中文对照(逐句)—— 顾问读英文吃力时,特别是被高亮标注
|
||||
// 「需要改写」的句子,需要它的中文意思才能判断怎么改。
|
||||
// 句子由前端拆分(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};
|
||||
|
||||
@@ -21,6 +21,7 @@ from prompts import (
|
||||
build_reference_prompt,
|
||||
build_scaffold_prompt,
|
||||
build_translate_prompt,
|
||||
build_translate_sentences_prompt,
|
||||
normalize_diagnose_payload,
|
||||
)
|
||||
from schemas import (
|
||||
@@ -36,6 +37,7 @@ from schemas import (
|
||||
RevisionTarget,
|
||||
ScaffoldRequest,
|
||||
ScaffoldResponse,
|
||||
SentencePair,
|
||||
TranslateRequest,
|
||||
TranslateResponse,
|
||||
)
|
||||
@@ -203,7 +205,12 @@ def reference(req: ReferenceRequest) -> ReferenceResponse:
|
||||
|
||||
@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)
|
||||
@@ -217,6 +224,28 @@ def translate(req: TranslateRequest) -> TranslateResponse:
|
||||
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")
|
||||
|
||||
+16
@@ -322,6 +322,22 @@ def build_translate_prompt(text: str) -> tuple[str, str]:
|
||||
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
|
||||
|
||||
+11
-2
@@ -170,12 +170,21 @@ class ReferenceResponse(BaseModel):
|
||||
|
||||
|
||||
# ------------------------------------------------------- 体验优化:中文对照翻译
|
||||
class SentencePair(BaseModel):
|
||||
en: str = "" # 原文句子(前端拆分,服务端原样回填,保证高亮对位可靠)
|
||||
zh: str = ""
|
||||
|
||||
|
||||
class TranslateRequest(BaseModel):
|
||||
text: str = Field(min_length=1)
|
||||
# 整段模式(改写稿翻译):text 必填;
|
||||
# 逐句模式(原文中文对照):sentences 必填。二者取其一。
|
||||
text: str = ""
|
||||
sentences: list[str] = []
|
||||
# 段落上下文(可选):帮助模型稳定语气,不参与翻译输出
|
||||
paragraph_id: str = ""
|
||||
|
||||
|
||||
class TranslateResponse(BaseModel):
|
||||
paragraph_id: str = ""
|
||||
translation: str = ""
|
||||
translation: str = "" # 整段模式
|
||||
sentences: list[SentencePair] = [] # 逐句模式(与请求句子等长、同序)
|
||||
|
||||
@@ -530,6 +530,25 @@ def test_api_translate_coerces_list_translation():
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user