test_production_brief.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. from __future__ import annotations
  2. import json
  3. import copy
  4. import tempfile
  5. import unittest
  6. from pathlib import Path
  7. from production_build_agents.contracts.models import IssueKind, IssueSeverity
  8. from production_build_agents.preprocess.production_brief import (
  9. preprocess_file,
  10. preprocess_payload,
  11. )
  12. GOLDEN_DIR = Path(__file__).parents[1] / "golden_case"
  13. INPUT_PATH = (
  14. Path(__file__).resolve().parents[2]
  15. / "容易内耗的人其实需要停止悲伤者叙事#自我(690788)_production_final.json"
  16. )
  17. EXPECTED_BRIEF_PATH = GOLDEN_DIR / "production_brief.json"
  18. EXPECTED_REPORT_PATH = GOLDEN_DIR / "preprocess_report.md"
  19. class GoldenCaseTest(unittest.TestCase):
  20. def test_real_input_matches_golden_outputs(self) -> None:
  21. with tempfile.TemporaryDirectory() as temp_dir:
  22. output_path = Path(temp_dir) / "production_brief.json"
  23. report_path = Path(temp_dir) / "preprocess_report.md"
  24. outcome = preprocess_file(INPUT_PATH, output_path, report_path)
  25. self.assertTrue(outcome.can_proceed)
  26. self.assertEqual(
  27. output_path.read_text(encoding="utf-8"),
  28. EXPECTED_BRIEF_PATH.read_text(encoding="utf-8"),
  29. )
  30. self.assertEqual(
  31. report_path.read_text(encoding="utf-8"),
  32. EXPECTED_REPORT_PATH.read_text(encoding="utf-8"),
  33. )
  34. def test_main_json_contains_no_report_or_ignored_source_fields(self) -> None:
  35. brief = json.loads(EXPECTED_BRIEF_PATH.read_text(encoding="utf-8"))
  36. self.assertEqual(
  37. set(brief),
  38. {
  39. "schema_version",
  40. "帖子类型",
  41. "核心制作点",
  42. "制作表",
  43. "source_assets",
  44. },
  45. )
  46. serialized = EXPECTED_BRIEF_PATH.read_text(encoding="utf-8")
  47. for ignored_name in ("帖子ID", "原始文本", "原始视频路径", "report"):
  48. self.assertNotIn(f'"{ignored_name}"', serialized)
  49. def test_source_assets_cover_and_deduplicate_full_retained_brief(self) -> None:
  50. brief = json.loads(EXPECTED_BRIEF_PATH.read_text(encoding="utf-8"))
  51. source_assets = brief["source_assets"]
  52. self.assertEqual(len(source_assets), 7)
  53. self.assertEqual(
  54. len(
  55. {
  56. (item["artifact_type"], item["source_uri"])
  57. for item in source_assets
  58. }
  59. ),
  60. 7,
  61. )
  62. character = next(
  63. item
  64. for item in source_assets
  65. if item["source_uri"].endswith(
  66. "d20b24da7f704398a15a46b3e3711858.png"
  67. )
  68. )
  69. self.assertEqual(
  70. character["source_paths"],
  71. [
  72. "$.制作表.段落结构[1]",
  73. "$.核心制作点[0]",
  74. "$.核心制作点[14]",
  75. ],
  76. )
  77. self.assertTrue(
  78. any(
  79. "$.制作表.形式结果[47]" in item["source_paths"]
  80. for item in source_assets
  81. )
  82. )
  83. def test_selected_business_structure_only_has_deterministic_normalization(self) -> None:
  84. source = json.loads(INPUT_PATH.read_text(encoding="utf-8"))
  85. brief = json.loads(EXPECTED_BRIEF_PATH.read_text(encoding="utf-8"))
  86. self.assertEqual(brief["帖子类型"], source["帖子类型"])
  87. self.assertEqual(brief["核心制作点"], source["核心制作点"])
  88. self.assertEqual(
  89. brief["制作表"]["段落结构"],
  90. source["制作表"]["段落结构"],
  91. )
  92. self.assertEqual(
  93. brief["制作表"]["形式结果"],
  94. source["制作表"]["形式结果"],
  95. )
  96. expected_relationships = copy.deepcopy(source["制作表"]["关系结果"])
  97. original_text = expected_relationships[0]["global_relationships"][4]["自然语言描述"]
  98. expected_relationships[0]["global_relationships"][4]["自然语言描述"] = (
  99. original_text.replace("16:9", "9:16")
  100. )
  101. self.assertEqual(
  102. brief["制作表"]["关系结果"],
  103. expected_relationships,
  104. )
  105. self.assertNotIn(
  106. "16:9的视频画幅",
  107. EXPECTED_BRIEF_PATH.read_text(encoding="utf-8"),
  108. )
  109. def test_golden_case_reports_only_the_aspect_ratio_conflict(self) -> None:
  110. payload = json.loads(INPUT_PATH.read_text(encoding="utf-8"))
  111. outcome = preprocess_payload(payload)
  112. self.assertEqual(len(outcome.issues), 1)
  113. issue = outcome.issues[0]
  114. self.assertEqual(issue.kind, IssueKind.CONFLICT)
  115. self.assertEqual(issue.severity, IssueSeverity.WARNING)
  116. self.assertEqual([value.value for value in issue.values], ["9:16", "16:9"])
  117. def test_same_input_produces_identical_bytes(self) -> None:
  118. with tempfile.TemporaryDirectory() as temp_dir:
  119. root = Path(temp_dir)
  120. first_json = root / "first.json"
  121. first_md = root / "first.md"
  122. second_json = root / "second.json"
  123. second_md = root / "second.md"
  124. preprocess_file(INPUT_PATH, first_json, first_md)
  125. preprocess_file(INPUT_PATH, second_json, second_md)
  126. self.assertEqual(first_json.read_bytes(), second_json.read_bytes())
  127. self.assertEqual(first_md.read_bytes(), second_md.read_bytes())
  128. def test_time_range_is_normalized_by_code(self) -> None:
  129. payload = {
  130. "帖子类型": "video",
  131. "核心制作点": [],
  132. "制作表": {
  133. "段落结构": [
  134. {
  135. "段落ID": "段落1",
  136. "内容范围": {
  137. "时间轴": ["0:0:1.5 - 0:0:3.25"],
  138. },
  139. }
  140. ],
  141. "形式结果": [],
  142. "关系结果": [],
  143. },
  144. }
  145. outcome = preprocess_payload(payload)
  146. self.assertTrue(outcome.can_proceed)
  147. self.assertEqual(
  148. outcome.brief.production_table.segment_structure[0]["内容范围"]["时间轴"][0],
  149. "00:00:01.500-00:00:03.250",
  150. )
  151. class ContractErrorTest(unittest.TestCase):
  152. def test_critical_error_writes_report_but_refuses_brief(self) -> None:
  153. invalid_payload = {
  154. "帖子类型": "video",
  155. "制作表": {
  156. "段落结构": [],
  157. "形式结果": [],
  158. "关系结果": [],
  159. },
  160. }
  161. with tempfile.TemporaryDirectory() as temp_dir:
  162. root = Path(temp_dir)
  163. input_path = root / "input.json"
  164. output_path = root / "production_brief.json"
  165. report_path = root / "preprocess_report.md"
  166. input_path.write_text(
  167. json.dumps(invalid_payload, ensure_ascii=False),
  168. encoding="utf-8",
  169. )
  170. output_path.write_text("上一次成功结果", encoding="utf-8")
  171. outcome = preprocess_file(input_path, output_path, report_path)
  172. self.assertFalse(outcome.can_proceed)
  173. self.assertFalse(output_path.exists())
  174. self.assertTrue(report_path.exists())
  175. self.assertIn("不可进入 Planner", report_path.read_text(encoding="utf-8"))
  176. def test_conflicting_structured_aspect_ratios_block_planner(self) -> None:
  177. payload = {
  178. "帖子类型": "video",
  179. "核心制作点": [],
  180. "制作表": {
  181. "段落结构": [],
  182. "形式结果": [
  183. {
  184. "段落ID": "段落1",
  185. "属性维度": [
  186. {"维度名": "画幅比例", "维度值": "9:16"},
  187. ],
  188. },
  189. {
  190. "段落ID": "段落2",
  191. "属性维度": [
  192. {"维度名": "画幅比例", "维度值": "16:9"},
  193. ],
  194. },
  195. ],
  196. "关系结果": [],
  197. },
  198. }
  199. outcome = preprocess_payload(payload)
  200. self.assertFalse(outcome.can_proceed)
  201. self.assertTrue(
  202. any(
  203. issue.kind == IssueKind.CONFLICT
  204. and issue.severity == IssueSeverity.ERROR
  205. and issue.source_path == "$.制作表.形式结果"
  206. for issue in outcome.issues
  207. )
  208. )
  209. def test_optional_relationship_results_becomes_null_and_does_not_block(self) -> None:
  210. payload = {
  211. "帖子类型": "video",
  212. "核心制作点": [],
  213. "制作表": {
  214. "段落结构": [],
  215. "形式结果": [],
  216. },
  217. }
  218. outcome = preprocess_payload(payload)
  219. self.assertTrue(outcome.can_proceed)
  220. self.assertIsNone(outcome.brief.production_table.relationship_results)
  221. self.assertTrue(
  222. any(
  223. issue.kind == IssueKind.MISSING_FIELD
  224. and issue.severity == IssueSeverity.WARNING
  225. and issue.source_path == "$.制作表.关系结果"
  226. for issue in outcome.issues
  227. )
  228. )
  229. def test_critical_field_becomes_null_and_blocks(self) -> None:
  230. payload = {
  231. "帖子类型": "video",
  232. "制作表": {
  233. "段落结构": [],
  234. "形式结果": [],
  235. "关系结果": [],
  236. },
  237. }
  238. outcome = preprocess_payload(payload)
  239. self.assertFalse(outcome.can_proceed)
  240. self.assertIsNone(outcome.brief.core_production_points)
  241. self.assertTrue(
  242. any(
  243. issue.kind == IssueKind.MISSING_FIELD
  244. and issue.severity == IssueSeverity.ERROR
  245. and issue.source_path == "$.核心制作点"
  246. for issue in outcome.issues
  247. )
  248. )
  249. def test_ignored_fields_are_never_validated_or_emitted(self) -> None:
  250. payload = {
  251. "帖子ID": {"任意": "错误类型也忽略"},
  252. "原始文本": 123,
  253. "原始视频路径": ["不是", "路径"],
  254. "帖子类型": "video",
  255. "核心制作点": [],
  256. "制作表": {
  257. "段落结构": [],
  258. "形式结果": [],
  259. "关系结果": [],
  260. },
  261. }
  262. outcome = preprocess_payload(payload)
  263. serialized = outcome.brief.model_dump()
  264. self.assertTrue(outcome.can_proceed)
  265. self.assertNotIn("帖子ID", serialized)
  266. self.assertNotIn("原始文本", serialized)
  267. self.assertNotIn("原始视频路径", serialized)
  268. if __name__ == "__main__":
  269. unittest.main()