Skip to content

Commit f2c8ec8

Browse files
author
Адель
committed
feat: add safe Direct UTM and keyword bid workflows
1 parent a5c6cff commit f2c8ec8

16 files changed

Lines changed: 13258 additions & 7473 deletions

AGENTS.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,64 @@ Purpose: quick onboarding for external humans and generic agents (including codi
3030
- Marketing / semantic tasks: use `docs/MARKETER_GUIDE.md` and corresponding DirectPilot endpoints.
3131
- If a direct Yandex API method exists in DirectPilot, **do not** call raw Yandex endpoints (`api.direct.yandex.com`, `api-metrika.yandex.net`, AI Studio/Search API) directly.
3232

33+
## Human Approval Contract (mandatory for all write operations)
34+
35+
Technical `approved=true` in a request body is NOT a self-approval by an agent.
36+
For any **real Direct/Metrika write** (minuses, keywords, ads, moderation,
37+
strategy, bids, budgets, time-targeting, autotargeting, goals, UTM, pause/resume,
38+
live-create, bids modifiers, etc.) the agent/marketer/script MUST follow this
39+
sequence:
40+
41+
1. **Read-only / dry-run preview** — gather current state via read endpoints and
42+
`dry_run=true` previews. Produce a concrete diff/impact summary (what entities,
43+
what will change, expected effect, risk).
44+
2. **Show to user** — present the preview and diff in chat, ticket, or operator
45+
UI. Never skip this step.
46+
3. **Get explicit user confirmation** — the user must explicitly approve the
47+
action in the conversation/ticket/UI. The agent must not interpret silence,
48+
an unrelated "ok", or a general instruction as approval for a specific write.
49+
4. **Apply only after confirmation** — only then send the write with
50+
`approved=true`, `idempotency_key`, `dry_run=false`, and
51+
`DIRECTPILOT_MODE=live_write`.
52+
5. **Readback** — verify what changed using the operation readback in the
53+
response or a dedicated follow-up read endpoint.
54+
55+
Marketers do NOT apply live writes themselves. They prepare recommendations and
56+
read-only/dry-run outputs only. The apply step is performed by an operator or
57+
orchestrator after explicit user approval.
58+
59+
## UTM workflow for external agents (summary)
60+
61+
UTM is the primary mechanism for tracking ad performance in Yandex Metrika.
62+
Every external agent (marketing, coding, reviewer) must follow this workflow:
63+
64+
**Existing campaigns:**
65+
1. `GET /yandex/campaigns/{campaign_id}/utm-audit` — read-only audit
66+
2. `POST /yandex/campaigns/{campaign_id}/utm-plan` — always dry_run, preview only; preserves query/fragment and emits concrete old_url → new_url
67+
3. **User approval** (see Human Approval Contract above) — confirm slug, overwrite, sitelinks
68+
4. `POST /yandex/campaigns/{campaign_id}/utm-apply` — with gates
69+
5. Readback — verify confirmed URLs from response
70+
71+
**New campaigns (live-create):**
72+
- Include `utm_config` in the `POST /yandex/campaigns/live-create` request to
73+
tag ads with UTM at birth.
74+
- `LiveCreateCampaignRequest.utm_config` overrides `draft.utm_config` when both
75+
are set — the request-level config takes precedence.
76+
- If `utm_config` is omitted, the draft's stored `utm_config` is used as fallback.
77+
78+
**When to ask the user:**
79+
- Campaign slug/naming is unclear.
80+
- **Cyrillic campaign names:** when the campaign name contains only Cyrillic
81+
characters, the automatic slug generator falls back to ``campaign-{id}``
82+
because transliteration drops non-ASCII chars. For readable Metrika reports,
83+
ask the user to provide an explicit ``campaign_slug`` (Latin transliteration
84+
or semantic slug, e.g. ``turbiny-rostov`` instead of ``campaign-12345``).
85+
- Overwrite of existing UTM is requested.
86+
- Sitelinks UTM apply is desired; `sitelink` part is preview-only and apply is currently `not_implemented` (fail-closed).
87+
88+
See `docs/MARKETER_GUIDE.md` (UTM section) and `docs/API_SIMPLE.md`
89+
for the full endpoint contracts.
90+
3391
## Public skill vs private local skills
3492
- This repository is self-contained for external humans and agents. Start with `AGENTS.md`, then read `skills/directpilot-operations/SKILL.md` and the docs listed above.
3593
- Private Hermes skills such as a maintainer's local `yandex-direct-api` skill are convenience memory for that maintainer only. They are not required to operate or contribute to this repository.

app/main.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,17 @@
9696
YandexAutotargetingReadResult,
9797
YandexAutotargetingRequest,
9898
YandexAutotargetingResult,
99+
# UTM
100+
UtmAuditResult,
101+
UtmApplyRequest,
102+
UtmApplyResult,
103+
UtmConfig,
104+
UtmPlanRequest,
105+
UtmPlanResult,
106+
# Keyword bids
107+
KeywordBidItem,
108+
KeywordBidUpdateRequest,
109+
KeywordBidUpdateResult,
99110
)
100111
from app.store import store
101112
from app.yandex_direct import YandexDirectClient, YandexDirectError
@@ -3133,6 +3144,212 @@ def yandex_autotargeting_update(
31333144
) from exc
31343145

