diff --git a/src/ad_seller/crews/inventory_crews.py b/src/ad_seller/crews/inventory_crews.py index 67c73b2..e6d7ef2 100644 --- a/src/ad_seller/crews/inventory_crews.py +++ b/src/ad_seller/crews/inventory_crews.py @@ -25,6 +25,7 @@ create_upsell_agent, ) from ..config import get_settings +from ..models.flow_state import ProposalReviewOutput from ..tools.audience import ( AudienceCapabilityTool, AudienceValidationTool, @@ -412,12 +413,13 @@ def create_proposal_review_crew(proposal_data: dict) -> Crew: - If Reject: Clear explanation and alternatives - Audience coverage impact on delivery confidence - Upsell opportunities to pursue""", - expected_output="""Final proposal recommendation with: -- Decision (Accept/Counter/Reject) -- Counter-terms if applicable (including audience modifications) -- Rejection reason and alternatives if applicable -- Audience validation summary -- Prioritized upsell opportunities""", + expected_output="""A structured proposal decision with: +- decision: accept, counter, or reject +- rationale: brief explanation +- counter_price_cpm and counter_terms if countering +- rejection_reason if rejecting +- audience_summary +- upsell_opportunities""", agent=proposal_review_agent, context=[ pricing_check_task, @@ -425,6 +427,7 @@ def create_proposal_review_crew(proposal_data: dict) -> Crew: audience_validation_task, upsell_task, ], + output_pydantic=ProposalReviewOutput, ) return Crew( diff --git a/src/ad_seller/flows/proposal_handling_flow.py b/src/ad_seller/flows/proposal_handling_flow.py index b5200ac..790bbff 100644 --- a/src/ad_seller/flows/proposal_handling_flow.py +++ b/src/ad_seller/flows/proposal_handling_flow.py @@ -27,6 +27,7 @@ from ..models.flow_state import ( ExecutionStatus, ProposalEvaluation, + ProposalReviewOutput, SellerFlowState, ) from ..models.ucp import AudienceCapability, SignalType @@ -419,17 +420,13 @@ async def run_crew_evaluation(self) -> None: crew = create_proposal_review_crew(self.state.proposal_data) try: - result = crew.kickoff() + result = await crew.kickoff_async() - # Parse crew recommendation - result_text = str(result).lower() - - if "accept" in result_text: - self.state.recommendation = "accept" - elif "counter" in result_text: - self.state.recommendation = "counter" + review: Optional[ProposalReviewOutput] = result.pydantic + if review is not None: + self.state.recommendation = review.decision.value else: - self.state.recommendation = "reject" + self._fallback_evaluation() # Emit proposal.evaluated event await emit_event( diff --git a/src/ad_seller/models/flow_state.py b/src/ad_seller/models/flow_state.py index ca64800..0406710 100644 --- a/src/ad_seller/models/flow_state.py +++ b/src/ad_seller/models/flow_state.py @@ -135,6 +135,36 @@ class ProposalEvaluation(BaseModel): upsell_opportunities: list[str] = Field(default_factory=list) +class ProposalDecision(str, Enum): + """Final decision on a proposal, as returned by the review crew.""" + + ACCEPT = "accept" + COUNTER = "counter" + REJECT = "reject" + + +class ProposalReviewOutput(BaseModel): + """Structured final decision from the proposal-review crew.""" + + decision: ProposalDecision = Field( + description="Final recommendation: accept, counter, or reject" + ) + rationale: str = Field(description="Brief explanation of why this decision was reached") + counter_price_cpm: Optional[float] = Field( + default=None, description="Counter-offer CPM, only if decision is counter" + ) + counter_terms: Optional[str] = Field( + default=None, description="Full counter-terms detail, only if decision is counter" + ) + rejection_reason: Optional[str] = Field( + default=None, description="Reason and alternatives, only if decision is reject" + ) + audience_summary: str = Field(description="Audience validation summary") + upsell_opportunities: list[str] = Field( + default_factory=list, description="Prioritized upsell opportunities" + ) + + class PricingDecision(BaseModel): """Pricing decision for a deal."""