Skip to content

Commit 5400174

Browse files
authored
feat: add safe bid modifiers update
Adds gated bid modifiers update endpoint with dry-run/apply safety gates, item-level SetResults partial-failure handling, docs/OpenAPI updates, evidence and CI passing.
1 parent c8e2530 commit 5400174

18 files changed

Lines changed: 1330 additions & 12 deletions

app/main.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@
113113
KeywordBidItem,
114114
KeywordBidUpdateRequest,
115115
KeywordBidUpdateResult,
116+
# Bid modifiers
117+
BidModifiersUpdateRequest,
118+
BidModifiersUpdateResult,
116119
)
117120
from app.store import store
118121
from app.yandex_direct import YandexDirectClient, YandexDirectError
@@ -3643,6 +3646,103 @@ def yandex_keyword_bids_update(
36433646
) from exc
36443647

36453648

3649+
@app.post(
3650+
"/yandex/campaigns/{campaign_id}/bid-modifiers",
3651+
response_model=BidModifiersUpdateResult,
3652+
responses={
3653+
409: {
3654+
"description": "Safety gate or idempotency conflict for bid modifier update.",
3655+
},
3656+
502: {
3657+
"description": "Upstream Yandex Direct bidmodifiers.set / readback failure, with redacted diagnostics only.",
3658+
},
3659+
},
3660+
)
3661+
def yandex_bid_modifiers_update(
3662+
campaign_id: str,
3663+
payload: BidModifiersUpdateRequest,
3664+
settings: Settings = Depends(get_settings),
3665+
client: YandexDirectClient | None = Depends(get_yandex_client),
3666+
) -> BidModifiersUpdateResult:
3667+
"""Preview/apply existing demographic bid modifier coefficient changes.
3668+
3669+
Direct API v5 ``bidmodifiers.set`` updates an existing modifier by
3670+
``Id`` and ``BidModifier``. The request keeps operator-facing
3671+
``adjustment_percent`` semantics where ``-100`` becomes Direct
3672+
``BidModifier=0``. Real apply still requires ``live_write``,
3673+
``approved=True``, valid ``idempotency_key``, and ``dry_run=False``.
3674+
"""
3675+
if not payload.approved:
3676+
raise HTTPException(
3677+
status_code=409,
3678+
detail="Action requires explicit approval before bid modifiers update",
3679+
)
3680+
if not payload.idempotency_key:
3681+
raise HTTPException(
3682+
status_code=409,
3683+
detail="idempotency_key is required before bid modifiers update",
3684+
)
3685+
if not payload.dry_run and settings.directpilot_mode != "live_write":
3686+
raise HTTPException(
3687+
status_code=409,
3688+
detail=(
3689+
f"Live writes require DIRECTPILOT_MODE=live_write; "
3690+
f"current mode is {settings.directpilot_mode!r}; "
3691+
f"bid modifiers apply is not allowed in this mode "
3692+
f"(dry_run=True is the only allowed path)"
3693+
),
3694+
)
3695+
try:
3696+
return store.yandex_bid_modifiers_update(
3697+
campaign_id, payload, settings=settings, client=client
3698+
)
3699+
except ValueError as exc:
3700+
raise HTTPException(status_code=409, detail=str(exc)) from exc
3701+
except YandexDirectError as exc:
3702+
diagnostics = exc.diagnostics or {}
3703+
detail: dict[str, Any] = {
3704+
"error_type": "YandexDirectError",
3705+
"message": str(exc),
3706+
}
3707+
if "error_code" in diagnostics:
3708+
detail["error_code"] = diagnostics["error_code"]
3709+
if "error_detail" in diagnostics:
3710+
detail["error_detail"] = diagnostics["error_detail"]
3711+
if "payload_preview" in diagnostics:
3712+
detail["payload_preview"] = diagnostics["payload_preview"]
3713+
raise HTTPException(status_code=502, detail=detail) from exc
3714+
except Exception as exc:
3715+
try:
3716+
store.append_audit(
3717+
"yandex_bid_modifiers_failed",
3718+
campaign_id,
3719+
dry_run=payload.dry_run,
3720+
details={
3721+
"campaign_id": campaign_id,
3722+
"approved": payload.approved,
3723+
"idempotency_key": payload.idempotency_key,
3724+
"endpoint_safety_net": True,
3725+
"yandex_error": (
3726+
f"unexpected error in bid modifiers endpoint: "
3727+
f"{type(exc).__name__}: {exc}"
3728+
),
3729+
"exception_type": type(exc).__name__,
3730+
},
3731+
)
3732+
except Exception:
3733+
pass
3734+
raise HTTPException(
3735+
status_code=502,
3736+
detail={
3737+
"error_type": "YandexDirectError",
3738+
"message": (
3739+
f"unexpected error during bid modifiers update: "
3740+
f"{type(exc).__name__}"
3741+
),
3742+
},
3743+
) from exc
3744+
3745+
36463746
# ---------------------------------------------------------------------------
36473747
# Yandex Direct UTM — audit / plan / apply
36483748
# ---------------------------------------------------------------------------

