Просмотр исходного кода

feat(production): 放宽 Shot 输入覆盖并按真实血缘计算素材使用

- 将 artifact_inputs 和 Shot 素材声明固定为规划建议,不再作为 Executor 的消费白名单

- Segment 主产物只强制覆盖 PlannedShot 明确承诺的 ProductionInput,允许未分配要求留给整片 Validator 判断

- ProductionCoverage 根据 SegmentArtifactGraph 的实际可达输入和素材计算 covered 结果

- 同步 Planner、Executor、Segment Validator Skill 口径并补齐合同与运行时回归测试
SamLee 2 дней назад
Родитель
Сommit
c75e30559a

+ 2 - 0
production_build_agents/agents/production_planner/skills/production-planning/SKILL.md

@@ -31,6 +31,8 @@ Planner 不拆分、合并或新增业务 Segment。
 2. 每个 Segment 的 `production_input_ids` 必须逐项复制
    `production_input_directory.required_input_ids_by_source_segment_id` 中该
    `source_segment_id` 的完整数组;不得凭记忆重新分组、增删或跨段复用 ID;
+   `shots[].production_input_ids` 只选择该镜头真正负责表现的输入,不要求机械覆盖
+   Segment 的完整输入池,也不得为了通过血缘门禁把全局或跨段要求虚挂到镜头;
 3. `artifact_inputs` 只绑定 GlobalDataStageDelivery.active_artifacts,并通过已满足的
    Expectation 说明建议用途;它是创作意图和 Plan 业务输入,不是 Executor 素材
    白名单,也不是素材已经被消费的证明。不要为了让所有已采集素材进入成片而强制

+ 3 - 0
production_build_agents/agents/segment/skills/segment-production/SKILL.md

@@ -7,6 +7,9 @@
 执行规则:
 
 1. 只使用当前 AgentRun 提供并通过校验的 ProductionInput、正式素材目录和镜头。
+   Segment 的完整 ProductionInput 是可参考的业务要求池;每个 Shot 明确选择的
+   ProductionInput 才是该镜头必须实现并进入最终血缘的承诺。不得把未选择的输入
+   为了机械覆盖而虚挂到产物;
    `artifact_inputs` 与 `shot.artifact_ids` 是 Planner 的创作建议,不是素材白名单,
    也不代表已经消费;可以按业务需要选用目录中的其他 Global Data 或已验收
    Segment 素材。实际使用关系必须由成功 ToolCall 的精确输入形成,不得为了满足

+ 3 - 2
production_build_agents/agents/segment/skills/segment-validation/SKILL.md

@@ -11,6 +11,7 @@
 3. 使用 `inspect_ass_subtitles` 读取正式 ASS,检查文本、顺序、时间重叠以及是否
    超出 Segment 时长;
 4. 使用 `extract_frames` 和 `view_images` 检查字幕是否真实烧录、画面是否可见;
-5. 核对最终 Segment 的血缘是否可达计划要求的图片、视频、配音、字幕和
-   ProductionInput;
+5. 核对最终 Segment 的血缘是否可达实际使用的图片、视频、配音、字幕,以及所有
+   PlannedShot 明确承诺的 ProductionInput;Segment 完整输入池中未分配给 Shot 的
+   全局或跨段要求不应被伪造成当前产物血缘;
 6. 本 Skill 没有语音生成、字幕生成、字幕烧录、媒体修改或远端生成权限。

+ 7 - 6
production_build_agents/contracts/production_coverage.py

@@ -5,7 +5,10 @@ from __future__ import annotations
 from collections.abc import Mapping
 
 from .models import GlobalDataStageDelivery
