删除旧的事件驱动章节(流水账),并同步文档到人物主线
- 移除 output/volumes/v1/chapters/001-1-9.md 与 002-10-19.md(仅普通提交,git 历史保留) - 移除被取代的写作规范 prompts/chronicler.md 与旧的按年代切章模块 - README 全面改写为人物主线流程、参数位置与踩坑记录 - scripts/dfannals 启动器:自动挑一个装了 defusedxml 的解释器
This commit is contained in:
+5
-3
@@ -135,8 +135,10 @@ def _bio_prompt(world: World, rows: list[CastRow], events: list[Event]) -> str:
|
||||
lines = [BIO_INSTRUCTIONS, "", "材料:"]
|
||||
for row in rows[:BIO_LIMIT]:
|
||||
facts = [
|
||||
f"{row.name}({row.race or '种族不详'},{row.span},本卷出场 {row.appearances} 次,"
|
||||
f"身份:{row.identity or '不详'},在故事里是{row.role})"
|
||||
(
|
||||
f"{row.name}({row.race or '种族不详'},{row.span},本卷出场 {row.appearances} 次,"
|
||||
f"身份:{row.identity or '不详'},在故事里是{row.role})"
|
||||
)
|
||||
]
|
||||
for e in events:
|
||||
if row.fid not in e.figure_ids():
|
||||
@@ -159,7 +161,7 @@ def _parse_bios(raw: str) -> dict[str, str]:
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"小传 JSON 无法解析:{exc}|原文:{raw[:200]}") from None
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("小传返回的不是对象")
|
||||
raise TypeError("小传返回的不是对象")
|
||||
return {str(k): str(v) for k, v in data.items()}
|
||||
|
||||
|
||||
|
||||
+26
-115
@@ -1,59 +1,50 @@
|
||||
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
|
||||
# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages),
|
||||
# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。
|
||||
"""进度状态与中文篇幅统计。
|
||||
|
||||
"""组织 prompt、调用模型、控制篇幅、跑事实校验,产出一章成品。"""
|
||||
(旧版这里还有整套「按年代切章 → 写编年史」的逻辑;那条路径已经被人物主线取代,
|
||||
相关代码与提示词一并删除,只留下仍然需要的东西。)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from dfannals import factcheck
|
||||
from dfannals import slice as chapter_slice
|
||||
from dfannals.config import (
|
||||
CHAPTER_MAX_CHARS,
|
||||
CHAPTER_MIN_CHARS,
|
||||
DATA_DIR,
|
||||
PROMPT_DIR,
|
||||
Gateway,
|
||||
)
|
||||
from dfannals.legends import World
|
||||
from dfannals.llm import Reply, chat
|
||||
from dfannals.config import DATA_DIR
|
||||
|
||||
STATE_FILE = DATA_DIR / "state.json"
|
||||
SPEC_FILE = PROMPT_DIR / "chronicler.md"
|
||||
|
||||
TAIL_CHARS = 600 # 交给下一章衔接的上一章结尾长度
|
||||
REPAIR_ATTEMPTS = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class State:
|
||||
world: str = ""
|
||||
volume: int = 1
|
||||
done: list[str] = None # type: ignore[assignment]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.done is None:
|
||||
self.done = []
|
||||
done: list[str] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path = STATE_FILE) -> State:
|
||||
if not path.is_file():
|
||||
return cls()
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except json.JSONDecodeError:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return cls()
|
||||
return cls(world=data.get("world", ""), volume=data.get("volume", 1), done=data.get("done", []))
|
||||
if not isinstance(data, dict):
|
||||
return cls()
|
||||
return cls(
|
||||
world=data.get("world", ""),
|
||||
volume=data.get("volume", 1),
|
||||
done=list(data.get("done", [])),
|
||||
)
|
||||
|
||||
def save(self, path: Path = STATE_FILE) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"world": self.world, "volume": self.volume, "done": self.done},
|
||||
ensure_ascii=False, indent=2))
|
||||
path.write_text(
|
||||
json.dumps({"world": self.world, "volume": self.volume, "done": self.done},
|
||||
ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def mark_done(self, key: str) -> None:
|
||||
if key not in self.done:
|
||||
@@ -61,7 +52,11 @@ class State:
|
||||
|
||||
|
||||
def cjk_length(text: str) -> int:
|
||||
"""按中文习惯计字数:CJK 字符与拉丁单词都算 1。"""
|
||||
"""按中文习惯计字数:CJK 字符、拉丁字母数字、中文标点各算 1。
|
||||
|
||||
统计前先去掉代码块与 Markdown 行首标记(标题、引用、列表符),
|
||||
使结果接近读者感知的"正文字数"。
|
||||
"""
|
||||
without_code = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
|
||||
without_meta = re.sub(r"^\s*[#>|\-*].*$", "", without_code, flags=re.MULTILINE)
|
||||
n = 0
|
||||
@@ -73,87 +68,3 @@ def cjk_length(text: str) -> int:
|
||||
):
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def previous_tail(chapter_dir: Path, chapters: list[chapter_slice.Chapter], key: str) -> str:
|
||||
"""取上一章结尾,作为本章的衔接线索。"""
|
||||
idx = next((i for i, c in enumerate(chapters) if c.key == key), None)
|
||||
if idx is None or idx == 0:
|
||||
return ""
|
||||
prev = chapters[idx - 1]
|
||||
path = chapter_dir / f"{prev.key}.md"
|
||||
if not path.is_file():
|
||||
return ""
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
return text[-TAIL_CHARS:]
|
||||
|
||||
|
||||
def build_messages(world: World, chapter: chapter_slice.Chapter, tail: str) -> list[dict]:
|
||||
spec = SPEC_FILE.read_text(encoding="utf-8")
|
||||
payload = chapter_slice.material(world, chapter)
|
||||
user = [
|
||||
f"世界:{world.name}" + (f"({world.altname})" if world.altname else ""),
|
||||
f"本书体例:第 {chapter.index} 章,覆盖 {chapter.span}",
|
||||
"",
|
||||
"以下是本章的史料素材(来自 legends 导出,事件名称保留游戏原文):",
|
||||
"",
|
||||
payload,
|
||||
]
|
||||
if tail:
|
||||
user += [
|
||||
"",
|
||||
"### 上一章结尾(用于衔接,不要复述,只承接状态)",
|
||||
"",
|
||||
tail,
|
||||
]
|
||||
user += [
|
||||
"",
|
||||
f"现在请写出本章正文,{CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字。",
|
||||
]
|
||||
return [
|
||||
{"role": "system", "content": spec},
|
||||
{"role": "user", "content": "\n".join(user)},
|
||||
]
|
||||
|
||||
|
||||
def _repair(messages: list[dict], draft: str, length: int, gateway: Gateway | None) -> str:
|
||||
target = (
|
||||
f"内容太短({length} 字),请扩写到 {CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字,补充细节与对白,不要注水。"
|
||||
if length < CHAPTER_MIN_CHARS
|
||||
else f"内容太长({length} 字),请压缩到 {CHAPTER_MAX_CHARS} 字以内,删掉次要枝节,保留主干与最有趣的段落。"
|
||||
)
|
||||
reply: Reply = chat(
|
||||
messages + [{"role": "assistant", "content": draft}, {"role": "user", "content": target}],
|
||||
gateway=gateway,
|
||||
)
|
||||
return reply.content
|
||||
|
||||
|
||||
def generate(
|
||||
world: World,
|
||||
chapter: chapter_slice.Chapter,
|
||||
*,
|
||||
tail: str = "",
|
||||
gateway: Gateway | None = None,
|
||||
max_repairs: int = REPAIR_ATTEMPTS,
|
||||
) -> tuple[str, list[factcheck.Suspicion], Reply | None]:
|
||||
"""生成一章:调用模型 → 篇幅修正 → 事实校验 → 标注。"""
|
||||
messages = build_messages(world, chapter, tail)
|
||||
reply = chat(messages, gateway=gateway)
|
||||
body = reply.content
|
||||
|
||||
for _ in range(max_repairs):
|
||||
length = cjk_length(body)
|
||||
if CHAPTER_MIN_CHARS <= length <= CHAPTER_MAX_CHARS:
|
||||
break
|
||||
body = _repair(messages, body, length, gateway)
|
||||
|
||||
suspicions = factcheck.check(body, world)
|
||||
return factcheck.annotate(body, suspicions), suspicions, reply
|
||||
|
||||
|
||||
def write_chapter(chapter_dir: Path, chapter: chapter_slice.Chapter, body: str) -> Path:
|
||||
chapter_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = chapter_dir / f"{chapter.key}.md"
|
||||
path.write_text(body.strip() + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
-333
@@ -1,333 +0,0 @@
|
||||
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
|
||||
# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages),
|
||||
# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。
|
||||
|
||||
"""命令行入口。
|
||||
|
||||
python3 -m dfannals.cli status 查看当前进度
|
||||
python3 -m dfannals.cli index 解析 exports → 年表 + 人物索引
|
||||
python3 -m dfannals.cli plan 显示切章方案(不调用模型)
|
||||
python3 -m dfannals.cli next 生成下一章 → 校验 → 推送
|
||||
python3 -m dfannals.cli run 一条命令跑完:索引 + 下一章 + 推送
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from dfannals import (
|
||||
cast,
|
||||
chronicle,
|
||||
episodes,
|
||||
factcheck,
|
||||
legends,
|
||||
publish,
|
||||
threads,
|
||||
timeline,
|
||||
)
|
||||
from dfannals import slice as chapter_slice
|
||||
from dfannals import volume as volume_mod
|
||||
from dfannals.config import (
|
||||
DATA_DIR,
|
||||
EXPORT_DIR,
|
||||
GITEA_WEB,
|
||||
chapter_dir,
|
||||
ensure_dirs,
|
||||
volume_dir,
|
||||
)
|
||||
|
||||
|
||||
def find_exports(export_dir: Path) -> list[Path]:
|
||||
"""找出 legends 导出文件。原生 legends.xml 与 DFHack 的 legends_plus.xml 都要。"""
|
||||
if not export_dir.is_dir():
|
||||
return []
|
||||
files = sorted(export_dir.glob("*.xml"))
|
||||
base = [f for f in files if not f.name.endswith("-legends_plus.xml") and "legends_plus" not in f.name]
|
||||
plus = [f for f in files if "legends_plus" in f.name]
|
||||
return base + plus
|
||||
|
||||
|
||||
def load_world(export_dir: Path) -> legends.World:
|
||||
paths = find_exports(export_dir)
|
||||
if not paths:
|
||||
raise SystemExit(
|
||||
f"在 {export_dir} 里没找到 legends XML。\n"
|
||||
"请先在游戏里导出(DF 的 Export XML + DFHack 的 exportlegends),"
|
||||
"再把文件复制进这个目录。"
|
||||
)
|
||||
return legends.load(paths)
|
||||
|
||||
|
||||
def _current_volume(state: chronicle.State, world: legends.World) -> int:
|
||||
"""世界换了就开新卷。"""
|
||||
if state.world != world.name:
|
||||
if state.world:
|
||||
state.volume += 1
|
||||
state.done = []
|
||||
state.world = world.name
|
||||
return state.volume
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> int:
|
||||
state = chronicle.State.load()
|
||||
if not state.world:
|
||||
print("尚未开始。先运行 index。")
|
||||
return 0
|
||||
vdir = volume_dir(state.volume)
|
||||
print(f"世界:{state.world}")
|
||||
print(f"卷号:第 {state.volume} 卷")
|
||||
print(f"已完成章节:{len(state.done)}")
|
||||
for key in state.done:
|
||||
print(f" · {key}")
|
||||
print(f"成品目录:{vdir}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_index(args: argparse.Namespace) -> int:
|
||||
ensure_dirs()
|
||||
world = load_world(EXPORT_DIR)
|
||||
state = chronicle.State.load()
|
||||
volume = _current_volume(state, world)
|
||||
chapters = chapter_slice.plan(world)
|
||||
|
||||
vdir = volume_dir(volume)
|
||||
timeline.write_outputs(world, vdir)
|
||||
state.save()
|
||||
|
||||
print(legends.describe(world))
|
||||
print()
|
||||
print(chapter_slice.overview(world, chapters))
|
||||
print()
|
||||
print(f"年表与人物索引已写入 {vdir}")
|
||||
|
||||
if not args.no_push:
|
||||
publish.ensure_repo()
|
||||
head = publish.commit_and_push(
|
||||
f"第 {volume} 卷索引:{world.name}({len(chapters)} 章规划)",
|
||||
[vdir / "timeline.md", vdir / "figures.md"],
|
||||
)
|
||||
print(f"已推送索引:{head or '(无改动)'} → {GITEA_WEB}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_plan(args: argparse.Namespace) -> int:
|
||||
world = load_world(EXPORT_DIR)
|
||||
chapters = chapter_slice.plan(world)
|
||||
print(chapter_slice.overview(world, chapters))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_next(args: argparse.Namespace) -> int:
|
||||
ensure_dirs()
|
||||
world = load_world(EXPORT_DIR)
|
||||
state = chronicle.State.load()
|
||||
volume = _current_volume(state, world)
|
||||
chapters = chapter_slice.plan(world)
|
||||
cdir = chapter_dir(volume)
|
||||
|
||||
todo = [c for c in chapters if c.key not in state.done]
|
||||
if not todo:
|
||||
print("本卷史料已全部写完。需要新世界才能开新卷。")
|
||||
return 0
|
||||
|
||||
chapter = todo[0]
|
||||
print(f"正在写第 {chapter.index} 章:{chapter.span}({len(chapter.events)} 条事件)")
|
||||
tail = chronicle.previous_tail(cdir, chapters, chapter.key)
|
||||
body, suspicions, reply = chronicle.generate(world, chapter, tail=tail)
|
||||
length = chronicle.cjk_length(body)
|
||||
print(f"生成完成:{length} 字" + (f" | 总 token {reply.total_tokens}" if reply else ""))
|
||||
|
||||
print(factcheck.report(suspicions))
|
||||
|
||||
if args.dry_run:
|
||||
print("\n--- 试运行,不写文件 ---\n")
|
||||
print(body[:1200])
|
||||
return 0
|
||||
|
||||
path = chronicle.write_chapter(cdir, chapter, body)
|
||||
state.mark_done(chapter.key)
|
||||
state.save()
|
||||
print(f"已写入 {path}")
|
||||
|
||||
publish.ensure_repo()
|
||||
head = publish.commit_and_push(
|
||||
f"第 {volume} 卷第 {chapter.index} 章:{chapter.span}"
|
||||
+ (f"(专名校验可疑 {len(suspicions)} 处)" if suspicions else "")
|
||||
)
|
||||
print(f"已推送:{head or '(无改动)'} → {GITEA_WEB}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_threads(args: argparse.Namespace) -> int:
|
||||
ensure_dirs()
|
||||
world = load_world(EXPORT_DIR)
|
||||
found = threads.find_threads(
|
||||
world,
|
||||
min_interactions=args.min_interactions,
|
||||
min_span=args.min_span,
|
||||
limit=args.top,
|
||||
)
|
||||
print(threads.render_report(world, found, per_thread=args.samples))
|
||||
path = threads.save_json(world, found, DATA_DIR / "threads.json")
|
||||
print(f"共 {len(found)} 条候选;明细已写入 {path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_episodes(args: argparse.Namespace) -> int:
|
||||
ensure_dirs()
|
||||
world = load_world(EXPORT_DIR)
|
||||
found = threads.find_threads(
|
||||
world, min_interactions=args.min_interactions, min_span=args.min_span
|
||||
)
|
||||
if not found:
|
||||
print("没有候选线索")
|
||||
return 1
|
||||
if not 1 <= args.thread <= len(found):
|
||||
print(f"候选只有 {len(found)} 条,--thread 超出范围")
|
||||
return 1
|
||||
thread = found[args.thread - 1]
|
||||
_, planned = volume_mod.extended_plan(world, thread)
|
||||
print(episodes.render_plan(world, thread, planned))
|
||||
print()
|
||||
print(episodes.budget_report(planned))
|
||||
if args.samples:
|
||||
print("\n=== 前两集事件样例 ===")
|
||||
for ep in planned[:2]:
|
||||
print(f" 第 {ep.index} 集({ep.span})")
|
||||
for e in ep.events[: args.samples]:
|
||||
print(f" · {e.year}年 {e.type} " + " · ".join(world.render_refs(e)))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_cast(args: argparse.Namespace) -> int:
|
||||
ensure_dirs()
|
||||
world = load_world(EXPORT_DIR)
|
||||
found = threads.find_threads(
|
||||
world, min_interactions=args.min_interactions, min_span=args.min_span
|
||||
)
|
||||
if not found:
|
||||
print("没有候选线索")
|
||||
return 1
|
||||
if not 1 <= args.thread <= len(found):
|
||||
print(f"候选只有 {len(found)} 条,--thread 超出范围")
|
||||
return 1
|
||||
|
||||
thread = found[args.thread - 1]
|
||||
_, planned = volume_mod.extended_plan(world, thread)
|
||||
text, rows, suspicions = cast.build_cast(
|
||||
world, thread, planned, with_bios=not args.no_bios
|
||||
)
|
||||
if args.dry_run:
|
||||
print(text[:2000])
|
||||
return 0
|
||||
|
||||
state = chronicle.State.load()
|
||||
volume = state.volume if state.world == world.name else 1
|
||||
path = cast.write_cast(volume_dir(volume) / "cast.md", text)
|
||||
print(f"人物表已写入 {path}\n涵盖 {len(rows)} 人:{', '.join(r.name for r in rows)}")
|
||||
print(factcheck.report(suspicions) if suspicions else "专名校验:小传里的专名全部能在史料中找到。")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_episode(args: argparse.Namespace) -> int:
|
||||
ensure_dirs()
|
||||
world = load_world(EXPORT_DIR)
|
||||
found = threads.find_threads(
|
||||
world, min_interactions=args.min_interactions, min_span=args.min_span
|
||||
)
|
||||
if not found:
|
||||
print("没有候选线索")
|
||||
return 1
|
||||
|
||||
out_dir = None if args.dry_run else chapter_dir(1)
|
||||
produced = volume_mod.produce_episode(
|
||||
world, found, episode_index=args.index, out_dir=out_dir
|
||||
)
|
||||
|
||||
print("选择依据:" + produced.selection.reason)
|
||||
print(f"本集字数:{chronicle.cjk_length(produced.text)}")
|
||||
if args.dry_run:
|
||||
print("\n--- 试运行,不落盘 ---\n")
|
||||
print(produced.text[:1600])
|
||||
return 0
|
||||
|
||||
print(f"已写入 {produced.path}")
|
||||
if not args.no_push:
|
||||
publish.ensure_repo()
|
||||
head = publish.commit_and_push(
|
||||
f"第 1 卷第 {produced.episode.index} 集(人物主线):{produced.episode.span}"
|
||||
)
|
||||
print(f"已推送:{head or '(无改动)'} → {GITEA_WEB}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_run(args: argparse.Namespace) -> int:
|
||||
rc = cmd_index(args)
|
||||
if rc:
|
||||
return rc
|
||||
return cmd_next(args)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="dfannals", description="矮人要塞编年史生成管道")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p = sub.add_parser("status", help="查看当前进度")
|
||||
p.set_defaults(func=cmd_status)
|
||||
|
||||
p = sub.add_parser("index", help="解析 exports → 年表 + 人物索引")
|
||||
p.add_argument("--no-push", action="store_true", help="只写本地,不推送")
|
||||
p.set_defaults(func=cmd_index)
|
||||
|
||||
p = sub.add_parser("plan", help="显示切章方案")
|
||||
p.set_defaults(func=cmd_plan)
|
||||
|
||||
p = sub.add_parser("threads", help="挖掘人物线索候选")
|
||||
p.add_argument("--min-interactions", type=int, default=threads.DEFAULT_MIN_INTERACTIONS,
|
||||
help="强边阈值:一对人物至少反复互动多少次(默认 5)")
|
||||
p.add_argument("--min-span", type=int, default=threads.DEFAULT_MIN_SPAN,
|
||||
help="线索最小跨度年数(默认 30)")
|
||||
p.add_argument("--top", type=int, default=0, help="只显示前 N 条(0 为全部)")
|
||||
p.add_argument("--samples", type=int, default=3, help="每条线索展示几条事件样例")
|
||||
p.set_defaults(func=cmd_threads)
|
||||
|
||||
p = sub.add_parser("episodes", help="按人物线索切出剧集")
|
||||
p.add_argument("--thread", type=int, default=1, help="用第几条候选线索(默认 1)")
|
||||
p.add_argument("--min-interactions", type=int, default=threads.DEFAULT_MIN_INTERACTIONS)
|
||||
p.add_argument("--min-span", type=int, default=threads.DEFAULT_MIN_SPAN)
|
||||
p.add_argument("--samples", type=int, default=0, help="额外打印每集前 N 条事件")
|
||||
p.set_defaults(func=cmd_episodes)
|
||||
|
||||
p = sub.add_parser("cast", help="生成每卷人物表(表格 + 小传)")
|
||||
p.add_argument("--thread", type=int, default=1, help="用第几条候选线索(默认 1)")
|
||||
p.add_argument("--min-interactions", type=int, default=threads.DEFAULT_MIN_INTERACTIONS)
|
||||
p.add_argument("--min-span", type=int, default=threads.DEFAULT_MIN_SPAN)
|
||||
p.add_argument("--no-bios", action="store_true", help="只出表格,不让模型写小传")
|
||||
p.add_argument("--dry-run", action="store_true", help="只打印不落盘")
|
||||
p.set_defaults(func=cmd_cast)
|
||||
|
||||
p = sub.add_parser("episode", help="按人物主线产出下一集")
|
||||
p.add_argument("--index", type=int, default=1, help="写该线索的第几集(默认 1)")
|
||||
p.add_argument("--min-interactions", type=int, default=threads.DEFAULT_MIN_INTERACTIONS)
|
||||
p.add_argument("--min-span", type=int, default=threads.DEFAULT_MIN_SPAN)
|
||||
p.add_argument("--dry-run", action="store_true", help="只生成不落盘、不推送")
|
||||
p.add_argument("--no-push", action="store_true", help="落盘但不推送")
|
||||
p.set_defaults(func=cmd_episode)
|
||||
|
||||
p = sub.add_parser("next", help="生成下一章")
|
||||
p.add_argument("--dry-run", action="store_true", help="只生成不落盘、不推送")
|
||||
p.set_defaults(func=cmd_next)
|
||||
|
||||
p = sub.add_parser("run", help="一条命令:索引 + 下一章 + 推送")
|
||||
p.add_argument("--no-push", action="store_true")
|
||||
p.add_argument("--dry-run", action="store_true")
|
||||
p.set_defaults(func=cmd_run)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
ensure_dirs()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
+5
-3
@@ -97,7 +97,7 @@ def _parse(raw: str) -> dict:
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"自评返回的 JSON 无法解析:{exc}|原文:{raw[:200]}") from None
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"自评返回的不是对象:{type(data).__name__}")
|
||||
raise TypeError(f"自评返回的不是对象:{type(data).__name__}")
|
||||
return data
|
||||
|
||||
|
||||
@@ -120,8 +120,10 @@ def evaluate(
|
||||
scores: dict[str, int] = {}
|
||||
for key, _label, _hint in DIMENSIONS:
|
||||
value = data.get(key)
|
||||
if not isinstance(value, (int, float)):
|
||||
raise ValueError(f"自评维度 {key} 缺失或非数字:{data.get(key)!r}")
|
||||
if value is None:
|
||||
raise ValueError(f"自评维度 {key} 缺失:{data!r}")
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError(f"自评维度 {key} 不是数字:{value!r}")
|
||||
scores[key] = max(0, min(MAX_SCORE, int(value)))
|
||||
|
||||
reason = str(data.get("reason", "")).strip() or "(未给出理由)"
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
"""把史料切成章节素材。
|
||||
|
||||
真实数据(403853 条事件 / 250 年)证明:按"重要事件数量"切章会得到几千章。
|
||||
所以改为先按时间窗分桶(每窗 10 年),再在窗内按显著度取前 N 条——
|
||||
这样一章天然覆盖一段年月,既有大事件链,也不会漏掉时代的推进。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from dfannals import legends as lg
|
||||
from dfannals import score
|
||||
from dfannals.legends import Event, World
|
||||
|
||||
YEARS_PER_CHAPTER = 10 # 一个时间窗覆盖的年数
|
||||
EVENTS_PER_CHAPTER = 12 # 每章最多收录的史料条目
|
||||
MIN_EVENTS_PER_CHAPTER = 3 # 少于这个数量就并入上一章(太平年月)
|
||||
MAX_PER_CATEGORY = 4 # 同一类事件每章上限,避免整章都是讣告
|
||||
|
||||
|
||||
@dataclass
|
||||
class Chapter:
|
||||
index: int
|
||||
start_year: int
|
||||
end_year: int
|
||||
events: list[Event] = field(default_factory=list)
|
||||
quiet_years: list[tuple[int, int]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.index:03d}-{self.start_year}-{self.end_year}"
|
||||
|
||||
@property
|
||||
def span(self) -> str:
|
||||
if self.start_year == self.end_year:
|
||||
return f"{self.start_year} 年"
|
||||
return f"{self.start_year}–{self.end_year} 年"
|
||||
|
||||
def categories(self) -> list[str]:
|
||||
seen: list[str] = []
|
||||
for e in self.events:
|
||||
c = score.category(e)
|
||||
if c not in seen:
|
||||
seen.append(c)
|
||||
return seen
|
||||
|
||||
|
||||
def plan(
|
||||
world: World,
|
||||
years_per_chapter: int = YEARS_PER_CHAPTER,
|
||||
per_chapter: int = EVENTS_PER_CHAPTER,
|
||||
min_events: int = MIN_EVENTS_PER_CHAPTER,
|
||||
max_per_category: int = MAX_PER_CATEGORY,
|
||||
) -> list[Chapter]:
|
||||
prom = score.prominence(world)
|
||||
scored = [(score.significance(e, prom), e) for e in world.events]
|
||||
scored = [pair for pair in scored if pair[0] > 0]
|
||||
if not scored:
|
||||
lo, hi = world.event_years()
|
||||
return [Chapter(index=1, start_year=lo, end_year=hi)]
|
||||
|
||||
lo = min(e.year for _, e in scored)
|
||||
hi = max(e.year for _, e in scored)
|
||||
|
||||
buckets: dict[int, list[tuple[int, Event]]] = defaultdict(list)
|
||||
for sig, e in scored:
|
||||
buckets[e.year // years_per_chapter].append((sig, e))
|
||||
|
||||
chapters: list[Chapter] = []
|
||||
for b in range(lo // years_per_chapter, hi // years_per_chapter + 1):
|
||||
window_start = max(b * years_per_chapter, lo)
|
||||
window_end = min(b * years_per_chapter + years_per_chapter - 1, hi)
|
||||
ranked = sorted(buckets.get(b, []), key=lambda p: (-p[0], p[1].year, p[1].id))
|
||||
# 按类别限额挑选:让一章里有战事、兴亡、器物,而不是 12 条死亡讣告
|
||||
picks: list[tuple[int, Event]] = []
|
||||
used: dict[str, int] = {}
|
||||
for sig, ev in ranked:
|
||||
cat = score.category(ev)
|
||||
if used.get(cat, 0) >= max_per_category:
|
||||
continue
|
||||
used[cat] = used.get(cat, 0) + 1
|
||||
picks.append((sig, ev))
|
||||
if len(picks) >= per_chapter:
|
||||
break
|
||||
events = sorted((e for _, e in picks), key=lambda e: (e.year, e.seconds72, e.id))
|
||||
|
||||
if not events:
|
||||
if chapters:
|
||||
chapters[-1].quiet_years.append((window_start, window_end))
|
||||
chapters[-1].end_year = window_end
|
||||
continue
|
||||
|
||||
if len(events) < min_events and chapters:
|
||||
chapters[-1].events.extend(events)
|
||||
chapters[-1].end_year = window_end
|
||||
continue
|
||||
|
||||
chapters.append(
|
||||
Chapter(index=len(chapters) + 1, start_year=window_start, end_year=window_end, events=events)
|
||||
)
|
||||
|
||||
for i, c in enumerate(chapters, 1):
|
||||
c.index = i
|
||||
return chapters
|
||||
|
||||
|
||||
def _figure_line(world: World, fid: int, prom) -> str:
|
||||
fig = world.figures.get(fid)
|
||||
if not fig:
|
||||
return f"- HF#{fid}(史料无记载)"
|
||||
bits = [lg.pretty(fig.name) or f"HF#{fid}", fig.race or "未知种族", fig.alive_span]
|
||||
if fig.profession:
|
||||
bits.append(fig.profession)
|
||||
bits.append(f"史料出现 {prom.get(fid, 0)} 次")
|
||||
return "- " + " | ".join(bits)
|
||||
|
||||
|
||||
def material(world: World, chapter: Chapter, max_figures: int = 40) -> str:
|
||||
"""把一章的史料渲染成给模型看的素材文本。"""
|
||||
prom = score.prominence(world)
|
||||
lines: list[str] = [
|
||||
f"## 本章范围:{chapter.span}",
|
||||
"",
|
||||
f"共 {len(chapter.events)} 条史料,类型:{('、'.join(chapter.categories())) or '无'}。",
|
||||
"",
|
||||
"### 事件流水(按时间排序,专名保留游戏原文)",
|
||||
"",
|
||||
]
|
||||
|
||||
participation: dict[int, int] = {}
|
||||
for e in chapter.events:
|
||||
# render_refs 会带出双方(比如 slayer_hfid=谁),而不是只剩单方面 hfid
|
||||
chunks = [f"{e.year}年", e.type, *world.render_refs(e)]
|
||||
for i in e.figure_ids():
|
||||
participation[i] = participation.get(i, 0) + 1
|
||||
extra = []
|
||||
for field_name in ("state", "reason", "circumstance"):
|
||||
v = e.text(field_name)
|
||||
if v and v not in ("-1", ""):
|
||||
extra.append(f"{field_name}={v}")
|
||||
if extra:
|
||||
chunks.append("·".join(extra))
|
||||
lines.append("- " + " · ".join(chunks))
|
||||
|
||||
if chapter.quiet_years:
|
||||
spans = "、".join(f"{a}–{b} 年" for a, b in chapter.quiet_years)
|
||||
lines += [
|
||||
"",
|
||||
"### 太平年月(史料无值得立传的事件)",
|
||||
"",
|
||||
f"- {spans}:这段时期只留下琐碎记录,可一笔带过。",
|
||||
]
|
||||
|
||||
lines += ["", "### 本章登场人物(史料中的身份信息)", ""]
|
||||
for fid, _ in sorted(participation.items(), key=lambda kv: -prom.get(kv[0], 0))[:max_figures]:
|
||||
lines.append(_figure_line(world, fid, prom))
|
||||
|
||||
sites = sorted({n for e in chapter.events for i in e.ids("site_id") if (n := world.site_name(i))})
|
||||
if sites:
|
||||
lines += ["", "### 相关地点", "", "- " + "、".join(sites)]
|
||||
|
||||
ents = sorted({n for e in chapter.events for i in e.ids("entity_id") if (n := world.entity_name(i))})
|
||||
if ents:
|
||||
lines += ["", "### 相关势力", "", "- " + "、".join(ents)]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def overview(world: World, chapters: list[Chapter], limit: int = 10) -> str:
|
||||
lo, hi = world.event_years()
|
||||
lines = [
|
||||
f"世界:{world.name}" + (f"({world.altname})" if world.altname else ""),
|
||||
(
|
||||
f"历史:{lo}–{hi} 年 | 史料事件 {len(world.events)} 条 | "
|
||||
f"人物 {len(world.figures)} | 势力 {len(world.entities)} | 地点 {len(world.sites)}"
|
||||
),
|
||||
f"规划章节:{len(chapters)} 章(每章约 {YEARS_PER_CHAPTER} 年 / 最多 {EVENTS_PER_CHAPTER} 条史料)",
|
||||
]
|
||||
for c in chapters[:limit]:
|
||||
lines.append(f" 第 {c.index} 章 {c.span}:{len(c.events)} 条({('、'.join(c.categories())) or '无'})")
|
||||
if len(chapters) > limit:
|
||||
lines.append(f" …… 其余 {len(chapters) - limit} 章略")
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user