story_contracts.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. from __future__ import annotations
  4. import json
  5. from dataclasses import dataclass
  6. from pathlib import Path
  7. from typing import Any, Dict, Iterable, List
  8. from chapter_outline_loader import volume_num_for_chapter_from_state
  9. MARKER_BEGIN = "<!-- STORY-SYSTEM:BEGIN -->"
  10. MARKER_END = "<!-- STORY-SYSTEM:END -->"
  11. @dataclass(frozen=True)
  12. class StoryContractPaths:
  13. project_root: Path
  14. @classmethod
  15. def from_project_root(cls, project_root: str | Path) -> "StoryContractPaths":
  16. return cls(Path(project_root).expanduser().resolve())
  17. @property
  18. def root(self) -> Path:
  19. return self.project_root / ".story-system"
  20. @property
  21. def chapters_dir(self) -> Path:
  22. return self.root / "chapters"
  23. @property
  24. def volumes_dir(self) -> Path:
  25. return self.root / "volumes"
  26. @property
  27. def reviews_dir(self) -> Path:
  28. return self.root / "reviews"
  29. @property
  30. def commits_dir(self) -> Path:
  31. return self.root / "commits"
  32. @property
  33. def events_dir(self) -> Path:
  34. return self.root / "events"
  35. @property
  36. def master_json(self) -> Path:
  37. return self.root / "MASTER_SETTING.json"
  38. @property
  39. def anti_patterns_json(self) -> Path:
  40. return self.root / "anti_patterns.json"
  41. def chapter_json(self, chapter: int) -> Path:
  42. return self.chapters_dir / f"chapter_{chapter:03d}.json"
  43. def volume_json(self, volume: int) -> Path:
  44. return self.volumes_dir / f"volume_{volume:03d}.json"
  45. def review_json(self, chapter: int) -> Path:
  46. return self.reviews_dir / f"chapter_{chapter:03d}.review.json"
  47. def commit_json(self, chapter: int) -> Path:
  48. return self.commits_dir / f"chapter_{chapter:03d}.commit.json"
  49. def event_json(self, chapter: int) -> Path:
  50. return self.events_dir / f"chapter_{chapter:03d}.events.json"
  51. def _merge_append_only(master: Dict[str, Any], chapter: Dict[str, Any]) -> Dict[str, List[Any]]:
  52. merged: Dict[str, List[Any]] = {}
  53. for key in set(master) | set(chapter):
  54. seen: List[Any] = []
  55. for source_list in (master.get(key) or [], chapter.get(key) or []):
  56. for item in source_list:
  57. if item not in seen:
  58. seen.append(item)
  59. merged[key] = seen
  60. return merged
  61. def merge_contract_layers(master: Dict[str, Any], chapter: Dict[str, Any] | None) -> Dict[str, Any]:
  62. chapter = chapter or {}
  63. return {
  64. "locked": dict(master.get("locked") or {}),
  65. "append_only": _merge_append_only(
  66. master.get("append_only") or {},
  67. chapter.get("append_only") or {},
  68. ),
  69. "override_allowed": {
  70. **(master.get("override_allowed") or {}),
  71. **(chapter.get("override_allowed") or {}),
  72. },
  73. }
  74. def merge_anti_patterns(*groups: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
  75. seen: set[str] = set()
  76. merged: List[Dict[str, Any]] = []
  77. for group in groups:
  78. for row in group:
  79. text = str(row.get("text") or "").strip()
  80. if not text or text in seen:
  81. continue
  82. seen.add(text)
  83. merged.append(dict(row))
  84. return merged
  85. def read_json_if_exists(path: Path) -> Any | None:
  86. if not path.is_file():
  87. return None
  88. try:
  89. return json.loads(path.read_text(encoding="utf-8"))
  90. except json.JSONDecodeError as exc:
  91. raise ValueError(f"Bad JSON in {path}") from exc
  92. def write_json(path: Path, payload: Any) -> None:
  93. path.parent.mkdir(parents=True, exist_ok=True)
  94. path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  95. def write_marked_markdown(path: Path, generated_block: str) -> None:
  96. wrapped = f"{MARKER_BEGIN}\n{generated_block.rstrip()}\n{MARKER_END}\n"
  97. path.parent.mkdir(parents=True, exist_ok=True)
  98. if path.exists():
  99. current = path.read_text(encoding="utf-8")
  100. if current.count(MARKER_BEGIN) > 1 or current.count(MARKER_END) > 1:
  101. raise ValueError(f"{path} contains multiple STORY-SYSTEM markers")
  102. if MARKER_BEGIN in current and MARKER_END in current:
  103. before, _, rest = current.partition(MARKER_BEGIN)
  104. _, _, after = rest.partition(MARKER_END)
  105. path.write_text(f"{before}{wrapped}{after.lstrip()}", encoding="utf-8")
  106. return
  107. path.write_text(wrapped, encoding="utf-8")
  108. def render_master_markdown(master_payload: Dict[str, Any]) -> str:
  109. route = master_payload.get("route") or {}
  110. constraints = master_payload.get("master_constraints") or {}
  111. return "\n".join(
  112. [
  113. "# MASTER_SETTING",
  114. f"- 题材:{route.get('primary_genre', '')}",
  115. f"- 调性:{constraints.get('core_tone', '')}",
  116. f"- 节奏:{constraints.get('pacing_strategy', '')}",
  117. ]
  118. )
  119. def render_anti_patterns_markdown(anti_patterns: List[Dict[str, Any]]) -> str:
  120. lines = ["# ANTI_PATTERNS"]
  121. for row in anti_patterns:
  122. lines.append(f"- {row.get('text', '')}")
  123. return "\n".join(lines)
  124. def render_chapter_markdown(chapter_payload: Dict[str, Any]) -> str:
  125. focus = (chapter_payload.get("override_allowed") or {}).get("chapter_focus", "")
  126. return "\n".join(
  127. [
  128. f"# CHAPTER_{int(chapter_payload['meta']['chapter']):03d}",
  129. f"- 章节焦点:{focus}",
  130. ]
  131. )
  132. def persist_story_seed(
  133. project_root: Path,
  134. master_payload: Dict[str, Any],
  135. chapter_payload: Dict[str, Any] | None,
  136. anti_patterns: List[Dict[str, Any]],
  137. ) -> None:
  138. paths = StoryContractPaths.from_project_root(project_root)
  139. paths.root.mkdir(parents=True, exist_ok=True)
  140. paths.chapters_dir.mkdir(parents=True, exist_ok=True)
  141. write_json(paths.master_json, master_payload)
  142. write_json(paths.anti_patterns_json, anti_patterns)
  143. write_marked_markdown(paths.master_json.with_suffix(".md"), render_master_markdown(master_payload))
  144. write_marked_markdown(
  145. paths.anti_patterns_json.with_suffix(".md"),
  146. render_anti_patterns_markdown(anti_patterns),
  147. )
  148. if chapter_payload is not None:
  149. chapter_num = int(chapter_payload["meta"]["chapter"])
  150. write_json(paths.chapter_json(chapter_num), chapter_payload)
  151. write_marked_markdown(
  152. paths.chapter_json(chapter_num).with_suffix(".md"),
  153. render_chapter_markdown(chapter_payload),
  154. )
  155. def persist_runtime_contracts(
  156. project_root: Path,
  157. chapter: int,
  158. volume_brief: Dict[str, Any],
  159. review_contract: Dict[str, Any],
  160. ) -> None:
  161. paths = StoryContractPaths.from_project_root(project_root)
  162. volume = volume_num_for_chapter_from_state(paths.project_root, chapter) or 1
  163. paths.volumes_dir.mkdir(parents=True, exist_ok=True)
  164. paths.reviews_dir.mkdir(parents=True, exist_ok=True)
  165. write_json(paths.volume_json(volume), volume_brief)
  166. write_json(paths.review_json(chapter), review_contract)