Files
dwarf-fortress-annals/dfannals/expand.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

398 lines
15 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.
"""文笔层:把已过闸门的编年稿大量扩写成有现场的长文。
与 volume.write_episode 的分工:
- volume 从史料素材写出「骨架正确」的一集(事实优先,1500 字内);
- expand 拿那一稿逐事件展开(场景优先,目标 8000 字)。
**为什么要分块**:一次调用来写 8000 字,模型会跑偏、提前收尾、漏掉后半段事件。
所以按「年份场景」把原稿切块,每块目标约 2600 字,逐块展开后用上一块结尾承接,
再把各块拼接。每块都只喂它自己那一段史料,避免不同块写出重复内容。
这是 A/B 层:产物写成 `<key>.literary.md`,永不覆盖原稿。
"""
from __future__ import annotations
import math
import re
from collections import Counter
from dataclasses import replace
from pathlib import Path
from dfannals import deslop, volume
from dfannals import episodes as ep_mod
from dfannals.chronicle import cjk_length
from dfannals.config import (
EXPAND_CEILING,
EXPAND_CHUNK_TARGET,
EXPAND_FLOOR,
EXPAND_TARGET_CHARS,
LITERARY_MODEL,
MAX_TOKENS_EXPAND,
PROMPT_DIR,
)
from dfannals.factcheck import Suspicion
from dfannals.legends import World
from dfannals.llm import chat
from dfannals.normalize import Normalized, normalize
from dfannals.threads import Thread
SPEC_FILE = PROMPT_DIR / "literary-expander.md"
TAIL_CHARS = 300 # 上一块结尾取多少字做承接
YEAR_RE = re.compile(r"^\s*\d{3,4}\s*年")
ANY_YEAR_RE = re.compile(r"(\d{3,4})\s*年")
def scene_groups(draft: str) -> list[list[str]]:
"""按「年份开头的段落」切场景组;紧随其后的非年份段落归入同一组。"""
groups: list[list[str]] = []
for para in (p for p in draft.split("\n") if p.strip()):
if YEAR_RE.match(para) or not groups:
groups.append([para])
else:
groups[-1].append(para)
return groups
def plan_chunks(draft: str, per_chunk_chars: int) -> list[str]:
"""把原稿装进若干块,尽量在场景边界切割。"""
groups = scene_groups(draft)
chunks: list[list[str]] = []
current: list[str] = []
size = 0
for group in groups:
glen = sum(cjk_length(line) for line in group)
if current and size + glen > per_chunk_chars:
chunks.append(current)
current, size = [], 0
current += group
size += glen
if current:
chunks.append(current)
return ["\n".join(c).strip() for c in chunks if "".join(c).strip()]
def _chunk_episode(episode: ep_mod.Episode, chunk_text: str) -> ep_mod.Episode:
"""把这一块覆盖的史料事件挑出来,作为该块的素材。"""
years = [int(m.group(1)) for m in ANY_YEAR_RE.finditer(chunk_text)]
if not years:
return episode
low, high = min(years), max(years)
events = [e for e in episode.events if low <= e.year <= high] or episode.events
return replace(episode, events=events, start_year=low, end_year=high)
def _title_form(value: str) -> str:
"""YETI → Yeti、EAGLE_MAN → Eagle_man:正文里模型就是这么写的。"""
return "_".join(part[:1].upper() + part[1:].lower() for part in value.split("_"))
def allowed_names(world: World, thread: Thread, episode: ep_mod.Episode) -> list[str]:
"""本集史料里真实出现过的专名。
这份名单既发给模型(事前防幻觉),也用于修复可疑专名(事后)。
实测:不给名单时模型造出了 "Farewell" 这个史料里不存在的地名。
"""
names: set[str] = set()
heros, others = ep_mod.cast_of(world, thread, episode)
for fid in [*heros, *others, *thread.members]:
name = world.figure_name(fid)
if name:
names.add(name)
figure = world.figures.get(fid)
if figure and figure.race:
names.add(figure.race)
names.add(_title_form(figure.race))
for event in episode.events:
for ref in world.render_refs(event):
value = ref.partition("=")[2].strip()
if value:
names.add(value)
return sorted(n for n in names if n)
def chunk_messages(
world: World,
thread: Thread,
episode: ep_mod.Episode,
total_episodes: int,
chunk_text: str,
chunk_target: int,
index: int,
count: int,
prev_tail: str = "",
elided: Counter | None = None,
) -> list[dict]:
spec = SPEC_FILE.read_text(encoding="utf-8")
material = volume.render_material(world, thread, episode, total_episodes, "", elided)
user = [
material,
"",
f"### 这一块原稿(第 {index}/{count} 块,只写这一块的年代范围)",
"",
chunk_text,
"",
"### 这一块的 AI 味诊断(这些必须改掉)",
"",
deslop.digest(chunk_text),
"",
]
if prev_tail:
user += ["### 上一块写到哪儿(用于承接状态,不要复述)", "", prev_tail, ""]
names = allowed_names(world, thread, episode)
if names:
user += [
"### 允许使用的专名(史料里真实存在,逗号分隔;除此之外的人名地名一律不许写)",
"",
"、".join(names),
"",
"(上面提到的种族名可以当普通名词用,不要写成新角色。)",
"",
]
user += [
(
f"### 要求:把这一块逐事件展开到 {chunk_target} 字左右,只输出正文。"
"不要写标题,不要重复上一块已经写过的内容,也不要跳到下一块的年代。"
),
]
return [{"role": "system", "content": spec}, {"role": "user", "content": "\n".join(user)}]
def _fit_length(
messages: list[dict],
text: str,
*,
floor: int,
ceiling: int,
target: int,
gateway=None,
model: str = LITERARY_MODEL,
max_repairs: int = 1,
) -> str:
"""把长度拉进区间:太短继续展开,太长压缩(都不许动史实)。"""
for _ in range(max_repairs):
length = cjk_length(text)
if floor <= length <= ceiling:
break
ask = (
f"只有 {length} 字,还差很多。继续展开:把这一段里还没落到现场的事件逐件写成场景,"
f"补对白、动作、视角人物此刻的感知与算计,写到 {target} 字左右。"
"不许新增史料里没有的事件或专名,也不要用形容词灌水。"
if length < floor
else f"有 {length} 字,超了。删掉重复交代和没有功能的描写,压到 {ceiling} 字以内,"
"冲突与转折的现场感要保留。"
)
text = chat(messages + [{"role": "assistant", "content": text},
{"role": "user", "content": ask}],
gateway=gateway, model=model, max_tokens=MAX_TOKENS_EXPAND).content
return text.strip()
def build_messages(
world: World,
thread: Thread,
episode: ep_mod.Episode,
total_episodes: int,
draft: str,
prev_tail: str = "",
elided: Counter | None = None,
target: int = EXPAND_TARGET_CHARS,
) -> list[dict]:
"""整集一次写完时用(目标不长时):素材 + 原稿 + 诊断 + 字数目标。"""
spec = SPEC_FILE.read_text(encoding="utf-8")
material = volume.render_material(world, thread, episode, total_episodes, prev_tail, elided)
user = [
material,
"",
"### 原稿(事实与事件顺序按它,写法不要学它)",
"",
draft.strip(),
"",
"### 原稿的 AI 味诊断(这些必须改掉)",
"",
deslop.digest(draft),
"",
(
f"### 现在开始:按原稿的事件顺序逐件展开,写到 {target} 字左右"
f"({EXPAND_FLOOR}–{EXPAND_CEILING} 字以内),只输出正文。"
),
]
return [{"role": "system", "content": spec}, {"role": "user", "content": "\n".join(user)}]
def expand_episode(
world: World,
thread: Thread,
episode: ep_mod.Episode,
total_episodes: int,
draft: str,
prev_tail: str = "",
*,
gateway=None,
model: str = LITERARY_MODEL,
target: int = EXPAND_TARGET_CHARS,
elided: Counter | None = None,
max_repairs: int = 1,
) -> Normalized:
"""大量扩写:分块展开 → 拼接 → 规范化标点。
返回 Normalized,notes 里带上每块的实际字数,便于发现哪一块没写够。
"""
# 标题不进正文块:否则模型会把 "# 守门人" 当正文写进去。最后再拼回开头。
lines = draft.split("\n")
headings = [ln for ln in lines if ln.strip().startswith("#")]
body = "\n".join(ln for ln in lines if not ln.strip().startswith("#")).strip()
length = cjk_length(body) or 1
blocks = max(1, math.ceil(target / EXPAND_CHUNK_TARGET))
chunks = plan_chunks(body, max(1, round(length / blocks)))
chunk_target = max(700, target // len(chunks))
parts: list[str] = []
notes: list[str] = []
for index, chunk in enumerate(chunks, 1):
prev_tail_text = parts[-1][-TAIL_CHARS:] if parts else prev_tail[-TAIL_CHARS:]
sub_episode = _chunk_episode(episode, chunk)
messages = chunk_messages(world, thread, sub_episode, total_episodes, chunk,
chunk_target, index, len(chunks), prev_tail_text, elided)
text = chat(messages, gateway=gateway, model=model,
max_tokens=MAX_TOKENS_EXPAND).content
text = _fit_length(messages, text,
floor=int(chunk_target * 0.75), ceiling=int(chunk_target * 1.35),
target=chunk_target, gateway=gateway, model=model,
max_repairs=max_repairs)
parts.append(text)
notes.append(f"第 {index}/{len(chunks)} 块:{cjk_length(text)} 字(目标 {chunk_target})")
stitched = "\n\n".join(parts)
total_now = cjk_length(stitched)
# 拼接后总量仍不足,再整篇催一次(只此一次,避免无限加长)
if total_now < EXPAND_FLOOR:
messages = build_messages(world, thread, episode, total_episodes, body, prev_tail,
elided, target)
stitched = _fit_length(messages, stitched, floor=EXPAND_FLOOR, ceiling=EXPAND_CEILING,
target=target, gateway=gateway, model=model, max_repairs=1)
result = normalize(stitched.strip())
result.notes = notes + result.notes
if headings:
result.text = "\n\n".join([*headings, result.text])
return result
def literary_path(directory: Path, episode: ep_mod.Episode) -> Path:
"""扩写稿的落盘位置:与原稿并列,便于 A/B 对照。"""
return directory / f"{episode.key}.literary.md"
NAME_FIX_SPEC = """你在做一件很窄的事:正文里出现了史料中不存在的专名(模型凭空造的人名、地名、称号)。
把包含这些专名的句子改掉:
- 优先换成「允许使用的专名」里真实存在、语义接近的名字;
- 没有合适的名字,就把句子改成不指名(例如把"把 Farewell 挡回去"改成"把下一个要拦的人挡回去")。
不许增删情节,不许改动没被点到的句子,不许把段落合并或拆分。
只输出改好的段落,每段以 [序号] 开头,序号与段数必须和输入一致。不写任何解释。
"""
_NUMBERED_RE = re.compile(r"^\s*\[(\d+)\]\s*(.+?)\s*$")
def _parse_numbered(raw: str) -> dict[int, str]:
"""把模型返回的 `[n] 段落` 解析成 {序号: 段落},容忍跨行。"""
out: dict[int, str] = {}
current: int | None = None
buffer: list[str] = []
for line in raw.split("\n"):
match = _NUMBERED_RE.match(line)
if match:
if current is not None:
out[current] = "\n".join(buffer).strip()
current = int(match.group(1))
buffer = [match.group(2)]
elif current is not None and line.strip():
buffer.append(line.strip())
if current is not None:
out[current] = "\n".join(buffer).strip()
return {k: v for k, v in out.items() if v}
def repair_names(
world: World,
thread: Thread,
episode: ep_mod.Episode,
text: str,
suspects: list[Suspicion],
*,
gateway=None,
model: str = LITERARY_MODEL,
) -> Normalized:
"""只重写含可疑专名的那几段,其他段落一字不动。
整篇重写代价高(几千字)且模型会顺手改别的地方;段级手术安全得多——
段数对不上就不写回,宁可不改也不把全文换掉。
"""
bad = {s.name for s in suspects}
paragraphs = text.split("\n")
targets = [i for i, para in enumerate(paragraphs) if any(name in para for name in bad)]
if not targets:
return normalize(text)
numbered = "\n".join(f"[{n}] {paragraphs[i]}" for n, i in enumerate(targets, 1))
messages = [
{"role": "system", "content": NAME_FIX_SPEC},
{"role": "user", "content": "\n".join([
"### 允许使用的专名", "", "、".join(allowed_names(world, thread, episode)), "",
"### 史料里找不到的专名", "",
*[f"- {s.name}(出现 {s.count} 次)|例:…{s.context}…" for s in suspects], "",
f"### 需要修的段落(共 {len(targets)} 段)", "", numbered, "",
f"### 要求:只输出改好的 {len(targets)} 段,每段以 [序号] 开头。",
])},
]
reply = chat(messages, gateway=gateway, model=model, max_tokens=MAX_TOKENS_EXPAND).content
fixed = _parse_numbered(reply)
applied = 0
for n, index in enumerate(targets, 1):
if n in fixed:
paragraphs[index] = fixed[n]
applied += 1
result = normalize("\n".join(paragraphs))
result.notes = [f"可疑专名修复:改写 {applied}/{len(targets)} 段"] + result.notes
return result
def _hits(text: str) -> tuple[int, float, int]:
"""返回(硬伤数、每千字硬伤密度、字数)。"""
hard = [h for h in deslop.check(text) if h.level == "hard"]
count = sum(h.count for h in hard)
length = cjk_length(text)
return count, count * 1000 / (length or 1), length
def compare(draft: str, literary: str) -> str:
"""A/B 对照:字数与 AI 味硬伤密度。"""
rows = ["A/B 对照:"]
for label, text in (("原稿", draft), ("扩写稿", literary)):
count, density, length = _hits(text)
rows.append(f" {label:<8}{length:>6} 字|硬伤 {count:>4} 处({density:.1f}/千字)")
before = _hits(draft)[2]
if before:
rows.append(f" 篇幅倍数:{_hits(literary)[2] / before:.1f}×")
return "\n".join(rows)
def full_report(draft: str, literary: str) -> str:
"""给 CLI 用:A/B 对照 + 两稿各自的诊断。"""
return "\n".join([
compare(draft, literary),
"",
"— 原稿诊断 —",
deslop.report(draft),
"",
"— 扩写稿诊断 —",
deslop.report(literary),
])