原文${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