# 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())