app/models.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2471,3 +2471,138 @@ class KeywordBidUpdateResult(BaseModel):
24712471
not_implemented: list[str] = Field(default_factory=list)
24722472
yandex_units: int | None = None
24732473
yandex_error: str | None = None
2474+
2475+
2476+
# ---------------------------------------------------------------------------
2477+
# Bid modifiers update — semantic preview + Direct v5 set payload
2478+
# ---------------------------------------------------------------------------
2479+
2480+
2481+
class BidModifierAgeAdjustment(BaseModel):
2482+
"""Age/demographic bid modifier adjustment for a safe write preview.
2483+
2484+
Yandex Direct v5 ``bidmodifiers.set`` updates an existing modifier by
2485+
``Id`` and ``BidModifier``. DirectPilot keeps the human-facing request in
2486+
adjustment-percent form (``-100`` means exclude the segment) and converts it
2487+
to the Direct coefficient where ``BidModifier = 100 + adjustment_percent``.
2488+
Therefore ``-100`` becomes Direct ``BidModifier=0``.
2489+
"""
2490+
2491+
modifier_id: int | None = Field(
2492+
default=None,
2493+
ge=1,
2494+
description=(
2495+
"Existing Yandex Direct bid modifier Id. Required for live apply; "
2496+
"dry-run previews may omit it until readback resolves the modifier."
2497+
),
2498+
)
2499+
age_range: Literal["AGE_0_17"] = Field(
2500+
default="AGE_0_17",
2501+
description="Under-18 age segment used for demographic exclusion previews.",
2502+
)
2503+
adjustment_percent: int = Field(
2504+
...,
2505+
ge=-100,
2506+
le=1200,
2507+
description=(
2508+
"Human-facing adjustment percent. Direct v5 uses BidModifier as a "
2509+
"coefficient in percent, so this value is converted with "
2510+
"BidModifier = 100 + adjustment_percent."
2511+
),
2512+
)
2513+
2514+
@property
2515+
def direct_bid_modifier(self) -> int:
2516+
return 100 + self.adjustment_percent
2517+
2518+
def to_preview_item(self, campaign_id: int | str) -> dict:
2519+
item: dict = {
2520+
"CampaignId": int(campaign_id),
2521+
"AgeRange": self.age_range,
2522+
"AdjustmentPercent": self.adjustment_percent,
2523+
"BidModifier": self.direct_bid_modifier,
2524+
}
2525+
if self.modifier_id is not None:
2526+
item["Id"] = self.modifier_id
2527+
return item
2528+
2529+
def to_direct_set_item(self) -> dict:
2530+
if self.modifier_id is None:
2531+
raise ValueError("modifier_id is required to build bidmodifiers.set payload")
2532+
return {"Id": self.modifier_id, "BidModifier": self.direct_bid_modifier}
2533+
2534+
2535+
class BidModifiersUpdateRequest(BaseModel):
2536+
"""Body model for a live-safe bid-modifier update endpoint.
2537+
2538+
``dry_run=True`` is preview-only. A real ``bidmodifiers.set`` apply must be
2539+
gated at the store/route layer by ``DIRECTPILOT_MODE=live_write``,
2540+
``approved=True``, valid ``idempotency_key``, and ``dry_run=False``.
2541+
"""
2542+
2543+
dry_run: bool = True
2544+
approved: bool = False
2545+
idempotency_key: str | None = Field(default=None, min_length=6)
2546+
adjustments: list[BidModifierAgeAdjustment] = Field(..., min_length=1, max_length=1000)
2547+
reason: str | None = None
2548+
2549+
def build_payload_preview(self, campaign_id: int | str) -> dict:
2550+
return {
2551+
"BidModifiers": [
2552+
adjustment.to_preview_item(campaign_id) for adjustment in self.adjustments
2553+
]
2554+
}
2555+
2556+
def build_direct_set_payload(self) -> dict:
2557+
return {
2558+
"BidModifiers": [
2559+
adjustment.to_direct_set_item() for adjustment in self.adjustments
2560+
]
2561+
}
2562+
2563+
2564+
class BidModifierSetItemResult(BaseModel):
2565+
"""Per-item outcome from Direct v5 ``bidmodifiers.set`` ``SetResults``.
2566+
2567+
Only redacted provider fields are surfaced. ``has_errors=True`` means
2568+
this item was not safely accepted as applied; the aggregate response must
2569+
use ``applied=False`` and ``partial_failure=True``.
2570+
"""
2571+
2572+
modifier_id: int
2573+
has_errors: bool = False
2574+
has_warnings: bool = False
2575+
errors: list["ProviderWarning"] = Field(default_factory=list)
2576+
warnings: list["ProviderWarning"] = Field(default_factory=list)
2577+
2578+
2579+
class BidModifiersUpdateResult(BaseModel):
2580+
"""Response for ``POST /yandex/campaigns/{campaign_id}/bid-modifiers``."""
2581+
2582+
campaign_id: str
2583+
mode: str
2584+
dry_run: bool
2585+
applied: bool
2586+
source: Literal["mock", "yandex"] = "yandex"
2587+
read_only: bool = False
2588+
audit_id: str
2589+
payload_preview: dict | None = None
2590+
readback: list[dict] | None = None
2591+
provider_warnings: list["ProviderWarning"] = Field(default_factory=list)
2592+
set_results: list["BidModifierSetItemResult"] | None = Field(
2593+
default=None,
2594+
description=(
2595+
"Per-item outcomes from the v5 ``SetResults`` envelope. "
2596+
"Populated on live apply when Direct returns item-level results."
2597+
),
2598+
)
2599+
partial_failure: bool = Field(
2600+
default=False,
2601+
description=(
2602+
"True when the top-level v5 call succeeded but at least one "
2603+
"``SetResults`` item contains provider errors. In this case "
2604+
"``applied`` is false."
2605+
),
2606+
)
2607+
yandex_units: int | None = None
2608+
yandex_error: str | None = None

0 commit comments

Comments
 (0)