31353146

3147+
# ---------------------------------------------------------------------------
3148+
# Keyword bids update — live-safe SearchBid / ContextBid changes
3149+
# ---------------------------------------------------------------------------
3150+
3151+
3152+
@app.post(
3153+
"/yandex/campaigns/{campaign_id}/bids",
3154+
response_model=KeywordBidUpdateResult,
3155+
responses={
3156+
409: {
3157+
"description": "Safety gate or idempotency conflict: missing approval, non-live_write apply, or replay payload mismatch.",
3158+
},
3159+
502: {
3160+
"description": "Upstream Yandex Direct keywordbids.set / readback failure, with redacted diagnostics only.",
3161+
},
3162+
},
3163+
)
3164+
def yandex_keyword_bids_update(
3165+
campaign_id: str,
3166+
payload: KeywordBidUpdateRequest,
3167+
settings: Settings = Depends(get_settings),
3168+
client: YandexDirectClient | None = Depends(get_yandex_client),
3169+
) -> KeywordBidUpdateResult:
3170+
"""Update SearchBid / ContextBid for existing keywords via v5 ``keywordbids.set``.
3171+
3172+
Standard product gate contract:
3173+
``dry_run=True`` (default) is preview-only and never performs a network
3174+
write; the response includes the exact v5 ``keywordbids.set`` payload
3175+
that WOULD be sent, with ``applied=False``.
3176+
3177+
``dry_run=False`` requires ``DIRECTPILOT_MODE=live_write``,
3178+
``approved=True`` and a valid ``idempotency_key``.
3179+
3180+
Request items use RUBLES at the REST boundary; the store converts to
3181+
Direct micros (× 1 000 000). The minimal v5 item shape is
3182+
``KeywordId + SearchBid`` / ``KeywordId + ContextBid`` — no
3183+
CampaignId / AdGroupId in the item.
3184+
3185+
After apply, the endpoint reads back keyword bids for the campaign
3186+
and returns the changed keyword ids with current Bid/ContextBid.
3187+
"""
3188+
if not payload.approved:
3189+
raise HTTPException(
3190+
status_code=409,
3191+
detail="Action requires explicit approval before keyword bids update",
3192+
)
3193+
if not payload.dry_run and settings.directpilot_mode != "live_write":
3194+
raise HTTPException(
3195+
status_code=409,
3196+
detail=(
3197+
f"Live writes require DIRECTPILOT_MODE=live_write; "
3198+
f"current mode is {settings.directpilot_mode!r}; "
3199+
f"keyword bids apply is not allowed in this mode "
3200+
f"(dry_run=True is the only allowed path)"
3201+
),
3202+
)
3203+
try:
3204+
return store.yandex_keyword_bids_update(
3205+
campaign_id, payload, settings=settings, client=client
3206+
)
3207+
except ValueError as exc:
3208+
raise HTTPException(status_code=409, detail=str(exc)) from exc
3209+
except YandexDirectError as exc:
3210+
diagnostics = exc.diagnostics or {}
3211+
detail: dict[str, Any] = {
3212+
"error_type": "YandexDirectError",
3213+
"message": str(exc),
3214+
}
3215+
if "error_code" in diagnostics:
3216+
detail["error_code"] = diagnostics["error_code"]
3217+
if "error_detail" in diagnostics:
3218+
detail["error_detail"] = diagnostics["error_detail"]
3219+
if "payload_preview" in diagnostics:
3220+
detail["payload_preview"] = diagnostics["payload_preview"]
3221+
raise HTTPException(status_code=502, detail=detail) from exc
3222+
except Exception as exc:
3223+
try:
3224+
store.append_audit(
3225+
"yandex_keyword_bids_failed",
3226+
campaign_id,
3227+
dry_run=False,
3228+
details={
3229+
"campaign_id": campaign_id,
3230+
"approved": payload.approved,
3231+
"idempotency_key": payload.idempotency_key,
3232+
"endpoint_safety_net": True,
3233+
"yandex_error": (
3234+
f"unexpected error in keyword bids endpoint: "
3235+
f"{type(exc).__name__}: {exc}"
3236+
),
3237+
"exception_type": type(exc).__name__,
3238+
},
3239+
)
3240+
except Exception:
3241+
pass
3242+
raise HTTPException(
3243+
status_code=502,
3244+
detail={
3245+
"error_type": "YandexDirectError",
3246+
"message": (
3247+
f"unexpected error during keyword bids update: "
3248+
f"{type(exc).__name__}"
3249+
),
3250+
},
3251+
) from exc
3252+
3253+
3254+
# ---------------------------------------------------------------------------
3255+
# Yandex Direct UTM — audit / plan / apply
3256+
# ---------------------------------------------------------------------------
3257+
3258+
3259+
@app.get(
3260+
"/yandex/campaigns/{campaign_id}/utm-audit",
3261+
response_model=UtmAuditResult,
3262+
)
3263+
def yandex_utm_audit(
3264+
campaign_id: str,
3265+
settings: Settings = Depends(get_settings),
3266+
client: YandexDirectClient | None = Depends(get_yandex_client),
3267+
) -> UtmAuditResult:
3268+
"""Read-only UTM audit for all ad and sitelink URLs in a campaign.
3269+
3270+
In mock mode, returns deterministic mock data. In sandbox/live modes,
3271+
reads real ads and sitelinks from Yandex Direct (no writes).
3272+
"""
3273+
return store.utm_audit(
3274+
campaign_id,
3275+
settings=settings,
3276+
client=client,
3277+
)
3278+
3279+
3280+
@app.post(
3281+
"/yandex/campaigns/{campaign_id}/utm-plan",
3282+
response_model=UtmPlanResult,
3283+
)
3284+
def yandex_utm_plan(
3285+
campaign_id: str,
3286+
payload: UtmPlanRequest,
3287+
settings: Settings = Depends(get_settings),
3288+
client: YandexDirectClient | None = Depends(get_yandex_client),
3289+
) -> UtmPlanResult:
3290+
"""Generate UTM plan/preview — always dry_run, never writes.
3291+
3292+
Returns the list of URL changes (old → new with UTM) and a
3293+
preview of the v5 ``ads.update`` payload that WOULD be sent on apply.
3294+
Sitelink previews are included but apply is not_implemented.
3295+
"""
3296+
return store.utm_plan(
3297+
campaign_id,
3298+
payload,
3299+
settings=settings,
3300+
client=client,
3301+
)
3302+
3303+
3304+
@app.post(
3305+
"/yandex/campaigns/{campaign_id}/utm-apply",
3306+
response_model=UtmApplyResult,
3307+
)
3308+
def yandex_utm_apply(
3309+
campaign_id: str,
3310+
payload: UtmApplyRequest,
3311+
settings: Settings = Depends(get_settings),
3312+
client: YandexDirectClient | None = Depends(get_yandex_client),
3313+
) -> UtmApplyResult:
3314+
"""Apply UTM URLs to live ads — write-gated.
3315+
3316+
* ``dry_run=True`` → preview only, ``applied=False``.
3317+
* ``dry_run=False`` requires:
3318+
1. ``DIRECTPILOT_MODE=live_write``
3319+
2. ``approved=true``
3320+
3. ``idempotency_key`` (>= 6 chars)
3321+
3322+
Uses ``ads.update`` (REPLACE-shaped) to safely update TextAd.Href.
3323+
Sitelink apply is not_implemented — surfaced in ``not_implemented``.
3324+
"""
3325+
if not payload.approved:
3326+
raise HTTPException(
3327+
status_code=409,
3328+
detail="Action requires explicit approval before UTM apply",
3329+
)
3330+
if not payload.dry_run and settings.directpilot_mode != "live_write":
3331+
raise HTTPException(
3332+
status_code=409,
3333+
detail=(
3334+
f"Live writes require DIRECTPILOT_MODE=live_write; "
3335+
f"current mode is {settings.directpilot_mode!r}; "
3336+
f"UTM apply is not allowed in this mode "
3337+
f"(dry_run=True is the only allowed path)"
3338+
),
3339+
)
3340+
try:
3341+
return store.utm_apply(
3342+
campaign_id,
3343+
payload,
3344+
settings=settings,
3345+
client=client,
3346+
)
3347+
except ValueError as exc:
3348+
raise HTTPException(status_code=409, detail=str(exc)) from exc
3349+
except YandexDirectError as exc:
3350+
raise _yandex_error_to_502(exc) from exc
3351+
3352+
31363353
# ---------------------------------------------------------------------------
31373354
# Yandex Metrika — read-only (counters, goals, summary, traffic-sources)
31383355
#

0 commit comments

Comments
 (0)