feat(editor): add live glossary editing

This commit is contained in:
xin61
2026-07-07 17:38:47 +08:00
parent 9b5c55688a
commit 8080a4ced9
7 changed files with 752 additions and 56 deletions
+13 -2
View File
@@ -4,7 +4,7 @@
本工具服务于中日双语 EPUB 的网页审校:长篇连续阅读、逐段修改中文译文、标记翻译问题、导出反馈与修订 EPUB。翻译质量规则仍由项目根目录的 `AGENTS.md``rules/translation_rules.md`、当前系列的 `series-style.md` 与术语表共同约束。
审校器不负责整卷首译流程,也不直接改正式术语表。用户在网页中留下的编辑、问题备注、长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。
审校器不负责整卷首译流程。用户在网页中留下的编辑、问题备注、长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。网页术语表编辑是用户显式操作正式术语表的入口,保存时会直接写入配置路径指向的 JSON 文件。
## 数据模型与会话目录
@@ -34,6 +34,7 @@ epub_review_sessions/
- `state.json.edits` 保存用户修改与标记,字段缺省时必须能回退到原始译文。
- `session.json``app_state.json` 只保存会话定位信息。
- `gpt_config.json` 只属于本地运行环境,不写入 EPUB、不写入导出文件、不通过 API 回传密钥。
- `gpt_config.json` 可保存本地提示词设置与术语表路径,但仍不得写入 EPUB 或导出文件。
## 目录与插图
@@ -91,8 +92,13 @@ GPT 能力是可选增强,不应影响离线审校。
安全与配置:
- 支持 OpenAI 兼容的 `base_url``model``api_key`
- 支持单独设置 `translation_prompt``format_prompt``character_prompt`;缺省时必须回退到内置预设。
- API Key 优先读取 `gpt_config.json`,也支持环境变量 `OPENAI_API_KEY`
- `/api/gpt/config` 不得返回 API Key。
- `/api/gpt/config` 可以返回提示词与预设,但不得返回密钥。
- `/api/glossary` 读取和保存配置路径指向的 `mingcibiao.json`;默认路径是项目根目录下当前 TRPG 系列的正式术语表。
- 术语表保存应保持 JSON 对象结构:key 为源语或匹配词,value 为译名,可继续使用 `#` 作为备注分隔。
- 重翻时必须实时读取术语表,按目标段落日文、HTML 与当前中文命中相关条目,不得只在提示词里泛泛要求“遵守术语表”。
- 未配置 Key 时,重翻接口应返回可读错误,不影响编辑保存。
- 重翻请求不得临时覆盖 `base_url``model` 后继续复用已保存密钥;要更换端点或模型必须先保存配置,避免把密钥发往未确认端点。
@@ -100,9 +106,10 @@ GPT 能力是可选增强,不应影响离线审校。
1. 用户点击阅读段落,打开右侧面板。
2. 用户可填写额外重翻要求。
3. `/api/row/<row_id>/retranslate` 使用当前日文、当前中文、术语提示生成候选。
3. `/api/row/<row_id>/retranslate` 使用提示词、目标语句、当前中文、实时命中的术语表条目、同文件前后一段少量上下文、角色口吻提示生成候选。
4. 候选只显示在面板里,不自动覆盖。
5. 用户点击“应用重翻”后写入编辑框,再点击保存才进入审校记录。
6. 候选事件写入 jsonl,便于后续根据精修反馈优化提示词;当前版本不自动改写提示词。
Prompt 硬规则:
@@ -111,6 +118,7 @@ Prompt 硬规则:
- 不合并、不拆分段落。
- 有意义 ruby 必须保留为真实 `<ruby><rb>...</rb><rt>...</rt></ruby>`,不要改成括号。
- Falchion / ファルシオン 固定译为“大砍刀”,不要译成“偃月刀”。
- 标点统一为简中出版格式:对话引号用「」,省略号用……,破折号用——,问号用?,感叹号用!,问叹连用用?!/!?。
## 版本规则
@@ -127,6 +135,7 @@ Prompt 硬规则:
- `version.py`
- `static/index.html` 顶部版本徽标 fallback
- `README.md` 当前版本与版本说明
- 必要时同步 `MAINTENANCE.md` 中的维护口径
## 回归清单
@@ -145,3 +154,5 @@ Prompt 硬规则:
- 阅读段落下方没有编号和“快速编辑/快速标记”
- 未配置 API Key 时重翻返回清晰错误
- 配置保存后 `/api/gpt/config` 不包含密钥字段
- `/api/glossary` 能读取术语表;新增、修改、删除条目后保存,再读取内容一致
- 重翻候选事件包含 `glossary_path``glossary_matches`
+10 -4
View File
@@ -1,6 +1,6 @@
# 双语 EPUB 审校编辑器
当前版本:`0.5.0`
当前版本:`0.7.0`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
@@ -26,6 +26,10 @@
`0.5.0` 改进长篇目录与检索:目录章节标题不再用正文首段猜测,章节内插图作为次级条目挂在所属章节下,阅读时可按原 spine 顺序穿插显示;点击目录不会自动收起侧栏,桌面端正文会为常驻侧栏让位;检索新增“当前范围 / 全书全局”切换。
`0.6.0` 新增 GPT 提示词设置:可在网页中单独配置“翻译内容提示词”“格式与标点提示词”“角色状态与口吻提示词”,并提供默认预设;单段重翻只携带目标语句、当前译文、相关名词表、少量前后文和角色口吻提示,候选输出会进行中文标点规整与术语 ruby 括号形式修正。
`0.7.0` 新增实时术语表能力:GPT 重翻会从配置的 `mingcibiao.json` 实时读取命中术语并写入提示词;网页中可查看当前术语表路径、搜索术语,新增、修改、删除条目并保存,保存后的术语会立即用于下一次重翻。
## 独立安装
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
@@ -88,7 +92,8 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio
- 检索支持“当前范围”和“全书全局”两种范围,适合在单章精查和整本书排查术语时切换。
- 在阅读模式中点击段落即可打开右侧精修与重翻面板,直接编辑中文译文、标记问题或调用 GPT 重翻;打开/关闭面板时会尽量保持原来的阅读位置。
- 精修模式保留原有逐段编辑界面,用于较细的逐段复审。
- 可在网页中配置 OpenAI 兼容的 `base_url``model`API Key,对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
- 可在网页中配置 OpenAI 兼容的 `base_url``model`API Key 与重翻提示词,对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
- 可在“提示词与 API”里配置并读取正式术语表,实时搜索、增删改术语并保存;单段重翻会按当前段落命中术语表条目,而不是只在提示词里泛泛要求遵守术语。
- 直接编辑中文译文。
- 标记问题段落或选中文本。
- 记录问题类型、严重程度、标签、备注和可沉淀的长期规则。
@@ -127,12 +132,13 @@ epub_review_sessions\<epub-name>_<hash>\
- 只想记录问题时:勾选“标记为翻译问题”,写备注,保存本段。
- 想直接修正译文时:编辑中文译文后保存本段。
- 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“Falchion 固定译为大砍刀,不要译成偃月刀”。
- 想单段重翻时:点击段落打开右侧面板,先在“API 设置”中保存 GPT 配置再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。
- 想单段重翻时:点击段落打开右侧面板,先在“提示词与 API”中保存 GPT 配置;提示词分为翻译内容、格式标点、角色口吻三块,可恢复预设。术语表路径默认指向本项目的 `TRPG玩家在异世界打造最强角色/mingcibiao.json`,也可以改成其他 `mingcibiao.json`再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。
- 想调整术语时:展开“提示词与 API”,在术语表区域搜索、修改或新增条目,点击“保存术语表”;下一次重翻会重新读取已保存的最新术语。
- 审校结束后点击“生成反馈”,再在聊天里告诉 Codex 读取 `feedback_for_codex.md``translation_feedback.jsonl`
## 当前限制
- 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。
- 目录用于审校导航;插图可以阅读和跳转,但当前版本不提供图片内容编辑。
- GPT API Key 仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;重翻时只能使用已保存的 Base URL 和模型,避免把密钥发往未确认端点。服务器部署时请保护好审校目录,不要把没有鉴权的服务暴露到公网。
- GPT API Key、提示词与术语表路径仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;重翻时只能使用已保存的 Base URL 和模型,避免把密钥发往未确认端点。术语表编辑会直接写入配置路径指向的 JSON 文件,服务器部署时请保护好审校目录和术语表,不要把没有鉴权的服务暴露到公网。
- 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。
+387 -29
View File
@@ -36,7 +36,7 @@ except ImportError:
"MINOR": "新增向后兼容能力、API 字段或可选配置",
"MAJOR": "破坏兼容的 API、配置或行为变更",
}
version = "0.4.1"
version = "0.7.0"
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
@@ -58,6 +58,34 @@ STATE_VERSION = 1
UPLOAD_DIR_NAME = "uploads"
DEFAULT_GPT_BASE_URL = "https://api.openai.com/v1"
DEFAULT_GPT_MODEL = "gpt-4o-mini"
DEFAULT_TRANSLATION_PROMPT = (
"你是日语轻小说到简体中文的审校重翻助手。目标是把当前目标语句译成自然、流畅、忠实的简体中文轻小说文风。"
"意义优先,再调整为中文语序;不要贴着日语句序硬译,也不要补写原文没有的信息。"
"术语、人名、地名、TRPG/系统词优先服从给定名词表;Falchion / ファルシオン固定译为“大砍刀”,绝对不要译成“偃月刀”。"
)
DEFAULT_FORMAT_PROMPT = (
"只输出目标语句的新中文译文 HTML 片段,不要解释、不要 Markdown、不要代码围栏。"
"不得合并或拆分段落。保留必要的 HTML 内联标签,尤其是有意义的 ruby,必须写成"
"<ruby><rb>中文</rb><rt>读音/外语</rt></ruby>,不要改成括号。"
"标点统一为简中出版格式:对话引号用「」;省略号用……;破折号用——;问号用?,感叹号用!,问叹连用用?!或!?;"
"逗号、句号、顿号、冒号、分号用,。、:;。不要用英文半角标点替代中文标点。"
)
DEFAULT_CHARACTER_PROMPT = (
"根据目标语句和少量前后文判断说话人、情绪和角色口吻。"
"本系列以第一人称轻小说叙述为主,主角聪明、爱吐槽、有TRPG玩家思维;对话要符合身份差异,"
"不要把所有角色写成同一种口吻,也不要过度网络化。"
)
GPT_PROMPT_DEFAULTS = {
"translation_prompt": DEFAULT_TRANSLATION_PROMPT,
"format_prompt": DEFAULT_FORMAT_PROMPT,
"character_prompt": DEFAULT_CHARACTER_PROMPT,
}
GPT_PROMPT_MAX_CHARS = {
"translation_prompt": 5000,
"format_prompt": 5000,
"character_prompt": 3000,
}
GLOSSARY_ENTRY_LIMIT = 80
def now_iso() -> str:
@@ -262,11 +290,53 @@ def normalize_gpt_base_url(value: str) -> str:
return value
def normalize_gpt_prompt(name: str, value: Any) -> str:
text = str(value or "").strip()
if not text:
text = GPT_PROMPT_DEFAULTS[name]
return text[: GPT_PROMPT_MAX_CHARS[name]]
def gpt_prompt_defaults() -> dict[str, str]:
return dict(GPT_PROMPT_DEFAULTS)
def project_root() -> Path:
return Path(__file__).resolve().parents[2]
def default_glossary_path() -> Path:
return project_root() / "TRPG玩家在异世界打造最强角色" / "mingcibiao.json"
def normalize_glossary_path_value(value: str = "") -> str:
raw = str(value or "").strip()
path = Path(raw).expanduser() if raw else default_glossary_path()
if not path.is_absolute():
path = project_root() / path
if path.suffix.lower() != ".json":
raise ValueError("术语表路径必须指向 .json 文件")
return str(path.resolve())
def update_gpt_config_fields(review_root: Path, fields: dict[str, Any]) -> None:
existing = read_json(gpt_config_path(review_root), {})
if not isinstance(existing, dict):
existing = {}
existing.update(fields)
existing["updated_at"] = now_iso()
write_json(gpt_config_path(review_root), existing)
def read_gpt_config(review_root: Path) -> dict[str, Any]:
data = read_json(gpt_config_path(review_root), {})
api_key = str(data.get("api_key") or os.environ.get("OPENAI_API_KEY") or "").strip()
base_url = str(data.get("base_url") or os.environ.get("OPENAI_BASE_URL") or DEFAULT_GPT_BASE_URL).strip()
model = str(data.get("model") or os.environ.get("OPENAI_MODEL") or DEFAULT_GPT_MODEL).strip()
prompts = {
name: normalize_gpt_prompt(name, data.get(name, default))
for name, default in GPT_PROMPT_DEFAULTS.items()
}
return {
"base_url": normalize_gpt_base_url(base_url),
"model": model or DEFAULT_GPT_MODEL,
@@ -274,6 +344,8 @@ def read_gpt_config(review_root: Path) -> dict[str, Any]:
"configured": bool(api_key),
"key_source": "config" if str(data.get("api_key") or "").strip() else ("env" if os.environ.get("OPENAI_API_KEY") else ""),
"updated_at": data.get("updated_at", ""),
"glossary_path": normalize_glossary_path_value(str(data.get("glossary_path") or "")),
**prompts,
}
@@ -285,6 +357,11 @@ def public_gpt_config(review_root: Path) -> dict[str, Any]:
"model": config["model"],
"key_source": config["key_source"],
"updated_at": config.get("updated_at", ""),
"translation_prompt": config["translation_prompt"],
"format_prompt": config["format_prompt"],
"character_prompt": config["character_prompt"],
"glossary_path": config["glossary_path"],
"prompt_defaults": gpt_prompt_defaults(),
}
@@ -296,26 +373,118 @@ def save_gpt_config(review_root: Path, data: dict[str, Any]) -> dict[str, Any]:
existing = read_json(gpt_config_path(review_root), {})
if keep_existing and not api_key:
api_key = str(existing.get("api_key") or "").strip()
prompts = {
name: normalize_gpt_prompt(
name,
data.get(name) if name in data else existing.get(name, default),
)
for name, default in GPT_PROMPT_DEFAULTS.items()
}
record = {
"base_url": base_url,
"model": model,
"api_key": api_key,
"glossary_path": normalize_glossary_path_value(str(data.get("glossary_path") or existing.get("glossary_path") or "")),
**prompts,
"updated_at": now_iso(),
}
write_json(gpt_config_path(review_root), record)
return public_gpt_config(review_root)
def compact_glossary_for_prompt(row: dict[str, Any], limit: int = 36) -> list[str]:
project_root = Path(__file__).resolve().parents[2]
glossary_path = project_root / "TRPG玩家在异世界打造最强角色" / "mingcibiao.json"
if not glossary_path.exists():
return ["Falchion/ファルシオン:大砍刀,不要译成偃月刀。"]
def glossary_path(review_root: Path) -> Path:
data = read_json(gpt_config_path(review_root), {})
configured = str(data.get("glossary_path") or "").strip()
return Path(normalize_glossary_path_value(configured))
def read_glossary(review_root: Path) -> dict[str, str]:
path = glossary_path(review_root)
if not path.exists():
return {}
try:
glossary = read_json(glossary_path, {})
except Exception:
glossary = {}
source = f"{row.get('jp_text', '')}\n{row.get('jp_html', '')}"
data = read_json(path, {})
except json.JSONDecodeError as exc:
raise ValueError(f"术语表 JSON 解析失败:{exc}") from exc
if not isinstance(data, dict):
raise ValueError("术语表必须是 JSON 对象")
glossary: dict[str, str] = {}
for key, value in data.items():
if isinstance(key, str) and isinstance(value, str):
glossary[key] = value
return glossary
def public_glossary(review_root: Path) -> dict[str, Any]:
path = glossary_path(review_root)
glossary = read_glossary(review_root)
entries = [
{
"source": key,
"target": value,
"clean_target": value.split("#", 1)[0].strip(),
}
for key, value in glossary.items()
]
return {
"path": str(path),
"exists": path.exists(),
"count": len(entries),
"entries": entries,
"updated_at": dt.datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat(timespec="seconds") if path.exists() else "",
}
def save_glossary(review_root: Path, entries: list[dict[str, Any]]) -> dict[str, Any]:
path = glossary_path(review_root)
if path.exists() and not path.is_file():
raise ValueError("术语表路径不是文件")
glossary: dict[str, str] = {}
for entry in entries:
if not isinstance(entry, dict):
raise ValueError("术语条目必须是对象")
source = str(entry.get("source") or "").strip()
target = str(entry.get("target") or "").strip()
if not source:
continue
if not target:
raise ValueError(f"术语“{source}”缺少译名")
if len(source) > 300:
raise ValueError(f"术语“{source[:80]}...”过长")
if len(target) > 1200:
raise ValueError(f"术语“{source}”的译名/备注过长")
if source in glossary:
raise ValueError(f"术语“{source}”重复")
glossary[source] = target
path.parent.mkdir(parents=True, exist_ok=True)
write_json(path, glossary)
return public_glossary(review_root)
def search_glossary_entries(glossary: dict[str, str], term: str, limit: int = GLOSSARY_ENTRY_LIMIT) -> list[str]:
needle = term.strip().lower()
if not needle:
return []
selected: list[str] = []
for key, value in glossary.items():
clean_value = value.split("#", 1)[0].strip()
if needle in key.lower() or needle in value.lower():
selected.append(f"{key} => {clean_value or value}")
if len(selected) >= limit:
break
return selected
def glossary_matches_for_prompt(review_root: Path, row: dict[str, Any], limit: int = GLOSSARY_ENTRY_LIMIT) -> list[str]:
try:
glossary = read_glossary(review_root)
except ValueError:
raise
except Exception as exc:
raise ValueError(f"读取术语表失败:{exc}") from exc
if not glossary and not default_glossary_path().exists():
return ["Falchion/ファルシオン:大砍刀,不要译成偃月刀。"]
source = f"{row.get('jp_text', '')}\n{row.get('jp_html', '')}\n{strip_tags(row_translation_html(row))}\n{row_translation_html(row)}"
selected: list[str] = []
for key, value in glossary.items():
if not isinstance(key, str) or not isinstance(value, str):
@@ -323,7 +492,7 @@ def compact_glossary_for_prompt(row: dict[str, Any], limit: int = 36) -> list[st
clean_value = value.split("#", 1)[0].strip()
if not clean_value:
continue
if key and key in source:
if (key and key in source) or (clean_value and clean_value in source):
selected.append(f"{key} => {clean_value}")
if len(selected) >= limit:
break
@@ -333,28 +502,144 @@ def compact_glossary_for_prompt(row: dict[str, Any], limit: int = 36) -> list[st
return selected[:limit]
def build_retranslate_messages(row: dict[str, Any], instruction: str = "") -> list[dict[str, str]]:
glossary_lines = "\n".join(f"- {item}" for item in compact_glossary_for_prompt(row))
system_prompt = (
"你是日语轻小说到简体中文的审校重翻助手。请只输出当前这一段的中文译文 HTML 片段,"
"不要解释,不要输出 Markdown。译文要自然流畅,符合简中轻小说文风;意义优先,不贴日语语序硬译。"
"不得合并或拆分段落。保留必要的 HTML 内联标签,尤其是有意义的 ruby:"
"必须使用 <ruby><rb>中文</rb><rt>读音/外语</rt></ruby>,不要改成括号。"
"纯读音 ruby 可自然译入中文。术语优先服从给定术语表。"
def truncate_prompt_text(value: Any, limit: int = 1600) -> str:
text = compact_text(str(value or ""))
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + ""
def row_translation_html(row: dict[str, Any]) -> str:
return str(row.get("current_html") or row.get("cn_html") or row.get("cn_text") or "")
def row_source_html(row: dict[str, Any]) -> str:
return str(row.get("jp_html") or row.get("jp_text") or "")
def build_retranslate_context(row: dict[str, Any], rows: list[dict[str, Any]], radius: int = 1) -> list[str]:
same_file_rows = [item for item in rows if item.get("file") == row.get("file")]
index = next((idx for idx, item in enumerate(same_file_rows) if item.get("id") == row.get("id")), -1)
if index < 0:
return []
start = max(0, index - radius)
end = min(len(same_file_rows), index + radius + 1)
context: list[str] = []
for idx in range(start, end):
item = same_file_rows[idx]
if item.get("id") == row.get("id"):
continue
direction = "前文" if idx < index else "后文"
context.append(
f"{direction} {item.get('id', '')}: 日文:{truncate_prompt_text(item.get('jp_text'), 500)} "
f"现译:{truncate_prompt_text(strip_tags(row_translation_html(item)), 500)}"
)
return context
def normalize_candidate_punctuation(fragment: str) -> str:
protected: list[str] = []
def protect(match: re.Match[str]) -> str:
protected.append(match.group(0))
return f"\uE000{len(protected) - 1}\uE001"
protected_pattern = (
r"<[^>]+>|"
r"&(?:#[0-9]+|#x[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]+);|"
r"https?://[^\s<>\"]+|"
r"[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}|"
r"\b\d+(?:[.,]\d+)+\b|"
r"\b[A-Za-z0-9]+(?:\.[A-Za-z0-9]+)+\b"
)
text = re.sub(protected_pattern, protect, fragment)
text = text.replace("", "").replace("", "")
text = text.replace("", "").replace("", "")
text = text.replace("...", "……").replace("", "……")
text = re.sub(r"(?:……)+", "……", text)
text = re.sub(r"[-]{2,}", "——", text)
text = text.translate(str.maketrans({
",": "",
".": "",
":": "",
";": "",
}))
text = text.replace("?", "").replace("!", "")
text = re.sub(r"++", "?!", text)
text = re.sub(r"++", "!?", text)
text = re.sub(r"{2,}", "", text)
text = re.sub(r"{2,}", "", text)
def restore(match: re.Match[str]) -> str:
return protected[int(match.group(1))]
return re.sub(r"\uE000(\d+)\uE001", restore, text)
def restore_glossary_ruby_candidates(fragment: str, glossary_lines: list[str]) -> str:
pairs: list[tuple[str, str]] = []
for line in glossary_lines:
value = line.split("=>", 1)[1].strip() if "=>" in line else line
match = re.fullmatch(r"(.+?)\(([^()<>]{1,80})\)", value)
if match:
rb, rt = match.group(1).strip(), match.group(2).strip()
if rb and rt and len(rb) <= 80:
pairs.append((rb, rt))
if not pairs:
return fragment
protected: list[str] = []
def protect(match: re.Match[str]) -> str:
protected.append(match.group(0))
return f"\uE010{len(protected) - 1}\uE011"
text = re.sub(r"<ruby\b.*?</ruby>|<[^>]+>", protect, fragment, flags=re.S | re.I)
for rb, rt in sorted(pairs, key=lambda item: len(item[0]), reverse=True):
pattern = re.escape(f"{rb}({rt})")
ruby = f"<ruby><rb>{html.escape(rb)}</rb><rt>{html.escape(rt)}</rt></ruby>"
text = re.sub(pattern, ruby, text)
def restore(match: re.Match[str]) -> str:
return protected[int(match.group(1))]
return re.sub(r"\uE010(\d+)\uE011", restore, text)
def build_retranslate_messages(
row: dict[str, Any],
rows: list[dict[str, Any]],
config: dict[str, Any],
glossary_lines: list[str],
instruction: str = "",
) -> list[dict[str, str]]:
glossary_text = "\n".join(f"- {item}" for item in glossary_lines)
context_lines = build_retranslate_context(row, rows)
system_prompt = "\n\n".join(
[
"你是日语轻小说到简体中文的审校重翻助手。重翻只处理一个目标语句/段落,保持上下文简洁。",
"【翻译内容提示词】",
str(config.get("translation_prompt") or DEFAULT_TRANSLATION_PROMPT).strip(),
"【格式与标点提示词】",
str(config.get("format_prompt") or DEFAULT_FORMAT_PROMPT).strip(),
"【角色状态与口吻提示词】",
str(config.get("character_prompt") or DEFAULT_CHARACTER_PROMPT).strip(),
]
)
user_parts = [
"重翻以下单段。",
"根据以下信息重翻目标语句,只输出新的中文译文 HTML 片段。",
"",
"日文原文 HTML",
row.get("jp_html") or row.get("jp_text") or "",
"【目标语句:日文原文 HTML",
row_source_html(row),
"",
"当前中文译文 HTML",
row.get("current_html") or row.get("cn_html") or row.get("cn_text") or "",
"当前中文译文 HTML",
row_translation_html(row),
]
if glossary_lines:
user_parts.extend(["", "相关术语:", glossary_lines])
if context_lines:
user_parts.extend(["", "【少量上下文】", "\n".join(f"- {item}" for item in context_lines)])
if glossary_text:
user_parts.extend(["", "【名词表】", glossary_text])
if instruction.strip():
user_parts.extend(["", "本次额外要求", instruction.strip()[:1200]])
user_parts.extend(["", "本次额外要求", instruction.strip()[:1200]])
return [
{"role": "system", "content": system_prompt},
{"role": "user", "content": "\n".join(user_parts)},
@@ -1722,10 +2007,73 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
return jsonify(public_gpt_config(review_root))
data = request.get_json(silent=True) or {}
try:
if data.get("reset_prompts"):
current = read_gpt_config(review_root)
data = {
**data,
"base_url": data.get("base_url") or current["base_url"],
"model": data.get("model") or current["model"],
"keep_existing": data.get("keep_existing", True),
**gpt_prompt_defaults(),
}
return jsonify(save_gpt_config(review_root, data))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@app.route("/api/glossary", methods=["GET", "POST"])
def api_glossary():
if request.method == "GET":
try:
return jsonify(public_glossary(review_root))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
data = request.get_json(silent=True) or {}
try:
requested_path = str(data.get("path") or "").strip()
if requested_path:
update_gpt_config_fields(review_root, {"glossary_path": normalize_glossary_path_value(requested_path)})
if "entries" not in data:
return jsonify(public_glossary(review_root))
entries = data.get("entries", [])
if not isinstance(entries, list):
return jsonify({"error": "entries 必须是数组"}), 400
return jsonify(save_glossary(review_root, entries))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@app.route("/api/glossary/entry", methods=["POST", "DELETE"])
def api_glossary_entry():
data = request.get_json(silent=True) or {}
source = str(data.get("source") or "").strip()
if not source:
return jsonify({"error": "缺少术语原文"}), 400
try:
glossary = read_glossary(review_root)
if request.method == "DELETE":
if source not in glossary:
return jsonify({"error": "术语不存在"}), 404
del glossary[source]
else:
target = str(data.get("target") or "").strip()
if not target:
return jsonify({"error": "缺少术语译名"}), 400
old_source = str(data.get("old_source") or source).strip()
if old_source and old_source != source:
glossary.pop(old_source, None)
glossary[source] = target
return jsonify(save_glossary(review_root, [{"source": key, "target": value} for key, value in glossary.items()]))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@app.route("/api/glossary/search")
def api_glossary_search():
term = str(request.args.get("q") or "")
try:
matches = search_glossary_entries(read_glossary(review_root), term)
return jsonify({"query": term, "count": len(matches), "matches": matches})
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@app.route("/api/row/<row_id>/retranslate", methods=["POST"])
def api_retranslate_row(row_id: str):
session_root, _epub_path = active_session()
@@ -1745,8 +2093,9 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
if requested_model and requested_model != config["model"]:
return jsonify({"error": "重翻请求不能临时覆盖模型。请先保存 API 设置后再重翻。"}), 400
instruction = str(data.get("instruction") or "")
raw = call_openai_compatible_chat(config, build_retranslate_messages(row, instruction))
candidate_html = sanitize_cn_html(raw)
glossary_lines = glossary_matches_for_prompt(review_root, row)
raw = call_openai_compatible_chat(config, build_retranslate_messages(row, rows, config, glossary_lines, instruction))
candidate_html = sanitize_cn_html(restore_glossary_ruby_candidates(normalize_candidate_punctuation(raw), glossary_lines))
if not candidate_html:
return jsonify({"error": "GPT 返回内容为空或无法作为译文保存"}), 502
candidate_text = strip_tags(candidate_html)
@@ -1757,6 +2106,13 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
"file": row["file"],
"model": config["model"],
"base_url": config["base_url"],
"prompt_version": version,
"translation_prompt": config.get("translation_prompt", ""),
"format_prompt": config.get("format_prompt", ""),
"character_prompt": config.get("character_prompt", ""),
"glossary_path": str(glossary_path(review_root)),
"glossary_matches": glossary_lines,
"instruction": instruction[:1200],
"jp_text": row["jp_text"],
"before_cn_text": strip_tags(row.get("current_html") or row.get("cn_html") or ""),
"candidate_cn_text": candidate_text,
@@ -1769,6 +2125,8 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
"candidate_html": candidate_html,
"candidate_text": candidate_text,
"model": config["model"],
"glossary_path": str(glossary_path(review_root)),
"glossary_matches": glossary_lines,
"updated_at": record["ts"],
}
)
+195 -4
View File
@@ -16,6 +16,9 @@ let sidePanelMode = null;
let searchScope = "current";
let gptConfigLoaded = false;
let gptCandidateHtml = "";
let glossaryLoaded = false;
let glossaryEntries = [];
let glossaryDirty = false;
const $ = (id) => document.getElementById(id);
@@ -92,7 +95,18 @@ const dom = {
gptBaseUrlInput: $("gptBaseUrlInput"),
gptModelInput: $("gptModelInput"),
gptApiKeyInput: $("gptApiKeyInput"),
glossaryPathInput: $("glossaryPathInput"),
gptTranslationPromptInput: $("gptTranslationPromptInput"),
gptFormatPromptInput: $("gptFormatPromptInput"),
gptCharacterPromptInput: $("gptCharacterPromptInput"),
glossaryMeta: $("glossaryMeta"),
glossarySearchInput: $("glossarySearchInput"),
reloadGlossaryBtn: $("reloadGlossaryBtn"),
addGlossaryEntryBtn: $("addGlossaryEntryBtn"),
saveGlossaryBtn: $("saveGlossaryBtn"),
glossaryList: $("glossaryList"),
saveGptConfigBtn: $("saveGptConfigBtn"),
resetGptPromptsBtn: $("resetGptPromptsBtn"),
gptInstructionInput: $("gptInstructionInput"),
retranslateBtn: $("retranslateBtn"),
applyRetranslationBtn: $("applyRetranslationBtn"),
@@ -251,10 +265,16 @@ function resetReviewState() {
searchScope = "current";
gptCandidateHtml = "";
gptConfigLoaded = false;
glossaryLoaded = false;
glossaryEntries = [];
glossaryDirty = false;
dom.showTouchedBtn.classList.remove("primary");
dom.searchScopeCurrentBtn.classList.add("active");
dom.searchScopeBookBtn.classList.remove("active");
dom.searchInput.value = "";
if (dom.glossarySearchInput) dom.glossarySearchInput.value = "";
if (dom.glossaryList) dom.glossaryList.innerHTML = "";
if (dom.glossaryMeta) dom.glossaryMeta.textContent = "未读取术语表";
dom.sidebar.hidden = true;
dom.tocPanel.hidden = true;
dom.searchPanel.hidden = true;
@@ -1360,16 +1380,29 @@ function renderGptConfig(config) {
dom.gptBaseUrlInput.value = config.base_url || "https://api.openai.com/v1";
dom.gptModelInput.value = config.model || "gpt-4o-mini";
dom.gptApiKeyInput.value = "";
dom.glossaryPathInput.value = config.glossary_path || "";
dom.gptTranslationPromptInput.value = config.translation_prompt || (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "";
dom.gptFormatPromptInput.value = config.format_prompt || (config.prompt_defaults && config.prompt_defaults.format_prompt) || "";
dom.gptCharacterPromptInput.value = config.character_prompt || (config.prompt_defaults && config.prompt_defaults.character_prompt) || "";
dom.resetGptPromptsBtn.dataset.translationPrompt = (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "";
dom.resetGptPromptsBtn.dataset.formatPrompt = (config.prompt_defaults && config.prompt_defaults.format_prompt) || "";
dom.resetGptPromptsBtn.dataset.characterPrompt = (config.prompt_defaults && config.prompt_defaults.character_prompt) || "";
const source = config.key_source === "env" ? "环境变量" : config.key_source === "config" ? "本地配置" : "未配置";
dom.gptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key,保存设置后可重翻。";
}
async function loadGptConfig(force = false) {
if (gptConfigLoaded && !force) return;
if (gptConfigLoaded && !force) {
if (!glossaryLoaded) loadGlossary(false).catch(() => {});
return;
}
try {
const config = await api("/api/gpt/config");
renderGptConfig(config);
gptConfigLoaded = true;
loadGlossary(force).catch((error) => {
dom.glossaryMeta.textContent = `读取术语表失败:${error.message}`;
});
} catch (error) {
dom.gptStatus.textContent = `读取 GPT 配置失败:${error.message}`;
}
@@ -1383,17 +1416,149 @@ async function saveGptConfig() {
base_url: dom.gptBaseUrlInput.value,
model: dom.gptModelInput.value,
api_key: dom.gptApiKeyInput.value,
glossary_path: dom.glossaryPathInput.value,
translation_prompt: dom.gptTranslationPromptInput.value,
format_prompt: dom.gptFormatPromptInput.value,
character_prompt: dom.gptCharacterPromptInput.value,
keep_existing: true,
}),
});
renderGptConfig(config);
gptConfigLoaded = true;
toast("GPT API 设置已保存。");
glossaryLoaded = false;
await loadGlossary(true);
toast("GPT 提示词与 API 设置已保存。");
} catch (error) {
toast(`保存 GPT 设置失败:${error.message}`, 7000);
}
}
function glossaryFilteredEntries() {
const q = (dom.glossarySearchInput.value || "").trim().toLowerCase();
if (!q) return glossaryEntries.map((entry, index) => ({ entry, index }));
return glossaryEntries
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => {
return [entry.source, entry.target, entry.clean_target]
.join("\n")
.toLowerCase()
.includes(q);
});
}
function updateGlossaryMeta(shownCount = null, matchCount = null) {
const matches = shownCount === null || matchCount === null ? glossaryFilteredEntries() : null;
const shown = shownCount === null ? Math.min(80, matches.length) : shownCount;
const matched = matchCount === null ? matches.length : matchCount;
const path = dom.glossaryPathInput.value || "未设置";
const dirtyText = glossaryDirty ? " · 有未保存修改" : "";
dom.glossaryMeta.textContent = `当前术语表:${path} · ${glossaryEntries.length} 条 · 显示 ${shown}/${matched}${dirtyText}`;
}
function setGlossaryDirty(value) {
glossaryDirty = value;
dom.saveGlossaryBtn.disabled = !glossaryDirty;
if (glossaryLoaded) updateGlossaryMeta();
}
function renderGlossary(payload = null) {
if (payload) {
glossaryEntries = (payload.entries || []).map((entry) => ({
source: entry.source || "",
target: entry.target || "",
clean_target: entry.clean_target || "",
}));
glossaryLoaded = true;
setGlossaryDirty(false);
if (payload.path) dom.glossaryPathInput.value = payload.path;
}
const matches = glossaryFilteredEntries();
const shown = matches.slice(0, 80);
updateGlossaryMeta(shown.length, matches.length);
dom.glossaryList.innerHTML = "";
if (!glossaryLoaded) {
dom.glossaryList.innerHTML = '<div class="glossaryEmpty">展开后会读取术语表。</div>';
return;
}
if (!shown.length) {
dom.glossaryList.innerHTML = '<div class="glossaryEmpty">没有匹配的术语。</div>';
return;
}
const frag = document.createDocumentFragment();
shown.forEach(({ entry, index }) => {
const row = document.createElement("div");
row.className = "glossaryRow";
row.dataset.index = String(index);
row.innerHTML = `
<input class="glossarySource" type="text" value="${escapeHtml(entry.source)}" aria-label="术语原文">
<input class="glossaryTarget" type="text" value="${escapeHtml(entry.target)}" aria-label="术语译名">
<button class="glossaryDelete" type="button" aria-label="删除术语">删除</button>
`;
row.querySelector(".glossarySource").addEventListener("input", (event) => {
glossaryEntries[index].source = event.target.value;
setGlossaryDirty(true);
});
row.querySelector(".glossaryTarget").addEventListener("input", (event) => {
glossaryEntries[index].target = event.target.value;
glossaryEntries[index].clean_target = event.target.value.split("#", 1)[0].trim();
setGlossaryDirty(true);
});
row.querySelector(".glossaryDelete").addEventListener("click", () => {
glossaryEntries.splice(index, 1);
setGlossaryDirty(true);
renderGlossary();
});
frag.appendChild(row);
});
dom.glossaryList.appendChild(frag);
}
async function loadGlossary(force = false) {
if (glossaryLoaded && !force) return;
const payload = await api("/api/glossary");
renderGlossary(payload);
}
function addGlossaryEntry() {
glossaryEntries.unshift({ source: "", target: "", clean_target: "" });
dom.glossarySearchInput.value = "";
glossaryLoaded = true;
setGlossaryDirty(true);
renderGlossary();
requestAnimationFrame(() => dom.glossaryList.querySelector(".glossarySource")?.focus());
}
async function saveGlossary() {
try {
const payload = await api("/api/glossary", {
method: "POST",
body: JSON.stringify({
path: dom.glossaryPathInput.value,
entries: glossaryEntries,
}),
});
renderGlossary(payload);
toast("术语表已保存,下一次重翻会实时使用最新术语。");
} catch (error) {
toast(`保存术语表失败:${error.message}`, 9000);
}
}
function glossaryMatchedText(data) {
const matches = data.glossary_matches || [];
if (!matches.length) return "";
return `<div class="gptCandidateMeta">术语命中:${escapeHtml(matches.slice(0, 10).join(" "))}${matches.length > 10 ? `${matches.length}` : ""}</div>`;
}
function resetGptPrompts() {
dom.gptTranslationPromptInput.value = dom.resetGptPromptsBtn.dataset.translationPrompt || "";
dom.gptFormatPromptInput.value = dom.resetGptPromptsBtn.dataset.formatPrompt || "";
dom.gptCharacterPromptInput.value = dom.resetGptPromptsBtn.dataset.characterPrompt || "";
toast("已恢复预设提示词,保存后生效。");
}
async function retranslateCurrentRow() {
if (!currentRow) return;
if (quickDirty) {
@@ -1413,7 +1578,7 @@ async function retranslateCurrentRow() {
}),
});
gptCandidateHtml = data.candidate_html || "";
dom.gptCandidate.innerHTML = gptCandidateHtml || escapeHtml(data.candidate_text || "");
dom.gptCandidate.innerHTML = `${glossaryMatchedText(data)}${gptCandidateHtml || escapeHtml(data.candidate_text || "")}`;
dom.applyRetranslationBtn.disabled = !gptCandidateHtml;
toast("已生成重翻候选,可确认后应用。");
} catch (error) {
@@ -1610,9 +1775,35 @@ function initEvents() {
dom.quickMarkSelectionBtn.addEventListener("click", markQuickSelection);
dom.toggleGptConfigBtn.addEventListener("click", () => {
dom.gptConfig.hidden = !dom.gptConfig.hidden;
if (!dom.gptConfig.hidden) loadGptConfig(true).catch(() => {});
if (!dom.gptConfig.hidden) {
loadGptConfig(true).catch(() => {});
}
});
dom.saveGptConfigBtn.addEventListener("click", saveGptConfig);
dom.resetGptPromptsBtn.addEventListener("click", resetGptPrompts);
dom.reloadGlossaryBtn.addEventListener("click", () => {
if (glossaryDirty && !window.confirm("术语表有未保存修改,重新读取会丢失这些修改。继续吗?")) return;
loadGlossary(true)
.then(() => toast("术语表已重新读取。"))
.catch((error) => toast(`读取术语表失败:${error.message}`, 9000));
});
dom.addGlossaryEntryBtn.addEventListener("click", addGlossaryEntry);
dom.saveGlossaryBtn.addEventListener("click", saveGlossary);
dom.glossarySearchInput.addEventListener("input", () => renderGlossary());
dom.glossaryPathInput.addEventListener("change", async () => {
if (glossaryDirty && !window.confirm("术语表有未保存修改,切换路径会丢失这些修改。继续吗?")) return;
try {
await api("/api/glossary", {
method: "POST",
body: JSON.stringify({ path: dom.glossaryPathInput.value }),
});
glossaryLoaded = false;
await loadGlossary(true);
toast("已切换术语表路径。");
} catch (error) {
toast(`切换术语表失败:${error.message}`, 9000);
}
});
dom.retranslateBtn.addEventListener("click", retranslateCurrentRow);
dom.applyRetranslationBtn.addEventListener("click", applyRetranslation);
dom.quickOpenFullBtn.addEventListener("click", async () => {
+51 -15
View File
@@ -9,7 +9,7 @@
<body>
<header class="topbar">
<div class="brandBlock">
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.5.0</span></h1>
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.7.0</span></h1>
<p id="sessionMeta">正在载入……</p>
</div>
<div class="modeTabs" role="tablist" aria-label="审校模式">
@@ -200,23 +200,59 @@
<section class="gptBox" id="gptBox">
<div class="sectionTitle">
<h2>GPT 重翻</h2>
<button id="toggleGptConfigBtn" type="button">API 设置</button>
<button id="toggleGptConfigBtn" type="button">提示词与 API</button>
</div>
<div class="gptStatus" id="gptStatus">未读取配置</div>
<div class="gptConfig" id="gptConfig" hidden>
<label>
Base URL
<input id="gptBaseUrlInput" type="url" placeholder="https://api.openai.com/v1">
</label>
<label>
Model
<input id="gptModelInput" type="text" placeholder="gpt-4o-mini">
</label>
<label>
API Key
<input id="gptApiKeyInput" type="password" placeholder="留空则保留已有密钥">
</label>
<button id="saveGptConfigBtn" type="button">保存 API 设置</button>
<div class="gptConfigGroup">
<div class="gptConfigTitle">API</div>
<label>
Base URL
<input id="gptBaseUrlInput" type="url" placeholder="https://api.openai.com/v1">
</label>
<label>
Model
<input id="gptModelInput" type="text" placeholder="gpt-4o-mini">
</label>
<label>
API Key
<input id="gptApiKeyInput" type="password" placeholder="留空则保留已有密钥">
</label>
<label>
术语表路径
<input id="glossaryPathInput" type="text" placeholder="mingcibiao.json 的完整路径或项目内相对路径">
</label>
</div>
<div class="gptConfigGroup">
<div class="gptConfigTitle">提示词</div>
<label>
翻译内容提示词
<textarea id="gptTranslationPromptInput" rows="5" placeholder="设置忠实度、文风、术语优先级等内容要求。"></textarea>
</label>
<label>
格式与标点提示词
<textarea id="gptFormatPromptInput" rows="5" placeholder="设置 HTML、ruby、标点替换与输出格式规则。"></textarea>
</label>
<label>
角色状态与口吻提示词
<textarea id="gptCharacterPromptInput" rows="4" placeholder="设置角色状态、性格口吻、对话风格判断规则。"></textarea>
</label>
</div>
<div class="gptConfigGroup glossaryGroup">
<div class="gptConfigTitle">术语表</div>
<div class="glossaryMeta" id="glossaryMeta">未读取术语表</div>
<div class="glossaryTools">
<input id="glossarySearchInput" type="search" placeholder="搜索原文、译名或备注">
<button id="reloadGlossaryBtn" type="button">重新读取</button>
<button id="addGlossaryEntryBtn" type="button">新增术语</button>
<button id="saveGlossaryBtn" type="button" disabled>保存术语表</button>
</div>
<div class="glossaryList" id="glossaryList"></div>
</div>
<div class="gptConfigActions">
<button id="resetGptPromptsBtn" type="button">恢复预设提示词</button>
<button id="saveGptConfigBtn" type="button">保存提示词与 API</button>
</div>
</div>
<label class="quickField">
重翻要求
+95 -1
View File
@@ -989,7 +989,7 @@ textarea {
.gptConfig {
display: grid;
gap: 8px;
gap: 10px;
margin-bottom: 10px;
}
@@ -997,6 +997,84 @@ textarea {
display: none !important;
}
.gptConfigGroup {
display: grid;
gap: 8px;
padding-top: 10px;
border-top: 1px solid #e3e9f0;
}
.gptConfigGroup:first-child {
padding-top: 0;
border-top: 0;
}
.gptConfigTitle {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.gptConfigActions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.gptConfig textarea {
min-height: 112px;
font-size: 13px;
line-height: 1.55;
}
.glossaryMeta {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.glossaryTools {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 8px;
align-items: center;
}
.glossaryList {
display: grid;
gap: 6px;
max-height: 280px;
overflow: auto;
padding-right: 2px;
}
.glossaryRow {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) auto;
gap: 6px;
align-items: center;
}
.glossaryRow input {
min-width: 0;
font-size: 12px;
}
.glossaryDelete {
padding-inline: 8px;
}
.glossaryEmpty {
padding: 10px;
border: 1px dashed #cbd5df;
border-radius: 6px;
color: var(--muted);
font-size: 12px;
background: #ffffff;
}
.gptActions {
display: flex;
gap: 8px;
@@ -1015,6 +1093,15 @@ textarea {
white-space: pre-wrap;
}
.gptCandidateMeta {
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #e3e9f0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.toast {
position: fixed;
right: 16px;
@@ -1120,3 +1207,10 @@ textarea {
grid-template-columns: 1fr;
}
}
@media (max-width: 680px) {
.glossaryTools,
.glossaryRow {
grid-template-columns: 1fr;
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
version = "0.5.0"
version = "0.7.0"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",