| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440 |
- from __future__ import annotations
- import json
- import tempfile
- import unittest
- from pathlib import Path
- from unittest.mock import patch
- from langchain_core.messages import AIMessage
- from production_build_agents.capabilities import CAPABILITIES
- from production_build_agents.agents.executor.context import load_task_contract
- from production_build_agents.agents.validator.skills.registry import (
- ValidatorSkillError,
- load_validator_skill,
- )
- from production_build_agents.agents.validator.task_agent import (
- run_validator_agent,
- )
- from production_build_agents.agents.validator.task_context import (
- ValidatorContextError,
- build_validator_user_message,
- )
- from production_build_agents.contracts.evaluation import (
- task_expectation_pairs,
- )
- from production_build_agents.contracts.identifiers import validator_run_id_for
- from production_build_agents.contracts.models import (
- Artifact,
- ArtifactExpectation,
- ArtifactExpectationBinding,
- CandidateArtifact,
- ExecutorCandidate,
- ExecutorDelivery,
- ToolCallRecord,
- ValidationCriterionResult,
- ValidatorCandidate,
- )
- from production_build_agents.tools.registry import ToolRegistry
- from production_build_agents.run.artifacts import content_identity_for_path
- from tests.support.fake_models import ToolAwareFakeChatModel
- from tests.support.planner_fixtures import (
- build_planned_task,
- build_task_package,
- )
- BRIEF_PATH = Path(__file__).parents[1] / "fixtures" / "minimal_brief.json"
- def _task(
- root: Path,
- *,
- skill_id: str = "structured-analysis",
- deliverable_type: str = "structured_data",
- importance: str = "critical",
- ):
- task = build_task_package(
- BRIEF_PATH,
- run_id="validator-test-run",
- planned_task=build_planned_task(
- skill_id=skill_id,
- deliverable_type=deliverable_type,
- ),
- plan_path=root / "plan.json",
- )
- if importance != "critical":
- _, plan, _ = load_task_contract(task)
- requirement = plan.stage_requirements[0].model_copy(
- update={"importance": importance}
- )
- Path(task.plan_uri).write_text(
- plan.model_copy(
- update={"stage_requirements": [requirement]}
- ).model_dump_json(indent=2),
- encoding="utf-8",
- )
- return task
- def _delivery(
- root: Path,
- task,
- *,
- artifact_uri: str | None = None,
- ) -> ExecutorDelivery:
- _, plan, planned_task = load_task_contract(task)
- expectation = task_expectation_pairs(plan, planned_task)[0][1]
- manifest = root / "executor_candidate.json"
- materialized_artifact = root / "delivery_artifact.json"
- media = expectation.artifact_type in {"image", "video", "audio"}
- final_uri = artifact_uri or (
- f"https://example.test/final.{expectation.artifact_type}"
- if media
- else str(materialized_artifact)
- )
- candidate = ExecutorCandidate(
- run_id=task.run_id,
- plan_id=task.plan_id,
- task_id=task.task_id,
- plan_version=task.plan_version,
- deliverable_type=planned_task.deliverable_type,
- payload=(
- {"constraints": ["统一视觉"]}
- if planned_task.deliverable_type == "structured_data"
- else {}
- ),
- artifacts=(
- [
- CandidateArtifact(
- artifact_type=expectation.artifact_type,
- uri=final_uri,
- description="最终测试产物",
- )
- ]
- if media
- else []
- ),
- findings=[],
- unresolved=[],
- summary="已完成当前 Task",
- )
- manifest.write_text(candidate.model_dump_json(), encoding="utf-8")
- if not media:
- materialized_artifact.write_text(
- json.dumps(
- {
- "deliverable_type": planned_task.deliverable_type,
- "payload": candidate.payload,
- "findings": [],
- "summary": candidate.summary,
- },
- ensure_ascii=False,
- ),
- encoding="utf-8",
- )
- artifact_id = (
- f"{task.task_id}-v{task.plan_version}-artifact-1"
- )
- evidence_required = bool(
- set(expectation.verification_capabilities).intersection(
- {
- "source_identity",
- "technical_integrity",
- "visual_content",
- "external_source",
- }
- )
- )
- tool_calls = (
- [
- ToolCallRecord(
- tool_call_id="evidence-call-1",
- tool_name="view_images",
- success=True,
- output_refs=[final_uri],
- )
- ]
- if evidence_required
- else []
- )
- return ExecutorDelivery(
- run_id=task.run_id,
- plan_id=task.plan_id,
- task_id=task.task_id,
- plan_version=task.plan_version,
- executor_run_id=f"executor-{task.task_id}-v{task.plan_version}-stable",
- skill_id=planned_task.skill_id,
- deliverable_type=planned_task.deliverable_type,
- manifest_uri=str(manifest),
- artifacts=[
- Artifact(
- artifact_id=artifact_id,
- artifact_type=expectation.artifact_type,
- uri=final_uri,
- **(
- {}
- if media
- else content_identity_for_path(materialized_artifact)
- ),
- description="最终测试产物",
- )
- ],
- artifact_expectation_bindings=[
- ArtifactExpectationBinding(
- expectation_id=expectation.expectation_id,
- artifact_id=artifact_id,
- evidence_tool_call_ids=(
- ["evidence-call-1"] if evidence_required else []
- ),
- )
- ],
- findings=[],
- unresolved=[],
- tool_calls=tool_calls,
- attempt_count=1,
- summary="已完成当前 Task",
- )
- def _candidate(
- task,
- delivery: ExecutorDelivery,
- *,
- verdict: str = "PASS",
- expectation_id: str | None = None,
- ) -> ValidatorCandidate:
- _, plan, planned_task = load_task_contract(task)
- results = [
- ValidationCriterionResult(
- expectation_id=expectation_id or expectation.expectation_id,
- verification_capability=capability,
- verdict=verdict,
- evidence=[f"{expectation.expectation_id} 的可复查证据"],
- reason=f"{expectation.expectation_id} 检查完成",
- )
- for _, expectation in task_expectation_pairs(plan, planned_task)
- for capability in expectation.verification_capabilities
- ]
- return ValidatorCandidate(
- run_id=task.run_id,
- plan_id=task.plan_id,
- plan_version=task.plan_version,
- task_id=task.task_id,
- executor_run_id=delivery.executor_run_id,
- criterion_results=results,
- summary="逐项检查完成",
- )
- def _model(*candidates: ValidatorCandidate) -> ToolAwareFakeChatModel:
- return ToolAwareFakeChatModel(
- responses=[
- AIMessage(content=candidate.model_dump_json())
- for candidate in candidates
- ]
- )
- class ValidatorSkillTest(unittest.TestCase):
- def test_executor_skill_and_deliverable_map_to_one_validator(self) -> None:
- cases = (
- ("structured-analysis", "structured_data", "structured-validation"),
- ("external-research", "research_result", "research-validation"),
- ("image-production", "image", "image-validation"),
- ("video-production", "video", "video-validation"),
- (
- "reference-inspection",
- "reference_collection",
- "reference-validation",
- ),
- )
- for executor_skill, deliverable, validator_skill in cases:
- with self.subTest(executor_skill=executor_skill):
- skill = load_validator_skill(executor_skill, deliverable)
- self.assertEqual(skill.skill_id, validator_skill)
- def test_invalid_skill_deliverable_pair_is_rejected(self) -> None:
- with self.assertRaises(ValidatorSkillError):
- load_validator_skill("structured-analysis", "image")
- with self.assertRaises(ValidatorSkillError):
- load_validator_skill("unknown-skill", "document")
- class GeneralValidatorTest(unittest.TestCase):
- def test_context_uses_plan_expectations_and_compact_output_contract(
- self,
- ) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- root = Path(temp_dir)
- task = _task(root)
- delivery = _delivery(root, task)
- _, original_plan, _ = load_task_contract(task)
- requirement = original_plan.stage_requirements[0]
- sibling = ArtifactExpectation(
- expectation_id="Requirement1-Expectation2",
- artifact_type="document",
- minimum_count=1,
- usage_scope="兄弟 Task 的说明文档",
- verification_capabilities=["document_content"],
- )
- Path(task.plan_uri).write_text(
- original_plan.model_copy(
- update={
- "stage_requirements": [
- requirement.model_copy(
- update={
- "artifact_expectations": [
- *requirement.artifact_expectations,
- sibling,
- ]
- }
- )
- ]
- }
- ).model_dump_json(indent=2),
- encoding="utf-8",
- )
- brief, plan, planned_task = load_task_contract(task)
- Path(delivery.manifest_uri).unlink()
- message = build_validator_user_message(
- validator_run_id=validator_run_id_for(delivery),
- task=task,
- delivery=delivery,
- brief=brief,
- plan=plan,
- planned_task=planned_task,
- skill=load_validator_skill(
- delivery.skill_id,
- delivery.deliverable_type,
- ),
- tools=[],
- )
- payload = json.loads(message["content"][0]["text"])
- self.assertEqual(
- payload["validation_targets"][0]["expectation_id"],
- "Requirement1-Expectation1",
- )
- self.assertEqual(len(payload["validation_targets"]), 1)
- self.assertNotIn(
- "Requirement1-Expectation2",
- json.dumps(payload, ensure_ascii=False),
- )
- self.assertNotIn("executor_candidate", payload)
- self.assertEqual(
- payload["materialized_artifacts"][0]["content"]["payload"],
- {"constraints": ["统一视觉"]},
- )
- self.assertNotIn("requirements", payload["task_package"])
- self.assertNotIn("acceptance_criteria", payload["task_package"])
- self.assertEqual(
- payload["output_json_schema"],
- ValidatorCandidate.model_json_schema(),
- )
- def test_critical_failure_deterministically_fails_report(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- root = Path(temp_dir)
- task = _task(root)
- delivery = _delivery(root, task)
- report = run_validator_agent(
- task,
- delivery,
- run_dir=root,
- model=_model(_candidate(task, delivery, verdict="FAIL")),
- tool_registry=ToolRegistry({}),
- )
- self.assertEqual(report.verdict, "FAIL")
- def test_minor_failure_does_not_fail_report(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- root = Path(temp_dir)
- task = _task(root, importance="minor")
- delivery = _delivery(root, task)
- report = run_validator_agent(
- task,
- delivery,
- run_dir=root,
- model=_model(_candidate(task, delivery, verdict="FAIL")),
- tool_registry=ToolRegistry({}),
- )
- self.assertEqual(report.verdict, "PASS")
- def test_stale_delivery_is_rejected_before_model_execution(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- root = Path(temp_dir)
- task = _task(root)
- delivery = _delivery(root, task).model_copy(
- update={"plan_version": 2}
- )
- with self.assertRaisesRegex(
- ValidatorContextError,
- "plan_version",
- ):
- run_validator_agent(
- task,
- delivery,
- run_dir=root,
- model=ToolAwareFakeChatModel(
- responses=[AIMessage(content="不应被调用")]
- ),
- tool_registry=ToolRegistry({}),
- )
- def test_missing_structured_artifact_is_rejected_before_model_execution(
- self,
- ) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- root = Path(temp_dir)
- task = _task(root)
- delivery = _delivery(root, task)
- Path(delivery.artifacts[0].uri).unlink()
- model = _model(_candidate(task, delivery))
- with (
- patch.object(
- model,
- "_generate",
- side_effect=AssertionError("Validator 模型不应被调用"),
- ),
- self.assertRaisesRegex(
- ValidatorContextError,
- "正式 Artifact 不存在",
- ),
- ):
- run_validator_agent(
- task,
- delivery,
- run_dir=root,
- model=model,
- tool_registry=ToolRegistry({}),
- )
- def test_invalid_target_retries_inside_same_validator_run(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- root = Path(temp_dir)
- task = _task(root)
- delivery = _delivery(root, task)
- report = run_validator_agent(
- task,
- delivery,
- run_dir=root,
- model=_model(
- _candidate(
- task,
- delivery,
- expectation_id="Requirement9-Expectation1",
- ),
- _candidate(task, delivery),
- ),
- tool_registry=ToolRegistry({}),
- )
- self.assertEqual(report.verdict, "PASS")
- self.assertEqual(
- report.validator_run_id,
- validator_run_id_for(delivery),
- )
- if __name__ == "__main__":
- unittest.main()
|