Files
dwarf-fortress-annals/dfannals/factcheck.py
T
Chen Yi 8630dcad55 文笔层:AI 味机检、标点规范化、分块大量扩写(第 1 集成稿 10805 字)
- 修复上次提交把 dfannals/cli.py 写成 0 字节的问题(它是唯一入口,导致管道不可运行)
- 性别:解析 <caste>,人物表与写作素材带性别(Ral Fastenhatchets 实为女性)
- 新增 dfannals/deslop.py:AI 味机械诊断(硬伤词/句式/标点,按千字密度报告)
- 新增 dfannals/normalize.py:标点与结构清理(引号配对、重复段落与句子、模型自加的小节标记)
- 新增 dfannals/expand.py 与 prompts/literary-expander.md:按年份场景分块大量扩写
- 专名防幻觉:每块附史料专名白名单,事后按段自动修复可疑专名
- episode 命令并进文笔层(骨架稿另存 .skeleton.md),新增 expand 命令做 A/B 对照
- 新增 notes/switched-threads.md 与 test_deslop / test_normalize 回归测试
- 提交前拦截「已跟踪文件为空」,防止上述事故复发
2026-10-05 22:27:02 +08:00

147 lines
6.0 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",
}
# 拉丁专名序列:首字母大写的词,可含 ' - 与数字
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)
# 大小写不该决定是不是幻觉:史料里是 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