state.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  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"] = "internal_keyword"
  30. max_pages: int = Field(default=1, ge=1, le=2)
  31. coverage_targets: list[str] = Field(default_factory=list)
  32. class EvaluationBrief(BaseModel):
  33. """Demand semantics required by evaluators, without the original request."""
  34. relevance_criteria: list[str] = Field(min_length=1)
  35. audience_criteria: list[str] = Field(default_factory=list)
  36. temporal_requirements: list[str] = Field(default_factory=list)
  37. exclusion_criteria: list[str] = Field(default_factory=list)
  38. class ExecutionPlan(BaseModel):
  39. """Validated contract passed from the planner to later stages."""
  40. schema_version: Literal["1.0"] = "1.0"
  41. demand_brief: DemandBrief
  42. search_tasks: list[SearchTask] = Field(min_length=1, max_length=24)
  43. evaluation_brief: EvaluationBrief
  44. @model_validator(mode="after")
  45. def validate_unique_task_ids(self):
  46. task_ids = [item.task_id for item in self.search_tasks]
  47. if len(task_ids) != len(set(task_ids)):
  48. raise ValueError("search_tasks.task_id 不能重复")
  49. return self
  50. class SupervisorDecision(BaseModel):
  51. """One routing decision; subsequent decisions may add search tasks."""
  52. next_action: SupervisorAction
  53. reason: str = ""
  54. worker_count: int = Field(default=4, ge=1, le=8)
  55. evidence_scope: Literal["detail", "portrait", "both"] = "both"
  56. additional_search_tasks: list[SearchTask] = Field(default_factory=list, max_length=6)
  57. class PlanningDecision(SupervisorDecision):
  58. execution_plan: ExecutionPlan
  59. class PlannerAssignment(BaseModel):
  60. run_id: str
  61. round_index: int
  62. raw_demand: str
  63. class SupervisorAssignment(BaseModel):
  64. run_id: str
  65. round_index: int
  66. execution_plan: dict[str, Any]
  67. execution_state: dict[str, Any]
  68. class SearchAssignment(BaseModel):
  69. run_id: str
  70. round_index: int
  71. tasks: list[SearchTask]
  72. class EvidenceAssignment(BaseModel):
  73. run_id: str
  74. round_index: int
  75. candidate_ids: list[int]
  76. evidence_type: Literal["detail", "portrait"]
  77. candidates: list[dict[str, Any]]
  78. class EvaluationAssignment(BaseModel):
  79. run_id: str
  80. round_index: int
  81. candidate_ids: list[int]
  82. evaluation_brief: EvaluationBrief
  83. quality_gate_rules: dict[str, Any]
  84. current_datetime: str = ""
  85. timezone: str = ""
  86. video_understanding_candidate_ids: list[int] = Field(default_factory=list)
  87. candidates: list[dict[str, Any]]
  88. class ReportAssignment(BaseModel):
  89. run_id: str
  90. final_state: dict[str, Any]
  91. @dataclass(frozen=True)
  92. class DiscoverySnapshot:
  93. """Small deterministic projection of the database state."""
  94. status: str
  95. search_count: int
  96. candidate_count: int
  97. pending_count: int
  98. primary_count: int
  99. valid_primary_count: int
  100. rejected_count: int
  101. outcome_status: str = ""
  102. @dataclass(frozen=True)
  103. class NodeRun:
  104. """One node's observable result."""
  105. node: str
  106. round_index: int
  107. content: str
  108. iterations: int
  109. tool_calls_made: int
  110. @classmethod
  111. def from_agent_result(
  112. cls,
  113. node: str,
  114. round_index: int,
  115. result: AgentResult,
  116. ) -> "NodeRun":
  117. return cls(
  118. node=node,
  119. round_index=round_index,
  120. content=result.content or "",
  121. iterations=int(result.iterations or 0),
  122. tool_calls_made=int(result.tool_calls_made or 0),
  123. )
  124. @dataclass
  125. class FindAgentState:
  126. """Control state passed through the single-round graph."""
  127. run_id: str
  128. user_input: str
  129. round_index: int = 0
  130. phase: Phase = "planning"
  131. execution_plan: ExecutionPlan | None = None
  132. stop: bool = False
  133. stop_reason: str = ""
  134. previous_snapshot: DiscoverySnapshot | None = None
  135. snapshot: DiscoverySnapshot | None = None
  136. node_runs: list[NodeRun] = field(default_factory=list)
  137. failures: list[dict[str, Any]] = field(default_factory=list)
  138. class FindAgentGraphState(TypedDict, total=False):
  139. """Serializable state used by the real one-round LangGraph."""
  140. run_id: str
  141. user_input: str
  142. round_index: int
  143. execution_plan: ExecutionPlan | None
  144. phase: Phase
  145. node_runs: list[NodeRun]
  146. snapshot: DiscoverySnapshot | None
  147. supervisor_step: int
  148. approved_action: SupervisorAction
  149. action_count: int
  150. search_actions: int
  151. worker_count: int
  152. evidence_scope: str
  153. decision_history: list[dict[str, Any]]
  154. evaluator_stagnation: int
  155. @dataclass(frozen=True)
  156. class FindAgentResult:
  157. """Workflow result with technical and business status kept separate."""
  158. run_id: str
  159. status: EndKind
  160. succeeded: bool
  161. business_outcome: str
  162. valid_primary_count: int
  163. rounds: int
  164. final_output: str
  165. node_runs: tuple[NodeRun, ...]
  166. stop_reason: str = ""
  167. @property
  168. def iterations(self) -> int:
  169. return sum(item.iterations for item in self.node_runs)
  170. @property
  171. def tool_calls_made(self) -> int:
  172. return sum(item.tool_calls_made for item in self.node_runs)