planner_fixtures.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. from __future__ import annotations
  2. import hashlib
  3. import tempfile
  4. from pathlib import Path
  5. from langchain_core.messages import AIMessage
  6. from production_build_agents.contracts.models import (
  7. ArtifactExpectation,
  8. GlobalDataPlan,
  9. GlobalDataRequirement,
  10. PlannedTask,
  11. TaskPackage,
  12. )
  13. from production_build_agents.agents.planner.agent import materialize_task_package
  14. from tests.support.fake_models import ToolAwareFakeChatModel
  15. GLOBAL_DATA_AUDIT_PATHS = [
  16. "$.帖子类型",
  17. "$.核心制作点[0]",
  18. ]
  19. _PLAN_FIXTURE_DIR = tempfile.TemporaryDirectory(
  20. prefix="production-build-plan-fixtures-"
  21. )
  22. _PLAN_FIXTURE_ROOT = Path(_PLAN_FIXTURE_DIR.name)
  23. def verification_capabilities(artifact_type: str) -> list[str]:
  24. return {
  25. "structured_data": ["document_content"],
  26. "document": ["document_content"],
  27. "research_result": ["external_source", "document_content"],
  28. "reference": ["external_source"],
  29. "image": ["visual_content"],
  30. "video": ["visual_content"],
  31. "audio": ["source_identity", "technical_integrity"],
  32. }[artifact_type]
  33. def build_planned_task(
  34. *,
  35. task_id: str = "Task1",
  36. objective: str = "整理全片需要统一遵循的视觉约束",
  37. reason: str = "后续素材需要共享同一组明确约束",
  38. depends_on: list[str] | None = None,
  39. expectation_ids: list[str] | None = None,
  40. skill_id: str = "structured-analysis",
  41. source_paths: list[str] | None = None,
  42. deliverable_type: str = "structured_data",
  43. priority: int = 10,
  44. max_attempts: int = 2,
  45. ) -> PlannedTask:
  46. return PlannedTask(
  47. task_id=task_id,
  48. objective=objective,
  49. reason=reason,
  50. depends_on=depends_on or [],
  51. expectation_ids=expectation_ids or ["Requirement1-Expectation1"],
  52. replaces_artifact_ids=[],
  53. skill_id=skill_id,
  54. source_paths=source_paths or GLOBAL_DATA_AUDIT_PATHS,
  55. deliverable_type=deliverable_type,
  56. priority=priority,
  57. max_attempts=max_attempts,
  58. )
  59. def build_global_data_plan(
  60. *,
  61. tasks: list[PlannedTask] | None = None,
  62. plan_version: int = 1,
  63. ) -> GlobalDataPlan:
  64. selected_tasks = tasks or [build_planned_task()]
  65. requirement_source_paths = list(
  66. dict.fromkeys(
  67. source_path
  68. for task in selected_tasks
  69. for source_path in task.source_paths
  70. )
  71. )
  72. artifact_types: list[str] = []
  73. task_artifact_types: list[str] = []
  74. for task in selected_tasks:
  75. artifact_type = {
  76. "structured_data": "structured_data",
  77. "document": "document",
  78. "research_result": "research_result",
  79. "image": "image",
  80. "video": "video",
  81. "reference_collection": (
  82. "image"
  83. if task.skill_id == "reference-inspection"
  84. else "reference"
  85. ),
  86. }[task.deliverable_type]
  87. task_artifact_types.append(artifact_type)
  88. if artifact_type not in artifact_types:
  89. artifact_types.append(artifact_type)
  90. selected_tasks = [
  91. task.model_copy(
  92. update={
  93. "expectation_ids": [
  94. "Requirement1-Expectation"
  95. f"{artifact_types.index(artifact_type) + 1}"
  96. ]
  97. }
  98. )
  99. for task, artifact_type in zip(
  100. selected_tasks,
  101. task_artifact_types,
  102. )
  103. ]
  104. return GlobalDataPlan(
  105. plan_id="GlobalDataPlan",
  106. plan_version=plan_version,
  107. goal="准备正式视频制作需要的全局资料",
  108. stage_requirements=[
  109. GlobalDataRequirement(
  110. requirement_id="Requirement1",
  111. description="准备正式生产需要复用的全局资料",
  112. importance="critical",
  113. source_paths=requirement_source_paths,
  114. artifact_expectations=[
  115. ArtifactExpectation(
  116. expectation_id=(
  117. f"Requirement1-Expectation{index}"
  118. ),
  119. artifact_type=artifact_type,
  120. minimum_count=1,
  121. usage_scope="全片正式生产",
  122. verification_capabilities=verification_capabilities(
  123. artifact_type
  124. ),
  125. )
  126. for index, artifact_type in enumerate(
  127. artifact_types,
  128. start=1,
  129. )
  130. ],
  131. )
  132. ],
  133. tasks=selected_tasks,
  134. revision_summary="先完成最必要的全局资料,再进入正式制作。",
  135. )
  136. def build_task_package(
  137. production_brief_path: Path,
  138. *,
  139. run_id: str = "test-run",
  140. planned_task: PlannedTask | None = None,
  141. plan_version: int = 1,
  142. dependency_artifacts: dict[str, str] | None = None,
  143. expectation_artifact_type: str | None = None,
  144. plan_path: Path | None = None,
  145. ) -> TaskPackage:
  146. task = planned_task or build_planned_task()
  147. plan_tasks = [task]
  148. for index, dependency in enumerate(task.depends_on, start=1):
  149. plan_tasks.insert(
  150. index - 1,
  151. build_planned_task(
  152. task_id=dependency,
  153. objective=f"准备 {dependency} 的前置资料",
  154. ),
  155. )
  156. plan = build_global_data_plan(tasks=plan_tasks, plan_version=plan_version)
  157. if expectation_artifact_type is not None:
  158. requirement = plan.stage_requirements[0]
  159. expectation = requirement.artifact_expectations[0].model_copy(
  160. update={
  161. "artifact_type": expectation_artifact_type,
  162. "verification_capabilities": verification_capabilities(
  163. expectation_artifact_type
  164. ),
  165. }
  166. )
  167. plan = plan.model_copy(
  168. update={
  169. "stage_requirements": [
  170. requirement.model_copy(
  171. update={"artifact_expectations": [expectation]}
  172. )
  173. ]
  174. }
  175. )
  176. plan_json = plan.model_dump_json(indent=2)
  177. if plan_path is None:
  178. digest = hashlib.sha256(plan_json.encode("utf-8")).hexdigest()[:16]
  179. plan_path = _PLAN_FIXTURE_ROOT / (
  180. f"{plan.plan_id}.v{plan.plan_version}.{digest}.json"
  181. )
  182. plan_path.parent.mkdir(parents=True, exist_ok=True)
  183. plan_path.write_text(plan_json, encoding="utf-8")
  184. selected_task = next(
  185. item for item in plan.tasks if item.task_id == task.task_id
  186. )
  187. return materialize_task_package(
  188. plan,
  189. selected_task,
  190. run_id=run_id,
  191. production_brief_path=production_brief_path,
  192. plan_path=plan_path,
  193. dependency_artifacts=dependency_artifacts,
  194. )
  195. def build_planner_model(
  196. *plans: GlobalDataPlan,
  197. read_paths: list[str] | None = None,
  198. ) -> ToolAwareFakeChatModel:
  199. selected_paths = (
  200. read_paths
  201. if read_paths is not None
  202. else GLOBAL_DATA_AUDIT_PATHS
  203. )
  204. responses: list[AIMessage] = []
  205. for index, plan in enumerate(plans, start=1):
  206. responses.extend(
  207. [
  208. AIMessage(
  209. content="",
  210. tool_calls=[
  211. {
  212. "name": "read_production_brief",
  213. "args": {"source_paths": selected_paths},
  214. "id": f"planner-read-production-brief-{index}",
  215. "type": "tool_call",
  216. }
  217. ],
  218. ),
  219. AIMessage(content=plan.model_dump_json()),
  220. ]
  221. )
  222. return ToolAwareFakeChatModel(
  223. responses=responses,
  224. )