update_master_outline.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. from __future__ import annotations
  4. import argparse
  5. import json
  6. import sys
  7. from pathlib import Path
  8. from typing import Any
  9. from runtime_compat import enable_windows_utf8_stdio
  10. REQUIRED_VOLUME_ARTIFACTS = (
  11. "第{volume}卷-节拍表.md",
  12. "第{volume}卷-时间线.md",
  13. "第{volume}卷-详细大纲.md",
  14. )
  15. class MasterOutlineSyncError(RuntimeError):
  16. pass
  17. def _read_json(path: Path) -> dict[str, Any]:
  18. try:
  19. payload = json.loads(path.read_text(encoding="utf-8"))
  20. except FileNotFoundError as exc:
  21. raise MasterOutlineSyncError(f"missing writeback file: {path}") from exc
  22. except json.JSONDecodeError as exc:
  23. raise MasterOutlineSyncError(f"invalid writeback JSON: {path}: {exc}") from exc
  24. if not isinstance(payload, dict):
  25. raise MasterOutlineSyncError("writeback JSON must be an object")
  26. return payload
  27. def _require_current_volume_artifacts(project_root: Path, volume: int) -> list[str]:
  28. missing: list[str] = []
  29. outline_dir = project_root / "大纲"
  30. for pattern in REQUIRED_VOLUME_ARTIFACTS:
  31. path = outline_dir / pattern.format(volume=volume)
  32. if not path.is_file() or not path.read_text(encoding="utf-8").strip():
  33. missing.append(path.relative_to(project_root).as_posix())
  34. if missing:
  35. raise MasterOutlineSyncError(
  36. "current volume planning artifacts are incomplete: " + ", ".join(missing)
  37. )
  38. return [f.format(volume=volume) for f in REQUIRED_VOLUME_ARTIFACTS]
  39. def _resolve_writeback_source(
  40. project_root: Path,
  41. outline_dir: Path,
  42. volume: int,
  43. writeback_file: str | Path | None,
  44. ) -> Path:
  45. expected = (outline_dir / f"第{volume}卷-总纲写回.json").resolve()
  46. if writeback_file:
  47. candidate = Path(writeback_file)
  48. if not candidate.is_absolute():
  49. candidate = project_root / candidate
  50. candidate = candidate.resolve()
  51. if candidate != expected:
  52. raise MasterOutlineSyncError(
  53. "writeback source must be the structured planning file: "
  54. f"{expected.relative_to(project_root).as_posix()}"
  55. )
  56. return candidate
  57. return expected
  58. def _cell(value: Any) -> str:
  59. text = "" if value is None else str(value)
  60. return text.replace("\n", " ").replace("|", "/").strip()
  61. def _split_row(line: str) -> list[str]:
  62. return [part.strip() for part in line.strip().strip("|").split("|")]
  63. def _render_row(cells: list[Any]) -> str:
  64. return "| " + " | ".join(_cell(cell) for cell in cells) + " |"
  65. def _normalize_anchor(payload: dict[str, Any], expected_volume: int) -> dict[str, str]:
  66. raw = payload.get("next_volume_anchor")
  67. if not isinstance(raw, dict):
  68. raise MasterOutlineSyncError("writeback JSON missing object field: next_volume_anchor")
  69. volume_value = raw.get("volume") or raw.get("volume_id") or raw.get("卷号") or expected_volume
  70. try:
  71. volume = int(volume_value)
  72. except (TypeError, ValueError) as exc:
  73. raise MasterOutlineSyncError(f"invalid next volume value: {volume_value}") from exc
  74. if volume != expected_volume:
  75. raise MasterOutlineSyncError(f"next_volume_anchor.volume must be {expected_volume}, got {volume}")
  76. name = raw.get("volume_name") or raw.get("name") or raw.get("卷名")
  77. conflict = raw.get("core_conflict") or raw.get("核心冲突")
  78. climax = raw.get("volume_end_climax") or raw.get("end_climax") or raw.get("卷末高潮")
  79. if not all(_cell(v) for v in (name, conflict, climax)):
  80. raise MasterOutlineSyncError(
  81. "next_volume_anchor requires volume_name, core_conflict, and volume_end_climax"
  82. )
  83. return {
  84. "volume": str(expected_volume),
  85. "volume_name": _cell(name),
  86. "core_conflict": _cell(conflict),
  87. "volume_end_climax": _cell(climax),
  88. "chapters_range": _cell(raw.get("chapters_range") or raw.get("章节范围") or ""),
  89. }
  90. def _update_volume_table(text: str, anchor: dict[str, str]) -> tuple[str, bool]:
  91. lines = text.splitlines()
  92. header_idx = next((i for i, line in enumerate(lines) if line.strip().startswith("| 卷号")), None)
  93. new_row = _render_row(
  94. [
  95. anchor["volume"],
  96. anchor["volume_name"],
  97. anchor["chapters_range"],
  98. anchor["core_conflict"],
  99. anchor["volume_end_climax"],
  100. ]
  101. )
  102. if header_idx is None:
  103. addition = [
  104. "",
  105. "## 卷划分",
  106. "| 卷号 | 卷名 | 章节范围 | 核心冲突 | 卷末高潮 |",
  107. "|------|------|----------|----------|----------|",
  108. new_row,
  109. ]
  110. return "\n".join(lines + addition).rstrip() + "\n", True
  111. row_start = header_idx + 2
  112. row_end = row_start
  113. while row_end < len(lines) and lines[row_end].strip().startswith("|"):
  114. row_end += 1
  115. changed = False
  116. for idx in range(row_start, row_end):
  117. cells = _split_row(lines[idx])
  118. if cells and cells[0] == anchor["volume"]:
  119. while len(cells) < 5:
  120. cells.append("")
  121. cells[1] = anchor["volume_name"]
  122. if anchor["chapters_range"]:
  123. cells[2] = anchor["chapters_range"]
  124. cells[3] = anchor["core_conflict"]
  125. cells[4] = anchor["volume_end_climax"]
  126. rendered = _render_row(cells[:5])
  127. changed = rendered != lines[idx]
  128. lines[idx] = rendered
  129. return "\n".join(lines).rstrip() + "\n", changed
  130. lines.insert(row_end, new_row)
  131. return "\n".join(lines).rstrip() + "\n", True
  132. def _structured_writeback_items(payload: dict[str, Any]) -> list[dict[str, str]]:
  133. items: list[dict[str, str]] = []
  134. for field, default_level in (
  135. ("foreshadow_writeback", "伏笔"),
  136. ("open_loop_writeback", "持续开放环"),
  137. ):
  138. raw_items = payload.get(field, [])
  139. if raw_items is None:
  140. continue
  141. if not isinstance(raw_items, list):
  142. raise MasterOutlineSyncError(f"{field} must be a list")
  143. for raw in raw_items:
  144. if not isinstance(raw, dict):
  145. raise MasterOutlineSyncError(f"{field} entries must be objects")
  146. content = raw.get("content") or raw.get("text") or raw.get("伏笔内容")
  147. if not _cell(content):
  148. continue
  149. items.append(
  150. {
  151. "content": _cell(content),
  152. "buried_chapter": _cell(raw.get("buried_chapter") or raw.get("bury_chapter") or raw.get("埋设章") or ""),
  153. "payoff_chapter": _cell(raw.get("payoff_chapter") or raw.get("recover_chapter") or raw.get("回收章") or ""),
  154. "level": _cell(raw.get("level") or raw.get("层级") or default_level),
  155. }
  156. )
  157. return items
  158. def _append_foreshadow_rows(text: str, items: list[dict[str, str]]) -> tuple[str, int]:
  159. if not items:
  160. return text, 0
  161. lines = text.splitlines()
  162. header_idx = next((i for i, line in enumerate(lines) if line.strip().startswith("| 伏笔内容")), None)
  163. if header_idx is None:
  164. lines.extend(
  165. [
  166. "",
  167. "## 伏笔表",
  168. "| 伏笔内容 | 埋设章 | 回收章 | 层级 |",
  169. "|----------|--------|--------|------|",
  170. ]
  171. )
  172. header_idx = len(lines) - 2
  173. row_start = header_idx + 2
  174. row_end = row_start
  175. while row_end < len(lines) and lines[row_end].strip().startswith("|"):
  176. row_end += 1
  177. existing_contents = set()
  178. blank_row_indices: list[int] = []
  179. for idx in range(row_start, row_end):
  180. cells = _split_row(lines[idx])
  181. if cells and any(cell for cell in cells):
  182. existing_contents.add(cells[0])
  183. else:
  184. blank_row_indices.append(idx)
  185. for idx in reversed(blank_row_indices):
  186. del lines[idx]
  187. row_end -= 1
  188. appended = 0
  189. insert_at = row_end
  190. for item in items:
  191. if item["content"] in existing_contents:
  192. continue
  193. lines.insert(
  194. insert_at,
  195. _render_row([item["content"], item["buried_chapter"], item["payoff_chapter"], item["level"]]),
  196. )
  197. insert_at += 1
  198. appended += 1
  199. existing_contents.add(item["content"])
  200. return "\n".join(lines).rstrip() + "\n", appended
  201. def sync_master_outline(
  202. project_root: str | Path,
  203. volume: int,
  204. *,
  205. writeback_file: str | Path | None = None,
  206. ) -> dict[str, Any]:
  207. root = Path(project_root).expanduser().resolve()
  208. if volume < 1:
  209. raise MasterOutlineSyncError("volume must be >= 1")
  210. _require_current_volume_artifacts(root, volume)
  211. outline_dir = root / "大纲"
  212. master_path = outline_dir / "总纲.md"
  213. if not master_path.is_file():
  214. raise MasterOutlineSyncError("missing master outline: 大纲/总纲.md")
  215. source_path = _resolve_writeback_source(root, outline_dir, volume, writeback_file)
  216. payload = _read_json(source_path)
  217. anchor = _normalize_anchor(payload, volume + 1)
  218. structured_items = _structured_writeback_items(payload)
  219. before = master_path.read_text(encoding="utf-8")
  220. after, volume_changed = _update_volume_table(before, anchor)
  221. after, appended_count = _append_foreshadow_rows(after, structured_items)
  222. if after != before:
  223. master_path.write_text(after, encoding="utf-8")
  224. return {
  225. "ok": True,
  226. "master_outline": master_path.relative_to(root).as_posix(),
  227. "writeback_file": source_path.relative_to(root).as_posix(),
  228. "next_volume": volume + 1,
  229. "volume_anchor_written": volume_changed,
  230. "structured_items_appended": appended_count,
  231. "updated": after != before,
  232. }
  233. def main() -> None:
  234. parser = argparse.ArgumentParser(description="Sync minimal next-volume anchors into 大纲/总纲.md")
  235. parser.add_argument("--project-root", required=True)
  236. parser.add_argument("--volume", type=int, required=True, help="当前已完成规划的卷号")
  237. parser.add_argument("--writeback-file", default="", help="显式结构化写回 JSON;默认 大纲/第N卷-总纲写回.json")
  238. parser.add_argument("--format", choices=["json", "text"], default="json")
  239. args = parser.parse_args()
  240. try:
  241. result = sync_master_outline(
  242. args.project_root,
  243. args.volume,
  244. writeback_file=args.writeback_file or None,
  245. )
  246. except MasterOutlineSyncError as exc:
  247. if args.format == "json":
  248. print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2))
  249. else:
  250. print(f"ERROR {exc}", file=sys.stderr)
  251. raise SystemExit(1)
  252. if args.format == "json":
  253. print(json.dumps(result, ensure_ascii=False, indent=2))
  254. else:
  255. print(
  256. "OK master outline synced: "
  257. f"next_volume={result['next_volume']} "
  258. f"structured_items_appended={result['structured_items_appended']}"
  259. )
  260. if __name__ == "__main__":
  261. enable_windows_utf8_stdio(skip_in_pytest=True)
  262. main()