Files
dwarf-fortress-annals/dfannals/factcheck.py
T
Chen Yi 16b476167e 专名本土化(信达雅):冻结译名表 + 渲染层,并修掉校验器的正则盲点
- 新增 dfannals/glossary.py(译名表)与 dfannals/localize.py(渲染层)
  人名=音译+绰号意译(Ral Fastenhatchets→拉尔·缚斧、朗古德·商熊「剖胎之寒」),
  地名整体意译(Fordedwinds→涉风渡、The Volcano Of Ravens→渡鸦火山)
- 生成链路本土化:素材渲染、人物表、扩写白名单全部走译名;已有产物做确定性替换
- 修复 factcheck 的 \b 盲点:Unicode 下汉字也算词字符,紧贴汉字的英文名不被检查
  (曾因此漏掉编造专名)。修好后抓出并定点修掉长版第 1 集的 2 处编造
- 修正 title_form 大小写(YETI→Yeti),此前 Yeti/Goblin 一直漏译
- 新增 tests/test_localize.py(13 项回归)
2026-10-05 22:39:53 +08:00

150 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages),
# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。
"""专名事实校验:正文里出现的专有名词必须能在史料中找到。
策略(按访谈决策:自动直推、不阻断,但标注可疑处):
- 抽出正文中的拉丁字母专名序列(本项目的约定是专名保留英文原文)
- 与史料已知专名集合比对
- 未知专名既不阻断推送,也不静默放过,而是连同上下文一起标注出来
注意:这一步只能拦住"凭空出现的人名地名",拦不住编造的对白与心理描写——
后者按用户选择属于允许的自由演绎范围。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from dfannals.legends import World, pretty
# 正文里允许出现、但不算专名的常见英文词(游戏术语/通用词)
COMMON_WORDS = {
"Dwarf", "Dwarves", "Dwarven", "Fortress", "Fort", "Mountain", "Mountains", "Hill",
"River", "Lake", "Forest", "Desert", "Ocean", "Cave", "Caves", "Town", "City",
"King", "Queen", "Lord", "Lady", "Captain", "General", "Soldier", "Miner", "Smith",
"Hammer", "Axe", "Sword", "Shield", "Gold", "Silver", "Iron", "Steel", "Copper",
"Bronze", "Wood", "Stone", "Water", "Beer", "Ale", "Wine", "Goblin", "Goblins",
"Elf", "Elves", "Human", "Humans", "Kobold", "Kobolds", "Troll", "Trolls",
"Beast", "Beasts", "Titan", "Titans", "Dragon", "Dragons", "Demon", "Demons",
"God", "Gods", "Temple", "Shrine", "Tomb", "Market", "Tavern", "Library",
"Legend", "Legends", "Annals", "Chronicle", "Chapter", "Year", "Years",
"North", "South", "East", "West", "Spring", "Summer", "Autumn", "Winter",
"The", "A", "An", "And", "Or", "But", "In", "On", "At", "By", "Of", "To", "For",
}
# 拉丁专名序列:首字母大写的词,可含 ' - 与数字
# 英文专名的边界不能用 \b:Unicode 下汉字也算「词字符」,于是 `在Mistbreath的`
# 这种紧贴汉字的英文名根本不会被匹配——实测正是这个盲点让一个编造的地名混过了校验。
# 改成前后看「是不是英文字母」,与汉字相邻也能抓到。
NAME_RE = re.compile(r"(?<![A-Za-z])[A-Z][A-Za-z'\-]*(?:\s+[A-Z][A-Za-z'\-]*)*(?![A-Za-z])")
_CJK = re.compile(r"[\u4e00-\u9fff]")
@dataclass
class Suspicion:
name: str
count: int
context: str
def known_names(world: World) -> set[str]:
"""史料中出现过的全部专名,含单词与整名两种粒度。"""
names: set[str] = set()
def add(value: str) -> None:
value = (value or "").strip()
if not value:
return
names.add(value)
# 同时收录展示形式:正文里专名会做首字母大写,两边必须一致
shown = pretty(value)
names.add(shown)
for token in re.split(r"[\s,]+", shown):
token = token.strip("'\"()[]")
if token and token[0].isupper():
names.add(token)
# 原样小写形式也保留,避免漏报
names.add(value.lower())
# 世界名必须拆词:否则 "The World of Prophecy" 里的 Prophecy 会被当成凭空编造
add(world.name)
add(world.altname)
for fig in world.figures.values():
add(fig.name)
add(fig.race)
add(fig.profession)
for ent in world.entities.values():
add(ent.name)
add(ent.type)
add(ent.race)
for site in world.sites.values():
add(site.name)
add(site.type)
return names
def _is_candidate(name: str, known: set[str]) -> bool:
if name in known:
return False
tokens = name.split()
# 全部由常见词组成的串不是专名
if all(t in COMMON_WORDS for t in tokens):
return False
# 全大写的是史料里的枚举值(GOBLIN、MINER、DARK_FORTRESS),不是人名地名
return not all(len(t) >= 2 and t.isupper() for t in tokens)
def check(text: str, world: World) -> list[Suspicion]:
"""返回可疑专名列表(按出现次数降序)。"""
known = known_names(world)
# 大小写不该决定是不是幻觉:史料里是 YETI/EAGLE_MAN,正文写成 Yeti/Eagle_man
# 是同一件事(模型从种族字段学来的),不能报成凭空编造。
folded = {k.casefold() for k in known}
found: dict[str, list[str]] = {}
for match in NAME_RE.finditer(text):
raw = match.group(0)
if raw.casefold() in folded:
continue
# 逐级回退:整串不认,就试着拆成更短的已知名字,减少误报
if not _is_candidate(raw, known):
continue
if any(part.casefold() in folded for part in raw.split()):
# 名字里有一部分是史料已知的(例如 "Urist the Bold"),不当作凭空编造
continue
start = max(0, match.start() - 20)
end = min(len(text), match.end() + 20)
found.setdefault(raw, []).append(text[start:end].replace("\n", " "))
return [
Suspicion(name=n, count=len(ctxs), context=ctxs[0])
for n, ctxs in sorted(found.items(), key=lambda kv: (-len(kv[1]), kv[0]))
]
def report(suspicions: list[Suspicion], top: int = 12) -> str:
if not suspicions:
return "专名校验:未发现史料之外的专有名词。"
lines = [f"专名校验:发现 {len(suspicions)} 个可疑专名(不阻断推送,仅标注)"]
for s in suspicions[:top]:
lines.append(f" · {s.name} ×{s.count} —— 上下文:…{s.context}…")
if len(suspicions) > top:
lines.append(f" (其余 {len(suspicions) - top} 个见校验报告文件)")
return "\n".join(lines)
def annotate(chapter_md: str, suspicions: list[Suspicion]) -> str:
"""在章节末尾追加可见的校验提示。"""
if not suspicions:
return chapter_md
names = "、".join(f"{s.name}×{s.count}" for s in suspicions[:10])
block = (
"\n\n---\n\n"
f"> ⚠️ **史料核验提示**:以下专名未在 legends 史料中找到,可能是演绎或错误:{names}。\n"
"> 其余对白与心理描写属于允许的文学演绎,不在校验范围内。\n"
)
return chapter_md.rstrip() + block