# 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"(? 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