From 6e0db821a8a0052a2983d5cdc541cd50d2a28c75 Mon Sep 17 00:00:00 2001 From: LuminousRuoxi <2494735589@qq.com> Date: Thu, 10 Sep 2026 14:39:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8E=9F=E6=96=87=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E5=AF=B9=E7=85=A7=EF=BC=88=E9=80=90=E5=8F=A5=EF=BC=89=E2=80=94?= =?UTF-8?q?=20=E9=AB=98=E4=BA=AE=E5=8F=A5=E5=90=8C=E8=89=B2=E5=90=8C?= =?UTF-8?q?=E5=BA=8F=E5=8F=B7=E5=AF=B9=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/translate 增加逐句模式(sentences): 前端拆句(en 为原文精确文本)→ 模型逐句翻译, 数量/顺序校验复用 _count_check(不一致自动重试);响应 en 原样回填保证对位可靠 - 左栏原文区新增「原文中文对照 →」: 逐句中文展示,命中批注 evidence 的句子 与原文同色(note-rhetoric/repeat/growth/structure)+ 同序号(sup), 点击对照行可联动高亮原文句与批注卡片 - 对照缓存按段保存并持久化(原文变化/换文书自动失效清空) - 测试: 新增逐句模式 2 个 hermetic 测试(33 passed) - 浏览器实测 18/18 通过(含逐句渲染/高亮对位/点击联动/刷新持久化) --- index.html | 85 ++++++++++++++++++++++++++++++++++++++++++++++++---- main.py | 31 ++++++++++++++++++- prompts.py | 16 ++++++++++ schemas.py | 13 ++++++-- test_demo.py | 19 ++++++++++++ 5 files changed, 156 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index a5582d0..71508f5 100644 --- a/index.html +++ b/index.html @@ -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.lengthb.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){
重写要做什么
${escapeHtml(fusedGoal(current))}
AI味要注意
${escapeHtml(fusedAiFocus(current))}
`; + const otRec = origTranslations[current]; + const otFresh = otRec && otRec.text === String(paragraphs[current]||'').trim(); + const otOpen = !!(otFresh && origTranslateOpen[current]); const paper = `
原文${notes.length} 类重写提醒 · 颜色区分问题类型

${renderAnnotatedText(paragraphs[current]||'', notes)}

+
+ ${otOpen ? `
${origTranslateLinesHtml(otRec)}
` : ''}
${annotationCardsHtml(notes)}
`; const visibleMeaning = `
@@ -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{ + const hit = noteHitForSentence(s.en, notes); + return `
${hit?`${hit.index+1}`:''}${escapeHtml(s.zh)}
`; + }).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}; diff --git a/main.py b/main.py index 49c8f2b..b325bd8 100644 --- a/main.py +++ b/main.py @@ -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") diff --git a/prompts.py b/prompts.py index 34dccfc..9ac44cd 100644 --- a/prompts.py +++ b/prompts.py @@ -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 diff --git a/schemas.py b/schemas.py index 24c99b8..9d121c7 100644 --- a/schemas.py +++ b/schemas.py @@ -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] = [] # 逐句模式(与请求句子等长、同序) diff --git a/test_demo.py b/test_demo.py index 87ea1db..cb7fe9d 100644 --- a/test_demo.py +++ b/test_demo.py @@ -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