AI-Pricing-Agent/src/pricing_agent/schemas.py

173 lines
6.2 KiB
Python

"""Typed data contracts for the pricing pipeline.
These mirror the master-tracker columns the Pricing Analyst owns (playbook §11)
and the Definition of Done (playbook §13). Keeping them as pydantic models means
every hop in the LangGraph state machine is validated.
"""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field
class Decision(str, Enum):
APPROVED = "APPROVED" # -> tracker Status "Pricing Approved"
BLOCKED = "BLOCKED" # -> tracker Status "Pricing Blocked"
NEEDS_REVIEW = "NEEDS_REVIEW"
class BuyBoxStatus(str, Enum):
WON = "WON" # we are the Featured Offer
LOST_PRICE = "LOST_PRICE" # lost Featured Offer on price
LOST_ELIGIBILITY = "LOST_ELIGIBILITY" # lost on account/listing eligibility, not price
SUPPRESSED = "SUPPRESSED" # Buy Box hidden entirely (price significantly high)
UNKNOWN = "UNKNOWN"
class SkuInput(BaseModel):
"""One row read from the master tracker before pricing."""
sku: str
asin: str | None = None
product_name: str = ""
marketplace: str = "US"
category: str = ""
landed_cost: float = Field(..., description="COGS from Inventory Planner, per unit")
target_price: float = Field(..., description="Proposed / current selling price to evaluate")
# Optional inputs; if absent we fall back to the fee API / mock.
fba_fee: float | None = None
referral_pct: float | None = Field(
default=None, description="Modeled/negotiated referral fraction, e.g. 0.12"
)
dimensions: str | None = None
weight: str | None = None
class FeeQuote(BaseModel):
"""Normalized per-unit fee/cost breakdown from a data provider at a given price.
COSMOS fills this from `takehome-calculator`; the mock fills it synthetically.
Dollar amounts are per unit at `selling_price`.
"""
selling_price: float
landed_cost: float # COGS (purchase + freight + duty)
fba_fee: float = 0.0
referral_amt: float = 0.0 # Amazon referral fee, in dollars
returns_reserve: float = 0.0
other_fees: float = 0.0 # inbound placement, low-inventory, EPR, VAT, handling…
net_takehome: float | None = None # provider's authoritative net profit/unit, if given
source: str = "unknown"
class FeeStack(BaseModel):
"""Full per-unit fee stack + derived margin figures (playbook SOP A)."""
selling_price: float
landed_cost: float
fba_fee: float
referral_pct: float
referral_amt: float
returns_reserve: float
storage_alloc: float
total_cost: float
profit: float
contribution_margin_pct: float
break_even_price: float
map_floor: float
# The referral basis used to derive this stack ("modeled" or "worst_case").
referral_basis: str = "worst_case"
class CompetitorOffer(BaseModel):
"""One seller offer on the ASIN's Amazon listing (from Apify offers / Buy Box)."""
seller_name: str = "Unknown seller"
seller_id: str | None = None
price: float | None = None
is_buy_box: bool = False
condition: str | None = None
# True when seller_id matches APIFY_SELLER_ID — this is our own offer, not a rival.
# Our own offers are excluded from the competitive band.
is_ours: bool = False
class CompetitiveSnapshot(BaseModel):
"""Result of the competitive / Buy Box scan (playbook SOP B)."""
asin: str | None = None
buy_box_status: BuyBoxStatus = BuyBoxStatus.UNKNOWN
buy_box_price: float | None = None
buy_box_seller_name: str | None = None
buy_box_seller_id: str | None = None
competitive_low: float | None = None
competitive_median: float | None = None
competitive_high: float | None = None
is_suppressed: bool = False
reason: str = ""
# Named sellers + prices on this ASIN (featured first when present).
offers: list[CompetitorOffer] = Field(default_factory=list)
class PricingDecision(BaseModel):
"""The proposal presented to the human and (on approval) written to the tracker."""
sku: str
asin: str | None = None
decision: Decision
recommended_price: float
fee_stack: FeeStack
competitive: CompetitiveSnapshot
# Deterministic gate reasons (from evaluate node).
gate_reasons: list[str] = Field(default_factory=list)
# LLM-written, human-readable narrative (rationale node).
rationale: str = ""
competitor_summary: str = ""
suppression_note: str = ""
recommended_action: str = ""
def tracker_row(self) -> dict:
"""Map to the master-tracker columns this role owns (playbook §11)."""
fs = self.fee_stack
status = {
Decision.APPROVED: "Pricing Approved",
Decision.BLOCKED: "Pricing Blocked",
Decision.NEEDS_REVIEW: "Pricing Review",
}[self.decision]
return {
"SKU": self.sku,
"ASIN": self.asin or "",
"Landed Cost": round(fs.landed_cost, 2),
"FBA Fee": round(fs.fba_fee, 2),
"Referral Fee %": round(fs.referral_pct * 100, 2),
"Referral Fee $": round(fs.referral_amt, 2),
"Break-even Price": round(fs.break_even_price, 2),
"MAP Floor": round(fs.map_floor, 2),
"Target Selling Price": round(self.recommended_price, 2),
"Contribution Margin %": round(fs.contribution_margin_pct * 100, 2),
"Buy Box Status": self.competitive.buy_box_status.value,
"Buy Box Seller": self.competitive.buy_box_seller_name or "",
"Buy Box Price": (
round(self.competitive.buy_box_price, 2)
if self.competitive.buy_box_price is not None else ""
),
"Competitor Offers": "; ".join(
f"{o.seller_name} @ "
f"{('$' + format(o.price, '.2f')) if o.price is not None else 'n/a'}"
+ (" [BB]" if o.is_buy_box else "")
for o in self.competitive.offers[:8]
),
"Suppression Risk": "YES" if self.competitive.is_suppressed else "NO",
"Status": status,
}
class LlmNarrative(BaseModel):
"""Structured output schema the LLM must return (no numbers invented here)."""
rationale: str
competitor_summary: str
suppression_note: str
recommended_action: str