142 lines
5.7 KiB
Python
142 lines
5.7 KiB
Python
# 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",
|
||
}
|
||
|
||
# 拉丁专名序列:首字母大写的词,可含 ' - 与数字
|
||
NAME_RE = re.compile(r"\b[A-Z][A-Za-z'\-]*(?:\s+[A-Z][A-Za-z'\-]*)*\b")
|
||
_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)
|
||
found: dict[str, list[str]] = {}
|
||
|
||
for match in NAME_RE.finditer(text):
|
||
raw = match.group(0)
|
||
# 逐级回退:整串不认,就试着拆成更短的已知名字,减少误报
|
||
if not _is_candidate(raw, known):
|
||
continue
|
||
if any(part in known 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
|