project_phase.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. from __future__ import annotations
  4. import json
  5. import re
  6. from dataclasses import dataclass, field
  7. from pathlib import Path
  8. from typing import Any
  9. try:
  10. from chapter_outline_loader import volume_num_for_chapter_from_state
  11. from chapter_paths import find_chapter_file, volume_num_for_chapter
  12. except ImportError: # pragma: no cover
  13. from scripts.chapter_outline_loader import volume_num_for_chapter_from_state
  14. from scripts.chapter_paths import find_chapter_file, volume_num_for_chapter
  15. from .projection_log import latest_projection_run, projection_status_from_run
  16. PHASE_NO_PROJECT = "no_project"
  17. PHASE_UNKNOWN = "unknown"
  18. PHASE_INIT_SCAFFOLDED = "init_scaffolded"
  19. PHASE_INIT_READY = "init_ready"
  20. PHASE_PLAN_IN_PROGRESS = "plan_in_progress"
  21. PHASE_CHAPTER_CONTRACT_READY = "chapter_contract_ready"
  22. PHASE_DRAFT_IN_PROGRESS = "draft_in_progress"
  23. PHASE_READY_TO_COMMIT = "ready_to_commit"
  24. PHASE_CHAPTER_COMMITTED = "chapter_committed"
  25. PHASE_PROJECTION_FAILED = "projection_failed"
  26. PHASES = (
  27. PHASE_NO_PROJECT,
  28. PHASE_UNKNOWN,
  29. PHASE_INIT_SCAFFOLDED,
  30. PHASE_INIT_READY,
  31. PHASE_PLAN_IN_PROGRESS,
  32. PHASE_CHAPTER_CONTRACT_READY,
  33. PHASE_DRAFT_IN_PROGRESS,
  34. PHASE_READY_TO_COMMIT,
  35. PHASE_CHAPTER_COMMITTED,
  36. PHASE_PROJECTION_FAILED,
  37. )
  38. INIT_REQUIRED_DIRS = (
  39. ".webnovel",
  40. ".webnovel/backups",
  41. ".webnovel/archive",
  42. ".webnovel/summaries",
  43. "设定集",
  44. "大纲",
  45. "正文",
  46. "审查报告",
  47. )
  48. INIT_REQUIRED_FILES = (
  49. ".webnovel/state.json",
  50. "设定集/世界观.md",
  51. "设定集/力量体系.md",
  52. "设定集/主角卡.md",
  53. "设定集/反派设计.md",
  54. "大纲/总纲.md",
  55. ".env.example",
  56. )
  57. COMMIT_ARTIFACT_FILES = (
  58. ".webnovel/tmp/review_results.json",
  59. ".webnovel/tmp/fulfillment_result.json",
  60. ".webnovel/tmp/disambiguation_result.json",
  61. ".webnovel/tmp/extraction_result.json",
  62. )
  63. _CHAPTER_FILE_RE = re.compile(r"chapter_(\d{3,4})")
  64. @dataclass(frozen=True)
  65. class ChapterCommitInfo:
  66. chapter: int
  67. status: str
  68. path: str
  69. projection_status: dict[str, str] = field(default_factory=dict)
  70. projection_source: str = "commit"
  71. def to_dict(self) -> dict[str, Any]:
  72. return {
  73. "chapter": self.chapter,
  74. "status": self.status,
  75. "path": self.path,
  76. "projection_status": dict(self.projection_status),
  77. "projection_source": self.projection_source,
  78. }
  79. @dataclass(frozen=True)
  80. class ProjectPhaseSnapshot:
  81. project_root: str
  82. phase: str
  83. target_chapter: int
  84. latest_accepted_chapter: int
  85. latest_commit: ChapterCommitInfo | None = None
  86. state_current_chapter: int = 0
  87. missing_init_files: tuple[str, ...] = ()
  88. missing_init_dirs: tuple[str, ...] = ()
  89. missing_contract_files: tuple[str, ...] = ()
  90. missing_commit_artifacts: tuple[str, ...] = ()
  91. draft_file: str = ""
  92. blocking: tuple[str, ...] = ()
  93. warnings: tuple[str, ...] = ()
  94. def to_dict(self) -> dict[str, Any]:
  95. return {
  96. "project_root": self.project_root,
  97. "phase": self.phase,
  98. "target_chapter": self.target_chapter,
  99. "latest_accepted_chapter": self.latest_accepted_chapter,
  100. "latest_commit": self.latest_commit.to_dict() if self.latest_commit else None,
  101. "state_current_chapter": self.state_current_chapter,
  102. "missing_init_files": list(self.missing_init_files),
  103. "missing_init_dirs": list(self.missing_init_dirs),
  104. "missing_contract_files": list(self.missing_contract_files),
  105. "missing_commit_artifacts": list(self.missing_commit_artifacts),
  106. "draft_file": self.draft_file,
  107. "blocking": list(self.blocking),
  108. "warnings": list(self.warnings),
  109. }
  110. def _read_json_object(path: Path) -> tuple[dict[str, Any], str]:
  111. try:
  112. payload = json.loads(path.read_text(encoding="utf-8"))
  113. except FileNotFoundError:
  114. return {}, "missing"
  115. except json.JSONDecodeError as exc:
  116. return {}, f"invalid_json:{exc}"
  117. except OSError as exc:
  118. return {}, f"read_error:{exc}"
  119. if not isinstance(payload, dict):
  120. return {}, "not_object"
  121. return payload, ""
  122. def _chapter_from_path(path: Path) -> int:
  123. match = _CHAPTER_FILE_RE.search(path.name)
  124. if not match:
  125. return 0
  126. try:
  127. return int(match.group(1))
  128. except ValueError:
  129. return 0
  130. def _state_current_chapter(project_root: Path) -> tuple[int, str]:
  131. state_path = project_root / ".webnovel" / "state.json"
  132. state, error = _read_json_object(state_path)
  133. if error:
  134. return 0, error
  135. progress = state.get("progress") if isinstance(state, dict) else {}
  136. if not isinstance(progress, dict):
  137. return 0, ""
  138. try:
  139. return max(0, int(progress.get("current_chapter") or 0)), ""
  140. except (TypeError, ValueError):
  141. return 0, ""
  142. def _scan_commits(project_root: Path) -> list[ChapterCommitInfo]:
  143. commits_dir = project_root / ".story-system" / "commits"
  144. if not commits_dir.is_dir():
  145. return []
  146. commits: list[ChapterCommitInfo] = []
  147. for path in sorted(commits_dir.glob("chapter_*.commit.json")):
  148. chapter = _chapter_from_path(path)
  149. if chapter <= 0:
  150. continue
  151. payload, error = _read_json_object(path)
  152. meta = payload.get("meta") if isinstance(payload, dict) else {}
  153. if error:
  154. status = "invalid"
  155. elif isinstance(meta, dict):
  156. status = str(meta.get("status") or "missing").strip() or "missing"
  157. else:
  158. status = "missing"
  159. raw_projection = payload.get("projection_status") if isinstance(payload, dict) else {}
  160. projection_status = {
  161. str(key): str(value)
  162. for key, value in (raw_projection or {}).items()
  163. if isinstance(raw_projection, dict)
  164. }
  165. projection_source = "commit"
  166. try:
  167. latest_run = latest_projection_run(project_root, chapter=chapter)
  168. logged_projection_status = projection_status_from_run(latest_run)
  169. except Exception:
  170. logged_projection_status = {}
  171. if logged_projection_status:
  172. projection_status = logged_projection_status
  173. projection_source = "projection_log"
  174. commits.append(
  175. ChapterCommitInfo(
  176. chapter=chapter,
  177. status=status,
  178. path=str(path),
  179. projection_status=projection_status,
  180. projection_source=projection_source,
  181. )
  182. )
  183. return commits
  184. def _latest_story_system_chapter(project_root: Path) -> int:
  185. story_root = project_root / ".story-system"
  186. if not story_root.is_dir():
  187. return 0
  188. chapters: list[int] = []
  189. for pattern in (
  190. "chapters/chapter_*.json",
  191. "reviews/chapter_*.review.json",
  192. "commits/chapter_*.commit.json",
  193. ):
  194. chapters.extend(_chapter_from_path(path) for path in story_root.glob(pattern))
  195. return max(chapters or [0])
  196. def _latest_draft_chapter(project_root: Path) -> int:
  197. chapters_dir = project_root / "正文"
  198. if not chapters_dir.is_dir():
  199. return 0
  200. chapters: list[int] = []
  201. for path in chapters_dir.rglob("第*章*.md"):
  202. match = re.search(r"第0*(\d+)章", path.name)
  203. if not match:
  204. continue
  205. try:
  206. chapters.append(int(match.group(1)))
  207. except ValueError:
  208. continue
  209. return max(chapters or [0])
  210. def _target_chapter(
  211. project_root: Path,
  212. chapter: int | None,
  213. *,
  214. latest_accepted_chapter: int,
  215. ) -> int:
  216. if chapter is not None:
  217. try:
  218. return max(0, int(chapter))
  219. except (TypeError, ValueError):
  220. return 0
  221. latest_runtime = max(
  222. _latest_story_system_chapter(project_root),
  223. _latest_draft_chapter(project_root),
  224. )
  225. if latest_runtime > latest_accepted_chapter:
  226. return latest_runtime
  227. return latest_accepted_chapter + 1 if latest_accepted_chapter >= 0 else 0
  228. def _volume_num(project_root: Path, chapter: int) -> int:
  229. if chapter <= 0:
  230. return 1
  231. try:
  232. return volume_num_for_chapter_from_state(project_root, chapter) or volume_num_for_chapter(chapter)
  233. except Exception:
  234. return volume_num_for_chapter(chapter)
  235. def contract_files_for_chapter(project_root: Path, chapter: int) -> dict[str, Path]:
  236. volume = _volume_num(project_root, chapter)
  237. story_root = project_root / ".story-system"
  238. return {
  239. "master": story_root / "MASTER_SETTING.json",
  240. "volume": story_root / "volumes" / f"volume_{volume:03d}.json",
  241. "chapter": story_root / "chapters" / f"chapter_{chapter:03d}.json",
  242. "review": story_root / "reviews" / f"chapter_{chapter:03d}.review.json",
  243. }
  244. def missing_contract_files(project_root: Path, chapter: int) -> tuple[str, ...]:
  245. if chapter <= 0:
  246. return tuple(str(path.relative_to(project_root)) for path in contract_files_for_chapter(project_root, 1).values())
  247. missing: list[str] = []
  248. for path in contract_files_for_chapter(project_root, chapter).values():
  249. if not path.is_file():
  250. missing.append(str(path.relative_to(project_root)))
  251. return tuple(missing)
  252. def missing_commit_artifacts(project_root: Path) -> tuple[str, ...]:
  253. missing: list[str] = []
  254. for rel in COMMIT_ARTIFACT_FILES:
  255. if not (project_root / rel).is_file():
  256. missing.append(rel)
  257. return tuple(missing)
  258. def missing_init_dirs(project_root: Path) -> tuple[str, ...]:
  259. return tuple(rel for rel in INIT_REQUIRED_DIRS if not (project_root / rel).is_dir())
  260. def missing_init_files(project_root: Path) -> tuple[str, ...]:
  261. return tuple(rel for rel in INIT_REQUIRED_FILES if not (project_root / rel).is_file())
  262. def has_projection_blocker(commit: ChapterCommitInfo | None) -> bool:
  263. if commit is None:
  264. return False
  265. return any(
  266. str(value).startswith("failed:") or str(value) == "pending"
  267. for value in commit.projection_status.values()
  268. )
  269. def resolve_project_phase(project_root: str | Path | None, chapter: int | None = None) -> ProjectPhaseSnapshot:
  270. if project_root is None:
  271. return ProjectPhaseSnapshot(
  272. project_root="",
  273. phase=PHASE_NO_PROJECT,
  274. target_chapter=0,
  275. latest_accepted_chapter=0,
  276. blocking=("project_root_missing",),
  277. )
  278. root = Path(project_root)
  279. state_path = root / ".webnovel" / "state.json"
  280. if not state_path.is_file():
  281. return ProjectPhaseSnapshot(
  282. project_root=str(root),
  283. phase=PHASE_NO_PROJECT,
  284. target_chapter=0,
  285. latest_accepted_chapter=0,
  286. blocking=("missing .webnovel/state.json",),
  287. )
  288. state_chapter, state_error = _state_current_chapter(root)
  289. commits = _scan_commits(root)
  290. latest_commit = max(commits, key=lambda item: item.chapter) if commits else None
  291. accepted = [item.chapter for item in commits if item.status == "accepted"]
  292. latest_accepted = max(accepted or [0])
  293. target = _target_chapter(root, chapter, latest_accepted_chapter=latest_accepted)
  294. init_dirs_missing = missing_init_dirs(root)
  295. init_files_missing = missing_init_files(root)
  296. contract_missing = missing_contract_files(root, target)
  297. artifacts_missing = missing_commit_artifacts(root)
  298. draft_path = find_chapter_file(root, target) if target > 0 else None
  299. draft_file = str(draft_path) if draft_path else ""
  300. warnings: list[str] = []
  301. blocking: list[str] = []
  302. if state_error:
  303. blocking.append(f"state_json_{state_error}")
  304. if state_chapter > latest_accepted:
  305. warnings.append("state_projection_ahead_of_latest_accepted_commit")
  306. if has_projection_blocker(latest_commit):
  307. phase = PHASE_PROJECTION_FAILED
  308. latest_statuses = [str(value) for value in (latest_commit.projection_status or {}).values()]
  309. blocking.append(
  310. "latest_commit_projection_incomplete"
  311. if any(value == "pending" for value in latest_statuses)
  312. else "latest_commit_projection_failed"
  313. )
  314. elif init_dirs_missing or init_files_missing:
  315. phase = PHASE_INIT_SCAFFOLDED
  316. blocking.extend([f"missing_init_dir:{rel}" for rel in init_dirs_missing])
  317. blocking.extend([f"missing_init_file:{rel}" for rel in init_files_missing])
  318. elif latest_commit and latest_commit.chapter >= target and latest_commit.status in {"accepted", "rejected"}:
  319. phase = PHASE_CHAPTER_COMMITTED
  320. elif draft_file and not artifacts_missing:
  321. phase = PHASE_READY_TO_COMMIT
  322. elif draft_file:
  323. phase = PHASE_DRAFT_IN_PROGRESS
  324. elif not contract_missing:
  325. phase = PHASE_CHAPTER_CONTRACT_READY
  326. elif (root / ".story-system" / "MASTER_SETTING.json").is_file() or any((root / "大纲").glob("第*卷*大纲.md")):
  327. phase = PHASE_PLAN_IN_PROGRESS
  328. else:
  329. phase = PHASE_INIT_READY
  330. return ProjectPhaseSnapshot(
  331. project_root=str(root),
  332. phase=phase,
  333. target_chapter=target,
  334. latest_accepted_chapter=latest_accepted,
  335. latest_commit=latest_commit,
  336. state_current_chapter=state_chapter,
  337. missing_init_files=init_files_missing,
  338. missing_init_dirs=init_dirs_missing,
  339. missing_contract_files=contract_missing,
  340. missing_commit_artifacts=artifacts_missing,
  341. draft_file=draft_file,
  342. blocking=tuple(blocking),
  343. warnings=tuple(warnings),
  344. )