postcommit.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. from __future__ import annotations
  4. from pathlib import Path
  5. from ..artifact_validator import validate_chapter_commit
  6. from ..config import DataModulesConfig
  7. from ..project_phase import resolve_project_phase
  8. from ..projection_log import latest_projection_run, projection_status_from_run
  9. from . import gate_report, issue
  10. def _commit_path(project_root: Path, chapter: int) -> Path:
  11. return project_root / ".story-system" / "commits" / f"chapter_{chapter:03d}.commit.json"
  12. def _projection_status_from_runtime(
  13. project_root: Path,
  14. chapter: int,
  15. payload: dict,
  16. ) -> tuple[dict[str, str], str, dict]:
  17. try:
  18. latest_run = latest_projection_run(project_root, chapter=chapter)
  19. logged_status = projection_status_from_run(latest_run)
  20. except Exception:
  21. latest_run = None
  22. logged_status = {}
  23. if logged_status:
  24. return logged_status, "projection_log", latest_run or {}
  25. raw_status = payload.get("projection_status") if isinstance(payload, dict) else {}
  26. if isinstance(raw_status, dict):
  27. return {str(key): str(value) for key, value in raw_status.items()}, "commit", {}
  28. return {}, "commit", {}
  29. def run_postcommit_gate(project_root: Path, chapter: int) -> dict:
  30. snapshot = resolve_project_phase(project_root, chapter=chapter)
  31. errors: list[dict] = []
  32. warnings: list[dict] = []
  33. commit_path = _commit_path(project_root, chapter)
  34. commit_report = validate_chapter_commit(commit_path)
  35. for item in commit_report.get("errors") or []:
  36. errors.append(
  37. issue(
  38. f"commit.{item.get('type')}",
  39. message=str(item.get("message") or ""),
  40. path=str(item.get("path") or commit_path),
  41. impact=str(item.get("impact") or ""),
  42. repair=str(item.get("repair") or ""),
  43. details=item,
  44. )
  45. )
  46. payload = commit_report.get("payload") if isinstance(commit_report.get("payload"), dict) else {}
  47. meta = payload.get("meta") if isinstance(payload, dict) else {}
  48. status = str((meta or {}).get("status") or "")
  49. if commit_path.is_file() and status != "accepted":
  50. errors.append(
  51. issue(
  52. "commit_not_accepted",
  53. message=f"chapter commit status is {status or 'missing'}",
  54. path=str(commit_path),
  55. impact="写章充分性闸门要求 accepted commit 才能进入备份和下一章。",
  56. repair="修复 review/fulfillment/disambiguation 阻断项后重新提交。",
  57. )
  58. )
  59. projection_status, projection_source, projection_run = _projection_status_from_runtime(
  60. project_root,
  61. chapter,
  62. payload,
  63. )
  64. if isinstance(projection_status, dict):
  65. for writer, writer_status in projection_status.items():
  66. status_text = str(writer_status)
  67. if projection_source == "projection_log" and status_text.startswith("failed"):
  68. errors.append(
  69. issue(
  70. "projection_failure",
  71. message=f"projection {writer} failed: {status_text}",
  72. path=str(commit_path),
  73. impact="最新 projection_log 显示 read-model 投影失败。",
  74. repair="查看 projection_log.jsonl 的 writers 字段,修复后补跑 projection retry/replay。",
  75. details={"source": projection_source, "run": projection_run},
  76. )
  77. )
  78. elif status_text == "pending":
  79. errors.append(
  80. issue(
  81. "projection_pending",
  82. message=f"projection {writer} is still pending",
  83. path=str(commit_path),
  84. impact="read-model 还没有确认写入完成。",
  85. repair="重新运行 chapter-commit 或后续 projection retry/replay。",
  86. )
  87. )
  88. cfg = DataModulesConfig.from_project_root(project_root)
  89. if isinstance(projection_status, dict) and projection_status.get("summary") == "done":
  90. summary_path = cfg.webnovel_dir / "summaries" / f"ch{chapter:04d}.md"
  91. if not summary_path.is_file():
  92. errors.append(
  93. issue(
  94. "summary_projection_missing",
  95. message="summary projection marked done but file is missing",
  96. path=str(summary_path),
  97. impact="后续上下文无法读取本章摘要。",
  98. repair="补跑 summary projection 或重新执行 chapter-commit。",
  99. )
  100. )
  101. if isinstance(projection_status, dict) and projection_status.get("index") == "done" and not cfg.index_db.is_file():
  102. errors.append(
  103. issue(
  104. "index_projection_missing",
  105. message="index projection marked done but index.db is missing",
  106. path=str(cfg.index_db),
  107. impact="查询、dashboard 和实体关系读模型不可用。",
  108. repair="补跑 index projection 或重新执行 chapter-commit。",
  109. )
  110. )
  111. if isinstance(projection_status, dict) and projection_status.get("memory") == "done" and not cfg.scratchpad_file.is_file():
  112. warnings.append(
  113. issue(
  114. "memory_projection_missing",
  115. message="memory projection marked done but scratchpad is missing",
  116. severity="warning",
  117. path=str(cfg.scratchpad_file),
  118. impact="长期记忆可能未写入。",
  119. repair="检查 memory projection 输出;必要时补跑。",
  120. )
  121. )
  122. return gate_report(
  123. stage="postcommit",
  124. project_root=project_root,
  125. chapter=chapter,
  126. phase=snapshot.phase,
  127. errors=errors,
  128. warnings=warnings,
  129. details={
  130. "phase": snapshot.to_dict(),
  131. "commit_path": str(commit_path),
  132. "commit_report": commit_report,
  133. "projection_source": projection_source,
  134. "projection_run": projection_run,
  135. },
  136. )