state.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. """Thin workflow state.
  2. Search pages, candidates, evidence and final buckets stay in the existing database.
  3. This state only contains orchestration pointers and audit summaries.
  4. """
  5. from __future__ import annotations
  6. from dataclasses import dataclass, field
  7. from typing import Any, Literal, TypedDict
  8. from pydantic import BaseModel, Field, model_validator
  9. from supply_agent.types import AgentResult
  10. Phase = Literal["planning", "searching", "evidence", "evaluating", "done"]
  11. SupervisorAction = Literal["search", "evidence", "evaluator", "finish"]
  12. EndKind = Literal["goal_met", "partial", "no_match", "failed", "stopped"]
  13. class DemandBrief(BaseModel):
  14. """Planner-owned compact interpretation of the immutable raw demand."""
  15. core_intent: str = Field(min_length=1)
  16. target_audience: list[str] = Field(default_factory=list)
  17. forwarding_audience: list[str] = Field(default_factory=list)
  18. key_scenarios: list[str] = Field(default_factory=list)
  19. reference_signals: list[str] = Field(default_factory=list)
  20. relevance_criteria: list[str] = Field(min_length=1)
  21. exclusion_criteria: list[str] = Field(default_factory=list)
  22. temporal_requirements: list[str] = Field(default_factory=list)
  23. class SearchTask(BaseModel):
  24. """One independently executable search task produced by planning."""
  25. task_id: str = Field(min_length=1)
  26. keyword: str = Field(min_length=1)
  27. query_reason: str = Field(min_length=1)
  28. source_type: Literal["demand", "seed", "point", "mixed"] = "mixed"
  29. provider: Literal["internal_keyword", "tikhub"] | None = Field(
  30. default=None,
  31. description="不填则先内部搜索、无结果再 TikHub;internal_keyword 只用内部搜索;tikhub 只用 TikHub。",
  32. )
  33. max_pages: int = Field(default=1, ge=1, le=2)
  34. coverage_targets: list[str] = Field(default_factory=list)
  35. class EvaluationBrief(BaseModel):
  36. """Demand semantics required by evaluators, without the original request."""
  37. relevance_criteria: list[str] = Field(min_length=1)
  38. audience_criteria: list[str] = Field(default_factory=list)
  39. temporal_requirements: list[str] = Field(default_factory=list)
  40. exclusion_criteria: list[str] = Field(default_factory=list)
  41. class ExecutionPlan(BaseModel):
  42. """Validated contract passed from the planner to later stages."""
  43. schema_version: Literal["1.0"] = "1.0"
  44. demand_brief: DemandBrief
  45. search_tasks: list[SearchTask] = Field(min_length=1, max_length=24)
  46. evaluation_brief: EvaluationBrief
  47. @model_validator(mode="after")
  48. def validate_unique_task_ids(self):
  49. task_ids = [item.task_id for item in self.search_tasks]
  50. if len(task_ids) != len(set(task_ids)):
  51. raise ValueError("search_tasks.task_id 不能重复")
  52. return self
  53. class SupervisorDecision(BaseModel):
  54. """One routing decision; subsequent decisions may add search tasks."""
  55. next_action: SupervisorAction
  56. reason: str = ""
  57. worker_count: int = Field(default=4, ge=1, le=8)
  58. evidence_scope: Literal["detail", "portrait", "both"] = "both"
  59. additional_search_tasks: list[SearchTask] = Field(default_factory=list, max_length=6)
  60. class PlanningDecision(SupervisorDecision):
  61. execution_plan: ExecutionPlan
  62. class PlannerAssignment(BaseModel):
  63. run_id: str
  64. round_index: int
  65. raw_demand: str
  66. class SupervisorAssignment(BaseModel):
  67. run_id: str
  68. round_index: int
  69. execution_plan: dict[str, Any]
  70. execution_state: dict[str, Any]
  71. class SearchAssignment(BaseModel):
  72. run_id: str
  73. round_index: int
  74. tasks: list[SearchTask]
  75. class EvidenceAssignment(BaseModel):
  76. run_id: str
  77. round_index: int
  78. candidate_ids: list[int]
  79. evidence_type: Literal["detail", "portrait"]
  80. candidates: list[dict[str, Any]]
  81. class EvaluationAssignment(BaseModel):
  82. run_id: str
  83. round_index: int
  84. candidate_ids: list[int]
  85. evaluation_brief: EvaluationBrief
  86. quality_gate_rules: dict[str, Any]
  87. current_datetime: str = ""
  88. timezone: str = ""
  89. video_understanding_candidate_ids: list[int] = Field(default_factory=list)
  90. candidates: list[dict[str, Any]]
  91. class ReportAssignment(BaseModel):
  92. run_id: str
  93. final_state: dict[str, Any]
  94. @dataclass(frozen=True)
  95. class DiscoverySnapshot:
  96. """Small deterministic projection of the database state."""
  97. status: str
  98. search_count: int
  99. candidate_count: int
  100. pending_count: int
  101. primary_count: int
  102. valid_primary_count: int
  103. rejected_count: int
  104. outcome_status: str = ""
  105. @dataclass(frozen=True)
  106. class NodeRun:
  107. """One node's observable result."""
  108. node: str
  109. round_index: int
  110. content: str
  111. iterations: int
  112. tool_calls_made: int
  113. @classmethod
  114. def from_agent_result(
  115. cls,
  116. node: str,
  117. round_index: int,
  118. result: AgentResult,
  119. ) -> "NodeRun":
  120. return cls(
  121. node=node,
  122. round_index=round_index,
  123. content=result.content or "",
  124. iterations=int(result.iterations or 0),
  125. tool_calls_made=int(result.tool_calls_made or 0),
  126. )
  127. @dataclass
  128. class FindAgentState:
  129. """Control state passed through the single-round graph."""
  130. run_id: str
  131. user_input: str
  132. round_index: int = 0
  133. phase: Phase = "planning"
  134. execution_plan: ExecutionPlan | None = None
  135. stop: bool = False
  136. stop_reason: str = ""
  137. previous_snapshot: DiscoverySnapshot | None = None
  138. snapshot: DiscoverySnapshot | None = None
  139. node_runs: list[NodeRun] = field(default_factory=list)
  140. failures: list[dict[str, Any]] = field(default_factory=list)
  141. class FindAgentGraphState(TypedDict, total=False):
  142. """Serializable state used by the real one-round LangGraph."""
  143. run_id: str
  144. user_input: str
  145. round_index: int
  146. execution_plan: ExecutionPlan | None
  147. phase: Phase
  148. node_runs: list[NodeRun]
  149. snapshot: DiscoverySnapshot | None
  150. supervisor_step: int
  151. approved_action: SupervisorAction
  152. action_count: int
  153. search_actions: int
  154. worker_count: int
  155. evidence_scope: str
  156. decision_history: list[dict[str, Any]]
  157. evaluator_stagnation: int
  158. @dataclass(frozen=True)
  159. class FindAgentResult:
  160. """Workflow result with technical and business status kept separate."""
  161. run_id: str
  162. status: EndKind
  163. succeeded: bool
  164. business_outcome: str
  165. valid_primary_count: int
  166. rounds: int
  167. final_output: str
  168. node_runs: tuple[NodeRun, ...]
  169. stop_reason: str = ""
  170. @property
  171. def iterations(self) -> int:
  172. return sum(item.iterations for item in self.node_runs)
  173. @property
  174. def tool_calls_made(self) -> int:
  175. return sum(item.tool_calls_made for item in self.node_runs)