-from .production_evaluation import add_production_issue
+from .production_evaluation import (
+    add_production_issue,
+    required_shot_production_input_ids,
+)
 from .production_execution_models import SegmentPackage
 from .production_models import ProductionContractIssue, SegmentDelivery
 from .production_assembly_models import (
@@ -202,9 +205,7 @@ def build_production_coverage(
         package = segment_packages[segment_id]
         delivery = segment_deliveries.get(segment_id)
         accepted = accepted_by_id[segment_id]
-        required_inputs = [
-            item.input_id for item in package.production_inputs
-        ]
+        required_inputs = required_shot_production_input_ids(package)
         required_artifacts = [
             item.artifact_id for item in package.artifact_inputs
         ]
@@ -259,8 +260,8 @@ def build_production_coverage(
             for input_id in required_inputs
             if input_id in actual_production_input_ids
         ]
-        inputs_covered = set(actual_production_input_ids) == set(
-            required_inputs
+        inputs_covered = set(required_inputs).issubset(
+            actual_production_input_ids
         )
         segment_results.append(
             SegmentCoverage(

+ 7 - 2
production_build_agents/contracts/production_evaluation.py

@@ -601,6 +601,9 @@ def evaluate_segment_artifact_graph(
     package_input_ids = {
         item.input_id for item in package.production_inputs
     }
+    required_shot_input_ids = set(
+        required_shot_production_input_ids(package)
+    )
     if graph.primary_artifact_id not in lineages:
         add_production_issue(
             issues,
@@ -682,11 +685,13 @@ def evaluate_segment_artifact_graph(
             "最终主 Artifact 必须可追溯到正式共享视觉锚点",
             segment_id=package.segment_id,
         )
-    if reachable_production_inputs != package_input_ids:
+    if not required_shot_input_ids.issubset(
+        reachable_production_inputs
+    ):
         add_production_issue(
             issues,
             "lineage_input_coverage_mismatch",
-            "最终主 Artifact 必须可追溯到全部精确 ProductionInput",
+            "最终主 Artifact 必须可追溯到全部 PlannedShot ProductionInput",
             segment_id=package.segment_id,
         )
     return issues

+ 42 - 0
tests/agents/test_segment_runtime.py

@@ -1443,6 +1443,48 @@ class SegmentRuntimeTest(unittest.TestCase):
             self.assertEqual(executor_model.i, executor_calls)
             self.assertEqual(validator_model.i, validator_calls)
 
+    def test_multi_input_package_only_requires_shot_commitments(self) -> None:
+        with tempfile.TemporaryDirectory() as temp_dir:
+            root = Path(temp_dir)
+            package = _package(root)
+            extra_inputs = [
+                ProductionInput(
+                    input_id=f"ProductionInput-{index:064x}",
+                    source_path=f"$.核心制作点[{index}]",
+                    content_sha256=f"{index + 1:064x}",
+                    value={"要求": f"完整输入池中的全局要求{index}"},
+                    applies_to_source_segment_ids=[
+                        package.source_segment_id
+                    ],
+                )
+                for index in range(2, 7)
+            ]
+            package = package.model_copy(
+                update={
+                    "production_inputs": [
+                        *package.production_inputs,
+                        *extra_inputs,
+                    ]
+                }
+            )
+            candidate = _candidate(root, package)
+            output_paths = [Path(item.uri) for item in candidate.artifacts]
+            registry = _tool_registry(root, output_paths)
+            executor_model, validator_model = self._models(
+                package,
+                candidate,
+            )
+
+            report = run_segment_production(
+                package,
+                run_dir=root / "run",
+                executor_model=executor_model,
+                validator_model=validator_model,
+                tool_registry=registry,
+            )
+
+        self.assertEqual(report.verdict, "PASS")
+
     def test_validator_candidate_checkpoint_builds_report_without_tools(
         self,
     ) -> None:

+ 45 - 0
tests/contracts/test_production_coverage.py

@@ -307,6 +307,51 @@ class ProductionCoverageContractTest(unittest.TestCase):
         self.assertIn("Segment1", coverage.reachable_segment_ids)
         self.assertFalse(coverage.satisfied)
 
+    def test_unassigned_package_input_is_not_required_coverage(self) -> None:
+        package = self.package_map["Segment1"]
+        unassigned = package.production_inputs[0].model_copy(
+            update={
+                "input_id": "ProductionInput-" + "f" * 64,
+                "source_path": "$.制作表.关系结果[0]",
+                "content_sha256": "e" * 64,
+                "value": {"关系": "由整片而非当前镜头验收"},
+            }
+        )
+        changed_package = package.model_copy(
+            update={
+                "production_inputs": [
+                    *package.production_inputs,
+                    unassigned,
+                ]
+            }
+        )
+        coverage = build_production_coverage(
+            self.assembly,
+            {**self.package_map, "Segment1": changed_package},
+            self.delivery_map,
+            {
+                segment_artifact_ref_key(artifact)
+                for item in self.assembly.accepted_segments
+                for artifact in (
+                    item.primary_artifact,
+                    item.subtitle_artifact,
+                )
+            },
+            self.global_delivery,
+        )
+
+        segment = coverage.segment_results[0]
+        self.assertEqual(
+            segment.required_production_input_ids,
+            package.shots[0].production_input_ids,
+        )
+        self.assertNotIn(
+            unassigned.input_id,
+            segment.covered_production_input_ids,
+        )
+        self.assertTrue(segment.satisfied)
+        self.assertTrue(coverage.satisfied)
+
 
 
 if __name__ == "__main__":

+ 84 - 1
tests/contracts/test_production_evaluation.py

@@ -339,7 +339,7 @@ class ProductionSegmentOutputContractTest(unittest.TestCase):
             [],
         )
 
-    def test_artifact_graph_rejects_unreachable_planned_input(self) -> None:
+    def test_artifact_graph_allows_unassigned_package_input(self) -> None:
         extra_input = ProductionInput(
             input_id="ProductionInput-" + "f" * 64,
             source_path="$.制作表.形式结果[0]",
@@ -357,6 +357,89 @@ class ProductionSegmentOutputContractTest(unittest.TestCase):
                 ]
             }
         )
+        self.assertEqual(
+            evaluate_segment_artifact_graph(
+                package,
+                self.delivery.artifact_graph,
+            ),
+            [],
+        )
+
+    def test_artifact_graph_allows_extra_reachable_package_input(
+        self,
+    ) -> None:
+        extra_input = ProductionInput(
+            input_id="ProductionInput-" + "f" * 64,
+            source_path="$.制作表.关系结果[0]",
+            content_sha256="e" * 64,
+            value={"关系": "合法但未分配给当前镜头的全局输入"},
+            applies_to_source_segment_ids=[
+                self.package.source_segment_id
+            ],
+        )
+        package = self.package.model_copy(
+            update={
+                "production_inputs": [
+                    *self.package.production_inputs,
+                    extra_input,
+                ]
+            }
+        )
+        primary_id = self.delivery.artifact_graph.primary_artifact_id
+        graph = self.delivery.artifact_graph.model_copy(
+            update={
+                "lineages": [
+                    (
+                        lineage.model_copy(
+                            update={
+                                "production_input_ids": [
+                                    *lineage.production_input_ids,
+                                    extra_input.input_id,
+                                ]
+                            }
+                        )
+                        if lineage.artifact_id == primary_id
+                        else lineage
+                    )
+                    for lineage in self.delivery.artifact_graph.lineages
+                ]
+            }
+        )
+
+        self.assertEqual(
+            evaluate_segment_artifact_graph(package, graph),
+            [],
+        )
+
+    def test_artifact_graph_rejects_unreachable_shot_input(self) -> None:
+        extra_input = ProductionInput(
+            input_id="ProductionInput-" + "f" * 64,
+            source_path="$.制作表.形式结果[0]",
+            content_sha256="e" * 64,
+            value={"形式": "当前镜头明确承诺的输入"},
+            applies_to_source_segment_ids=[
+                self.package.source_segment_id
+            ],
+        )
+        package = self.package.model_copy(
+            update={
+                "production_inputs": [
+                    *self.package.production_inputs,
+                    extra_input,
+                ],
+                "shots": [
+                    self.package.shots[0].model_copy(
+                        update={
+                            "production_input_ids": [
+                                *self.package.shots[0].production_input_ids,
+                                extra_input.input_id,
+                            ]
+                        }
+                    )
+                ],
+            }
+        )
+
         self.assertIn(
             "lineage_input_coverage_mismatch",
             _issue_codes(