test_segment_runtime.py 66 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import tempfile
  5. import unittest
  6. from pathlib import Path
  7. from unittest.mock import patch
  8. from langchain_core.messages import AIMessage
  9. from langchain_core.tools import StructuredTool
  10. from PIL import Image
  11. from production_build_agents.agents.segment.run import (
  12. run_segment_production,
  13. )
  14. from production_build_agents.agents.segment.executor import (
  15. SegmentExecutorConfigurationError,
  16. SegmentExecutorOutputError,
  17. _remote_tool_call_limits,
  18. _remote_tool_call_counts,
  19. _seedance_call_count,
  20. _seedance_call_limit,
  21. _resolve_artifacts,
  22. )
  23. from production_build_agents.agents.segment.validator import (
  24. _expected_narration_text,
  25. )
  26. from production_build_agents.agents.segment.provenance import (
  27. SegmentProvenanceError,
  28. _text_recall,
  29. expected_narration_text,
  30. normalize_candidate_lineage,
  31. validate_required_production_trace,
  32. )
  33. from production_build_agents.agents.segment.skills.registry import (
  34. load_segment_executor_skill,
  35. load_segment_validator_skill,
  36. )
  37. from production_build_agents.contracts.models import Artifact, ToolCallRecord
  38. from production_build_agents.contracts.document_identity import (
  39. exact_document_sha256,
  40. )
  41. from production_build_agents.contracts.production_models import (
  42. SEGMENT_VALIDATION_CRITERIA,
  43. ArtifactInputBinding,
  44. ArtifactLineageClaim,
  45. PlannedShot,
  46. ProductionArtifact,
  47. ProductionInput,
  48. SegmentExecutorCandidate,
  49. SegmentValidationCriterionResult,
  50. SegmentValidatorCandidate,
  51. ShotOutputClaim,
  52. TimelineEntry,
  53. )
  54. from production_build_agents.contracts.production_execution_models import (
  55. ImmutableJsonRef,
  56. SegmentPackage,
  57. )
  58. from production_build_agents.run.artifacts import content_identity_for_path
  59. from production_build_agents.run.artifacts import seal_shared_visual_anchor
  60. from production_build_agents.run.layout import segment_validation_report_path
  61. from production_build_agents.tools.publishing import PublishingToolError
  62. from production_build_agents.tools import publishing
  63. from production_build_agents.tools.registry import ToolRegistry
  64. from tests.support.fake_models import ToolAwareFakeChatModel
  65. from tests.support.production_fixtures import output_profile
  66. from tests.support.production_planner_fixtures import (
  67. build_production_planner_scenario,
  68. )
  69. def _production_input() -> ProductionInput:
  70. return ProductionInput(
  71. input_id="ProductionInput-" + "1" * 64,
  72. source_path="$.制作表.段落结构[0]",
  73. content_sha256="2" * 64,
  74. value={"旁白": "测试旁白"},
  75. applies_to_source_segment_ids=["段落1"],
  76. )
  77. def _artifact(
  78. path: Path,
  79. *,
  80. artifact_id: str,
  81. artifact_type: str,
  82. source_uri: str | None = None,
  83. ) -> Artifact:
  84. return Artifact(
  85. artifact_id=artifact_id,
  86. artifact_type=artifact_type,
  87. uri=str(path.resolve()),
  88. source_uri=source_uri,
  89. description=artifact_id,
  90. **content_identity_for_path(path),
  91. )
  92. def _package(root: Path) -> SegmentPackage:
  93. person = root / "person.png"
  94. voice = root / "voice.wav"
  95. Image.new("RGB", (16, 16), "navy").save(person)
  96. voice.write_bytes(b"sealed voice")
  97. anchor = root / "shared-anchor.png"
  98. Image.new("RGB", (16, 16), "white").save(anchor)
  99. production_input = _production_input()
  100. artifacts = [
  101. _artifact(
  102. person,
  103. artifact_id="Task1-v1-artifact-1",
  104. artifact_type="image",
  105. source_uri="https://mutable.test/person.png",
  106. ),
  107. _artifact(
  108. voice,
  109. artifact_id="Task2-v1-artifact-1",
  110. artifact_type="audio",
  111. source_uri="https://mutable.test/voice.wav",
  112. ),
  113. ]
  114. global_delivery = build_production_planner_scenario(
  115. run_id="segment-test-global"
  116. ).global_data_delivery.model_copy(
  117. update={"active_artifacts": artifacts}
  118. )
  119. global_delivery_path = root / "global_data_stage_delivery.json"
  120. global_delivery_path.write_text(
  121. global_delivery.model_dump_json(),
  122. encoding="utf-8",
  123. )
  124. return SegmentPackage(
  125. run_id="segment-test-run",
  126. plan_id="ProductionPlan",
  127. plan_version=1,
  128. segment_id="Segment1",
  129. source_segment_id="段落1",
  130. timeline=TimelineEntry(
  131. segment_id="Segment1",
  132. order=1,
  133. start_ms=0,
  134. end_ms=2000,
  135. ),
  136. production_brief_ref=ImmutableJsonRef(
  137. uri="/global/brief.json",
  138. document_sha256="3" * 64,
  139. ),
  140. global_data_delivery_ref=ImmutableJsonRef(
  141. uri=str(global_delivery_path.resolve()),
  142. document_sha256=exact_document_sha256(
  143. global_delivery_path.read_bytes()
  144. ),
  145. ),
  146. production_input_catalog_ref=ImmutableJsonRef(
  147. uri="/global/catalog.json",
  148. document_sha256="5" * 64,
  149. ),
  150. production_inputs=[production_input],
  151. artifact_inputs=[
  152. ArtifactInputBinding(
  153. artifact_id=artifact.artifact_id,
  154. expectation_ids=[
  155. f"Requirement{index}-Expectation1"
  156. ],
  157. usage="正式输入",
  158. )
  159. for index, artifact in enumerate(artifacts, start=1)
  160. ],
  161. artifacts=artifacts,
  162. output_profile=output_profile(),
  163. shared_visual_anchor=seal_shared_visual_anchor(anchor),
  164. dependencies=[],
  165. shots=[
  166. PlannedShot(
  167. shot_id="Segment1-Shot1",
  168. objective="生成测试镜头",
  169. start_ms=0,
  170. end_ms=2000,
  171. production_input_ids=[production_input.input_id],
  172. artifact_ids=[artifacts[0].artifact_id],
  173. )
  174. ],
  175. )
  176. def _candidate(
  177. root: Path,
  178. package: SegmentPackage,
  179. *,
  180. artifact_root: Path | None = None,
  181. ) -> SegmentExecutorCandidate:
  182. output_root = artifact_root or root / "run"
  183. paths = {
  184. "narration": output_root / "narration.wav",
  185. "shot": output_root / "shot.mp4",
  186. "subtitle": output_root / "subtitle.ass",
  187. "primary": output_root / "primary.mp4",
  188. }
  189. return SegmentExecutorCandidate(
  190. run_id=package.run_id,
  191. plan_id=package.plan_id,
  192. plan_version=package.plan_version,
  193. segment_id=package.segment_id,
  194. artifacts=[
  195. {
  196. "artifact_type": "audio",
  197. "uri": str(paths["narration"].resolve()),
  198. "description": "正式旁白",
  199. },
  200. {
  201. "artifact_type": "video",
  202. "uri": str(paths["shot"].resolve()),
  203. "description": "镜头输出",
  204. },
  205. {
  206. "artifact_type": "document",
  207. "uri": str(paths["subtitle"].resolve()),
  208. "description": "ASS 字幕",
  209. },
  210. {
  211. "artifact_type": "video",
  212. "uri": str(paths["primary"].resolve()),
  213. "description": "最终 Segment",
  214. },
  215. ],
  216. lineage_claims=[
  217. ArtifactLineageClaim(
  218. artifact_uri=str(paths["narration"].resolve()),
  219. input_artifact_uris=[package.artifacts[1].uri],
  220. production_input_ids=[
  221. package.production_inputs[0].input_id
  222. ],
  223. evidence_tool_call_ids=["produce-segment"],
  224. ),
  225. ArtifactLineageClaim(
  226. artifact_uri=str(paths["shot"].resolve()),
  227. input_artifact_uris=[
  228. package.artifacts[0].uri,
  229. package.shared_visual_anchor.uri,
  230. ],
  231. production_input_ids=[
  232. package.production_inputs[0].input_id
  233. ],
  234. evidence_tool_call_ids=["produce-segment"],
  235. ),
  236. ArtifactLineageClaim(
  237. artifact_uri=str(paths["subtitle"].resolve()),
  238. input_artifact_uris=[package.artifacts[0].uri],
  239. production_input_ids=[
  240. package.production_inputs[0].input_id
  241. ],
  242. evidence_tool_call_ids=["produce-segment"],
  243. ),
  244. ArtifactLineageClaim(
  245. artifact_uri=str(paths["primary"].resolve()),
  246. input_artifact_uris=[
  247. str(paths["narration"].resolve()),
  248. str(paths["shot"].resolve()),
  249. str(paths["subtitle"].resolve()),
  250. ],
  251. production_input_ids=[
  252. package.production_inputs[0].input_id
  253. ],
  254. evidence_tool_call_ids=["produce-segment"],
  255. ),
  256. ],
  257. shot_outputs=[
  258. ShotOutputClaim(
  259. shot_id=package.shots[0].shot_id,
  260. artifact_uri=str(paths["shot"].resolve()),
  261. )
  262. ],
  263. narration_artifact_uri=str(paths["narration"].resolve()),
  264. subtitle_artifact_uri=str(paths["subtitle"].resolve()),
  265. primary_artifact_uri=str(paths["primary"].resolve()),
  266. summary="fake Segment 已完成",
  267. )
  268. def _validator_candidate(
  269. package: SegmentPackage,
  270. ) -> SegmentValidatorCandidate:
  271. return SegmentValidatorCandidate(
  272. run_id=package.run_id,
  273. plan_id=package.plan_id,
  274. plan_version=package.plan_version,
  275. segment_id=package.segment_id,
  276. executor_run_id=(
  277. f"{package.run_id}:{package.segment_id}:"
  278. f"v{package.plan_version}:executor"
  279. ),
  280. criterion_results=[
  281. SegmentValidationCriterionResult(
  282. criterion=criterion,
  283. verdict="PASS",
  284. evidence=[f"{criterion} evidence"],
  285. reason=f"{criterion} passed",
  286. )
  287. for criterion in SEGMENT_VALIDATION_CRITERIA
  288. ],
  289. summary="六项验收通过",
  290. )
  291. def _tool_registry(
  292. root: Path,
  293. output_paths: list[Path],
  294. *,
  295. primary_has_audio: bool = True,
  296. narration_transcript: str = "测试旁白",
  297. final_transcript: str = "测试旁白",
  298. subtitle_text: str = "测试旁白",
  299. subtitle_duration: float = 2.0,
  300. subtitle_overlaps: list[int] | None = None,
  301. video_width: int = 1080,
  302. ) -> ToolRegistry:
  303. frame = root / "frame.png"
  304. Image.new("RGB", (16, 16), "white").save(frame)
  305. def produce(sources: list[str]) -> dict:
  306. for path in output_paths:
  307. path.parent.mkdir(parents=True, exist_ok=True)
  308. if path.suffix == ".ass":
  309. path.write_text("subtitle", encoding="utf-8")
  310. else:
  311. path.write_bytes(f"artifact:{path.name}".encode())
  312. return {
  313. "success": True,
  314. "sources": sources,
  315. "local_paths": [str(path.resolve()) for path in output_paths],
  316. }
  317. def probe_media(source: str) -> dict:
  318. suffix = Path(source).suffix
  319. is_audio = suffix == ".wav"
  320. return {
  321. "success": True,
  322. "source": source,
  323. "local_path": source,
  324. "media_type": "audio" if is_audio else "video",
  325. "has_video": not is_audio,
  326. "has_audio": (
  327. is_audio
  328. or (
  329. Path(source).name == "primary.mp4"
  330. and primary_has_audio
  331. )
  332. ),
  333. "duration_sec": 2.0,
  334. "width": None if is_audio else video_width,
  335. "height": None if is_audio else 1920,
  336. }
  337. def transcribe_audio(audio: str, language: str = "zh") -> dict:
  338. return {
  339. "success": True,
  340. "source": audio,
  341. "transcript": (
  342. final_transcript
  343. if Path(audio).name == "primary.mp4"
  344. else narration_transcript
  345. ),
  346. "language": language,
  347. "duration_sec": 2.0,
  348. }
  349. def inspect_ass_subtitles(subtitle: str) -> dict:
  350. return {
  351. "success": True,
  352. "source": subtitle,
  353. "cues": [
  354. {
  355. "start_sec": 0.0,
  356. "end_sec": subtitle_duration,
  357. "text": subtitle_text,
  358. }
  359. ],
  360. "cue_count": 1,
  361. "duration_sec": subtitle_duration,
  362. "overlap_indexes": subtitle_overlaps or [],
  363. }
  364. def extract_frames(video: str, num_frames: int = 5) -> dict:
  365. return {
  366. "success": True,
  367. "frames": [
  368. {
  369. "timestamp_sec": index * 0.4,
  370. "local_path": str(frame.resolve()),
  371. }
  372. for index in range(num_frames)
  373. ],
  374. }
  375. def view_images(image_sources: list[str]) -> dict:
  376. return {
  377. "success": True,
  378. "images": [
  379. {"source": source, "local_path": source}
  380. for source in image_sources
  381. ],
  382. }
  383. tools = {
  384. "video_concat": StructuredTool.from_function(
  385. produce,
  386. name="video_concat",
  387. description="produce fake segment artifacts",
  388. ),
  389. "probe_media": StructuredTool.from_function(
  390. probe_media,
  391. name="probe_media",
  392. description="probe fake media",
  393. ),
  394. "transcribe_audio": StructuredTool.from_function(
  395. transcribe_audio,
  396. name="transcribe_audio",
  397. description="transcribe fake media",
  398. ),
  399. "inspect_ass_subtitles": StructuredTool.from_function(
  400. inspect_ass_subtitles,
  401. name="inspect_ass_subtitles",
  402. description="inspect fake subtitles",
  403. ),
  404. "extract_frames": StructuredTool.from_function(
  405. extract_frames,
  406. name="extract_frames",
  407. description="extract fake frames",
  408. ),
  409. "view_images": StructuredTool.from_function(
  410. view_images,
  411. name="view_images",
  412. description="view fake images",
  413. ),
  414. }
  415. allowed = set(load_segment_executor_skill().allowed_tools) | set(
  416. load_segment_validator_skill().allowed_tools
  417. )
  418. for name in allowed.difference(tools):
  419. tools[name] = StructuredTool.from_function(
  420. lambda: {"success": True},
  421. name=name,
  422. description=f"unused fake {name}",
  423. )
  424. return ToolRegistry(tools)
  425. def _file_hashes(root: Path) -> dict[str, str]:
  426. return {
  427. str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()
  428. for path in sorted(root.rglob("*"))
  429. if path.is_file()
  430. }
  431. class SegmentRuntimeTest(unittest.TestCase):
  432. def test_narration_recall_rejects_missing_sentence_tail(self) -> None:
  433. expected = "今天真的很想和大家分享一个话题,就是如何真正学会自我接纳"
  434. truncated = "今天真的很想和大家分享一个话题,就是如何真正学会"
  435. self.assertLess(_text_recall(expected, truncated), 0.95)
  436. self.assertEqual(_text_recall(expected, expected), 1.0)
  437. def test_candidate_lineage_is_rebuilt_in_artifact_order(self) -> None:
  438. with tempfile.TemporaryDirectory() as temp_dir:
  439. root = Path(temp_dir)
  440. package = _package(root)
  441. candidate = _candidate(root, package)
  442. record = ToolCallRecord(
  443. tool_call_id="deterministic-producer",
  444. operation_id="segment:1",
  445. tool_name="video_concat",
  446. arguments={
  447. "sources": [
  448. package.artifacts[0].uri,
  449. package.shared_visual_anchor.uri,
  450. ]
  451. },
  452. success=True,
  453. output_refs=[
  454. item.uri for item in candidate.artifacts
  455. ],
  456. )
  457. normalized = normalize_candidate_lineage(
  458. candidate.model_copy(
  459. update={
  460. "lineage_claims": list(
  461. reversed(candidate.lineage_claims)
  462. )
  463. }
  464. ),
  465. package=package,
  466. records=[record],
  467. )
  468. self.assertEqual(
  469. [item.artifact_uri for item in normalized.lineage_claims],
  470. [item.uri for item in normalized.artifacts],
  471. )
  472. self.assertTrue(
  473. all(
  474. item.evidence_tool_call_ids
  475. == ["deterministic-producer"]
  476. for item in normalized.lineage_claims
  477. )
  478. )
  479. shot_uri = normalized.shot_outputs[0].artifact_uri
  480. shot_lineage = next(
  481. item
  482. for item in normalized.lineage_claims
  483. if item.artifact_uri == shot_uri
  484. )
  485. self.assertEqual(
  486. shot_lineage.production_input_ids,
  487. package.shots[0].production_input_ids,
  488. )
  489. self.assertIn(
  490. package.artifacts[0].uri,
  491. shot_lineage.input_artifact_uris,
  492. )
  493. def test_candidate_lineage_uses_only_real_tool_inputs(self) -> None:
  494. with tempfile.TemporaryDirectory() as temp_dir:
  495. root = Path(temp_dir)
  496. package = _package(root)
  497. candidate = _candidate(root, package)
  498. narration_uri = candidate.narration_artifact_uri
  499. shot_uri = candidate.shot_outputs[0].artifact_uri
  500. subtitle_uri = candidate.subtitle_artifact_uri
  501. primary_uri = candidate.primary_artifact_uri
  502. records = [
  503. ToolCallRecord(
  504. tool_call_id="tts",
  505. tool_name="synthesize_speech",
  506. arguments={"text": "测试旁白"},
  507. success=True,
  508. output_refs=[narration_uri],
  509. ),
  510. ToolCallRecord(
  511. tool_call_id="shot",
  512. tool_name="make_video",
  513. arguments={
  514. "reference": package.shared_visual_anchor.uri
  515. },
  516. success=True,
  517. output_refs=[shot_uri],
  518. ),
  519. ToolCallRecord(
  520. tool_call_id="subtitle",
  521. tool_name="create_ass_subtitles",
  522. arguments={"text": "测试旁白"},
  523. success=True,
  524. output_refs=[subtitle_uri],
  525. ),
  526. ToolCallRecord(
  527. tool_call_id="primary",
  528. tool_name="video_mux_audio",
  529. arguments={
  530. "video": shot_uri,
  531. "audio": narration_uri,
  532. "subtitle": subtitle_uri,
  533. },
  534. success=True,
  535. output_refs=[primary_uri],
  536. ),
  537. ]
  538. for descriptions in (
  539. ["普通音频", "试听音频"],
  540. ["普通图片", "唯一音色参考"],
  541. ["音色参考", "人声参考"],
  542. ):
  543. with self.subTest(descriptions=descriptions):
  544. changed_package = package.model_copy(
  545. update={
  546. "artifacts": [
  547. artifact.model_copy(
  548. update={"description": description}
  549. )
  550. for artifact, description in zip(
  551. package.artifacts,
  552. descriptions,
  553. )
  554. ]
  555. }
  556. )
  557. normalized = normalize_candidate_lineage(
  558. candidate,
  559. package=changed_package,
  560. records=records,
  561. )
  562. claims = {
  563. item.artifact_uri: item
  564. for item in normalized.lineage_claims
  565. }
  566. self.assertEqual(
  567. claims[narration_uri].input_artifact_uris,
  568. [],
  569. )
  570. self.assertEqual(
  571. claims[subtitle_uri].input_artifact_uris,
  572. [],
  573. )
  574. self.assertEqual(
  575. claims[shot_uri].input_artifact_uris,
  576. [package.shared_visual_anchor.uri],
  577. )
  578. self.assertNotIn(
  579. package.artifacts[0].uri,
  580. claims[shot_uri].input_artifact_uris,
  581. )
  582. self.assertEqual(
  583. claims[primary_uri].input_artifact_uris,
  584. [shot_uri, narration_uri, subtitle_uri],
  585. )
  586. def test_unplanned_bgm_is_optional_but_traced_when_used(self) -> None:
  587. with tempfile.TemporaryDirectory() as temp_dir:
  588. root = Path(temp_dir)
  589. package = _package(root)
  590. candidate = _candidate(root, package)
  591. bgm_path = root / "optional-bgm.wav"
  592. bgm_path.write_bytes(b"optional-bgm")
  593. bgm = Artifact(
  594. artifact_id="Task2-v1-artifact-1",
  595. artifact_type="audio",
  596. uri=str(bgm_path.resolve()),
  597. source_uri=None,
  598. description="Planner 未选择但 Executor 实际使用的 BGM",
  599. **content_identity_for_path(bgm_path),
  600. )
  601. mixed_uri = str((root / "mixed.wav").resolve())
  602. narration_uri = candidate.narration_artifact_uri
  603. shot_uri = candidate.shot_outputs[0].artifact_uri
  604. subtitle_uri = candidate.subtitle_artifact_uri
  605. primary_uri = candidate.primary_artifact_uri
  606. records = [
  607. ToolCallRecord(
  608. tool_call_id="tts",
  609. tool_name="synthesize_speech",
  610. arguments={"text": "测试旁白"},
  611. success=True,
  612. output_refs=[narration_uri],
  613. ),
  614. ToolCallRecord(
  615. tool_call_id="shot",
  616. tool_name="make_video",
  617. arguments={
  618. "reference": package.shared_visual_anchor.uri
  619. },
  620. success=True,
  621. output_refs=[shot_uri],
  622. ),
  623. ToolCallRecord(
  624. tool_call_id="subtitle",
  625. tool_name="create_ass_subtitles",
  626. arguments={"text": "测试旁白"},
  627. success=True,
  628. output_refs=[subtitle_uri],
  629. ),
  630. ToolCallRecord(
  631. tool_call_id="mix",
  632. tool_name="mix_audio_tracks",
  633. arguments={
  634. "tracks": [
  635. {"audio": narration_uri},
  636. {"audio": bgm.uri},
  637. ]
  638. },
  639. success=True,
  640. output_refs=[mixed_uri],
  641. ),
  642. ToolCallRecord(
  643. tool_call_id="primary",
  644. tool_name="video_mux_audio",
  645. arguments={
  646. "video": shot_uri,
  647. "audio": mixed_uri,
  648. "subtitle": subtitle_uri,
  649. },
  650. success=True,
  651. output_refs=[primary_uri],
  652. ),
  653. ]
  654. normalized = normalize_candidate_lineage(
  655. candidate,
  656. package=package,
  657. records=records,
  658. dependency_artifacts=[bgm],
  659. )
  660. mixed = next(
  661. item
  662. for item in normalized.lineage_claims
  663. if item.artifact_uri == mixed_uri
  664. )
  665. self.assertEqual(
  666. mixed.input_artifact_uris,
  667. [narration_uri, bgm.uri],
  668. )
  669. def test_accepted_image_audio_and_video_can_be_actual_inputs(
  670. self,
  671. ) -> None:
  672. for artifact_type, suffix in (
  673. ("image", ".png"),
  674. ("audio", ".wav"),
  675. ("video", ".mp4"),
  676. ):
  677. with (
  678. self.subTest(artifact_type=artifact_type),
  679. tempfile.TemporaryDirectory() as temp_dir,
  680. ):
  681. root = Path(temp_dir)
  682. package = _package(root)
  683. candidate = _candidate(root, package)
  684. upstream_path = root / f"accepted{suffix}"
  685. upstream_path.write_bytes(
  686. f"accepted-{artifact_type}".encode()
  687. )
  688. upstream = ProductionArtifact(
  689. artifact_id="Segment9-v1-artifact-1",
  690. artifact_type=artifact_type,
  691. uri=str(upstream_path.resolve()),
  692. source_uri=None,
  693. description=f"Accepted Segment {artifact_type}",
  694. **content_identity_for_path(upstream_path),
  695. )
  696. record = ToolCallRecord(
  697. tool_call_id=f"use-accepted-{artifact_type}",
  698. tool_name="video_concat",
  699. arguments={"upstream": upstream.uri},
  700. success=True,
  701. output_refs=[
  702. item.uri for item in candidate.artifacts
  703. ],
  704. )
  705. normalized = normalize_candidate_lineage(
  706. candidate,
  707. package=package,
  708. records=[record],
  709. dependency_artifacts=[upstream],
  710. )
  711. shot_uri = normalized.shot_outputs[0].artifact_uri
  712. shot = next(
  713. item
  714. for item in normalized.lineage_claims
  715. if item.artifact_uri == shot_uri
  716. )
  717. self.assertEqual(
  718. shot.input_artifact_uris,
  719. [upstream.uri],
  720. )
  721. def test_failed_publish_alias_cannot_create_lineage(self) -> None:
  722. with tempfile.TemporaryDirectory() as temp_dir:
  723. root = Path(temp_dir)
  724. package = _package(root)
  725. candidate = _candidate(root, package)
  726. remote_reference = "https://failed.test/reference.png"
  727. shot_uri = candidate.shot_outputs[0].artifact_uri
  728. records = [
  729. ToolCallRecord(
  730. tool_call_id="failed-publish",
  731. tool_name="publish_media_reference",
  732. arguments={
  733. "local_path": package.artifacts[0].uri,
  734. },
  735. success=False,
  736. output_refs=[remote_reference],
  737. ),
  738. ToolCallRecord(
  739. tool_call_id="produce-shot",
  740. tool_name="make_video",
  741. arguments={"reference": remote_reference},
  742. success=True,
  743. output_refs=[shot_uri],
  744. ),
  745. ToolCallRecord(
  746. tool_call_id="produce-narration",
  747. tool_name="synthesize_speech",
  748. arguments={"text": "测试旁白"},
  749. success=True,
  750. output_refs=[candidate.narration_artifact_uri],
  751. ),
  752. ToolCallRecord(
  753. tool_call_id="produce-subtitle",
  754. tool_name="create_ass_subtitles",
  755. arguments={"text": "测试旁白"},
  756. success=True,
  757. output_refs=[candidate.subtitle_artifact_uri],
  758. ),
  759. ToolCallRecord(
  760. tool_call_id="produce-primary",
  761. tool_name="video_mux_audio",
  762. arguments={
  763. "video": shot_uri,
  764. "audio": candidate.narration_artifact_uri,
  765. "subtitle": candidate.subtitle_artifact_uri,
  766. },
  767. success=True,
  768. output_refs=[candidate.primary_artifact_uri],
  769. ),
  770. ]
  771. with self.assertRaisesRegex(
  772. SegmentProvenanceError,
  773. "无法绑定到封印素材",
  774. ):
  775. normalize_candidate_lineage(
  776. candidate,
  777. package=package,
  778. records=records,
  779. )
  780. def test_candidate_lineage_uses_latest_successful_producer(self) -> None:
  781. with tempfile.TemporaryDirectory() as temp_dir:
  782. root = Path(temp_dir)
  783. package = _package(root)
  784. candidate = _candidate(root, package)
  785. original = ToolCallRecord(
  786. tool_call_id="original-producer",
  787. tool_name="video_concat",
  788. arguments={"source": package.artifacts[0].uri},
  789. success=True,
  790. output_refs=[item.uri for item in candidate.artifacts],
  791. )
  792. corrected = ToolCallRecord(
  793. tool_call_id="corrected-producer",
  794. tool_name="video_concat",
  795. arguments={"source": package.artifacts[1].uri},
  796. success=True,
  797. output_refs=[candidate.primary_artifact_uri],
  798. )
  799. normalized = normalize_candidate_lineage(
  800. candidate,
  801. package=package,
  802. records=[original, corrected],
  803. )
  804. primary = next(
  805. item
  806. for item in normalized.lineage_claims
  807. if item.artifact_uri == candidate.primary_artifact_uri
  808. )
  809. self.assertEqual(
  810. primary.evidence_tool_call_ids,
  811. ["corrected-producer"],
  812. )
  813. def test_formal_shared_anchor_is_used_as_upstream_not_output(self) -> None:
  814. with tempfile.TemporaryDirectory() as temp_dir:
  815. root = Path(temp_dir)
  816. run_dir = root / "run"
  817. anchor_path = run_dir / "production_inputs" / "anchor.png"
  818. anchor_path.parent.mkdir(parents=True)
  819. Image.new("RGB", (16, 16), "white").save(anchor_path)
  820. package = _package(root).model_copy(
  821. update={
  822. "shared_visual_anchor": seal_shared_visual_anchor(
  823. anchor_path
  824. )
  825. }
  826. )
  827. candidate = _candidate(root, package)
  828. anchor_url = "https://input.test/formal-anchor.png"
  829. records = [
  830. ToolCallRecord(
  831. tool_call_id="publish-anchor",
  832. tool_name="publish_media_reference",
  833. arguments={"local_path": str(anchor_path.resolve())},
  834. success=True,
  835. output_refs=[anchor_url],
  836. ),
  837. ToolCallRecord(
  838. tool_call_id="produce",
  839. tool_name="video_concat",
  840. arguments={"reference_images": [anchor_url]},
  841. success=True,
  842. output_refs=[
  843. item.uri for item in candidate.artifacts
  844. ],
  845. ),
  846. ]
  847. normalized = normalize_candidate_lineage(
  848. candidate,
  849. package=package,
  850. records=records,
  851. )
  852. shot = next(
  853. item
  854. for item in normalized.lineage_claims
  855. if item.artifact_uri
  856. == normalized.shot_outputs[0].artifact_uri
  857. )
  858. self.assertIn(
  859. package.shared_visual_anchor.uri,
  860. shot.input_artifact_uris,
  861. )
  862. self.assertNotIn(
  863. package.shared_visual_anchor.uri,
  864. {item.uri for item in normalized.artifacts},
  865. )
  866. def test_formal_shared_anchor_forbids_per_segment_seedream(self) -> None:
  867. with tempfile.TemporaryDirectory() as temp_dir:
  868. root = Path(temp_dir)
  869. run_dir = root / "run"
  870. anchor_path = run_dir / "production_inputs" / "anchor.png"
  871. anchor_path.parent.mkdir(parents=True)
  872. Image.new("RGB", (16, 16), "white").save(anchor_path)
  873. package = _package(root).model_copy(
  874. update={
  875. "shared_visual_anchor": seal_shared_visual_anchor(
  876. anchor_path
  877. )
  878. }
  879. )
  880. candidate = _candidate(root, package)
  881. records = [
  882. ToolCallRecord(
  883. tool_call_id="forbidden-seedream",
  884. tool_name="run_tool",
  885. remote_tool_id="seedream_generate_image",
  886. success=True,
  887. )
  888. ]
  889. with self.assertRaisesRegex(
  890. SegmentProvenanceError,
  891. "不得各自重新生成",
  892. ):
  893. validate_required_production_trace(
  894. candidate,
  895. package=package,
  896. records=records,
  897. run_dir=run_dir,
  898. )
  899. def test_candidate_uses_trimmed_tts_consumed_by_formal_mix(self) -> None:
  900. with tempfile.TemporaryDirectory() as temp_dir:
  901. root = Path(temp_dir)
  902. package = _package(root)
  903. candidate = _candidate(root, package)
  904. trimmed = str((root / "trimmed.wav").resolve())
  905. records = [
  906. ToolCallRecord(
  907. tool_call_id="base-producer",
  908. tool_name="video_concat",
  909. success=True,
  910. output_refs=[item.uri for item in candidate.artifacts],
  911. ),
  912. ToolCallRecord(
  913. tool_call_id="tts",
  914. tool_name="synthesize_speech",
  915. success=True,
  916. output_refs=[candidate.narration_artifact_uri],
  917. ),
  918. ToolCallRecord(
  919. tool_call_id="trim",
  920. tool_name="audio_trim",
  921. arguments={"audio": candidate.narration_artifact_uri},
  922. success=True,
  923. output_refs=[trimmed],
  924. ),
  925. ToolCallRecord(
  926. tool_call_id="mix",
  927. tool_name="mix_audio_tracks",
  928. arguments={"tracks": [{"audio": trimmed}]},
  929. success=True,
  930. output_refs=[candidate.primary_artifact_uri],
  931. ),
  932. ]
  933. normalized = normalize_candidate_lineage(
  934. candidate,
  935. package=package,
  936. records=records,
  937. )
  938. self.assertEqual(normalized.narration_artifact_uri, trimmed)
  939. self.assertIn(trimmed, {item.uri for item in normalized.artifacts})
  940. def test_trace_rejects_seedance_after_final_render(
  941. self,
  942. ) -> None:
  943. with tempfile.TemporaryDirectory() as temp_dir:
  944. root = Path(temp_dir)
  945. package = _package(root)
  946. candidate = _candidate(root, package)
  947. records = [
  948. ToolCallRecord(
  949. tool_call_id="render",
  950. tool_name="render_ass_subtitles",
  951. success=True,
  952. output_refs=[candidate.primary_artifact_uri],
  953. ),
  954. ToolCallRecord(
  955. tool_call_id="test-submit",
  956. operation_id="segment:2",
  957. tool_name="generate_seedance_video",
  958. success=True,
  959. ),
  960. ]
  961. with self.assertRaisesRegex(
  962. SegmentProvenanceError,
  963. "禁止继续提交",
  964. ):
  965. validate_required_production_trace(
  966. candidate,
  967. package=package,
  968. records=records,
  969. run_dir=root / "run",
  970. )
  971. def test_trace_rejects_narration_asr_mismatch(self) -> None:
  972. with tempfile.TemporaryDirectory() as temp_dir:
  973. root = Path(temp_dir)
  974. run_dir = root / "run"
  975. run_dir.mkdir()
  976. package = _package(root)
  977. package_anchor = run_dir / "shared-anchor.png"
  978. Image.new("RGB", (16, 16), "white").save(package_anchor)
  979. package = package.model_copy(
  980. update={
  981. "shared_visual_anchor": seal_shared_visual_anchor(
  982. package_anchor
  983. )
  984. }
  985. )
  986. candidate = _candidate(root, package)
  987. anchor_uri = str((run_dir / "anchor.png").resolve())
  988. tts_uri = str((run_dir / "tts.wav").resolve())
  989. anchor_url = "https://input.test/anchor.png"
  990. candidate = candidate.model_copy(
  991. update={
  992. "artifacts": [
  993. candidate.artifacts[0].model_copy(
  994. update={
  995. "artifact_type": "image",
  996. "uri": anchor_uri,
  997. }
  998. ),
  999. candidate.artifacts[0].model_copy(
  1000. update={"uri": tts_uri}
  1001. ),
  1002. *candidate.artifacts,
  1003. ],
  1004. "lineage_claims": [
  1005. ArtifactLineageClaim(
  1006. artifact_uri=anchor_uri,
  1007. input_artifact_uris=[
  1008. package.artifacts[0].uri
  1009. ],
  1010. production_input_ids=[
  1011. package.production_inputs[0].input_id
  1012. ],
  1013. evidence_tool_call_ids=["seedream"],
  1014. ),
  1015. ArtifactLineageClaim(
  1016. artifact_uri=tts_uri,
  1017. input_artifact_uris=[
  1018. package.artifacts[1].uri
  1019. ],
  1020. production_input_ids=[
  1021. package.production_inputs[0].input_id
  1022. ],
  1023. evidence_tool_call_ids=["tts"],
  1024. ),
  1025. *[
  1026. (
  1027. claim.model_copy(
  1028. update={
  1029. "input_artifact_uris": [tts_uri]
  1030. }
  1031. )
  1032. if claim.artifact_uri
  1033. == candidate.narration_artifact_uri
  1034. else claim
  1035. )
  1036. for claim in candidate.lineage_claims
  1037. ],
  1038. ],
  1039. }
  1040. )
  1041. records = [
  1042. ToolCallRecord(
  1043. tool_call_id="seedream",
  1044. tool_name="run_tool",
  1045. remote_tool_id="seedream_generate_image",
  1046. success=True,
  1047. arguments={"prompt": "彩铅转绘"},
  1048. artifact_mappings=[
  1049. {
  1050. "source": "https://provider.test/anchor.png",
  1051. "local_path": anchor_uri,
  1052. }
  1053. ],
  1054. output_refs=[anchor_uri],
  1055. ),
  1056. ToolCallRecord(
  1057. tool_call_id="publish-anchor",
  1058. tool_name="publish_media_reference",
  1059. success=True,
  1060. arguments={"local_path": anchor_uri},
  1061. output_refs=[anchor_uri, anchor_url],
  1062. ),
  1063. ToolCallRecord(
  1064. tool_call_id="submit-shot",
  1065. tool_name="generate_seedance_video",
  1066. success=True,
  1067. arguments={"first_frame_url": anchor_url},
  1068. ),
  1069. ToolCallRecord(
  1070. tool_call_id="detect-anchor",
  1071. tool_name="detect_faces",
  1072. success=True,
  1073. arguments={"source": anchor_uri},
  1074. output_excerpt=json.dumps(
  1075. {
  1076. "decision": "NO_FACE",
  1077. "local_path": anchor_uri,
  1078. "input_sha256": "a" * 64,
  1079. }
  1080. ),
  1081. ),
  1082. ToolCallRecord(
  1083. tool_call_id="tts",
  1084. tool_name="synthesize_speech",
  1085. success=True,
  1086. arguments={
  1087. "text": expected_narration_text(package)
  1088. },
  1089. output_refs=[tts_uri],
  1090. ),
  1091. ToolCallRecord(
  1092. tool_call_id="asr",
  1093. tool_name="transcribe_audio",
  1094. success=True,
  1095. arguments={
  1096. "audio": candidate.narration_artifact_uri
  1097. },
  1098. output_excerpt=json.dumps(
  1099. {"transcript": "。"},
  1100. ensure_ascii=False,
  1101. ),
  1102. ),
  1103. ]
  1104. with self.assertRaisesRegex(
  1105. SegmentProvenanceError,
  1106. "ASR 与权威完整旁白不一致",
  1107. ):
  1108. validate_required_production_trace(
  1109. candidate,
  1110. package=package,
  1111. records=records,
  1112. run_dir=run_dir,
  1113. )
  1114. def test_segment_skill_calls_seedance_provider_directly(self) -> None:
  1115. skill = load_segment_executor_skill()
  1116. self.assertIn("generate_seedance_video", skill.allowed_tools)
  1117. self.assertIn("detect_faces", skill.allowed_tools)
  1118. self.assertNotIn(
  1119. "seedance_generate_video",
  1120. skill.allowed_remote_tool_ids,
  1121. )
  1122. self.assertIn("直接调用顶层", skill.instructions)
  1123. self.assertIn("三态结论", skill.instructions)
  1124. def test_segment_remote_budget_is_configurable_per_shot(self) -> None:
  1125. with tempfile.TemporaryDirectory() as temp_dir:
  1126. package = _package(Path(temp_dir))
  1127. second_shot = package.shots[0].model_copy(
  1128. update={
  1129. "shot_id": "Segment1-Shot2",
  1130. "start_ms": 1000,
  1131. "end_ms": 2000,
  1132. }
  1133. )
  1134. package = package.model_copy(
  1135. update={"shots": [package.shots[0], second_shot]}
  1136. )
  1137. with patch.dict(
  1138. "os.environ",
  1139. {
  1140. "SEGMENT_SEEDREAM_MAX_CALLS": "3",
  1141. "SEGMENT_SEEDANCE_MAX_CALLS_PER_SHOT": "6",
  1142. },
  1143. clear=False,
  1144. ):
  1145. limits = _remote_tool_call_limits()
  1146. video_limit = _seedance_call_limit(package)
  1147. self.assertEqual(limits["seedream_generate_image"], 3)
  1148. self.assertEqual(video_limit, 12)
  1149. def test_segment_remote_budget_rejects_invalid_config(self) -> None:
  1150. with tempfile.TemporaryDirectory() as temp_dir:
  1151. package = _package(Path(temp_dir))
  1152. for value in ("0", "-1", "not-an-int"):
  1153. with (
  1154. self.subTest(value=value),
  1155. patch.dict(
  1156. "os.environ",
  1157. {"SEGMENT_SEEDREAM_MAX_CALLS": value},
  1158. clear=False,
  1159. ),
  1160. self.assertRaises(
  1161. SegmentExecutorConfigurationError
  1162. ),
  1163. ):
  1164. _remote_tool_call_limits()
  1165. def test_remote_budget_counts_only_journaled_remote_calls(self) -> None:
  1166. records = [
  1167. ToolCallRecord(
  1168. tool_call_id="paid",
  1169. operation_id="segment:1",
  1170. tool_name="run_tool",
  1171. remote_tool_id="seedance_generate_video",
  1172. success=False,
  1173. ),
  1174. ToolCallRecord(
  1175. tool_call_id="preflight-failure",
  1176. tool_name="run_tool",
  1177. remote_tool_id="seedance_generate_video",
  1178. success=False,
  1179. ),
  1180. ToolCallRecord(
  1181. tool_call_id="local",
  1182. operation_id="segment:2",
  1183. tool_name="video_concat",
  1184. success=True,
  1185. ),
  1186. ]
  1187. self.assertEqual(
  1188. _remote_tool_call_counts(records),
  1189. {"seedance_generate_video": 1},
  1190. )
  1191. def test_video_budget_counts_only_journaled_submissions(self) -> None:
  1192. records = [
  1193. ToolCallRecord(
  1194. tool_call_id="submitted",
  1195. operation_id="segment:1",
  1196. tool_name="generate_seedance_video",
  1197. success=True,
  1198. ),
  1199. ToolCallRecord(
  1200. tool_call_id="preflight-failure",
  1201. tool_name="generate_seedance_video",
  1202. success=False,
  1203. ),
  1204. ]
  1205. self.assertEqual(_seedance_call_count(records), 1)
  1206. def test_expected_narration_merges_adjacent_overlap(self) -> None:
  1207. with tempfile.TemporaryDirectory() as temp_dir:
  1208. package = _package(Path(temp_dir))
  1209. production_input = package.production_inputs[0].model_copy(
  1210. update={
  1211. "value": {
  1212. "子段落": [
  1213. {
  1214. "内容类型": "旁白",
  1215. "内容实质": "就是难道真的就",
  1216. },
  1217. {
  1218. "内容类型": "旁白",
  1219. "内容实质": "就是难道真的就接纳自己",
  1220. },
  1221. {
  1222. "内容类型": "旁白",
  1223. "内容实质": "接纳自己继续行动",
  1224. },
  1225. ]
  1226. }
  1227. }
  1228. )
  1229. package = package.model_copy(
  1230. update={"production_inputs": [production_input]}
  1231. )
  1232. self.assertEqual(
  1233. _expected_narration_text(package),
  1234. "就是难道真的就接纳自己继续行动",
  1235. )
  1236. def test_expected_narration_includes_dialogue_and_deduplicates_tail(self) -> None:
  1237. with tempfile.TemporaryDirectory() as temp_dir:
  1238. package = _package(Path(temp_dir))
  1239. production_input = package.production_inputs[0].model_copy(
  1240. update={
  1241. "value": {
  1242. "子段落": [
  1243. {
  1244. "内容类型": "对话",
  1245. "内容实质": (
  1246. "前半段完整独白,其实你也可以把它当做一个经历,"
  1247. "然后慢慢地铸成内心更强大的自己。"
  1248. ),
  1249. },
  1250. {
  1251. "内容类型": "旁白",
  1252. "内容实质": (
  1253. "其实你也可以把它当做一个经历,"
  1254. "然后慢慢的铸成内心更强大的自己。"
  1255. ),
  1256. },
  1257. ]
  1258. }
  1259. }
  1260. )
  1261. package = package.model_copy(
  1262. update={"production_inputs": [production_input]}
  1263. )
  1264. self.assertEqual(
  1265. _expected_narration_text(package),
  1266. (
  1267. "前半段完整独白,其实你也可以把它当做一个经历,"
  1268. "然后慢慢地铸成内心更强大的自己。"
  1269. ),
  1270. )
  1271. def _models(
  1272. self,
  1273. package: SegmentPackage,
  1274. candidate: SegmentExecutorCandidate,
  1275. *,
  1276. validator_responses: list[AIMessage] | None = None,
  1277. ) -> tuple[ToolAwareFakeChatModel, ToolAwareFakeChatModel]:
  1278. return (
  1279. ToolAwareFakeChatModel(
  1280. responses=[
  1281. AIMessage(
  1282. content="",
  1283. tool_calls=[
  1284. {
  1285. "name": "video_concat",
  1286. "args": {
  1287. "sources": [
  1288. package.artifacts[0].uri,
  1289. package.shared_visual_anchor.uri,
  1290. ]
  1291. },
  1292. "id": "produce-segment",
  1293. "type": "tool_call",
  1294. }
  1295. ],
  1296. ),
  1297. AIMessage(
  1298. content="",
  1299. tool_calls=[
  1300. {
  1301. "name": "video_concat",
  1302. "args": {
  1303. "sources": [
  1304. candidate.shot_outputs[0].artifact_uri,
  1305. candidate.narration_artifact_uri,
  1306. candidate.subtitle_artifact_uri,
  1307. ]
  1308. },
  1309. "id": "assemble-segment",
  1310. "type": "tool_call",
  1311. }
  1312. ],
  1313. ),
  1314. AIMessage(content=candidate.model_dump_json()),
  1315. AIMessage(content=candidate.model_dump_json()),
  1316. AIMessage(content=candidate.model_dump_json()),
  1317. ]
  1318. ),
  1319. ToolAwareFakeChatModel(
  1320. responses=(
  1321. validator_responses
  1322. if validator_responses is not None
  1323. else [
  1324. AIMessage(
  1325. content=_validator_candidate(
  1326. package
  1327. ).model_dump_json()
  1328. )
  1329. ]
  1330. )
  1331. ),
  1332. )
  1333. def test_fake_segment_runs_then_same_thread_is_exact_noop(self) -> None:
  1334. with tempfile.TemporaryDirectory() as temp_dir:
  1335. root = Path(temp_dir)
  1336. package = _package(root)
  1337. candidate = _candidate(root, package)
  1338. output_paths = [Path(item.uri) for item in candidate.artifacts]
  1339. registry = _tool_registry(root, output_paths)
  1340. executor_model, validator_model = self._models(
  1341. package,
  1342. candidate,
  1343. )
  1344. first = run_segment_production(
  1345. package,
  1346. run_dir=root / "run",
  1347. executor_model=executor_model,
  1348. validator_model=validator_model,
  1349. tool_registry=registry,
  1350. )
  1351. self.assertEqual(first.verdict, "PASS")
  1352. before = _file_hashes(root / "run")
  1353. executor_calls = executor_model.i
  1354. validator_calls = validator_model.i
  1355. with patch.object(
  1356. publishing._SESSION,
  1357. "get",
  1358. side_effect=AssertionError("no-op 不得读取旧远程 URL"),
  1359. ):
  1360. second = run_segment_production(
  1361. package,
  1362. run_dir=root / "run",
  1363. executor_model=executor_model,
  1364. validator_model=validator_model,
  1365. tool_registry=registry,
  1366. )
  1367. after = _file_hashes(root / "run")
  1368. self.assertEqual(second, first)
  1369. self.assertEqual(after, before)
  1370. self.assertEqual(executor_model.i, executor_calls)
  1371. self.assertEqual(validator_model.i, validator_calls)
  1372. def test_multi_input_package_only_requires_shot_commitments(self) -> None:
  1373. with tempfile.TemporaryDirectory() as temp_dir:
  1374. root = Path(temp_dir)
  1375. package = _package(root)
  1376. extra_inputs = [
  1377. ProductionInput(
  1378. input_id=f"ProductionInput-{index:064x}",
  1379. source_path=f"$.核心制作点[{index}]",
  1380. content_sha256=f"{index + 1:064x}",
  1381. value={"要求": f"完整输入池中的全局要求{index}"},
  1382. applies_to_source_segment_ids=[
  1383. package.source_segment_id
  1384. ],
  1385. )
  1386. for index in range(2, 7)
  1387. ]
  1388. package = package.model_copy(
  1389. update={
  1390. "production_inputs": [
  1391. *package.production_inputs,
  1392. *extra_inputs,
  1393. ]
  1394. }
  1395. )
  1396. candidate = _candidate(root, package)
  1397. output_paths = [Path(item.uri) for item in candidate.artifacts]
  1398. registry = _tool_registry(root, output_paths)
  1399. executor_model, validator_model = self._models(
  1400. package,
  1401. candidate,
  1402. )
  1403. report = run_segment_production(
  1404. package,
  1405. run_dir=root / "run",
  1406. executor_model=executor_model,
  1407. validator_model=validator_model,
  1408. tool_registry=registry,
  1409. )
  1410. self.assertEqual(report.verdict, "PASS")
  1411. def test_validator_candidate_checkpoint_builds_report_without_tools(
  1412. self,
  1413. ) -> None:
  1414. """Candidate 已封印而 Report 尚未提交时只能确定性补 Report。"""
  1415. with tempfile.TemporaryDirectory() as temp_dir:
  1416. root = Path(temp_dir)
  1417. run_dir = root / "run"
  1418. package = _package(root)
  1419. candidate = _candidate(root, package)
  1420. output_paths = [Path(item.uri) for item in candidate.artifacts]
  1421. registry = _tool_registry(root, output_paths)
  1422. executor_model, validator_model = self._models(
  1423. package,
  1424. candidate,
  1425. )
  1426. first = run_segment_production(
  1427. package,
  1428. run_dir=run_dir,
  1429. executor_model=executor_model,
  1430. validator_model=validator_model,
  1431. tool_registry=registry,
  1432. )
  1433. before = _file_hashes(run_dir)
  1434. report_path = segment_validation_report_path(
  1435. run_dir,
  1436. package.segment_id,
  1437. package.plan_version,
  1438. )
  1439. report_path.unlink()
  1440. with patch(
  1441. "production_build_agents.agents.segment.run."
  1442. "run_segment_validator",
  1443. side_effect=AssertionError(
  1444. "已有 Validator Candidate 不得重调模型或证据工具"
  1445. ),
  1446. ):
  1447. second = run_segment_production(
  1448. package,
  1449. run_dir=run_dir,
  1450. executor_model=executor_model,
  1451. validator_model=validator_model,
  1452. tool_registry=registry,
  1453. )
  1454. self.assertEqual(second, first)
  1455. self.assertEqual(_file_hashes(run_dir), before)
  1456. def test_missing_oss_stops_before_executor_agent(self) -> None:
  1457. with tempfile.TemporaryDirectory() as temp_dir:
  1458. root = Path(temp_dir)
  1459. package = _package(root)
  1460. candidate = _candidate(root, package)
  1461. executor_model, validator_model = self._models(
  1462. package,
  1463. candidate,
  1464. )
  1465. empty_oss = {
  1466. "ALIYUN_OSS_ACCESS_KEY_ID": "",
  1467. "ALIYUN_OSS_ACCESS_KEY_SECRET": "",
  1468. "ALIYUN_OSS_ENDPOINT": "",
  1469. "ALIYUN_OSS_BUCKET": "",
  1470. "ALIYUN_OSS_CDN_BASE_URL": "",
  1471. }
  1472. with (
  1473. patch.dict("os.environ", empty_oss, clear=False),
  1474. patch(
  1475. "production_build_agents.agents.segment.run."
  1476. "run_segment_executor"
  1477. ) as executor,
  1478. self.assertRaises(PublishingToolError),
  1479. ):
  1480. run_segment_production(
  1481. package,
  1482. run_dir=root / "run",
  1483. executor_model=executor_model,
  1484. validator_model=validator_model,
  1485. )
  1486. executor.assert_not_called()
  1487. def test_executor_rejects_formal_artifacts_outside_run_dir(self) -> None:
  1488. with tempfile.TemporaryDirectory() as temp_dir:
  1489. root = Path(temp_dir)
  1490. package = _package(root)
  1491. candidate = _candidate(
  1492. root,
  1493. package,
  1494. artifact_root=root / "outside",
  1495. )
  1496. output_paths = [Path(item.uri) for item in candidate.artifacts]
  1497. for path in output_paths:
  1498. path.parent.mkdir(parents=True, exist_ok=True)
  1499. path.write_bytes(b"outside")
  1500. record = ToolCallRecord(
  1501. tool_call_id="produce-segment",
  1502. tool_name="video_concat",
  1503. success=True,
  1504. output_refs=[str(path.resolve()) for path in output_paths],
  1505. )
  1506. with self.assertRaisesRegex(
  1507. SegmentExecutorOutputError,
  1508. "必须位于当前 Run 目录",
  1509. ):
  1510. _resolve_artifacts(
  1511. candidate,
  1512. package,
  1513. [record],
  1514. run_dir=root / "run",
  1515. )
  1516. def test_completed_noop_rejects_tampered_primary_before_agents(self) -> None:
  1517. with tempfile.TemporaryDirectory() as temp_dir:
  1518. root = Path(temp_dir)
  1519. package = _package(root)
  1520. candidate = _candidate(root, package)
  1521. output_paths = [Path(item.uri) for item in candidate.artifacts]
  1522. registry = _tool_registry(root, output_paths)
  1523. executor_model, validator_model = self._models(
  1524. package,
  1525. candidate,
  1526. )
  1527. run_segment_production(
  1528. package,
  1529. run_dir=root / "run",
  1530. executor_model=executor_model,
  1531. validator_model=validator_model,
  1532. tool_registry=registry,
  1533. )
  1534. Path(candidate.primary_artifact_uri).write_bytes(b"tampered")
  1535. with self.assertRaisesRegex(ValueError, "内容已变化"):
  1536. run_segment_production(
  1537. package,
  1538. run_dir=root / "run",
  1539. executor_model=executor_model,
  1540. validator_model=validator_model,
  1541. tool_registry=registry,
  1542. )
  1543. def test_completed_noop_rejects_rewritten_validator_report(self) -> None:
  1544. with tempfile.TemporaryDirectory() as temp_dir:
  1545. root = Path(temp_dir)
  1546. run_dir = root / "run"
  1547. package = _package(root)
  1548. candidate = _candidate(root, package)
  1549. output_paths = [Path(item.uri) for item in candidate.artifacts]
  1550. registry = _tool_registry(root, output_paths)
  1551. executor_model, validator_model = self._models(
  1552. package,
  1553. candidate,
  1554. )
  1555. run_segment_production(
  1556. package,
  1557. run_dir=run_dir,
  1558. executor_model=executor_model,
  1559. validator_model=validator_model,
  1560. tool_registry=registry,
  1561. )
  1562. report_path = segment_validation_report_path(
  1563. run_dir,
  1564. package.segment_id,
  1565. package.plan_version,
  1566. )
  1567. payload = json.loads(report_path.read_text(encoding="utf-8"))
  1568. payload["criterion_results"][0]["verdict"] = "FAIL"
  1569. payload["verdict"] = "FAIL"
  1570. report_path.write_text(
  1571. json.dumps(payload, ensure_ascii=False),
  1572. encoding="utf-8",
  1573. )
  1574. with self.assertRaisesRegex(
  1575. ValueError,
  1576. "Validator Candidate",
  1577. ):
  1578. run_segment_production(
  1579. package,
  1580. run_dir=run_dir,
  1581. executor_model=executor_model,
  1582. validator_model=validator_model,
  1583. tool_registry=registry,
  1584. )
  1585. def test_missing_final_audio_is_rejected_before_validator_model(self) -> None:
  1586. with tempfile.TemporaryDirectory() as temp_dir:
  1587. root = Path(temp_dir)
  1588. package = _package(root)
  1589. candidate = _candidate(root, package)
  1590. output_paths = [Path(item.uri) for item in candidate.artifacts]
  1591. registry = _tool_registry(
  1592. root,
  1593. output_paths,
  1594. primary_has_audio=False,
  1595. )
  1596. executor_model, validator_model = self._models(
  1597. package,
  1598. candidate,
  1599. validator_responses=[],
  1600. )
  1601. with self.assertRaisesRegex(ValueError, "视频流和音频流"):
  1602. run_segment_production(
  1603. package,
  1604. run_dir=root / "run",
  1605. executor_model=executor_model,
  1606. validator_model=validator_model,
  1607. tool_registry=registry,
  1608. )
  1609. def test_wrong_final_transcript_is_rejected_before_model(self) -> None:
  1610. with tempfile.TemporaryDirectory() as temp_dir:
  1611. root = Path(temp_dir)
  1612. package = _package(root)
  1613. candidate = _candidate(root, package)
  1614. output_paths = [Path(item.uri) for item in candidate.artifacts]
  1615. registry = _tool_registry(
  1616. root,
  1617. output_paths,
  1618. final_transcript="完全错误的旁白",
  1619. )
  1620. executor_model, validator_model = self._models(
  1621. package,
  1622. candidate,
  1623. validator_responses=[],
  1624. )
  1625. with self.assertRaisesRegex(ValueError, "ASR 与正式旁白不一致"):
  1626. run_segment_production(
  1627. package,
  1628. run_dir=root / "run",
  1629. executor_model=executor_model,
  1630. validator_model=validator_model,
  1631. tool_registry=registry,
  1632. )
  1633. def test_consistently_wrong_audio_and_subtitle_cannot_fake_pass(self) -> None:
  1634. with tempfile.TemporaryDirectory() as temp_dir:
  1635. root = Path(temp_dir)
  1636. package = _package(root)
  1637. candidate = _candidate(root, package)
  1638. output_paths = [Path(item.uri) for item in candidate.artifacts]
  1639. registry = _tool_registry(
  1640. root,
  1641. output_paths,
  1642. narration_transcript="完全错误的旁白",
  1643. final_transcript="完全错误的旁白",
  1644. subtitle_text="完全错误的旁白",
  1645. )
  1646. executor_model, validator_model = self._models(
  1647. package,
  1648. candidate,
  1649. validator_responses=[],
  1650. )
  1651. with self.assertRaisesRegex(ValueError, "权威旁白不一致"):
  1652. run_segment_production(
  1653. package,
  1654. run_dir=root / "run",
  1655. executor_model=executor_model,
  1656. validator_model=validator_model,
  1657. tool_registry=registry,
  1658. )
  1659. def test_wrong_shot_resolution_is_rejected_before_model(self) -> None:
  1660. with tempfile.TemporaryDirectory() as temp_dir:
  1661. root = Path(temp_dir)
  1662. package = _package(root)
  1663. candidate = _candidate(root, package)
  1664. output_paths = [Path(item.uri) for item in candidate.artifacts]
  1665. registry = _tool_registry(
  1666. root,
  1667. output_paths,
  1668. video_width=720,
  1669. )
  1670. executor_model, validator_model = self._models(
  1671. package,
  1672. candidate,
  1673. validator_responses=[],
  1674. )
  1675. with self.assertRaisesRegex(ValueError, "1080x1920"):
  1676. run_segment_production(
  1677. package,
  1678. run_dir=root / "run",
  1679. executor_model=executor_model,
  1680. validator_model=validator_model,
  1681. tool_registry=registry,
  1682. )
  1683. def test_wrong_or_overlapping_ass_is_rejected_before_model(self) -> None:
  1684. scenarios = (
  1685. (
  1686. {"subtitle_text": "完全错误字幕"},
  1687. "字幕与正式旁白内容明显不一致",
  1688. ),
  1689. (
  1690. {"subtitle_overlaps": [1]},
  1691. "字幕存在时间重叠",
  1692. ),
  1693. (
  1694. {"subtitle_duration": 2.5},
  1695. "ASS 字幕 时长不匹配",
  1696. ),
  1697. )
  1698. for registry_kwargs, message in scenarios:
  1699. with self.subTest(message=message), tempfile.TemporaryDirectory() as temp_dir:
  1700. root = Path(temp_dir)
  1701. package = _package(root)
  1702. candidate = _candidate(root, package)
  1703. output_paths = [Path(item.uri) for item in candidate.artifacts]
  1704. registry = _tool_registry(
  1705. root,
  1706. output_paths,
  1707. **registry_kwargs,
  1708. )
  1709. executor_model, validator_model = self._models(
  1710. package,
  1711. candidate,
  1712. validator_responses=[],
  1713. )
  1714. with self.assertRaisesRegex(ValueError, message):
  1715. run_segment_production(
  1716. package,
  1717. run_dir=root / "run",
  1718. executor_model=executor_model,
  1719. validator_model=validator_model,
  1720. tool_registry=registry,
  1721. )
  1722. if __name__ == "__main__":
  1723. unittest.main()