From 8080a4ced92c316576eb31fd6f598cb87b78c0f1 Mon Sep 17 00:00:00 2001 From: xin61 Date: Tue, 7 Jul 2026 17:38:47 +0800 Subject: [PATCH] feat(editor): add live glossary editing --- tools/epub_review_editor/MAINTENANCE.md | 15 +- tools/epub_review_editor/README.md | 14 +- tools/epub_review_editor/server.py | 416 +++++++++++++++++++-- tools/epub_review_editor/static/app.js | 199 +++++++++- tools/epub_review_editor/static/index.html | 66 +++- tools/epub_review_editor/static/style.css | 96 ++++- tools/epub_review_editor/version.py | 2 +- 7 files changed, 752 insertions(+), 56 deletions(-) diff --git a/tools/epub_review_editor/MAINTENANCE.md b/tools/epub_review_editor/MAINTENANCE.md index fbeaa67..a65ce04 100644 --- a/tools/epub_review_editor/MAINTENANCE.md +++ b/tools/epub_review_editor/MAINTENANCE.md @@ -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//retranslate` 使用当前日文、当前中文、术语提示生成候选。 +3. `/api/row//retranslate` 使用提示词、目标语句、当前中文、实时命中的术语表条目、同文件前后一段少量上下文、角色口吻提示生成候选。 4. 候选只显示在面板里,不自动覆盖。 5. 用户点击“应用重翻”后写入编辑框,再点击保存才进入审校记录。 +6. 候选事件写入 jsonl,便于后续根据精修反馈优化提示词;当前版本不自动改写提示词。 Prompt 硬规则: @@ -111,6 +118,7 @@ Prompt 硬规则: - 不合并、不拆分段落。 - 有意义 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` diff --git a/tools/epub_review_editor/README.md b/tools/epub_review_editor/README.md index d836455..afebe20 100644 --- a/tools/epub_review_editor/README.md +++ b/tools/epub_review_editor/README.md @@ -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\_\ - 只想记录问题时:勾选“标记为翻译问题”,写备注,保存本段。 - 想直接修正译文时:编辑中文译文后保存本段。 - 想让 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”。 diff --git a/tools/epub_review_editor/server.py b/tools/epub_review_editor/server.py index 2a8352b..7bec4df 100644 --- a/tools/epub_review_editor/server.py +++ b/tools/epub_review_editor/server.py @@ -36,7 +36,7 @@ except ImportError: "MINOR": "新增向后兼容能力、API 字段或可选配置", "MAJOR": "破坏兼容的 API、配置或行为变更", } - version = "0.4.1" + version = "0.7.0" P_RE = re.compile(r"(]*>)(.*?)(

)", 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,必须写成" + "中文读音/外语,不要改成括号。" + "标点统一为简中出版格式:对话引号用「」;省略号用……;破折号用——;问号用?,感叹号用!,问叹连用用?!或!?;" + "逗号、句号、顿号、冒号、分号用,。、:;。不要用英文半角标点替代中文标点。" +) +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 可自然译入中文。术语优先服从给定术语表。" +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"|<[^>]+>", 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"{html.escape(rb)}{html.escape(rt)}" + 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//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"], } ) diff --git a/tools/epub_review_editor/static/app.js b/tools/epub_review_editor/static/app.js index b447bc2..a53754b 100644 --- a/tools/epub_review_editor/static/app.js +++ b/tools/epub_review_editor/static/app.js @@ -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 = '
展开后会读取术语表。
'; + return; + } + if (!shown.length) { + dom.glossaryList.innerHTML = '
没有匹配的术语。
'; + return; + } + + const frag = document.createDocumentFragment(); + shown.forEach(({ entry, index }) => { + const row = document.createElement("div"); + row.className = "glossaryRow"; + row.dataset.index = String(index); + row.innerHTML = ` + + + + `; + 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 `
术语命中:${escapeHtml(matches.slice(0, 10).join("; "))}${matches.length > 10 ? ` 等 ${matches.length} 条` : ""}
`; +} + +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 () => { diff --git a/tools/epub_review_editor/static/index.html b/tools/epub_review_editor/static/index.html index e257c36..5c39212 100644 --- a/tools/epub_review_editor/static/index.html +++ b/tools/epub_review_editor/static/index.html @@ -9,7 +9,7 @@
-

双语 EPUB 审校编辑器 v0.5.0

+

双语 EPUB 审校编辑器 v0.7.0

正在载入……

@@ -200,23 +200,59 @@

GPT 重翻

- +
未读取配置