diff --git a/.claude/commands/analyze-performance.md b/.claude/commands/analyze-performance.md index c3f91a4..c20d3b5 100644 --- a/.claude/commands/analyze-performance.md +++ b/.claude/commands/analyze-performance.md @@ -8,21 +8,32 @@ Analyze Google Ads and GA4 performance: $ARGUMENTS ## 1. Pull data (AdLoop MCP) - `get_campaign_performance` — relevant date range (default: last 30 days) +- `get_impression_share` — visibility and lost opportunity analysis +- `get_bid_strategy_status` — check learning status and strategy health - `analyze_campaign_conversions` — cross-referenced Ads + GA4 data with GDPR gap detection - If specific campaigns mentioned, filter by name - If keywords are relevant, also pull `get_keyword_performance` and `get_search_terms` +- If budget concerns, pull `get_budget_pacing` for month-to-date pacing **Context tip:** for the account-wide pass, call the performance tools with `compact=true` — you get totals, top-10 rows, and pre-computed offender lists instead of every row. Only switch to full mode when drilling into a specific campaign/keyword. In harnesses that support subagents, this whole data-pull step can be delegated to a subagent that returns just the summary. ## 2. Analyze - Spend, Clicks, Conversions, CPA, CTR per campaign +- Impression share: search IS, budget-lost IS, rank-lost IS — identify visibility gaps +- Bid strategy status: any campaigns in learning phase? Appropriate strategy type? - Paid vs organic comparison (from non_paid_channels) - GDPR gap (clicks vs sessions ratio — 2:1 to 5:1 is normal in EU) -- Flag: zero conversions with significant spend, CPA > 3x target, QS < 5, wasteful search terms +- Flag: zero conversions with significant spend, CPA > 3x target, QS < 5, wasteful search terms, high budget-lost IS If conversion issues found: run `attribution_check` If landing page problems suspected: run `landing_page_analysis` +If quality scores are low: run `get_quality_score_details` for component breakdowns +If device performance varies: run `get_device_performance` to compare mobile vs desktop +If performance changed unexpectedly: run `get_change_history` to correlate with account changes +If geographic waste suspected: run `get_location_performance` to identify underperforming areas +If timing patterns matter: run `get_ad_schedule_performance` for hour/day analysis +If competitive context needed: run `get_auction_insights` (requires allowlisted account) ## 3. Present results diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 84cf253..280ee63 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -1,5 +1,5 @@ --- -description: AdLoop MCP orchestration — Google Ads + GA4 + codebase intelligence +description: AdLoop MCP orchestration — Google Ads + GA4 + codebase intelligence --- # AdLoop — AI Orchestration Rules @@ -21,10 +21,12 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | Tool | When to Use | Key Parameters | |------|-------------|----------------| | `get_account_summaries` | First-time discovery — find which GA4 properties exist | (none — uses config) | -| `run_ga4_report` | Any analytics question — sessions, users, conversions, page performance | `dimensions`, `metrics`, `date_range_start`, `date_range_end`, `limit` | +| `run_ga4_report` | Any analytics question — sessions, users, conversions, page performance | `dimensions`, `metrics`, `date_range_start`, `date_range_end`, `limit`, `dimension_filter` | | `run_realtime_report` | After code deploys — verify tracking fires correctly | `dimensions`, `metrics` | | `get_tracking_events` | Understanding what events are configured and their volume | `date_range_start`, `date_range_end` | +**GA4 report filtering:** `run_ga4_report` supports a `dimension_filter` parameter — a dict of `dimension_name -> exact_value` pairs combined with AND logic. Use `{"sessionSource": "google", "sessionMedium": "cpc"}` to isolate paid search traffic server-side instead of pulling all sources and filtering in post-processing. + ### Google Ads Read Tools | Tool | When to Use | Key Parameters | @@ -33,7 +35,7 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | `get_campaign_performance` | Campaign-level metrics — impressions, clicks, cost, conversions | `date_range_start`, `date_range_end` | | `get_ad_performance` | Ad copy analysis — which headlines/descriptions work | `date_range_start`, `date_range_end` | | `get_keyword_performance` | Keyword analysis — quality scores, competitive metrics | `date_range_start`, `date_range_end` | -| `get_search_terms` | Find negative keyword opportunities and understand user intent | `date_range_start`, `date_range_end` | +| `get_search_terms` | Find negative keyword opportunities and understand user intent | `date_range_start`, `date_range_end`, `campaign_id` (optional) | | `get_negative_keywords` | List direct campaign-level negative keywords (not inside SharedSets) | `campaign_id` (optional) | | `get_negative_keyword_lists` | List all shared negative keyword lists — names, IDs, status, keyword count | (none) | | `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list | `shared_set_id` (required) | @@ -50,9 +52,36 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics - Ads read tools automatically compute `metrics.cost` and `metrics.cpa` from `metrics.cost_micros` — no manual division needed. `metrics.currency` contains the account's currency code (auto-detected). - `metrics.average_cpc_amount` is also pre-computed where available. - `get_ad_performance` returns full `headlines` and `descriptions` lists for RSAs. +- `get_keyword_performance` returns `ad_group.id`, `ad_group.name`, and `ad_group_criterion.criterion_id` — use these to construct `entity_id` strings (e.g. `"adGroupId~criterionId"`) for `pause_entity` calls, and `ad_group.name` for human-readable reporting. +- `get_search_terms` returns `campaign.id` and `metrics.cost` per search term — use `campaign.id` for `add_negative_keywords`, and cost for negative keyword analysis (flag terms spending > 2-3x CPA with zero conversions). + +### Performance Max Read Tools + +Performance Max (PMax) campaigns have a different structure than Search: +- **No ad groups, no keywords, no individual ads.** Instead, an `asset_group` bundles assets (headlines, descriptions, images, logos, videos) that Google assembles dynamically per impression. +- **Channel is decided at serve time.** A PMax campaign can serve on Search, Display, YouTube, Gmail, Discover, Maps, or Shopping — Google chooses based on signals. +- **Search terms are not exposed individually.** PMax surfaces aggregated category-level insights only. + +| Tool | When to Use | Key Parameters | +|------|-------------|----------------| +| `get_pmax_campaigns` | Discover PMax campaigns and their PMax-specific settings (brand guidelines) | `date_range_start`, `date_range_end` | +| `get_pmax_channel_breakdown` | "Where is my PMax spend going?" — Search vs YouTube vs Display etc. | `date_range_start`, `date_range_end`, `campaign_id` (optional) | +| `get_asset_groups` | List asset groups in a PMax campaign with ad strength + metrics | `campaign_id` (optional), `date_range_start`, `date_range_end` | +| `get_asset_group_assets` | Inspect every asset's `field_type`, status, and policy `review_status` | `asset_group_id` OR `campaign_id` | +| `get_asset_group_signals` | List search themes and audience signals attached to an asset group | `asset_group_id` OR `campaign_id` | +| `get_asset_group_top_combinations` | See which headline+description+image combos Google assembled (no metrics in v24) | `asset_group_id` OR `campaign_id`, `date_range_start`, `date_range_end` | +| `get_pmax_search_terms` | Get aggregated category-level insights (not individual queries) for a PMax campaign | `campaign_id` (REQUIRED), `date_range_start`, `date_range_end` | + +**PMax read tool notes:** +- `get_pmax_campaigns` returns `metrics.cost`, `metrics.cpa`, `metrics.roas`, and `campaign_budget.amount` pre-computed. The Search-style `bidding_strategy_type` is shown — for PMax it's typically `MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE` (Smart Bidding only). +- `get_pmax_channel_breakdown` is only reliable from **2025-06-01 onwards**. Earlier rows return `MIXED` for `segments.ad_network_type` because Google could not attribute. The tool emits a warning in `insights[]` when the date range overlaps that period. +- `get_asset_group_assets` no longer returns the `performance_label` (LOW/GOOD/BEST/PENDING) field — Google removed it from `asset_group_asset` in API v24. To judge per-asset performance now, look at `asset_group_top_combinations` (which assets actually serve together) or query `asset_field_type_view` for per-field-type metrics. +- `get_asset_group_signals` returns `signal_type = SEARCH_THEME | AUDIENCE | UNKNOWN`. Search themes are immutable once created — to "edit", you remove and re-create. +- `get_asset_group_top_combinations` does NOT return any `metrics.*` fields in v24 — the API rejects them with PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE. Combinations come pre-ordered by Google by serving frequency. +- `get_pmax_search_terms` requires API v23.2+. Returns `metrics.impressions` and `metrics.clicks` only — `metrics.cost_micros`, `metrics.conversions`, `metrics.conversions_value` are not selectable on `campaign_search_term_insight`. Google deliberately does NOT expose individual search queries for PMax campaigns. Don't tell users they can see exactly what someone typed, or that this tool surfaces per-category cost. - `get_recommendations` returns `estimated_improvement` per recommendation (potential minus base metrics) and `insights[]` that flag self-serving budget recommendations. - PMax tools: `get_pmax_performance` returns `insights[]` flagging weak ad strength and zero-conversion asset groups. `segments.ad_network_type` includes MIXED — a Google catch-all for most PMax traffic. Full channel splits (Search vs YouTube vs Display vs Discover) are not available via the API. -- `get_asset_performance` returns `by_status` and `by_field_type` summaries. Note: per-asset performance labels (BEST/GOOD/LOW) are not available for PMax assets in API v23. Use `get_detailed_asset_performance` for quality signals via top combinations. +- `get_asset_performance` returns `by_status` and `by_field_type` summaries. Note: per-asset performance labels (BEST/GOOD/LOW) are not available for PMax assets in the Google Ads API. Use `get_detailed_asset_performance` for quality signals via top combinations. - `get_audience_performance` works for campaigns with explicit audience targeting. PMax audience targeting is automatic and may not appear in this report. When the results include SEARCH campaigns, `insights[]` reminds you that custom segments cannot be attached to them (see the compatibility matrix below) — relay that constraint instead of proposing impossible pairings. - `get_demographic_targeting` returns an empty list when no demographics have been excluded or narrowed — that is the DEFAULT state (Google serves to all segments). Each criterion includes a composite `remove_id` (`adGroupId~criterionId` or `campaignId~criterionId`) that can be passed straight to `remove_entity`. - **Compact mode for audits**: `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`, returning account totals, breakdowns, the top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword waste candidates, thin RSAs, single-ad ad groups) instead of every row — roughly 90% smaller. Use it for account audits and overviews so raw tables don't flood the context; use the default full mode when you need a specific entity's exact rows before drafting a change. In harnesses with subagents, heavy multi-tool audits can additionally be delegated to a subagent that returns only the summary. @@ -64,14 +93,19 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | `analyze_campaign_conversions` | "What's my real CPA?", paid vs organic comparison, GDPR gap analysis | `date_range_start`, `date_range_end`, `campaign_name` (optional filter) | | `landing_page_analysis` | "Which landing pages convert?", identify pages with traffic but no conversions | `date_range_start`, `date_range_end` | | `attribution_check` | "Are my conversions tracked correctly?", Ads vs GA4 conversion discrepancies | `date_range_start`, `date_range_end`, `conversion_events` (optional GA4 event names) | +| `analyze_pmax_performance` | One-call PMax diagnostic — campaign + asset groups + asset counts + channel mix + GA4 | `date_range_start`, `date_range_end`, `campaign_id` (optional filter) | These tools call both APIs internally and return unified results with computed `insights[]`. They are read-only — no mutations. Each returns a `date_range` and auto-generates conditional warnings (GDPR gaps, zero conversions, attribution mismatches, orphaned URLs). +**`analyze_campaign_conversions` details:** Returns one row per campaign (with `campaign_id`) including `conversion_discrepancy_pct` between Ads and GA4. When `campaign_name` is omitted, all campaigns are returned — no need to call once per campaign. + +**`analyze_pmax_performance` details:** Aggregates everything you can see about Performance Max in one call: campaign metrics + bidding/brand-guidelines settings, every asset group with its `ad_strength` and asset counts, the channel-mix breakdown, and (when a GA4 property is configured) GA4 paid sessions/conversions per campaign. The `insights[]` flag POOR/AVERAGE asset groups, asset groups missing the documented PMax minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE, 1+ SQUARE_MARKETING_IMAGE, 1+ LOGO), channel-spend skew (>90% on a single surface), zero-conversion campaigns, GDPR consent gaps, and pre-2025-06-01 data caveats. Use this as the FIRST call when the user asks about PMax performance — it eliminates 4-5 separate read tool calls. + ### Tracking Tools | Tool | When to Use | Key Parameters | |------|-------------|----------------| -| `validate_tracking` | Compare codebase event code against actual GA4 events — find missing/broken tracking | `expected_events` (list of event names found in code), `date_range_start`, `date_range_end` | +| `validate_tracking` | Compare codebase event code against actual GA4 events — find missing/broken tracking | `expected_events` (list of event names found in code), `date_range_start`, `date_range_end`, `customer_id` (optional — cross-refs Ads conversion actions) | | `generate_tracking_code` | Generate ready-to-paste GA4 gtag JavaScript for an event | `event_name`, `event_params` (optional), `trigger` (form_submit/button_click/page_load) | `validate_tracking` requires the AI to first search the codebase for `gtag('event', ...)` or `dataLayer.push({event: ...})` calls, extract event names, then pass them to the tool. The tool queries GA4 and returns a structured comparison (matched, missing, unexpected, auto-collected). @@ -144,7 +178,7 @@ These tools call both APIs internally and return unified results with computed ` | Tool | When to Use | Key Parameters | |------|-------------|----------------| | `discover_keywords` | Discover new keyword ideas from seed keywords and/or a URL — returns avg monthly searches, competition, and bid range | `seed_keywords` (list, optional), `url` (optional), `geo_target_id`, `language_id`, `page_size`, `include_monthly_volumes` (per-month history + seasonality insight — use for "when does demand peak" questions) | -| `estimate_budget` | Budget planning before launching a campaign — forecasts clicks, impressions, cost for a set of keywords | `keywords` (list of {text, match_type, max_cpc}), `daily_budget` (optional), `geo_target_id`, `language_id`, `forecast_days` | +| `estimate_budget` | Budget planning before launching a campaign — forecasts clicks, cost, and conversions for a set of keywords | `keywords` (list of {text, match_type, max_cpc}), `daily_budget` (optional), `geo_target_id`, `language_id`, `forecast_days` | `estimate_budget` calls the Google Ads Keyword Planner API (read-only — creates nothing). Returns forecast metrics for the specified keywords and optional budget, including daily estimates and insights about budget sufficiency. Common geo targets: 2276=Germany, 2840=USA, 2826=UK. Common languages: 1000=English, 1001=German, 1002=French. @@ -152,11 +186,17 @@ These tools call both APIs internally and return unified results with computed ` | Tool | What It Does | Validation | |------|-------------|------------| -| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), optional `search_partners_enabled`, `display_network_enabled`, `display_expansion_enabled`, optional `max_cpc` for MANUAL_CPC ad-group bids or TARGET_SPEND CPC caps | -| `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | -| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` | +| `draft_campaign` | Create a SEARCH campaign (budget + campaign + ad group + keywords + geo/language targeting). Auto-sets Final URL Suffix with UTM tracking. **Rejects channel_type=PERFORMANCE_MAX** — use `draft_pmax_campaign` instead. | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated, `final_url_suffix` (auto-set for SEARCH, pass "" to disable), optional `search_partners_enabled`, `display_network_enabled`, `max_cpc` (MANUAL_CPC ad-group bid or TARGET_SPEND CPC cap) | +| `draft_pmax_campaign` | Create a Performance Max campaign with its first asset group + assets + signals atomically. PMax has no ad groups, no keywords, no `network_settings`. | `campaign_name`, `daily_budget`, `bidding_strategy` (Smart Bidding only), `geo_target_ids`, `language_ids`, `asset_group` dict (see PMax Write Tools section), `brand_guidelines_enabled` (default True — auto-links BUSINESS_NAME + first LOGO as CampaignAsset) | +| `draft_asset_group` | Add a new asset group (with assets + signals) to an existing PMax campaign | `campaign_id` (REQUIRED), `asset_group` dict | +| `draft_asset_group_assets` | Add headlines / long_headlines / descriptions / business_name / image refs / YouTube videos to an existing asset group | `asset_group_id` (REQUIRED), plus any of the asset arrays | +| `draft_asset_group_signal` | Add a single signal (search theme OR audience) to an asset group | `asset_group_id` (REQUIRED), plus exactly one of `search_theme` / `audience_resource_name` | +| `draft_image_asset` | Upload one or more local JPG/PNG/GIF images to the account as Google Ads Assets. Returns resource_names you can pass into `draft_pmax_campaign` / `draft_asset_group` / `draft_asset_group_assets`. | `images` list of `{file_path, name}` dicts. Absolute paths, ≤5 MB each, JPG/PNG/GIF only. Bytes are read at apply time. | +| `draft_ad_group` | Create a new ad group within an existing SEARCH campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo/language targets, Final URL suffix, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` | | `update_ad_group` | Update ad group name and/or MANUAL_CPC `max_cpc` | `ad_group_id`, optional `ad_group_name`, optional `max_cpc` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`. Google permits ≤2 headlines per slot, ≤1 description per slot. | +| `draft_rsa_replacement` | **Fix** an existing RSA — creates corrected replacement and removes the old ad. Use for copy errors, not A/B testing. For testing variants, use `draft_responsive_search_ad` instead. | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default true). Supports **pinning** — same format as `draft_responsive_search_ad`. | | `draft_callouts` | Create callout assets for a campaign (does NOT publish) | `campaign_id`, `callouts` list with 1-25 chars each | | `draft_structured_snippets` | Create structured snippet assets for a campaign (does NOT publish) | `campaign_id`, `snippets` list of `{header, values}` with official header values and 3-10 values | | `draft_image_assets` | Create image assets for a campaign from local files (does NOT publish) | `campaign_id`, `image_paths` list of local PNG/JPEG/GIF files | @@ -164,16 +204,20 @@ These tools call both APIs internally and return unified results with computed ` | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | | `add_negative_keywords` | Propose negative keywords directly on a campaign (does NOT add) | `campaign_id`, keyword list, `match_type` | | `add_negative_locations` | Propose negative geo exclusions on a campaign (does NOT add) — exclude cities/regions while keeping broader positive targets | `campaign_id`, `geo_target_ids` (numeric geo target constant IDs) | -| `draft_key_event` | Mark a GA4 event as a key event/conversion (does NOT apply) — closes the tracking loop after attribution_check finds an untracked conversion | `event_name`, `counting_method` (ONCE_PER_EVENT for purchases / ONCE_PER_SESSION for sign-ups), `property_id` (falls back to config) | | `propose_negative_keyword_list` | Draft a shared negative keyword list and attach it to a campaign (does NOT create) | `campaign_id`, `list_name`, keyword list, `match_type` | | `add_to_negative_keyword_list` | Append keywords to an EXISTING shared negative keyword list (does NOT add) | `shared_set_id` (from `get_negative_keyword_lists`), keyword list, `match_type` | | `attach_shared_set_to_campaigns` | Attach an EXISTING shared set (e.g. shared negative keyword list) to one or more campaigns. Use after creating a campaign to inherit pre-built negatives. | `shared_set_id` (from `get_negative_keyword_lists`), `campaign_ids` list | | `detach_shared_set_from_campaigns` | Detach a shared set from one or more campaigns. Removes only the linkage; the shared set and its keywords stay intact. | `shared_set_id`, `campaign_ids` list | | `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income range) at ad group or campaign level. Defaults to EXCLUSION (`negative=True`). | exactly one of `ad_group_id` or `campaign_id`, at least one of `age_ranges`/`genders`/`parental_statuses`/`income_ranges`, optional `negative` (default True) | -| `pause_entity` | Propose pausing campaign/ad group/ad/keyword | `entity_type`, `entity_id` | -| `enable_entity` | Propose enabling paused entity | `entity_type`, `entity_id` | -| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset"), `entity_id` | -| `confirm_and_apply` | Execute a previously previewed change | `plan_id` from a draft tool, `dry_run` (default true) | +| `draft_key_event` | Mark a GA4 event as a key event/conversion (does NOT apply) — closes the tracking loop after attribution_check finds an untracked conversion | `event_name`, `counting_method` (ONCE_PER_EVENT for purchases / ONCE_PER_SESSION for sign-ups), `property_id` (falls back to config) | +| `list_labels` | Read tool — list all labels in the account | (none) | +| `draft_label` | Create a Label resource | `name` (REQUIRED), `description`, `background_color` (hex like '#FF5733') | +| `apply_label` | Attach an existing Label to a campaign / ad_group / ad / keyword | `entity_type`, `entity_id`, `label_id` | +| `unapply_label` | Detach a Label from one entity (does NOT delete the Label itself) | same as `apply_label` | +| `pause_entity` | Propose pausing a campaign / ad group / ad / keyword / asset_group | `entity_type`, `entity_id` | +| `enable_entity` | Propose enabling a paused entity (campaign / ad group / ad / keyword / asset_group) | `entity_type`, `entity_id` | +| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset", "asset_group", "asset_group_signal", "label"), `entity_id` | +| `confirm_and_apply` | Execute a previously previewed change. With `dry_run=true` (default), runs the plan against the Google Ads API with `validate_only=True` — full server-side validation, no changes committed. | `plan_id` from a draft tool, `dry_run` (default true) | **Write tool workflow:** 1. Call a `draft_*` tool → returns a preview with a `plan_id` @@ -182,8 +226,15 @@ These tools call both APIs internally and return unified results with computed ` 4. Only call with `dry_run=false` after explicit user confirmation **Safety behaviors:** -- New campaigns and RSAs are created as PAUSED — user must explicitly enable them after review. +- New campaigns, asset groups, and RSAs are created as PAUSED — user must explicitly enable them after review. - `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting. +- `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, and warns if budget is below 5x target CPA. +- `draft_campaign` rejects `channel_type=PERFORMANCE_MAX` — PMax requires the asset_group + assets + signals to be created in the same mutate as the campaign, which the Search-shaped draft cannot produce. Use `draft_pmax_campaign` for PMax. +- `draft_pmax_campaign` enforces Smart Bidding (rejects MANUAL_CPC and TARGET_SPEND) and PMax asset minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO). Image and logo assets must be uploaded before the campaign mutate — either via `draft_image_asset` (point at local JPG/PNG/GIF paths) or via the Google Ads UI — then pass the resulting resource_names. The Google Ads API rejects an asset_group create that's missing any image / logo minimum (`ASSET_GROUP_NOT_ENOUGH_MARKETING_IMAGE_ASSET` etc.), so there is no "text-only PMax draft" workflow. +- `update_campaign` replaces geo/language targets entirely (not append). Pass the full desired list. +- `remove_entity` is IRREVERSIBLE — always prefer `pause_entity` unless the user explicitly wants permanent removal. Removal triggers double confirmation in the safety layer. +- `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "campaign_asset", "asset_group", "asset_group_signal", "label". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "asset_group_signal" to remove a search-theme or audience signal — entity_id is the composite `assetGroupId~criterionId` returned by `get_asset_group_signals`. Use "label" to delete a Label resource (cascade-removes all assignments — to detach a single assignment, use `unapply_label` instead). +- `confirm_and_apply` with `dry_run=true` runs the plan against the Google Ads API with `validate_only=True`. The API performs full validation server-side and returns errors if the plan is malformed (e.g. PMax with `network_settings`, invalid bidding strategy, dangling resource references) — but commits nothing. A passing dry run means the real apply will pass the same validation. A failing dry run returns `status: DRY_RUN_VALIDATION_FAILED` with the actual API error. - `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, warns if budget is below 5x target CPA, and interprets `max_cpc` by bidding strategy: MANUAL_CPC seeds the initial ad-group bid, TARGET_SPEND sets the Maximize Clicks CPC ceiling. - `display_network_enabled` is the canonical Search display-expansion flag. `display_expansion_enabled` is only a compatibility alias and should be normalized away before presenting the plan to the user. - `update_ad_group` is the right tool for later MANUAL_CPC bid changes. Use `update_campaign` for TARGET_SPEND (Maximize Clicks) `max_cpc` changes. @@ -192,6 +243,7 @@ These tools call both APIs internally and return unified results with computed ` - `remove_entity` is IRREVERSIBLE — always prefer `pause_entity` unless the user explicitly wants permanent removal. Removal triggers double confirmation in the safety layer. - `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset". Use "negative_keyword" to remove campaign-level negative keywords. Use "shared_criterion" to remove a keyword from a shared negative keyword list — the `entity_id` format is "sharedSetId~criterionId" (use the `resource_id` field from `get_negative_keyword_list_keywords`). Use "ad_group_criterion" or "campaign_criterion" to remove demographic targeting (age/gender/parental/income) — pass the `remove_id` returned by `get_demographic_targeting`. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "asset" to remove a standalone asset. Use "customer_asset" to remove a customer-level asset link. - `require_dry_run: true` in config overrides `dry_run=false` — the user must change the config to allow real mutations. +- `two_phase_apply: true` in config (always on for AdLoop Cloud) refuses `dry_run=false` with status `DRY_RUN_REQUIRED` until that plan_id has completed one `dry_run=true` pass. Run the dry run, show the user, then apply — do not retry `dry_run=false` in a loop. - All operations (including dry runs) are logged to `~/.adloop/audit.log`. ## Safety Rules (CRITICAL — always follow) @@ -247,6 +299,17 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 7. Compare against best practices (see Marketing Best Practices section) 8. If the account has active Google recommendations, mention that `get_recommendations` can surface Google's suggestions for improvement +### When user asks about Performance Max performance + +PMax is structurally different from Search — different tools, different diagnostics, different levers. + +1. **Default to `analyze_pmax_performance`.** It pulls campaign + asset groups + assets + channel breakdown + GA4 in one call and returns auto-generated `insights[]`. Don't manually chain `get_pmax_campaigns` + `get_asset_groups` + `get_asset_group_assets` unless you need data the cross-ref tool doesn't surface. +2. If the user asks specifically about creative quality: present the asset groups sorted by `ad_strength`, then look at `asset_counts_by_type` and `missing_asset_minimums` to identify groups under-supplied with assets. The per-asset `performance_label` was removed in v24, so individual LOW asset diagnosis is no longer available — `get_asset_group_top_combinations` (which assets actually serve together) is the closest replacement. +3. If the user asks "where is my budget going?": use the `channel_breakdown` array and emphasize that PMax decides channel mix at serve time. If one channel dominates (>90%), that's worth flagging — Google may be suppressing other surfaces due to weak creative for those formats. +4. **Do NOT compare PMax CPA to Search CPA directly.** PMax includes Display, YouTube, and Discovery surfaces that have intrinsically different conversion dynamics. Compare PMax CPA to the campaign's `target_cpa` (if set) or to historical PMax CPA, not to a Search benchmark. +5. **Search terms work differently for PMax.** When users ask "what are people searching for" — explain that PMax does NOT expose individual queries (Google's design), only aggregated category labels via `get_pmax_search_terms`. Don't promise data the API doesn't return. +6. **GDPR consent gaps apply identically** — the click-to-session ratio insight in `analyze_pmax_performance` factors this in. + ### When user asks about conversions or conversion drops 1. Call `attribution_check` with relevant date range and `conversion_events` if the user mentions specific events (e.g. sign_up, purchase) — this does the Ads vs GA4 comparison in one call and auto-generates insights @@ -277,6 +340,27 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 10. After the ad is created, **suggest sitelinks** if the campaign doesn't have any. Use `draft_sitelinks` with at least 4 relevant links (key pages like pricing, features, signup, etc.). Sitelinks increase ad real estate and CTR. 11. Wait for explicit user approval before calling `confirm_and_apply` +### When user wants to fix issues with an existing RSA + +**Use `draft_rsa_replacement` when an ad has problems that need correcting** — wrong data (e.g. "200+ reviews" should be "300+"), truncated city names, mixed A/B messaging, character limit violations, broken display paths, etc. This tool removes the broken ad and creates a corrected replacement. + +**Do NOT use this for A/B testing or creating ad variants.** For that, use `draft_responsive_search_ad` to add a new ad alongside the existing one, and optionally `pause_entity` to pause the old one. + +1. Call `get_ad_performance` to find the ad ID and see current copy +2. **Pre-write checks (same as ad creation):** + - Is the campaign's bidding strategy appropriate? + - Does the campaign have conversions? + - What are quality scores? +3. Review the current headlines and descriptions — identify exactly what needs fixing +4. Write the complete corrected set of headlines (3-15) and descriptions (2-4), following the "Ad Copy Character Limits" section. Count characters for every headline before generating. +5. If the user hasn't specified a `final_url`, the tool inherits it from the old ad — no need to provide it +6. Call `draft_rsa_replacement` with the old `ad_id` and the corrected copy +7. Present the diff preview (old vs new) to the user — the preview shows both old and new copy side-by-side +8. Wait for explicit user approval before calling `confirm_and_apply` +9. The old ad is **permanently removed** by default (so it can't be accidentally re-enabled). The new ad is created as PAUSED. +10. Remind the user to enable the new ad via `enable_entity` after reviewing in Google Ads UI +11. Only pass `remove_old=false` if the user explicitly wants to keep the old ad around (paused) for reference + ### When user wants to add keywords 1. Call `get_campaign_performance` to identify the target campaign and its **bidding strategy** @@ -307,6 +391,55 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has - Add ads via `draft_responsive_search_ad` (with display paths set) - Add sitelinks via `draft_sitelinks` (at least 4 recommended) - If the user needs multiple ad groups (e.g., different keyword themes), use `draft_ad_group` to add additional ad groups after the initial campaign is created and confirmed + - Enable the campaign via `enable_entity` only after ads and sitelinks are in place +8. Wait for explicit user approval before calling `confirm_and_apply` + +### When user wants to create a new Performance Max campaign + +PMax is structurally different — there is no `draft_campaign` path for it. PMax requires the campaign + asset group + assets + signals to be created in the same atomic mutate. There is no "text-only" or "draft-and-add-images-later" path: the Google Ads API rejects an asset_group create with any missing image/logo minimum (`ASSET_GROUP_NOT_ENOUGH_MARKETING_IMAGE_ASSET`, `...SQUARE_MARKETING_IMAGE_ASSET`, `...LOGO_ASSET`). Images and logos MUST exist as Asset resources before `draft_pmax_campaign` is called. + +1. Call `get_pmax_campaigns` to see existing PMax campaigns and avoid name duplicates +2. **Get the image/logo resource_names ready.** PMax needs at least one MARKETING_IMAGE (1.91:1, ≥600x314), one SQUARE_MARKETING_IMAGE (1:1, ≥300x300), and one LOGO (1:1, ≥128x128) before the campaign can be created. Two options: + - **Upload via the MCP**: call `draft_image_asset(images=[{file_path, name}, ...])`, confirm, and use the returned resource_names. JPG/PNG/GIF, ≤5 MB each, absolute paths. + - **Upload via the Google Ads UI**: ask the user for the resource_names of assets they've already uploaded (format `customers/.../assets/...`). +3. **Pre-write checks (CRITICAL):** + - **Bidding strategy**: PMax accepts only Smart Bidding — `MAXIMIZE_CONVERSIONS`, `MAXIMIZE_CONVERSION_VALUE`, `TARGET_CPA`, `TARGET_ROAS`. The tool rejects MANUAL_CPC and TARGET_SPEND for PMax. + - **Geo targeting**: ALWAYS ask the user which countries/regions to target. + - **Language targeting**: ALWAYS ask the user which languages. + - **Asset minimums**: 3-5 headlines (≤30 chars), 1-5 long_headlines (≤90 chars), 2-5 descriptions (≤90 chars), 1 business_name (≤25 chars), at least 1 marketing_image, 1 square_marketing_image, 1 logo (resource_names from step 2). + - **Brand Guidelines**: `draft_pmax_campaign` defaults `brand_guidelines_enabled=True` because new PMax campaigns default to it on Google's side. With it on, BUSINESS_NAME and the first LOGO are auto-linked at the campaign level via CampaignAsset (the API otherwise rejects with `REQUIRED_BUSINESS_NAME_ASSET_NOT_LINKED` / `REQUIRED_LOGO_ASSET_NOT_LINKED`). Pass `brand_guidelines_enabled=False` if the user explicitly wants the legacy "no brand guidelines" behavior. + - **Conversion tracking**: PMax depends heavily on Smart Bidding signals. Call `attribution_check` — if zero conversions across the board, WARN that PMax won't optimize without tracking working first. + - **Budget**: ideally ≥ 5x target CPA; the tool warns otherwise. +4. Call `draft_pmax_campaign` with campaign details + the full `asset_group` dict (name, final_urls, headlines, long_headlines, descriptions, business_name, marketing_image_assets, square_marketing_image_assets, logo_assets, and optionally search_themes / audience_resource_names). +5. Present the complete preview to the user — emphasize the campaign will be created as PAUSED. +6. Call `confirm_and_apply(plan_id=..., dry_run=true)` first — this runs `validate_only=True` against Google Ads and surfaces any API rejections (e.g. invalid asset shapes, missing minimums) before applying for real. +7. After dry run passes and user approves, call `confirm_and_apply(plan_id=..., dry_run=false)`. +8. Remind the user to enable the PMax campaign via `enable_entity(entity_type='campaign', entity_id=...)` after reviewing in Google Ads UI. + +### When user wants to upload images / logos for PMax + +1. Confirm the user has the files locally and knows the absolute paths. +2. Group the files into one `draft_image_asset(images=[{file_path, name}, ...])` call — a single batch is cheaper than one upload per file. Names are display names that show up in the Ads UI Asset Library; pick descriptive ones the user will recognize (e.g. `"Acme Logo - Square"`). +3. Present the preview. Each image has its file_path, mime_type, and file_size; on confirm the bytes are read fresh from disk and uploaded via `AssetService.MutateAssets`. +4. Call `confirm_and_apply(plan_id=..., dry_run=true)` to let Google validate dimensions and policy. A dry run failure here usually means wrong aspect ratio for the slot the user wants to fill — surface the exact Google Ads error. +5. Then call `confirm_and_apply(plan_id=..., dry_run=false)`. The result contains `uploaded` — a list of `{name, resource_name}`. Use those resource_names in `draft_pmax_campaign` / `draft_asset_group` / `draft_asset_group_assets`. +6. The same uploaded Asset can be linked as MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, or LOGO at link time as long as its real pixel dimensions fit the slot — Google enforces the aspect ratio when the AssetGroupAsset link is created, not at upload time. + +### When user wants to add a new asset group, more assets, or signals to an existing PMax campaign + +- **New asset group**: `draft_asset_group(campaign_id, asset_group=...)` — same `asset_group` shape as `draft_pmax_campaign`. Each asset group has independent assets and is its own creative bundle. +- **More assets on an existing asset group**: `draft_asset_group_assets(asset_group_id, headlines=[...], long_headlines=[...], descriptions=[...], business_name=..., marketing_image_assets=[...], square_marketing_image_assets=[...], logo_assets=[...], youtube_video_ids=[...])` — pass only what you want to add. Text and YouTube assets are created inline; images/logos must already exist as Asset resources (upload them via `draft_image_asset` first, or paste resource_names from the Google Ads UI). +- **New signal on an existing asset group**: `draft_asset_group_signal(asset_group_id, search_theme="..." OR audience_resource_name="customers/.../audiences/...")`. Pass exactly one. Search themes are immutable — to "edit" one, remove and re-add. + +To pause/enable an asset group, use `pause_entity`/`enable_entity` with `entity_type="asset_group"`. To remove one, use `remove_entity` (irreversible). + +### When user wants to add labels or filter by label + +1. Call `list_labels` to discover existing labels. +2. To create a new label, call `draft_label(name, description=..., background_color="#RRGGBB")`, then `confirm_and_apply`. +3. To attach a label, call `apply_label(entity_type='campaign'|'ad_group'|'ad'|'keyword', entity_id=..., label_id=...)`. The label must already exist — capture the `label_id` from `list_labels` or from the result of `draft_label` + apply. +4. To detach a label from one entity, call `unapply_label` with the same args. To delete the Label resource itself (cascades), use `remove_entity(entity_type='label', entity_id=...)`. + - **If a shared negative keyword list already exists** for the account (check via `get_negative_keyword_lists`), call `attach_shared_set_to_campaigns` so the new campaign inherits historical waste-blockers. New campaigns don't auto-inherit shared lists — this step is easy to forget. - Enable the campaign via `enable_entity` only after ads and sitelinks are in place 8. Wait for explicit user approval before calling `confirm_and_apply` @@ -343,7 +476,7 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 1. Ask the user for their target keywords (or suggest some based on the business context) 2. Ask for the target geography and language (or infer from the existing account) 3. Call `estimate_budget` with the keywords, match types, and optional daily budget -4. Present the forecast: estimated clicks, impressions, cost, and avg CPC +4. Present the forecast: estimated clicks, cost, avg CPC, and conversions 5. If the user provided a daily budget, highlight whether it's sufficient to capture most available traffic 6. Use the forecast to inform `draft_campaign` decisions — the estimated daily cost guides the budget parameter @@ -517,9 +650,11 @@ LIMIT n | `campaign_budget` | Budget information | | `bidding_strategy` | Bidding strategy details | | `customer_client` | List accounts under an MCC (uses login_customer_id) | -| `asset_group` | PMax asset group data (ad strength, status) | -| `asset_group_asset` | PMax per-asset performance labels | -| `asset_group_top_combination_view` | PMax top asset combinations | +| `asset_group` | Performance Max asset groups (PMax equivalent of ad groups) | +| `asset_group_asset` | Individual assets in PMax asset groups with field_type, status, policy review_status (no `performance_label` in v24) | +| `asset_group_signal` | Search themes and audience signals attached to PMax asset groups | +| `asset_group_top_combination_view` | Top serving combinations Google has assembled for PMax | +| `campaign_search_term_insight` | Aggregated PMax search-term categories (v23.2+, no individual queries) | | `recommendation` | Google's auto-generated recommendations | | `ad_group_audience_view` | Audience segment performance | @@ -554,7 +689,21 @@ LIMIT n **Segments (for time-based breakdowns):** - `segments.date` — daily breakdown - `segments.device` — MOBILE, DESKTOP, TABLET -- `segments.ad_network_type` — SEARCH, CONTENT, YOUTUBE +- `segments.ad_network_type` — SEARCH, CONTENT, YOUTUBE_WATCH, YOUTUBE_SEARCH, MIXED (PMax channel breakdown — only reliable from 2025-06-01 onwards) + +**Performance Max fields:** +- `campaign.advertising_channel_type = 'PERFORMANCE_MAX'` — filter for PMax campaigns +- `campaign.brand_guidelines_enabled` — boolean; when true, business name + logos are at campaign level not asset group +- `asset_group.id`, `asset_group.name`, `asset_group.status`, `asset_group.ad_strength` (POOR/AVERAGE/GOOD/EXCELLENT) +- `asset_group_asset.field_type` (HEADLINE, DESCRIPTION, MARKETING_IMAGE, LOGO, YOUTUBE_VIDEO, etc.) +- `asset_group_asset.policy_summary.review_status`, `asset_group_asset.status` +- `asset_group_signal.search_theme.text`, `asset_group_signal.audience.audience` + +**Removed in API v24 — DO NOT use:** +- `campaign.url_expansion_opt_out` (and `campaign.url_expansion_optimization`) — replaced by `Campaign.asset_automation_settings` with `AssetAutomationType=FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION`. +- `asset_group_asset.performance_label` — the LOW/GOOD/BEST/PENDING per-asset rating is no longer populated. Look at `asset_group_top_combination_view` (which assets actually serve) or query metrics on `asset_field_type_view` for per-field-type performance. +- `metrics.cost_micros`, `metrics.conversions`, `metrics.conversions_value` on `campaign_search_term_insight` — the API returns PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE. Only `metrics.clicks` and `metrics.impressions` are selectable on this resource. +- Any `metrics.*` on `asset_group_top_combination_view` — PROHIBITED_METRIC. The view exposes only `asset_group_top_combination_view.asset_group_top_combinations` (a repeated message). ### Date Ranges @@ -613,6 +762,31 @@ FROM ad_group WHERE campaign.id = 12345678 ``` +**Performance Max asset groups with ad strength:** +```sql +SELECT asset_group.id, asset_group.name, asset_group.ad_strength, + campaign.name, metrics.cost_micros, metrics.conversions +FROM asset_group +WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND segments.date DURING LAST_30_DAYS +ORDER BY metrics.cost_micros DESC +``` + +**Performance Max — list all assets in an asset group:** +```sql +SELECT asset_group.id, asset_group.name, + asset_group_asset.field_type, + asset_group_asset.status, + asset_group_asset.policy_summary.review_status, + asset.text_asset.text, + asset.image_asset.full_size.url +FROM asset_group_asset +WHERE asset_group.id = 6572147947 + AND asset_group_asset.status != 'REMOVED' +``` + +(`asset_group_asset.performance_label` was removed in v24 — see "Removed in API v24" above.) + ## Ad Copy Character Limits Google Ads enforces hard character limits. The `draft_responsive_search_ad` tool will reject copy that exceeds them, but you must write copy that fits on the FIRST attempt — do not generate copy and hope it fits. @@ -685,6 +859,7 @@ When advising on Google Ads: - **Display paths**: Always set `path1` and `path2` on RSAs. They cost nothing, improve ad relevance, and make the display URL informative (e.g. `example.com/Features/Pricing` instead of bare `example.com`). Derive them from the landing page path or the ad's core message. Max 15 chars each. - **Sitelinks**: Every campaign should have at least 4 sitelinks. They increase ad real estate (more screen space = higher CTR), direct users to key pages, and are free. Good candidates: pricing, features, signup/trial, about, key product pages. Use `draft_sitelinks` to create them. Link text max 25 chars, descriptions max 35 chars each. - **Clicks vs sessions gap**: Never report a clicks > sessions discrepancy as a tracking bug without first accounting for GDPR consent. In the EU, 30-70% of users may reject analytics cookies. This is normal, not broken. +- **Performance Max specifics**: PMax campaigns are Smart Bidding only (`MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE`) — no MANUAL_CPC option, so the BROAD-match-without-Smart-Bidding rule doesn't apply. Channel mix is decided by Google at serve time; you cannot directly target Search-only or YouTube-only. Asset groups should ideally have `ad_strength = GOOD` or better, and they should meet Google's documented asset-type minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, etc. — `analyze_pmax_performance` flags any below-minimum groups). PMax search-term insights expose categories only, not individual queries. **Image and logo assets must exist before the campaign mutate.** Use `draft_image_asset` to upload local JPG/PNG/GIF files (≤5 MB each) directly from the MCP, or upload via the Google Ads UI and paste the resource_names — there is no "text-only PMax" path (the API rejects asset_group creates that are missing any image / logo minimum). - **Performance Max asset diversity**: PMax campaigns need diverse assets to perform well. Minimum recommended: 5+ headlines (max 30 chars), 5+ long headlines (max 90 chars), 5+ descriptions (max 90 chars), 5+ marketing images (1200x628), 5+ square images (1200x1200), 1+ logo (1200x1200), 1+ landscape logo (1200x300). A YouTube video is strongly recommended. Ad strength below GOOD usually means missing asset types. - **PMax transparency limitations**: The Google Ads API does not provide full channel-level breakdowns for PMax. `segments.ad_network_type` returns SEARCH, CONTENT, YOUTUBE_SEARCH, YOUTUBE_WATCH, and MIXED — but MIXED is a catch-all for most traffic. Do not promise users exact Search vs Display vs YouTube splits. Be honest about what the data shows and what it doesn't. - **Google recommendations are not neutral**: Google's auto-generated recommendations optimize for Google's revenue, not necessarily the advertiser's ROI. Budget increase and Broad Match recommendations should always be cross-referenced against actual conversion data. Bid strategy recommendations (switch to Smart Bidding) are usually sound. Keyword recommendations may be too broad. Never blindly apply recommendations — evaluate each one against the account's actual performance. diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index 2ed7593..7adf03f 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -23,10 +23,12 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | Tool | When to Use | Key Parameters | |------|-------------|----------------| | `get_account_summaries` | First-time discovery — find which GA4 properties exist | (none — uses config) | -| `run_ga4_report` | Any analytics question — sessions, users, conversions, page performance | `dimensions`, `metrics`, `date_range_start`, `date_range_end`, `limit` | +| `run_ga4_report` | Any analytics question — sessions, users, conversions, page performance | `dimensions`, `metrics`, `date_range_start`, `date_range_end`, `limit`, `dimension_filter` | | `run_realtime_report` | After code deploys — verify tracking fires correctly | `dimensions`, `metrics` | | `get_tracking_events` | Understanding what events are configured and their volume | `date_range_start`, `date_range_end` | +**GA4 report filtering:** `run_ga4_report` supports a `dimension_filter` parameter — a dict of `dimension_name -> exact_value` pairs combined with AND logic. Use `{"sessionSource": "google", "sessionMedium": "cpc"}` to isolate paid search traffic server-side instead of pulling all sources and filtering in post-processing. + ### Google Ads Read Tools | Tool | When to Use | Key Parameters | @@ -35,7 +37,7 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | `get_campaign_performance` | Campaign-level metrics — impressions, clicks, cost, conversions | `date_range_start`, `date_range_end` | | `get_ad_performance` | Ad copy analysis — which headlines/descriptions work | `date_range_start`, `date_range_end` | | `get_keyword_performance` | Keyword analysis — quality scores, competitive metrics | `date_range_start`, `date_range_end` | -| `get_search_terms` | Find negative keyword opportunities and understand user intent | `date_range_start`, `date_range_end` | +| `get_search_terms` | Find negative keyword opportunities and understand user intent | `date_range_start`, `date_range_end`, `campaign_id` (optional) | | `get_negative_keywords` | List direct campaign-level negative keywords (not inside SharedSets) | `campaign_id` (optional) | | `get_negative_keyword_lists` | List all shared negative keyword lists — names, IDs, status, keyword count | (none) | | `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list | `shared_set_id` (required) | @@ -52,6 +54,33 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics - Ads read tools automatically compute `metrics.cost` and `metrics.cpa` from `metrics.cost_micros` — no manual division needed. `metrics.currency` contains the account's currency code (auto-detected). - `metrics.average_cpc_amount` is also pre-computed where available. - `get_ad_performance` returns full `headlines` and `descriptions` lists for RSAs. +- `get_keyword_performance` returns `ad_group.id`, `ad_group.name`, and `ad_group_criterion.criterion_id` — use these to construct `entity_id` strings (e.g. `"adGroupId~criterionId"`) for `pause_entity` calls, and `ad_group.name` for human-readable reporting. +- `get_search_terms` returns `campaign.id` and `metrics.cost` per search term — use `campaign.id` for `add_negative_keywords`, and cost for negative keyword analysis (flag terms spending > 2-3x CPA with zero conversions). + +### Performance Max Read Tools + +Performance Max (PMax) campaigns have a different structure than Search: +- **No ad groups, no keywords, no individual ads.** Instead, an `asset_group` bundles assets (headlines, descriptions, images, logos, videos) that Google assembles dynamically per impression. +- **Channel is decided at serve time.** A PMax campaign can serve on Search, Display, YouTube, Gmail, Discover, Maps, or Shopping — Google chooses based on signals. +- **Search terms are not exposed individually.** PMax surfaces aggregated category-level insights only. + +| Tool | When to Use | Key Parameters | +|------|-------------|----------------| +| `get_pmax_campaigns` | Discover PMax campaigns and their PMax-specific settings (brand guidelines) | `date_range_start`, `date_range_end` | +| `get_pmax_channel_breakdown` | "Where is my PMax spend going?" — Search vs YouTube vs Display etc. | `date_range_start`, `date_range_end`, `campaign_id` (optional) | +| `get_asset_groups` | List asset groups in a PMax campaign with ad strength + metrics | `campaign_id` (optional), `date_range_start`, `date_range_end` | +| `get_asset_group_assets` | Inspect every asset's `field_type`, status, and policy `review_status` | `asset_group_id` OR `campaign_id` | +| `get_asset_group_signals` | List search themes and audience signals attached to an asset group | `asset_group_id` OR `campaign_id` | +| `get_asset_group_top_combinations` | See which headline+description+image combos Google assembled (no metrics in v24) | `asset_group_id` OR `campaign_id`, `date_range_start`, `date_range_end` | +| `get_pmax_search_terms` | Get aggregated category-level insights (not individual queries) for a PMax campaign | `campaign_id` (REQUIRED), `date_range_start`, `date_range_end` | + +**PMax read tool notes:** +- `get_pmax_campaigns` returns `metrics.cost`, `metrics.cpa`, `metrics.roas`, and `campaign_budget.amount` pre-computed. The Search-style `bidding_strategy_type` is shown — for PMax it's typically `MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE` (Smart Bidding only). +- `get_pmax_channel_breakdown` is only reliable from **2025-06-01 onwards**. Earlier rows return `MIXED` for `segments.ad_network_type` because Google could not attribute. The tool emits a warning in `insights[]` when the date range overlaps that period. +- `get_asset_group_assets` no longer returns the `performance_label` (LOW/GOOD/BEST/PENDING) field — Google removed it from `asset_group_asset` in API v24. To judge per-asset performance now, look at `asset_group_top_combinations` (which assets actually serve together) or query `asset_field_type_view` for per-field-type metrics. +- `get_asset_group_signals` returns `signal_type = SEARCH_THEME | AUDIENCE | UNKNOWN`. Search themes are immutable once created — to "edit", you remove and re-create. +- `get_asset_group_top_combinations` does NOT return any `metrics.*` fields in v24 — the API rejects them with PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE. Combinations come pre-ordered by Google by serving frequency. +- `get_pmax_search_terms` requires API v23.2+. Returns `metrics.impressions` and `metrics.clicks` only — `metrics.cost_micros`, `metrics.conversions`, `metrics.conversions_value` are not selectable on `campaign_search_term_insight`. Google deliberately does NOT expose individual search queries for PMax campaigns. Don't tell users they can see exactly what someone typed, or that this tool surfaces per-category cost. - `get_recommendations` returns `estimated_improvement` per recommendation (potential minus base metrics) and `insights[]` that flag self-serving budget recommendations. - PMax tools: `get_pmax_performance` returns `insights[]` flagging weak ad strength and zero-conversion asset groups. `segments.ad_network_type` includes MIXED — a Google catch-all for most PMax traffic. Full channel splits (Search vs YouTube vs Display vs Discover) are not available via the API. - `get_asset_performance` returns `by_status` and `by_field_type` summaries. Note: per-asset performance labels (BEST/GOOD/LOW) are not available for PMax assets in the Google Ads API. Use `get_detailed_asset_performance` for quality signals via top combinations. @@ -66,14 +95,19 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | `analyze_campaign_conversions` | "What's my real CPA?", paid vs organic comparison, GDPR gap analysis | `date_range_start`, `date_range_end`, `campaign_name` (optional filter) | | `landing_page_analysis` | "Which landing pages convert?", identify pages with traffic but no conversions | `date_range_start`, `date_range_end` | | `attribution_check` | "Are my conversions tracked correctly?", Ads vs GA4 conversion discrepancies | `date_range_start`, `date_range_end`, `conversion_events` (optional GA4 event names) | +| `analyze_pmax_performance` | One-call PMax diagnostic — campaign + asset groups + asset counts + channel mix + GA4 | `date_range_start`, `date_range_end`, `campaign_id` (optional filter) | These tools call both APIs internally and return unified results with computed `insights[]`. They are read-only — no mutations. Each returns a `date_range` and auto-generates conditional warnings (GDPR gaps, zero conversions, attribution mismatches, orphaned URLs). +**`analyze_campaign_conversions` details:** Returns one row per campaign (with `campaign_id`) including `conversion_discrepancy_pct` between Ads and GA4. When `campaign_name` is omitted, all campaigns are returned — no need to call once per campaign. + +**`analyze_pmax_performance` details:** Aggregates everything you can see about Performance Max in one call: campaign metrics + bidding/brand-guidelines settings, every asset group with its `ad_strength` and asset counts, the channel-mix breakdown, and (when a GA4 property is configured) GA4 paid sessions/conversions per campaign. The `insights[]` flag POOR/AVERAGE asset groups, asset groups missing the documented PMax minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE, 1+ SQUARE_MARKETING_IMAGE, 1+ LOGO), channel-spend skew (>90% on a single surface), zero-conversion campaigns, GDPR consent gaps, and pre-2025-06-01 data caveats. Use this as the FIRST call when the user asks about PMax performance — it eliminates 4-5 separate read tool calls. + ### Tracking Tools | Tool | When to Use | Key Parameters | |------|-------------|----------------| -| `validate_tracking` | Compare codebase event code against actual GA4 events — find missing/broken tracking | `expected_events` (list of event names found in code), `date_range_start`, `date_range_end` | +| `validate_tracking` | Compare codebase event code against actual GA4 events — find missing/broken tracking | `expected_events` (list of event names found in code), `date_range_start`, `date_range_end`, `customer_id` (optional — cross-refs Ads conversion actions) | | `generate_tracking_code` | Generate ready-to-paste GA4 gtag JavaScript for an event | `event_name`, `event_params` (optional), `trigger` (form_submit/button_click/page_load) | `validate_tracking` requires the AI to first search the codebase for `gtag('event', ...)` or `dataLayer.push({event: ...})` calls, extract event names, then pass them to the tool. The tool queries GA4 and returns a structured comparison (matched, missing, unexpected, auto-collected). @@ -154,11 +188,17 @@ These tools call both APIs internally and return unified results with computed ` | Tool | What It Does | Validation | |------|-------------|------------| -| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), optional `search_partners_enabled`, `display_network_enabled`, `display_expansion_enabled`, optional `max_cpc` for MANUAL_CPC ad-group bids or TARGET_SPEND CPC caps | -| `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | -| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` | +| `draft_campaign` | Create a SEARCH campaign (budget + campaign + ad group + keywords + geo/language targeting). Auto-sets Final URL Suffix with UTM tracking. **Rejects channel_type=PERFORMANCE_MAX** — use `draft_pmax_campaign` instead. | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated, `final_url_suffix` (auto-set for SEARCH, pass "" to disable), optional `search_partners_enabled`, `display_network_enabled`, `max_cpc` (MANUAL_CPC ad-group bid or TARGET_SPEND CPC cap) | +| `draft_pmax_campaign` | Create a Performance Max campaign with its first asset group + assets + signals atomically. PMax has no ad groups, no keywords, no `network_settings`. | `campaign_name`, `daily_budget`, `bidding_strategy` (Smart Bidding only), `geo_target_ids`, `language_ids`, `asset_group` dict (see PMax Write Tools section), `brand_guidelines_enabled` (default True — auto-links BUSINESS_NAME + first LOGO as CampaignAsset) | +| `draft_asset_group` | Add a new asset group (with assets + signals) to an existing PMax campaign | `campaign_id` (REQUIRED), `asset_group` dict | +| `draft_asset_group_assets` | Add headlines / long_headlines / descriptions / business_name / image refs / YouTube videos to an existing asset group | `asset_group_id` (REQUIRED), plus any of the asset arrays | +| `draft_asset_group_signal` | Add a single signal (search theme OR audience) to an asset group | `asset_group_id` (REQUIRED), plus exactly one of `search_theme` / `audience_resource_name` | +| `draft_image_asset` | Upload one or more local JPG/PNG/GIF images to the account as Google Ads Assets. Returns resource_names you can pass into `draft_pmax_campaign` / `draft_asset_group` / `draft_asset_group_assets`. | `images` list of `{file_path, name}` dicts. Absolute paths, ≤5 MB each, JPG/PNG/GIF only. Bytes are read at apply time. | +| `draft_ad_group` | Create a new ad group within an existing SEARCH campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo/language targets, Final URL suffix, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` | | `update_ad_group` | Update ad group name and/or MANUAL_CPC `max_cpc` | `ad_group_id`, optional `ad_group_name`, optional `max_cpc` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`. Google permits ≤2 headlines per slot, ≤1 description per slot. | +| `draft_rsa_replacement` | **Fix** an existing RSA — creates corrected replacement and removes the old ad. Use for copy errors, not A/B testing. For testing variants, use `draft_responsive_search_ad` instead. | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default true). Supports **pinning** — same format as `draft_responsive_search_ad`. | | `draft_callouts` | Create callout assets for a campaign (does NOT publish) | `campaign_id`, `callouts` list with 1-25 chars each | | `draft_structured_snippets` | Create structured snippet assets for a campaign (does NOT publish) | `campaign_id`, `snippets` list of `{header, values}` with official header values and 3-10 values | | `draft_image_assets` | Create image assets for a campaign from local files (does NOT publish) | `campaign_id`, `image_paths` list of local PNG/JPEG/GIF files | @@ -166,16 +206,20 @@ These tools call both APIs internally and return unified results with computed ` | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | | `add_negative_keywords` | Propose negative keywords directly on a campaign (does NOT add) | `campaign_id`, keyword list, `match_type` | | `add_negative_locations` | Propose negative geo exclusions on a campaign (does NOT add) — exclude cities/regions while keeping broader positive targets | `campaign_id`, `geo_target_ids` (numeric geo target constant IDs) | -| `draft_key_event` | Mark a GA4 event as a key event/conversion (does NOT apply) — closes the tracking loop after attribution_check finds an untracked conversion | `event_name`, `counting_method` (ONCE_PER_EVENT for purchases / ONCE_PER_SESSION for sign-ups), `property_id` (falls back to config) | | `propose_negative_keyword_list` | Draft a shared negative keyword list and attach it to a campaign (does NOT create) | `campaign_id`, `list_name`, keyword list, `match_type` | | `add_to_negative_keyword_list` | Append keywords to an EXISTING shared negative keyword list (does NOT add) | `shared_set_id` (from `get_negative_keyword_lists`), keyword list, `match_type` | | `attach_shared_set_to_campaigns` | Attach an EXISTING shared set (e.g. shared negative keyword list) to one or more campaigns. Use after creating a campaign to inherit pre-built negatives. | `shared_set_id` (from `get_negative_keyword_lists`), `campaign_ids` list | | `detach_shared_set_from_campaigns` | Detach a shared set from one or more campaigns. Removes only the linkage; the shared set and its keywords stay intact. | `shared_set_id`, `campaign_ids` list | | `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income range) at ad group or campaign level. Defaults to EXCLUSION (`negative=True`). | exactly one of `ad_group_id` or `campaign_id`, at least one of `age_ranges`/`genders`/`parental_statuses`/`income_ranges`, optional `negative` (default True) | -| `pause_entity` | Propose pausing campaign/ad group/ad/keyword | `entity_type`, `entity_id` | -| `enable_entity` | Propose enabling paused entity | `entity_type`, `entity_id` | -| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset"), `entity_id` | -| `confirm_and_apply` | Execute a previously previewed change | `plan_id` from a draft tool, `dry_run` (default true) | +| `draft_key_event` | Mark a GA4 event as a key event/conversion (does NOT apply) — closes the tracking loop after attribution_check finds an untracked conversion | `event_name`, `counting_method` (ONCE_PER_EVENT for purchases / ONCE_PER_SESSION for sign-ups), `property_id` (falls back to config) | +| `list_labels` | Read tool — list all labels in the account | (none) | +| `draft_label` | Create a Label resource | `name` (REQUIRED), `description`, `background_color` (hex like '#FF5733') | +| `apply_label` | Attach an existing Label to a campaign / ad_group / ad / keyword | `entity_type`, `entity_id`, `label_id` | +| `unapply_label` | Detach a Label from one entity (does NOT delete the Label itself) | same as `apply_label` | +| `pause_entity` | Propose pausing a campaign / ad group / ad / keyword / asset_group | `entity_type`, `entity_id` | +| `enable_entity` | Propose enabling a paused entity (campaign / ad group / ad / keyword / asset_group) | `entity_type`, `entity_id` | +| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset", "asset_group", "asset_group_signal", "label"), `entity_id` | +| `confirm_and_apply` | Execute a previously previewed change. With `dry_run=true` (default), runs the plan against the Google Ads API with `validate_only=True` — full server-side validation, no changes committed. | `plan_id` from a draft tool, `dry_run` (default true) | **Write tool workflow:** 1. Call a `draft_*` tool → returns a preview with a `plan_id` @@ -184,8 +228,15 @@ These tools call both APIs internally and return unified results with computed ` 4. Only call with `dry_run=false` after explicit user confirmation **Safety behaviors:** -- New campaigns and RSAs are created as PAUSED — user must explicitly enable them after review. +- New campaigns, asset groups, and RSAs are created as PAUSED — user must explicitly enable them after review. - `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting. +- `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, and warns if budget is below 5x target CPA. +- `draft_campaign` rejects `channel_type=PERFORMANCE_MAX` — PMax requires the asset_group + assets + signals to be created in the same mutate as the campaign, which the Search-shaped draft cannot produce. Use `draft_pmax_campaign` for PMax. +- `draft_pmax_campaign` enforces Smart Bidding (rejects MANUAL_CPC and TARGET_SPEND) and PMax asset minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO). Image and logo assets must be uploaded before the campaign mutate — either via `draft_image_asset` (point at local JPG/PNG/GIF paths) or via the Google Ads UI — then pass the resulting resource_names. The Google Ads API rejects an asset_group create that's missing any image / logo minimum (`ASSET_GROUP_NOT_ENOUGH_MARKETING_IMAGE_ASSET` etc.), so there is no "text-only PMax draft" workflow. +- `update_campaign` replaces geo/language targets entirely (not append). Pass the full desired list. +- `remove_entity` is IRREVERSIBLE — always prefer `pause_entity` unless the user explicitly wants permanent removal. Removal triggers double confirmation in the safety layer. +- `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "campaign_asset", "asset_group", "asset_group_signal", "label". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "asset_group_signal" to remove a search-theme or audience signal — entity_id is the composite `assetGroupId~criterionId` returned by `get_asset_group_signals`. Use "label" to delete a Label resource (cascade-removes all assignments — to detach a single assignment, use `unapply_label` instead). +- `confirm_and_apply` with `dry_run=true` runs the plan against the Google Ads API with `validate_only=True`. The API performs full validation server-side and returns errors if the plan is malformed (e.g. PMax with `network_settings`, invalid bidding strategy, dangling resource references) — but commits nothing. A passing dry run means the real apply will pass the same validation. A failing dry run returns `status: DRY_RUN_VALIDATION_FAILED` with the actual API error. - `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, warns if budget is below 5x target CPA, and interprets `max_cpc` by bidding strategy: MANUAL_CPC seeds the initial ad-group bid, TARGET_SPEND sets the Maximize Clicks CPC ceiling. - `display_network_enabled` is the canonical Search display-expansion flag. `display_expansion_enabled` is only a compatibility alias and should be normalized away before presenting the plan to the user. - `update_ad_group` is the right tool for later MANUAL_CPC bid changes. Use `update_campaign` for TARGET_SPEND (Maximize Clicks) `max_cpc` changes. @@ -250,6 +301,17 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 7. Compare against best practices (see Marketing Best Practices section) 8. If the account has active Google recommendations, mention that `get_recommendations` can surface Google's suggestions for improvement +### When user asks about Performance Max performance + +PMax is structurally different from Search — different tools, different diagnostics, different levers. + +1. **Default to `analyze_pmax_performance`.** It pulls campaign + asset groups + assets + channel breakdown + GA4 in one call and returns auto-generated `insights[]`. Don't manually chain `get_pmax_campaigns` + `get_asset_groups` + `get_asset_group_assets` unless you need data the cross-ref tool doesn't surface. +2. If the user asks specifically about creative quality: present the asset groups sorted by `ad_strength`, then look at `asset_counts_by_type` and `missing_asset_minimums` to identify groups under-supplied with assets. The per-asset `performance_label` was removed in v24, so individual LOW asset diagnosis is no longer available — `get_asset_group_top_combinations` (which assets actually serve together) is the closest replacement. +3. If the user asks "where is my budget going?": use the `channel_breakdown` array and emphasize that PMax decides channel mix at serve time. If one channel dominates (>90%), that's worth flagging — Google may be suppressing other surfaces due to weak creative for those formats. +4. **Do NOT compare PMax CPA to Search CPA directly.** PMax includes Display, YouTube, and Discovery surfaces that have intrinsically different conversion dynamics. Compare PMax CPA to the campaign's `target_cpa` (if set) or to historical PMax CPA, not to a Search benchmark. +5. **Search terms work differently for PMax.** When users ask "what are people searching for" — explain that PMax does NOT expose individual queries (Google's design), only aggregated category labels via `get_pmax_search_terms`. Don't promise data the API doesn't return. +6. **GDPR consent gaps apply identically** — the click-to-session ratio insight in `analyze_pmax_performance` factors this in. + ### When user asks about conversions or conversion drops 1. Call `attribution_check` with relevant date range and `conversion_events` if the user mentions specific events (e.g. sign_up, purchase) — this does the Ads vs GA4 comparison in one call and auto-generates insights @@ -280,6 +342,27 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 10. After the ad is created, **suggest sitelinks** if the campaign doesn't have any. Use `draft_sitelinks` with at least 4 relevant links (key pages like pricing, features, signup, etc.). Sitelinks increase ad real estate and CTR. 11. Wait for explicit user approval before calling `confirm_and_apply` +### When user wants to fix issues with an existing RSA + +**Use `draft_rsa_replacement` when an ad has problems that need correcting** — wrong data (e.g. "200+ reviews" should be "300+"), truncated city names, mixed A/B messaging, character limit violations, broken display paths, etc. This tool removes the broken ad and creates a corrected replacement. + +**Do NOT use this for A/B testing or creating ad variants.** For that, use `draft_responsive_search_ad` to add a new ad alongside the existing one, and optionally `pause_entity` to pause the old one. + +1. Call `get_ad_performance` to find the ad ID and see current copy +2. **Pre-write checks (same as ad creation):** + - Is the campaign's bidding strategy appropriate? + - Does the campaign have conversions? + - What are quality scores? +3. Review the current headlines and descriptions — identify exactly what needs fixing +4. Write the complete corrected set of headlines (3-15) and descriptions (2-4), following the "Ad Copy Character Limits" section. Count characters for every headline before generating. +5. If the user hasn't specified a `final_url`, the tool inherits it from the old ad — no need to provide it +6. Call `draft_rsa_replacement` with the old `ad_id` and the corrected copy +7. Present the diff preview (old vs new) to the user — the preview shows both old and new copy side-by-side +8. Wait for explicit user approval before calling `confirm_and_apply` +9. The old ad is **permanently removed** by default (so it can't be accidentally re-enabled). The new ad is created as PAUSED. +10. Remind the user to enable the new ad via `enable_entity` after reviewing in Google Ads UI +11. Only pass `remove_old=false` if the user explicitly wants to keep the old ad around (paused) for reference + ### When user wants to add keywords 1. Call `get_campaign_performance` to identify the target campaign and its **bidding strategy** @@ -310,6 +393,55 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has - Add ads via `draft_responsive_search_ad` (with display paths set) - Add sitelinks via `draft_sitelinks` (at least 4 recommended) - If the user needs multiple ad groups (e.g., different keyword themes), use `draft_ad_group` to add additional ad groups after the initial campaign is created and confirmed + - Enable the campaign via `enable_entity` only after ads and sitelinks are in place +8. Wait for explicit user approval before calling `confirm_and_apply` + +### When user wants to create a new Performance Max campaign + +PMax is structurally different — there is no `draft_campaign` path for it. PMax requires the campaign + asset group + assets + signals to be created in the same atomic mutate. There is no "text-only" or "draft-and-add-images-later" path: the Google Ads API rejects an asset_group create with any missing image/logo minimum (`ASSET_GROUP_NOT_ENOUGH_MARKETING_IMAGE_ASSET`, `...SQUARE_MARKETING_IMAGE_ASSET`, `...LOGO_ASSET`). Images and logos MUST exist as Asset resources before `draft_pmax_campaign` is called. + +1. Call `get_pmax_campaigns` to see existing PMax campaigns and avoid name duplicates +2. **Get the image/logo resource_names ready.** PMax needs at least one MARKETING_IMAGE (1.91:1, ≥600x314), one SQUARE_MARKETING_IMAGE (1:1, ≥300x300), and one LOGO (1:1, ≥128x128) before the campaign can be created. Two options: + - **Upload via the MCP**: call `draft_image_asset(images=[{file_path, name}, ...])`, confirm, and use the returned resource_names. JPG/PNG/GIF, ≤5 MB each, absolute paths. + - **Upload via the Google Ads UI**: ask the user for the resource_names of assets they've already uploaded (format `customers/.../assets/...`). +3. **Pre-write checks (CRITICAL):** + - **Bidding strategy**: PMax accepts only Smart Bidding — `MAXIMIZE_CONVERSIONS`, `MAXIMIZE_CONVERSION_VALUE`, `TARGET_CPA`, `TARGET_ROAS`. The tool rejects MANUAL_CPC and TARGET_SPEND for PMax. + - **Geo targeting**: ALWAYS ask the user which countries/regions to target. + - **Language targeting**: ALWAYS ask the user which languages. + - **Asset minimums**: 3-5 headlines (≤30 chars), 1-5 long_headlines (≤90 chars), 2-5 descriptions (≤90 chars), 1 business_name (≤25 chars), at least 1 marketing_image, 1 square_marketing_image, 1 logo (resource_names from step 2). + - **Brand Guidelines**: `draft_pmax_campaign` defaults `brand_guidelines_enabled=True` because new PMax campaigns default to it on Google's side. With it on, BUSINESS_NAME and the first LOGO are auto-linked at the campaign level via CampaignAsset (the API otherwise rejects with `REQUIRED_BUSINESS_NAME_ASSET_NOT_LINKED` / `REQUIRED_LOGO_ASSET_NOT_LINKED`). Pass `brand_guidelines_enabled=False` if the user explicitly wants the legacy "no brand guidelines" behavior. + - **Conversion tracking**: PMax depends heavily on Smart Bidding signals. Call `attribution_check` — if zero conversions across the board, WARN that PMax won't optimize without tracking working first. + - **Budget**: ideally ≥ 5x target CPA; the tool warns otherwise. +4. Call `draft_pmax_campaign` with campaign details + the full `asset_group` dict (name, final_urls, headlines, long_headlines, descriptions, business_name, marketing_image_assets, square_marketing_image_assets, logo_assets, and optionally search_themes / audience_resource_names). +5. Present the complete preview to the user — emphasize the campaign will be created as PAUSED. +6. Call `confirm_and_apply(plan_id=..., dry_run=true)` first — this runs `validate_only=True` against Google Ads and surfaces any API rejections (e.g. invalid asset shapes, missing minimums) before applying for real. +7. After dry run passes and user approves, call `confirm_and_apply(plan_id=..., dry_run=false)`. +8. Remind the user to enable the PMax campaign via `enable_entity(entity_type='campaign', entity_id=...)` after reviewing in Google Ads UI. + +### When user wants to upload images / logos for PMax + +1. Confirm the user has the files locally and knows the absolute paths. +2. Group the files into one `draft_image_asset(images=[{file_path, name}, ...])` call — a single batch is cheaper than one upload per file. Names are display names that show up in the Ads UI Asset Library; pick descriptive ones the user will recognize (e.g. `"Acme Logo - Square"`). +3. Present the preview. Each image has its file_path, mime_type, and file_size; on confirm the bytes are read fresh from disk and uploaded via `AssetService.MutateAssets`. +4. Call `confirm_and_apply(plan_id=..., dry_run=true)` to let Google validate dimensions and policy. A dry run failure here usually means wrong aspect ratio for the slot the user wants to fill — surface the exact Google Ads error. +5. Then call `confirm_and_apply(plan_id=..., dry_run=false)`. The result contains `uploaded` — a list of `{name, resource_name}`. Use those resource_names in `draft_pmax_campaign` / `draft_asset_group` / `draft_asset_group_assets`. +6. The same uploaded Asset can be linked as MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, or LOGO at link time as long as its real pixel dimensions fit the slot — Google enforces the aspect ratio when the AssetGroupAsset link is created, not at upload time. + +### When user wants to add a new asset group, more assets, or signals to an existing PMax campaign + +- **New asset group**: `draft_asset_group(campaign_id, asset_group=...)` — same `asset_group` shape as `draft_pmax_campaign`. Each asset group has independent assets and is its own creative bundle. +- **More assets on an existing asset group**: `draft_asset_group_assets(asset_group_id, headlines=[...], long_headlines=[...], descriptions=[...], business_name=..., marketing_image_assets=[...], square_marketing_image_assets=[...], logo_assets=[...], youtube_video_ids=[...])` — pass only what you want to add. Text and YouTube assets are created inline; images/logos must already exist as Asset resources (upload them via `draft_image_asset` first, or paste resource_names from the Google Ads UI). +- **New signal on an existing asset group**: `draft_asset_group_signal(asset_group_id, search_theme="..." OR audience_resource_name="customers/.../audiences/...")`. Pass exactly one. Search themes are immutable — to "edit" one, remove and re-add. + +To pause/enable an asset group, use `pause_entity`/`enable_entity` with `entity_type="asset_group"`. To remove one, use `remove_entity` (irreversible). + +### When user wants to add labels or filter by label + +1. Call `list_labels` to discover existing labels. +2. To create a new label, call `draft_label(name, description=..., background_color="#RRGGBB")`, then `confirm_and_apply`. +3. To attach a label, call `apply_label(entity_type='campaign'|'ad_group'|'ad'|'keyword', entity_id=..., label_id=...)`. The label must already exist — capture the `label_id` from `list_labels` or from the result of `draft_label` + apply. +4. To detach a label from one entity, call `unapply_label` with the same args. To delete the Label resource itself (cascades), use `remove_entity(entity_type='label', entity_id=...)`. + - **If a shared negative keyword list already exists** for the account (check via `get_negative_keyword_lists`), call `attach_shared_set_to_campaigns` so the new campaign inherits historical waste-blockers. New campaigns don't auto-inherit shared lists — this step is easy to forget. - Enable the campaign via `enable_entity` only after ads and sitelinks are in place 8. Wait for explicit user approval before calling `confirm_and_apply` @@ -520,9 +652,11 @@ LIMIT n | `campaign_budget` | Budget information | | `bidding_strategy` | Bidding strategy details | | `customer_client` | List accounts under an MCC (uses login_customer_id) | -| `asset_group` | PMax asset group data (ad strength, status) | -| `asset_group_asset` | PMax per-asset performance labels | -| `asset_group_top_combination_view` | PMax top asset combinations | +| `asset_group` | Performance Max asset groups (PMax equivalent of ad groups) | +| `asset_group_asset` | Individual assets in PMax asset groups with field_type, status, policy review_status (no `performance_label` in v24) | +| `asset_group_signal` | Search themes and audience signals attached to PMax asset groups | +| `asset_group_top_combination_view` | Top serving combinations Google has assembled for PMax | +| `campaign_search_term_insight` | Aggregated PMax search-term categories (v23.2+, no individual queries) | | `recommendation` | Google's auto-generated recommendations | | `ad_group_audience_view` | Audience segment performance | @@ -557,7 +691,21 @@ LIMIT n **Segments (for time-based breakdowns):** - `segments.date` — daily breakdown - `segments.device` — MOBILE, DESKTOP, TABLET -- `segments.ad_network_type` — SEARCH, CONTENT, YOUTUBE +- `segments.ad_network_type` — SEARCH, CONTENT, YOUTUBE_WATCH, YOUTUBE_SEARCH, MIXED (PMax channel breakdown — only reliable from 2025-06-01 onwards) + +**Performance Max fields:** +- `campaign.advertising_channel_type = 'PERFORMANCE_MAX'` — filter for PMax campaigns +- `campaign.brand_guidelines_enabled` — boolean; when true, business name + logos are at campaign level not asset group +- `asset_group.id`, `asset_group.name`, `asset_group.status`, `asset_group.ad_strength` (POOR/AVERAGE/GOOD/EXCELLENT) +- `asset_group_asset.field_type` (HEADLINE, DESCRIPTION, MARKETING_IMAGE, LOGO, YOUTUBE_VIDEO, etc.) +- `asset_group_asset.policy_summary.review_status`, `asset_group_asset.status` +- `asset_group_signal.search_theme.text`, `asset_group_signal.audience.audience` + +**Removed in API v24 — DO NOT use:** +- `campaign.url_expansion_opt_out` (and `campaign.url_expansion_optimization`) — replaced by `Campaign.asset_automation_settings` with `AssetAutomationType=FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION`. +- `asset_group_asset.performance_label` — the LOW/GOOD/BEST/PENDING per-asset rating is no longer populated. Look at `asset_group_top_combination_view` (which assets actually serve) or query metrics on `asset_field_type_view` for per-field-type performance. +- `metrics.cost_micros`, `metrics.conversions`, `metrics.conversions_value` on `campaign_search_term_insight` — the API returns PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE. Only `metrics.clicks` and `metrics.impressions` are selectable on this resource. +- Any `metrics.*` on `asset_group_top_combination_view` — PROHIBITED_METRIC. The view exposes only `asset_group_top_combination_view.asset_group_top_combinations` (a repeated message). ### Date Ranges @@ -616,6 +764,31 @@ FROM ad_group WHERE campaign.id = 12345678 ``` +**Performance Max asset groups with ad strength:** +```sql +SELECT asset_group.id, asset_group.name, asset_group.ad_strength, + campaign.name, metrics.cost_micros, metrics.conversions +FROM asset_group +WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND segments.date DURING LAST_30_DAYS +ORDER BY metrics.cost_micros DESC +``` + +**Performance Max — list all assets in an asset group:** +```sql +SELECT asset_group.id, asset_group.name, + asset_group_asset.field_type, + asset_group_asset.status, + asset_group_asset.policy_summary.review_status, + asset.text_asset.text, + asset.image_asset.full_size.url +FROM asset_group_asset +WHERE asset_group.id = 6572147947 + AND asset_group_asset.status != 'REMOVED' +``` + +(`asset_group_asset.performance_label` was removed in v24 — see "Removed in API v24" above.) + ## Ad Copy Character Limits Google Ads enforces hard character limits. The `draft_responsive_search_ad` tool will reject copy that exceeds them, but you must write copy that fits on the FIRST attempt — do not generate copy and hope it fits. @@ -688,6 +861,7 @@ When advising on Google Ads: - **Display paths**: Always set `path1` and `path2` on RSAs. They cost nothing, improve ad relevance, and make the display URL informative (e.g. `example.com/Features/Pricing` instead of bare `example.com`). Derive them from the landing page path or the ad's core message. Max 15 chars each. - **Sitelinks**: Every campaign should have at least 4 sitelinks. They increase ad real estate (more screen space = higher CTR), direct users to key pages, and are free. Good candidates: pricing, features, signup/trial, about, key product pages. Use `draft_sitelinks` to create them. Link text max 25 chars, descriptions max 35 chars each. - **Clicks vs sessions gap**: Never report a clicks > sessions discrepancy as a tracking bug without first accounting for GDPR consent. In the EU, 30-70% of users may reject analytics cookies. This is normal, not broken. +- **Performance Max specifics**: PMax campaigns are Smart Bidding only (`MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE`) — no MANUAL_CPC option, so the BROAD-match-without-Smart-Bidding rule doesn't apply. Channel mix is decided by Google at serve time; you cannot directly target Search-only or YouTube-only. Asset groups should ideally have `ad_strength = GOOD` or better, and they should meet Google's documented asset-type minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, etc. — `analyze_pmax_performance` flags any below-minimum groups). PMax search-term insights expose categories only, not individual queries. **Image and logo assets must exist before the campaign mutate.** Use `draft_image_asset` to upload local JPG/PNG/GIF files (≤5 MB each) directly from the MCP, or upload via the Google Ads UI and paste the resource_names — there is no "text-only PMax" path (the API rejects asset_group creates that are missing any image / logo minimum). - **Performance Max asset diversity**: PMax campaigns need diverse assets to perform well. Minimum recommended: 5+ headlines (max 30 chars), 5+ long headlines (max 90 chars), 5+ descriptions (max 90 chars), 5+ marketing images (1200x628), 5+ square images (1200x1200), 1+ logo (1200x1200), 1+ landscape logo (1200x300). A YouTube video is strongly recommended. Ad strength below GOOD usually means missing asset types. - **PMax transparency limitations**: The Google Ads API does not provide full channel-level breakdowns for PMax. `segments.ad_network_type` returns SEARCH, CONTENT, YOUTUBE_SEARCH, YOUTUBE_WATCH, and MIXED — but MIXED is a catch-all for most traffic. Do not promise users exact Search vs Display vs YouTube splits. Be honest about what the data shows and what it doesn't. - **Google recommendations are not neutral**: Google's auto-generated recommendations optimize for Google's revenue, not necessarily the advertiser's ROI. Budget increase and Broad Match recommendations should always be cross-referenced against actual conversion data. Bid strategy recommendations (switch to Smart Bidding) are usually sound. Keyword recommendations may be too broad. Never blindly apply recommendations — evaluate each one against the account's actual performance. diff --git a/CLAUDE.md b/CLAUDE.md index e3332b4..31eca03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,12 +19,12 @@ src/adloop/ ├── __init__.py # Entry point — routes 'adloop init' vs MCP server ├── server.py # FastMCP server — all tool registrations + toolset tags ├── config.py # Config loader (~/.adloop/config.yaml) -├── auth.py # OAuth 2.0 (user-supplied credentials, headless fallback) + service accounts +├── auth.py # OAuth 2.0 (user-supplied credentials, headless fallback) + service accounts + token refresh ├── cli.py # Interactive setup wizard (own Google Cloud project + auto-discovery) -├── crossref.py # Cross-reference tools (GA4 + Ads combined) +├── crossref.py # Cross-reference tools (GA4 + Ads, incl. analyze_pmax_performance) ├── tracking.py # Tracking validation + code generation ├── ga4/ # GA4 Data + Admin API (reports, realtime, events) -├── ads/ # Google Ads API (read, write, GAQL, forecasting, PMax, recommendations) +├── ads/ # Google Ads API (read, write, GAQL, forecasting, pmax_read, pmax_write, labels, recommendations) └── safety/ # Guards, previews, audit logging ``` @@ -34,13 +34,13 @@ All tool usage rules, safety protocols, orchestration patterns, GAQL reference, **Read and follow `.claude/rules/adloop.md` for all AdLoop MCP tool orchestration.** -That file is the complete guide for combining AdLoop's tools. It covers: +That file is the complete guide for combining AdLoop's tools (Search + Performance Max read & write + Image upload + Labels + Recommendations). It covers: - Tool inventory with parameters and when to use each - 9 safety rules (budget caps, dry-run defaults, Broad Match prevention, pre-write validation) -- 16 orchestration patterns (performance review, PMax analysis, recommendations, ad creation, tracking diagnosis, etc.) -- GAQL quick reference with syntax, common queries, and gotchas +- 16 orchestration patterns (performance review, PMax analysis & diagnostics, recommendations, ad creation, tracking diagnosis, etc.) +- GAQL quick reference with syntax, common queries, and gotchas (incl. asset_group, asset_group_asset) - GDPR consent awareness for EU markets -- Ad copy character limits and marketing best practices +- Ad copy character limits and marketing best practices (incl. PMax-specific notes) ## Documentation Site diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..2b0ae12 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,329 @@ +#!/bin/bash +# ============================================================================== +# GAds-MCP Installer +# Rossman Media - Google Ads MCP Setup +# +# This script installs GAds-MCP (AdLoop) and connects it to Claude. +# Run the pre-flight check first to make sure your machine is ready. +# +# Usage: curl -sSL https://raw.githubusercontent.com/alexrich700/GAds-MCP/main/scripts/install.sh | bash +# ============================================================================== + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +REPO_URL="https://github.com/alexrich700/GAds-MCP.git" +INSTALL_DIR="$HOME/.gads-mcp" +CONFIG_DIR="$HOME/.adloop" +CLAUDE_CONFIG_DIR="$HOME/Library/Application Support/Claude" +CLAUDE_CONFIG_FILE="$CLAUDE_CONFIG_DIR/claude_desktop_config.json" + +echo "" +echo "===========================================" +echo " GAds-MCP Installer" +echo " Rossman Media" +echo "===========================================" +echo "" + +# ------------------------------------------- +# 1. Check prerequisites +# ------------------------------------------- +echo -e "${BOLD}Step 1/6: Checking prerequisites...${NC}" + +# Find Python 3.11+ (check versioned commands, Homebrew paths, then generic python3) +PYTHON_CMD="" +for cmd in python3.13 python3.12 python3.11; do + if command -v "$cmd" &> /dev/null; then + ver=$("$cmd" --version 2>&1 | awk '{print $2}') + minor=$(echo "$ver" | cut -d. -f2) + if [[ "$minor" -ge 11 ]]; then + PYTHON_CMD="$cmd" + break + fi + fi +done +if [[ -z "$PYTHON_CMD" ]]; then + for brew_cmd in /opt/homebrew/bin/python3.13 /opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11 /usr/local/bin/python3.13 /usr/local/bin/python3.12 /usr/local/bin/python3.11; do + if [[ -x "$brew_cmd" ]]; then + ver=$("$brew_cmd" --version 2>&1 | awk '{print $2}') + minor=$(echo "$ver" | cut -d. -f2) + if [[ "$minor" -ge 11 ]]; then + PYTHON_CMD="$brew_cmd" + break + fi + fi + done +fi +if [[ -z "$PYTHON_CMD" ]]; then + if command -v python3 &> /dev/null; then + ver=$(python3 --version 2>&1 | awk '{print $2}') + minor=$(echo "$ver" | cut -d. -f2) + if [[ "$minor" -ge 11 ]]; then + PYTHON_CMD="python3" + fi + fi +fi +if [[ -z "$PYTHON_CMD" ]]; then + echo -e "${RED}Error: Python 3.11+ not found. Run the pre-flight check first.${NC}" + echo -e " Tried: python3.12, python3.11, /opt/homebrew/bin/python3.12, python3" + exit 1 +fi + +PY_VERSION=$("$PYTHON_CMD" --version 2>&1 | awk '{print $2}') +echo -e " ${GREEN}Python $PY_VERSION (using: $PYTHON_CMD)${NC}" + +# Git +if ! command -v git &> /dev/null; then + echo -e "${RED}Error: Git not found. Run the pre-flight check first.${NC}" + exit 1 +fi +echo -e " ${GREEN}Git OK${NC}" +echo "" + +# ------------------------------------------- +# 2. Install uv if needed +# ------------------------------------------- +echo -e "${BOLD}Step 2/6: Setting up package manager...${NC}" + +if ! command -v uv &> /dev/null; then + echo " Installing uv..." + curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null + + # Source the env so uv is available in this session + if [[ -f "$HOME/.local/bin/env" ]]; then + source "$HOME/.local/bin/env" + fi + # Also add to path directly in case the source above doesn't work + export PATH="$HOME/.local/bin:$PATH" + + if command -v uv &> /dev/null; then + echo -e " ${GREEN}uv installed successfully${NC}" + else + echo -e "${RED}Error: uv installation failed. Try manually: curl -LsSf https://astral.sh/uv/install.sh | sh${NC}" + exit 1 + fi +else + echo -e " ${GREEN}uv already installed${NC}" +fi +echo "" + +# ------------------------------------------- +# 3. Clone or update the repo +# ------------------------------------------- +echo -e "${BOLD}Step 3/6: Getting GAds-MCP...${NC}" + +if [[ -d "$INSTALL_DIR" ]]; then + echo " Found existing installation, updating..." + cd "$INSTALL_DIR" + git pull --quiet origin main + echo -e " ${GREEN}Updated to latest version${NC}" +else + echo " Cloning from GitHub..." + git clone --quiet "$REPO_URL" "$INSTALL_DIR" + echo -e " ${GREEN}Downloaded${NC}" +fi + +cd "$INSTALL_DIR" +echo "" + +# ------------------------------------------- +# 4. Install Python dependencies +# ------------------------------------------- +echo -e "${BOLD}Step 4/6: Installing dependencies...${NC}" + +uv sync --python "$PYTHON_CMD" --quiet 2>/dev/null || uv sync --python "$PYTHON_CMD" +echo -e " ${GREEN}Dependencies installed${NC}" +echo "" + +# ------------------------------------------- +# 5. Run adloop init (OAuth + config) +# ------------------------------------------- +echo -e "${BOLD}Step 5/6: Setting up Google Ads connection...${NC}" +echo "" +echo -e " ${YELLOW}This will open your browser for Google sign-in.${NC}" +echo -e " ${YELLOW}Sign in with your Google account that has access to the MCC.${NC}" +echo "" +echo -e " ${BLUE}You'll need the following info (Alex can provide these):${NC}" +echo " - Google Cloud Project ID" +echo " - Google Ads Developer Token" +echo " - OAuth Client ID and Client Secret" +echo "" +read -p " Ready? Press Enter to continue (or Ctrl+C to exit)... " +echo "" + +# Run the init wizard +uv run adloop init + +echo "" +echo -e " ${GREEN}Google Ads connection configured${NC}" +echo "" + +# ------------------------------------------- +# 6. Configure Claude MCP +# ------------------------------------------- +echo -e "${BOLD}Step 6/6: Connecting to Claude...${NC}" + +# Get the full path to the Python in the venv +PYTHON_PATH="$INSTALL_DIR/.venv/bin/python" + +if [[ ! -f "$PYTHON_PATH" ]]; then + # Fallback: find the python in the venv + PYTHON_PATH=$(find "$INSTALL_DIR/.venv" -name "python3" -type f 2>/dev/null | head -1) +fi + +if [[ -z "$PYTHON_PATH" || ! -f "$PYTHON_PATH" ]]; then + echo -e "${RED}Error: Could not find Python in the virtual environment.${NC}" + echo " Please contact Alex for help." + exit 1 +fi + +# The MCP server entry we need to add +MCP_ENTRY=$(cat </dev/null; then + echo -e " ${GREEN}Claude Desktop already configured${NC}" + CLAUDE_CONFIGURED=true + else + # Need to merge. Use python for safe JSON manipulation. + python3 << PYEOF +import json +import sys +import shutil + +config_file = "$CLAUDE_CONFIG_FILE" +python_path = "$PYTHON_PATH" + +try: + with open(config_file, 'r') as f: + config = json.load(f) +except (json.JSONDecodeError, FileNotFoundError): + config = {} + +# Create backup +shutil.copy2(config_file, config_file + ".backup") + +# Add or update mcpServers +if 'mcpServers' not in config: + config['mcpServers'] = {} + +config['mcpServers']['gads-mcp'] = { + "command": python_path, + "args": ["-m", "adloop"] +} + +with open(config_file, 'w') as f: + json.dump(config, f, indent=2) + +print(" Config updated (backup saved as claude_desktop_config.json.backup)") +PYEOF + echo -e " ${GREEN}Claude Desktop configured${NC}" + CLAUDE_CONFIGURED=true + fi + else + # No config file yet, create one + echo "$MCP_ENTRY" > "$CLAUDE_CONFIG_FILE" + echo -e " ${GREEN}Claude Desktop config created${NC}" + CLAUDE_CONFIGURED=true + fi + fi +fi + +# --- Claude Code config (project-level .mcp.json) --- +echo "" +echo " For Claude Code, the MCP is configured per-project." +echo " When you open a project in Claude Code, create a .mcp.json file" +echo " in the project root with this content:" +echo "" +echo -e " ${BLUE}$(cat < "$MCP_JSON_FILE" < /dev/null; then + local ver + ver=$("$cmd" --version 2>&1 | awk '{print $2}') + local minor + minor=$(echo "$ver" | cut -d. -f2) + if [[ "$minor" -ge 11 ]]; then + echo "$cmd" + return 0 + fi + fi + done + for brew_cmd in /opt/homebrew/bin/python3.13 /opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11 /usr/local/bin/python3.13 /usr/local/bin/python3.12 /usr/local/bin/python3.11; do + if [[ -x "$brew_cmd" ]]; then + local ver + ver=$("$brew_cmd" --version 2>&1 | awk '{print $2}') + local minor + minor=$(echo "$ver" | cut -d. -f2) + if [[ "$minor" -ge 11 ]]; then + echo "$brew_cmd" + return 0 + fi + fi + done + if command -v python3 &> /dev/null; then + local ver + ver=$(python3 --version 2>&1 | awk '{print $2}') + local minor + minor=$(echo "$ver" | cut -d. -f2) + if [[ "$minor" -ge 11 ]]; then + echo "python3" + return 0 + fi + fi + return 1 +} + +# ------------------------------------------- +# 1. Operating System +# ------------------------------------------- +echo "Checking operating system..." +if [[ "$OSTYPE" == "darwin"* ]]; then + check_pass "macOS detected ($(sw_vers -productVersion))" +elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + check_pass "Linux detected" +else + check_warn "Detected OS: $OSTYPE - this setup is tested on macOS and Linux" +fi +echo "" + +# ------------------------------------------- +# 2. Homebrew (macOS only) +# ------------------------------------------- +if [[ "$OSTYPE" == "darwin"* ]]; then + echo "Checking Homebrew..." + if command -v brew &> /dev/null; then + check_pass "Homebrew installed ($(brew --version | head -1))" + else + check_fail "Homebrew not installed" + echo "" + echo -e " ${BLUE}Fix: Run this command, then re-run the pre-flight check:${NC}" + echo "" + echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + echo "" + fi + echo "" +fi + +# ------------------------------------------- +# 3. Python 3.11+ +# ------------------------------------------- +echo "Checking Python..." +FOUND_PYTHON=$(find_python || true) + +if [[ -n "$FOUND_PYTHON" ]]; then + PY_VERSION=$("$FOUND_PYTHON" --version 2>&1 | awk '{print $2}') + check_pass "Python $PY_VERSION found at: $FOUND_PYTHON" +else + if command -v python3 &> /dev/null; then + OLD_VERSION=$(python3 --version 2>&1 | awk '{print $2}') + check_fail "Python $OLD_VERSION found, but 3.11+ is required" + else + check_fail "Python 3 not found" + fi + echo "" + if [[ "$OSTYPE" == "darwin"* ]]; then + echo -e " ${BLUE}Fix: Run this command, then re-run the pre-flight check:${NC}" + echo "" + echo " brew install python@3.12" + echo "" + else + echo -e " ${BLUE}Fix: Install Python 3.12 from https://www.python.org/downloads/${NC}" + echo "" + fi +fi +echo "" + +# ------------------------------------------- +# 4. Git +# ------------------------------------------- +echo "Checking Git..." +if command -v git &> /dev/null; then + check_pass "Git installed ($(git --version))" +else + check_fail "Git not found" + echo "" + if [[ "$OSTYPE" == "darwin"* ]]; then + echo -e " ${BLUE}Fix: Run: xcode-select --install${NC}" + else + echo -e " ${BLUE}Fix: Install Git from https://git-scm.com/${NC}" + fi +fi +echo "" + +# ------------------------------------------- +# 5. uv (Python package manager) +# ------------------------------------------- +echo "Checking uv..." +if command -v uv &> /dev/null; then + check_pass "uv installed ($(uv --version))" +else + check_warn "uv not installed (the installer will handle this automatically)" +fi +echo "" + +# ------------------------------------------- +# 6. Claude Desktop / Cowork +# ------------------------------------------- +echo "Checking Claude Desktop..." +CLAUDE_CONFIG_DIR="$HOME/Library/Application Support/Claude" +CLAUDE_CONFIG_FILE="$CLAUDE_CONFIG_DIR/claude_desktop_config.json" + +if [[ "$OSTYPE" == "darwin"* ]]; then + if [[ -d "$CLAUDE_CONFIG_DIR" ]]; then + check_pass "Claude Desktop config directory found" + if [[ -f "$CLAUDE_CONFIG_FILE" ]]; then + check_pass "Claude Desktop config file exists" + else + check_warn "Claude Desktop config file not found (installer will create it)" + fi + else + check_warn "Claude Desktop config directory not found" + echo -e " This is fine if you only use Claude Code (not Claude Desktop/Cowork)" + echo -e " If you want Claude Desktop support, install it from https://claude.ai/download" + fi +else + check_warn "Claude Desktop config check skipped (non-macOS)" +fi +echo "" + +# ------------------------------------------- +# 7. Disk space +# ------------------------------------------- +echo "Checking disk space..." +if [[ "$OSTYPE" == "darwin"* ]]; then + AVAILABLE_GB=$(df -g "$HOME" | tail -1 | awk '{print $4}') +else + AVAILABLE_GB=$(df -BG "$HOME" | tail -1 | awk '{print $4}' | tr -d 'G') +fi + +if [[ "$AVAILABLE_GB" -ge 2 ]]; then + check_pass "${AVAILABLE_GB}GB available (need ~500MB)" +else + check_warn "Only ${AVAILABLE_GB}GB available, need at least 500MB" +fi +echo "" + +# ------------------------------------------- +# 8. Network connectivity +# ------------------------------------------- +echo "Checking network..." +if curl -sSf https://github.com > /dev/null 2>&1; then + check_pass "Can reach GitHub" +else + check_fail "Cannot reach GitHub - check your internet connection or VPN" +fi + +if curl -sSf https://pypi.org > /dev/null 2>&1; then + check_pass "Can reach PyPI (Python packages)" +else + check_fail "Cannot reach PyPI - check your internet connection or VPN" +fi +echo "" + +# ------------------------------------------- +# Summary +# ------------------------------------------- +echo "===========================================" +echo " Results" +echo "===========================================" +echo "" +echo -e " ${GREEN}Passed: $PASS${NC} ${RED}Failed: $FAIL${NC} ${YELLOW}Warnings: $WARN${NC}" +echo "" + +if [[ $FAIL -eq 0 ]]; then + echo -e " ${GREEN}You're good to go!${NC}" + echo "" + echo " Next step: Run the installer:" + echo "" + echo " curl -sSL https://raw.githubusercontent.com/alexrich700/GAds-MCP/main/scripts/install.sh | bash" + echo "" +else + echo -e " ${RED}Fix the failures above, then run this pre-flight check again.${NC}" + echo "" + echo " If you're stuck, screenshot this output and send it to Alex." + echo "" +fi diff --git a/src/adloop/ads/gaql.py b/src/adloop/ads/gaql.py index f67144a..c359700 100644 --- a/src/adloop/ads/gaql.py +++ b/src/adloop/ads/gaql.py @@ -156,8 +156,17 @@ def _to_python(obj: object) -> object: return [_to_python(item) for item in obj] except TypeError: pass - # AdTextAsset and similar message types + # AdTextAsset and similar message types — preserve pinning info if hasattr(obj, "text") and isinstance(getattr(obj, "text", None), str): + pinned = getattr(obj, "pinned_field", None) + if pinned is not None: + # Proto-plus enums are int subclasses with a .name attribute; + # 0 / UNSPECIFIED means "not pinned". + pin_name = getattr(pinned, "name", None) + if pin_name is not None and pin_name != "UNSPECIFIED": + return {"text": obj.text, "pinned_field": pin_name} + if isinstance(pinned, int) and pinned != 0: + return {"text": obj.text, "pinned_field": str(pinned)} return obj.text # Nested proto-plus messages (targeting settings, criteria, ...) — # serialize to a dict instead of collapsing to their str() repr. @@ -177,6 +186,14 @@ def _to_python(obj: object) -> object: return str(obj) +def _format_asset_item(v: object) -> str: + """Format a single list item, rendering pinned AdTextAsset dicts nicely.""" + if isinstance(v, dict) and "text" in v: + pinned = v.get("pinned_field") + return f"{v['text']} [{pinned}]" if pinned else v["text"] + return str(v) + + def _format_table(rows: list[dict], query: str) -> dict: """Format query results as an aligned text table.""" if not rows: @@ -191,7 +208,7 @@ def _format_table(rows: list[dict], query: str) -> dict: for h in headers: val = row.get(h) if isinstance(val, list): - s = ", ".join(str(v) for v in val) + s = ", ".join(_format_asset_item(v) for v in val) else: s = str(val) if val is not None else "" sr[h] = s @@ -220,6 +237,6 @@ def _format_csv(rows: list[dict], query: str) -> dict: writer = csv.DictWriter(output, fieldnames=rows[0].keys()) writer.writeheader() for row in rows: - writer.writerow({k: v if not isinstance(v, list) else "; ".join(str(i) for i in v) for k, v in row.items()}) + writer.writerow({k: v if not isinstance(v, list) else "; ".join(_format_asset_item(i) for i in v) for k, v in row.items()}) return {"csv": output.getvalue(), "row_count": len(rows), "query": query} diff --git a/src/adloop/ads/labels.py b/src/adloop/ads/labels.py new file mode 100644 index 0000000..8ae7d2f --- /dev/null +++ b/src/adloop/ads/labels.py @@ -0,0 +1,438 @@ +"""Google Ads label tools — list, create, apply, unapply. + +Labels are tags you attach to campaigns, ad groups, ads, and keywords for +filtering, reporting, and bulk operations. The API splits them across: + +- ``Label`` — the label definition itself (LabelService). +- ``CampaignLabel`` / ``AdGroupLabel`` / ``AdGroupAdLabel`` / + ``AdGroupCriterionLabel`` — assignments of a label to an entity. + +The draft tools here follow the same draft -> preview -> confirm_and_apply +flow as the rest of the write tools. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adloop.config import AdLoopConfig + + +# --------------------------------------------------------------------------- +# Read +# --------------------------------------------------------------------------- + + +def list_labels( + config: AdLoopConfig, + *, + customer_id: str = "", +) -> dict: + """List all labels in the account.""" + from adloop.ads.gaql import execute_query + + query = """ + SELECT label.id, label.name, label.status, + label.text_label.description, + label.text_label.background_color + FROM label + WHERE label.status != 'REMOVED' + ORDER BY label.name + """ + + rows = execute_query(config, customer_id, query) + return {"labels": rows, "total_labels": len(rows)} + + +# --------------------------------------------------------------------------- +# Draft tools +# --------------------------------------------------------------------------- + + +def draft_label( + config: AdLoopConfig, + *, + customer_id: str = "", + name: str = "", + description: str = "", + background_color: str = "", +) -> dict: + """Draft creating a new Label — returns preview, does NOT execute. + + background_color: hex color string like "#FF5733" (optional). + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_label", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors: list[str] = [] + if not name or not name.strip(): + errors.append("name is required") + if background_color and not _is_hex_color(background_color): + errors.append( + f"background_color must be a hex string like '#FF5733', " + f"got '{background_color}'" + ) + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_label", + entity_type="label", + customer_id=customer_id, + changes={ + "name": name, + "description": description, + "background_color": background_color, + }, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_apply_label( + config: AdLoopConfig, + *, + customer_id: str = "", + entity_type: str = "", + entity_id: str = "", + label_id: str = "", +) -> dict: + """Draft attaching a label to a campaign, ad group, ad, or keyword. + + entity_type: "campaign", "ad_group", "ad", or "keyword". + entity_id: bare ID for campaign/ad_group; "adGroupId~adId" for ad; + "adGroupId~criterionId" for keyword. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("apply_label", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors = _validate_label_assignment_inputs(entity_type, entity_id, label_id) + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="apply_label", + entity_type=entity_type, + entity_id=entity_id, + customer_id=customer_id, + changes={ + "entity_type": entity_type, + "entity_id": entity_id, + "label_id": label_id, + }, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_unapply_label( + config: AdLoopConfig, + *, + customer_id: str = "", + entity_type: str = "", + entity_id: str = "", + label_id: str = "", +) -> dict: + """Draft removing a label assignment (does NOT delete the Label itself).""" + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("unapply_label", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors = _validate_label_assignment_inputs(entity_type, entity_id, label_id) + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="unapply_label", + entity_type=entity_type, + entity_id=entity_id, + customer_id=customer_id, + changes={ + "entity_type": entity_type, + "entity_id": entity_id, + "label_id": label_id, + }, + ) + store_plan(plan) + return plan.to_preview() + + +# --------------------------------------------------------------------------- +# Apply helpers — wired into _execute_plan via LABEL_OPERATIONS +# --------------------------------------------------------------------------- + + +def _apply_create_label( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + service = client.get_service("LabelService") + operation = client.get_type("LabelOperation") + label = operation.create + label.name = changes["name"] + if changes.get("description"): + label.text_label.description = changes["description"] + if changes.get("background_color"): + label.text_label.background_color = changes["background_color"] + + response = service.mutate_labels( + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } + ) + if validate_only: + return {"status": "validated"} + return {"resource_name": response.results[0].resource_name} + + +def _apply_apply_label( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Attach an existing label to a campaign/ad_group/ad/keyword.""" + entity_type = changes["entity_type"] + entity_id = changes["entity_id"] + label_id = changes["label_id"] + label_resource = f"customers/{cid}/labels/{label_id}" + + if entity_type == "campaign": + service = client.get_service("CampaignLabelService") + operation = client.get_type("CampaignLabelOperation") + link = operation.create + link.campaign = client.get_service("CampaignService").campaign_path( + cid, entity_id + ) + link.label = label_resource + response = service.mutate_campaign_labels( + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } + ) + + elif entity_type == "ad_group": + service = client.get_service("AdGroupLabelService") + operation = client.get_type("AdGroupLabelOperation") + link = operation.create + link.ad_group = client.get_service("AdGroupService").ad_group_path( + cid, entity_id + ) + link.label = label_resource + response = service.mutate_ad_group_labels( + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } + ) + + elif entity_type == "ad": + from adloop.ads.write import _resolve_ad_entity_id + + resolved_id = _resolve_ad_entity_id(client, cid, entity_id) + service = client.get_service("AdGroupAdLabelService") + operation = client.get_type("AdGroupAdLabelOperation") + link = operation.create + link.ad_group_ad = f"customers/{cid}/adGroupAds/{resolved_id}" + link.label = label_resource + response = service.mutate_ad_group_ad_labels( + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } + ) + + elif entity_type == "keyword": + service = client.get_service("AdGroupCriterionLabelService") + operation = client.get_type("AdGroupCriterionLabelOperation") + link = operation.create + link.ad_group_criterion = f"customers/{cid}/adGroupCriteria/{entity_id}" + link.label = label_resource + response = service.mutate_ad_group_criterion_labels( + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } + ) + + else: + raise ValueError( + f"apply_label does not support entity_type '{entity_type}'. " + f"Supported: campaign, ad_group, ad, keyword." + ) + + if validate_only: + return {"status": "validated"} + return {"resource_name": response.results[0].resource_name} + + +def _apply_unapply_label( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Detach a label from an entity by removing the *Label resource.""" + entity_type = changes["entity_type"] + entity_id = changes["entity_id"] + label_id = changes["label_id"] + + if entity_type == "campaign": + service = client.get_service("CampaignLabelService") + operation = client.get_type("CampaignLabelOperation") + operation.remove = f"customers/{cid}/campaignLabels/{entity_id}~{label_id}" + response = service.mutate_campaign_labels( + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } + ) + + elif entity_type == "ad_group": + service = client.get_service("AdGroupLabelService") + operation = client.get_type("AdGroupLabelOperation") + operation.remove = f"customers/{cid}/adGroupLabels/{entity_id}~{label_id}" + response = service.mutate_ad_group_labels( + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } + ) + + elif entity_type == "ad": + from adloop.ads.write import _resolve_ad_entity_id + + resolved_id = _resolve_ad_entity_id(client, cid, entity_id) + service = client.get_service("AdGroupAdLabelService") + operation = client.get_type("AdGroupAdLabelOperation") + operation.remove = ( + f"customers/{cid}/adGroupAdLabels/{resolved_id}~{label_id}" + ) + response = service.mutate_ad_group_ad_labels( + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } + ) + + elif entity_type == "keyword": + service = client.get_service("AdGroupCriterionLabelService") + operation = client.get_type("AdGroupCriterionLabelOperation") + operation.remove = ( + f"customers/{cid}/adGroupCriterionLabels/{entity_id}~{label_id}" + ) + response = service.mutate_ad_group_criterion_labels( + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } + ) + + else: + raise ValueError( + f"unapply_label does not support entity_type '{entity_type}'. " + f"Supported: campaign, ad_group, ad, keyword." + ) + + if validate_only: + return {"status": "validated"} + return {"resource_name": response.results[0].resource_name} + + +def _apply_remove_label( + client: object, + cid: str, + entity_id: str, + *, + validate_only: bool = False, +) -> dict: + """Remove a Label resource itself (NOT just an assignment).""" + service = client.get_service("LabelService") + operation = client.get_type("LabelOperation") + operation.remove = f"customers/{cid}/labels/{entity_id}" + response = service.mutate_labels( + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } + ) + if validate_only: + return {"status": "validated"} + return {"resource_name": response.results[0].resource_name} + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +_LABEL_VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"} + + +def _validate_label_assignment_inputs( + entity_type: str, entity_id: str, label_id: str +) -> list[str]: + errors: list[str] = [] + if entity_type not in _LABEL_VALID_ENTITY_TYPES: + errors.append( + f"entity_type must be one of {sorted(_LABEL_VALID_ENTITY_TYPES)}, " + f"got '{entity_type}'" + ) + if not entity_id: + errors.append("entity_id is required") + if not label_id: + errors.append("label_id is required") + return errors + + +def _is_hex_color(value: str) -> bool: + if not value.startswith("#"): + return False + hex_part = value[1:] + if len(hex_part) not in (3, 6): + return False + return all(c in "0123456789abcdefABCDEF" for c in hex_part) + + +# --------------------------------------------------------------------------- +# Dispatch table +# --------------------------------------------------------------------------- + + +LABEL_OPERATIONS = { + "create_label": _apply_create_label, + "apply_label": _apply_apply_label, + "unapply_label": _apply_unapply_label, +} diff --git a/src/adloop/ads/pmax_read.py b/src/adloop/ads/pmax_read.py new file mode 100644 index 0000000..e41f534 --- /dev/null +++ b/src/adloop/ads/pmax_read.py @@ -0,0 +1,429 @@ +"""Google Ads Performance Max read tools. + +Performance Max is structurally different from Search: +- No ad_groups — instead, a campaign contains asset_groups +- No keywords — instead, signals (search themes + audiences) hint at intent +- No ads — instead, asset groups bundle assets that Google assembles dynamically +- Channel mix (Search/Display/YouTube/Shopping/Maps/Discover/Gmail) is decided + by Google at serve time, surfaced via segments.asset_interaction_target + +These tools focus on the things you CAN inspect: campaign performance, asset +group structure, individual asset performance ratings, search themes, and +the post-2025-06-01 channel breakdown. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adloop.config import AdLoopConfig + +# Channel breakdown is only reliable after this date — earlier dates return MIXED. +_CHANNEL_BREAKDOWN_AVAILABLE_FROM = "2025-06-01" + + +def get_pmax_campaigns( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get Performance Max campaigns with PMax-specific settings + metrics.""" + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + query = f""" + SELECT campaign.id, campaign.name, campaign.status, + campaign.advertising_channel_type, + campaign.bidding_strategy_type, + campaign.brand_guidelines_enabled, + campaign_budget.amount_micros, + metrics.impressions, metrics.clicks, metrics.cost_micros, + metrics.conversions, metrics.conversions_value, + metrics.ctr, metrics.average_cpc + FROM campaign + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND campaign.status != 'REMOVED' + {date_clause} + ORDER BY metrics.cost_micros DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_budget_fields(rows) + _enrich_roas(rows) + + return {"campaigns": rows, "total_campaigns": len(rows)} + + +def get_pmax_channel_breakdown( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get spend/conversions per serving surface (Search, Display, YouTube, Shopping, etc.). + + Segments the campaign-level metrics by `segments.ad_network_type`. Channel + attribution for PMax is only reliable from 2025-06-01 onwards — earlier + rows return MIXED because Google could not attribute a specific channel. + The tool emits a warning in `insights[]` when the date range overlaps that + period or when MIXED rows are present in the result. + """ + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + campaign_filter = "" + if campaign_id: + cid = _validate_numeric_id(campaign_id, "campaign_id") + campaign_filter = f"AND campaign.id = {cid}" + + query = f""" + SELECT campaign.id, campaign.name, + segments.ad_network_type, + metrics.impressions, metrics.clicks, metrics.cost_micros, + metrics.conversions, metrics.conversions_value + FROM campaign + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND campaign.status != 'REMOVED' + {campaign_filter} + {date_clause} + ORDER BY campaign.id, metrics.cost_micros DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_roas(rows) + + insights = [] + if date_range_start and date_range_start < _CHANNEL_BREAKDOWN_AVAILABLE_FROM: + insights.append( + f"Channel breakdown is only available from {_CHANNEL_BREAKDOWN_AVAILABLE_FROM} " + f"onwards. Earlier rows will appear as MIXED." + ) + + if any(r.get("segments.ad_network_type") == "MIXED" for r in rows): + insights.append( + "Some rows show MIXED ad_network_type — Google could not attribute " + "the spend to a specific channel (often historical pre-June-2025 data)." + ) + + return { + "channel_breakdown": rows, + "total_rows": len(rows), + "insights": insights, + } + + +def get_asset_groups( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """List asset groups for a Performance Max campaign with their metrics and ad strength.""" + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + campaign_filter = "" + if campaign_id: + cid = _validate_numeric_id(campaign_id, "campaign_id") + campaign_filter = f"AND campaign.id = {cid}" + + query = f""" + SELECT asset_group.id, asset_group.name, asset_group.status, + asset_group.final_urls, asset_group.path1, asset_group.path2, + asset_group.ad_strength, + campaign.id, campaign.name, + metrics.impressions, metrics.clicks, metrics.cost_micros, + metrics.conversions, metrics.conversions_value + FROM asset_group + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND asset_group.status != 'REMOVED' + {campaign_filter} + {date_clause} + ORDER BY metrics.cost_micros DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_roas(rows) + + return {"asset_groups": rows, "total_asset_groups": len(rows)} + + +def get_asset_group_assets( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_group_id: str = "", + campaign_id: str = "", +) -> dict: + """List individual assets in PMax asset groups with field type and policy review. + + Returns asset text/url, the field_type (HEADLINE, DESCRIPTION, MARKETING_IMAGE, + LOGO, YOUTUBE_VIDEO, etc.), status, and policy review_status. The + LOW/GOOD/BEST/PENDING performance_label was removed from this resource in + Google Ads API v24 — to judge per-asset performance, query metrics directly + via asset_field_type_view or asset_group_top_combination_view. + """ + from adloop.ads.gaql import execute_query + + filters = [] + if asset_group_id: + ag = _validate_numeric_id(asset_group_id, "asset_group_id") + filters.append(f"asset_group.id = {ag}") + if campaign_id: + cid = _validate_numeric_id(campaign_id, "campaign_id") + filters.append(f"campaign.id = {cid}") + extra_filter = ("AND " + " AND ".join(filters)) if filters else "" + + query = f""" + SELECT asset_group.id, asset_group.name, + asset_group_asset.field_type, + asset_group_asset.status, + asset_group_asset.policy_summary.review_status, + asset.id, asset.type, + asset.text_asset.text, + asset.image_asset.full_size.url, + asset.youtube_video_asset.youtube_video_id, + asset.youtube_video_asset.youtube_video_title, + campaign.id, campaign.name + FROM asset_group_asset + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND asset_group_asset.status != 'REMOVED' + {extra_filter} + ORDER BY asset_group.id, asset_group_asset.field_type + """ + + rows = execute_query(config, customer_id, query) + _enrich_youtube_url(rows) + + return {"assets": rows, "total_assets": len(rows)} + + +def get_asset_group_signals( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_group_id: str = "", + campaign_id: str = "", +) -> dict: + """List audience and search-theme signals attached to PMax asset groups.""" + from adloop.ads.gaql import execute_query + + filters = [] + if asset_group_id: + ag = _validate_numeric_id(asset_group_id, "asset_group_id") + filters.append(f"asset_group.id = {ag}") + if campaign_id: + cid = _validate_numeric_id(campaign_id, "campaign_id") + filters.append(f"campaign.id = {cid}") + extra_filter = ("AND " + " AND ".join(filters)) if filters else "" + + query = f""" + SELECT asset_group.id, asset_group.name, + asset_group_signal.resource_name, + asset_group_signal.audience.audience, + asset_group_signal.search_theme.text, + campaign.id, campaign.name + FROM asset_group_signal + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + {extra_filter} + ORDER BY asset_group.id + """ + + rows = execute_query(config, customer_id, query) + + for row in rows: + if row.get("asset_group_signal.search_theme.text"): + row["signal_type"] = "SEARCH_THEME" + elif row.get("asset_group_signal.audience.audience"): + row["signal_type"] = "AUDIENCE" + else: + row["signal_type"] = "UNKNOWN" + + return {"signals": rows, "total_signals": len(rows)} + + +def get_asset_group_top_combinations( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_group_id: str = "", + campaign_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get the asset combinations Google has assembled at serve time. + + Each row's `asset_group_top_combination_view.asset_group_top_combinations` + is a repeated message of the assets that served together. The view does + NOT expose `metrics.*` fields in v24 — combinations come pre-ordered by + Google by serving frequency, so impression-level sorting is not available. + """ + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + filters = [] + if asset_group_id: + ag = _validate_numeric_id(asset_group_id, "asset_group_id") + filters.append(f"asset_group.id = {ag}") + if campaign_id: + cid = _validate_numeric_id(campaign_id, "campaign_id") + filters.append(f"campaign.id = {cid}") + extra_filter = ("AND " + " AND ".join(filters)) if filters else "" + + query = f""" + SELECT asset_group.id, asset_group.name, + asset_group_top_combination_view.asset_group_top_combinations, + campaign.id, campaign.name + FROM asset_group_top_combination_view + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + {extra_filter} + {date_clause} + LIMIT 50 + """ + + rows = execute_query(config, customer_id, query) + + return {"combinations": rows, "total_rows": len(rows)} + + +def get_pmax_search_terms( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get aggregated search-category insights for PMax. + + Queries `campaign_search_term_insight`. Returns category labels with + impressions and clicks only — `metrics.cost_micros`, `metrics.conversions`, + and `metrics.conversions_value` are not selectable on this resource (the + API rejects them with PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE). Google + deliberately does not expose individual search queries for PMax campaigns, + only aggregated category labels. + """ + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + if not campaign_id: + return { + "error": "campaign_id is required for PMax search terms.", + "hint": ( + "PMax search-term insights are queried per-campaign. " + "Get a Performance Max campaign id from get_pmax_campaigns first." + ), + } + cid = _validate_numeric_id(campaign_id, "campaign_id") + + query = f""" + SELECT campaign_search_term_insight.id, + campaign_search_term_insight.category_label, + metrics.impressions, metrics.clicks + FROM campaign_search_term_insight + WHERE campaign_search_term_insight.campaign_id = {cid} + {date_clause} + ORDER BY metrics.impressions DESC + LIMIT 200 + """ + + try: + rows = execute_query(config, customer_id, query) + except Exception as exc: + err = str(exc) + if "UNRECOGNIZED_FIELD" in err or "INVALID_RESOURCE_NAME" in err: + return { + "error": "PMax search term insights are not available on this API version.", + "hint": ( + "campaign_search_term_insight requires Google Ads API v23.2 or " + "later. Bump GOOGLE_ADS_API_VERSION in ads/client.py if needed." + ), + } + raise + + return { + "search_term_categories": rows, + "total_rows": len(rows), + "note": ( + "metrics.cost, metrics.conversions, and metrics.conversions_value are " + "not exposed by the Google Ads API on campaign_search_term_insight — " + "PMax search-term insights show impression/click volume only." + ), + } + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _date_clause(start: str, end: str) -> str: + """Build a GAQL date WHERE fragment.""" + if start and end: + return f"AND segments.date BETWEEN '{start}' AND '{end}'" + return "AND segments.date DURING LAST_30_DAYS" + + +def _validate_numeric_id(value: str, name: str) -> str: + """Reject non-numeric IDs to prevent GAQL injection.""" + stripped = value.replace("-", "").strip() + if not stripped.isdigit(): + raise ValueError(f"Invalid {name}: {value!r} — must be numeric") + return stripped + + +def _enrich_cost_fields(rows: list[dict]) -> None: + """Add metrics.cost and metrics.cpa (account currency) from cost_micros.""" + for row in rows: + cost_micros = row.get("metrics.cost_micros", 0) or 0 + row["metrics.cost"] = round(cost_micros / 1_000_000, 2) + + conversions = row.get("metrics.conversions", 0) or 0 + if conversions > 0: + row["metrics.cpa"] = round(cost_micros / 1_000_000 / conversions, 2) + + avg_cpc_micros = row.get("metrics.average_cpc", 0) or 0 + if avg_cpc_micros: + row["metrics.average_cpc_amount"] = round(avg_cpc_micros / 1_000_000, 2) + + +def _enrich_budget_fields(rows: list[dict]) -> None: + """Compute human-readable daily budget from budget_micros.""" + for row in rows: + budget_micros = row.get("campaign_budget.amount_micros", 0) or 0 + if budget_micros: + row["campaign_budget.amount"] = round(budget_micros / 1_000_000, 2) + + +def _enrich_roas(rows: list[dict]) -> None: + """Compute ROAS = conversions_value / cost from cost_micros.""" + for row in rows: + cost_micros = row.get("metrics.cost_micros", 0) or 0 + value = row.get("metrics.conversions_value", 0) or 0 + if cost_micros > 0 and value: + row["metrics.roas"] = round(value / (cost_micros / 1_000_000), 2) + + +def _enrich_youtube_url(rows: list[dict]) -> None: + """Build a youtube_url shortcut from the youtube_video_id when present.""" + for row in rows: + vid = row.get("asset.youtube_video_asset.youtube_video_id") + if vid: + row["asset.youtube_video_asset.youtube_url"] = ( + f"https://www.youtube.com/watch?v={vid}" + ) diff --git a/src/adloop/ads/pmax_write.py b/src/adloop/ads/pmax_write.py new file mode 100644 index 0000000..71ddc44 --- /dev/null +++ b/src/adloop/ads/pmax_write.py @@ -0,0 +1,1313 @@ +"""Google Ads Performance Max write tools — behind the safety layer. + +Performance Max is structurally different from Search: +- No ad groups, no keywords, no individual ads. +- An ``asset_group`` bundles assets (headlines, descriptions, images, logos, + videos) that Google assembles dynamically per impression. +- Channel mix (Search/Display/YouTube/Shopping/Maps/Discover/Gmail) is decided + by Google at serve time. The API REJECTS any ``Campaign.network_settings`` + on PMax — both ``target_search_network`` and ``target_content_network``. +- For non-retail PMax, asset groups + linked assets must be created together + in the SAME bulk mutate as the campaign. + +These write tools follow the same draft -> preview -> confirm_and_apply flow +as the Search tools in ads/write.py. Each draft_* tool creates a ChangePlan +that ``confirm_and_apply`` (in ads/write.py) executes. + +Apply helpers live here too and are wired into ``_execute_plan``'s dispatch +table via ``PMAX_OPERATIONS``. +""" + +from __future__ import annotations + +import hashlib +import os +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adloop.config import AdLoopConfig + + +# --------------------------------------------------------------------------- +# Constants — character limits, field-type minimums, allowed enum values +# --------------------------------------------------------------------------- + +# Character limits per Google Ads documentation +_LIMITS = { + "HEADLINE": 30, + "LONG_HEADLINE": 90, + "DESCRIPTION": 90, + "BUSINESS_NAME": 25, +} + +# Image upload constraints (Google Ads ImageAsset). +_IMAGE_MAX_BYTES = 5 * 1024 * 1024 # 5 MB +_IMAGE_EXT_TO_MIME = { + ".jpg": "IMAGE_JPEG", + ".jpeg": "IMAGE_JPEG", + ".png": "IMAGE_PNG", + ".gif": "IMAGE_GIF", +} +_IMAGE_MAGIC_BYTES = { + "IMAGE_JPEG": (b"\xff\xd8\xff",), + "IMAGE_PNG": (b"\x89PNG\r\n\x1a\n",), + "IMAGE_GIF": (b"GIF87a", b"GIF89a"), +} + +# Per-field-type minimums for non-retail PMax asset groups (Google's minimums). +# An asset group below ANY minimum will fail Google's "minimum requirements" +# check at serve time even if the API accepts the create. +ASSET_MINIMUMS = { + "HEADLINE": 3, + "LONG_HEADLINE": 1, + "DESCRIPTION": 2, + "BUSINESS_NAME": 1, + "MARKETING_IMAGE": 1, + "SQUARE_MARKETING_IMAGE": 1, + "LOGO": 1, +} + +# Per-field-type maximums for the inputs this module accepts. +ASSET_MAXIMUMS = { + "HEADLINE": 5, + "LONG_HEADLINE": 5, + "DESCRIPTION": 5, + "BUSINESS_NAME": 1, + "MARKETING_IMAGE": 20, + "SQUARE_MARKETING_IMAGE": 20, + "LOGO": 5, +} + +# PMax is Smart Bidding only — MANUAL_CPC / TARGET_SPEND are rejected. +_PMAX_VALID_BIDDING = { + "MAXIMIZE_CONVERSIONS", + "MAXIMIZE_CONVERSION_VALUE", + "TARGET_CPA", + "TARGET_ROAS", +} + + +# --------------------------------------------------------------------------- +# Draft tools — return a preview with a plan_id, do NOT execute +# --------------------------------------------------------------------------- + + +def draft_pmax_campaign( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_name: str = "", + daily_budget: float = 0, + bidding_strategy: str = "", + target_cpa: float = 0, + target_roas: float = 0, + geo_target_ids: list[str] | None = None, + language_ids: list[str] | None = None, + final_url_suffix: str | None = None, + brand_guidelines_enabled: bool = True, + asset_group: dict | None = None, +) -> dict: + """Draft a Performance Max campaign with its first asset group. + + Per Google's PMax structure rules, the campaign and its first asset group + + assets MUST be created in the same API call. This tool produces a + single ChangePlan that, on confirm_and_apply, issues one bulk mutate + containing: CampaignBudget + Campaign (PAUSED) + geo/language targeting + + AssetGroup (PAUSED) + every Asset + every AssetGroupAsset link + every + AssetGroupSignal. + + bidding_strategy: PMax accepts only Smart Bidding strategies — + ``MAXIMIZE_CONVERSIONS``, ``MAXIMIZE_CONVERSION_VALUE``, ``TARGET_CPA``, + ``TARGET_ROAS``. ``MANUAL_CPC`` and ``TARGET_SPEND`` are rejected. + + asset_group: dict with these fields (required unless marked optional): + - ``name`` (str): asset group name. + - ``final_urls`` (list[str]): at least one. These are where ads send users. + - ``path1`` (str, optional, <=15 chars): display URL path component. + - ``path2`` (str, optional, <=15 chars): second display URL path component. + - ``headlines`` (list[str]): 3-5 short headlines, each <=30 chars. + - ``long_headlines`` (list[str]): 1-5 long headlines, each <=90 chars. + - ``descriptions`` (list[str]): 2-5 descriptions, each <=90 chars. + - ``business_name`` (str): your business name, <=25 chars. + - ``marketing_image_assets`` (list[str], optional): resource_names of + existing 1.91:1 marketing image Assets. PMax requires at least one + marketing image — if you have none uploaded yet, do that in Google + Ads UI first and pass the resource names here. + - ``square_marketing_image_assets`` (list[str], optional): resource_names + of existing 1:1 square marketing image Assets. + - ``logo_assets`` (list[str], optional): resource_names of existing logo + Assets. + - ``youtube_video_ids`` (list[str], optional): YouTube video IDs to add + as YOUTUBE_VIDEO assets (these are inline-creatable). + - ``search_themes`` (list[str], optional): search-theme signal phrases. + - ``audience_resource_names`` (list[str], optional): resource_names of + existing Audience resources to use as audience signals. + + brand_guidelines_enabled: defaults to True, matching Google's new default + for PMax campaigns. When True, the BUSINESS_NAME text asset and the + first LOGO asset are also linked at the campaign level via + ``CampaignAsset`` — the API otherwise rejects the mutate with + ``REQUIRED_BUSINESS_NAME_ASSET_NOT_LINKED`` / + ``REQUIRED_LOGO_ASSET_NOT_LINKED``. Set to False to opt out of Brand + Guidelines (assets stay at the asset-group level only). + + NOTE: image and logo Assets are not inline-creatable inside this mutate. + Upload them first via ``draft_image_asset`` (point at local JPG/PNG/GIF + paths), then pass the returned resource_names here. Resource_names from + assets you've already uploaded via the Google Ads UI work too. + """ + from adloop.safety.guards import ( + SafetyViolation, + check_blocked_operation, + check_budget_cap, + ) + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_pmax_campaign", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors, warnings = _validate_pmax_campaign( + campaign_name=campaign_name, + daily_budget=daily_budget, + bidding_strategy=bidding_strategy, + target_cpa=target_cpa, + target_roas=target_roas, + geo_target_ids=geo_target_ids, + language_ids=language_ids, + asset_group=asset_group, + ) + if errors: + return {"error": "Validation failed", "details": errors} + + try: + check_budget_cap(daily_budget, config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + plan = ChangePlan( + operation="create_pmax_campaign", + entity_type="campaign", + customer_id=customer_id, + changes={ + "campaign_name": campaign_name, + "daily_budget": daily_budget, + "bidding_strategy": bidding_strategy.upper(), + "target_cpa": target_cpa or None, + "target_roas": target_roas or None, + "geo_target_ids": geo_target_ids or [], + "language_ids": language_ids or [], + "final_url_suffix": final_url_suffix or "", + "brand_guidelines_enabled": brand_guidelines_enabled, + "asset_group": asset_group, + }, + ) + store_plan(plan) + preview = plan.to_preview() + if warnings: + preview["warnings"] = warnings + return preview + + +def draft_asset_group( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + asset_group: dict | None = None, +) -> dict: + """Draft a new asset group inside an existing PMax campaign. + + Creates: AssetGroup (PAUSED) + every text Asset + every YouTube video + Asset + every AssetGroupAsset link + every AssetGroupSignal in one + bulk mutate. + + asset_group: same dict shape as ``draft_pmax_campaign``'s asset_group — + see that tool's docstring for field descriptions. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_asset_group", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors: list[str] = [] + if not campaign_id: + errors.append("campaign_id is required") + if not asset_group: + errors.append("asset_group is required") + else: + errors.extend(_validate_asset_group(asset_group)) + + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_asset_group", + entity_type="asset_group", + customer_id=customer_id, + changes={"campaign_id": campaign_id, "asset_group": asset_group}, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_asset_group_assets( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_group_id: str = "", + headlines: list[str] | None = None, + long_headlines: list[str] | None = None, + descriptions: list[str] | None = None, + business_name: str = "", + marketing_image_assets: list[str] | None = None, + square_marketing_image_assets: list[str] | None = None, + logo_assets: list[str] | None = None, + youtube_video_ids: list[str] | None = None, +) -> dict: + """Draft adding assets to an existing asset group. + + Use this to extend an asset group with more headlines, descriptions, + images, etc. Each asset gets created (text/video assets inline; image/logo + by resource_name reference) and linked to the asset group via + AssetGroupAsset operations in one bulk mutate. + + Image and logo Assets are not inline-creatable. Upload local files first + via ``draft_image_asset`` and pass the resulting resource_names here, or + paste resource_names of assets already uploaded via the Google Ads UI. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_asset_group_assets", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors: list[str] = [] + if not asset_group_id: + errors.append("asset_group_id is required") + + new_text = { + "HEADLINE": list(headlines or []), + "LONG_HEADLINE": list(long_headlines or []), + "DESCRIPTION": list(descriptions or []), + } + if business_name: + new_text["BUSINESS_NAME"] = [business_name] + + for ftype, items in new_text.items(): + for i, text in enumerate(items, start=1): + errors.extend(_validate_asset_text(ftype, text, i)) + + new_resource_assets = { + "MARKETING_IMAGE": list(marketing_image_assets or []), + "SQUARE_MARKETING_IMAGE": list(square_marketing_image_assets or []), + "LOGO": list(logo_assets or []), + } + for ftype, items in new_resource_assets.items(): + for rn in items: + if not rn or not rn.startswith("customers/"): + errors.append( + f"{ftype} entries must be Asset resource_names " + f"like 'customers/123/assets/456' — got '{rn}'. Upload " + f"local files via draft_image_asset to obtain resource_names." + ) + + new_video_ids = list(youtube_video_ids or []) + + has_any = any(new_text.values()) or any(new_resource_assets.values()) or new_video_ids + if not has_any: + errors.append( + "At least one asset must be provided — pass headlines, " + "long_headlines, descriptions, business_name, " + "marketing_image_assets, square_marketing_image_assets, " + "logo_assets, or youtube_video_ids." + ) + + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_asset_group_assets", + entity_type="asset_group", + entity_id=asset_group_id, + customer_id=customer_id, + changes={ + "asset_group_id": asset_group_id, + "text_assets_by_type": new_text, + "resource_assets_by_type": new_resource_assets, + "youtube_video_ids": new_video_ids, + }, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_image_asset( + config: AdLoopConfig, + *, + customer_id: str = "", + images: list[dict] | None = None, +) -> dict: + """Draft uploading one or more local image files as Google Ads Assets. + + PMax campaigns require pre-uploaded MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, + and LOGO assets that are referenced by resource_name. This tool reads local + image files, validates extension / file size / magic bytes, and produces a + ChangePlan that uploads the bytes via ``AssetService.MutateAssets`` when + ``confirm_and_apply`` is called. On apply, returns the new Asset + resource_names so they can be passed to ``draft_pmax_campaign``, + ``draft_asset_group``, or ``draft_asset_group_assets``. + + The same uploaded Asset can be linked as MARKETING_IMAGE, + SQUARE_MARKETING_IMAGE, or LOGO at link time — Google checks the pixel + dimensions against the slot's aspect-ratio requirement when the + AssetGroupAsset link is created (MARKETING_IMAGE: 1.91:1, min 600x314; + SQUARE_MARKETING_IMAGE: 1:1, min 300x300; LOGO: 1:1, min 128x128). + + Accepted formats: JPG (.jpg/.jpeg), PNG (.png), static GIF (.gif). + Max file size: 5 MB per image. Bytes are read once at apply time, so the + file must still exist at its path when confirm_and_apply runs. + + images: list of dicts, each with: + - ``file_path`` (str, REQUIRED): absolute path to a local image file + - ``name`` (str, REQUIRED): the Asset display name in Google Ads + + Example: ``images=[{"file_path": "/abs/logo.png", "name": "Acme Logo"}]`` + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("upload_image_asset", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors: list[str] = [] + if not images: + errors.append( + "images is required — pass a list of " + "{file_path, name} dicts." + ) + return {"error": "Validation failed", "details": errors} + + validated: list[dict] = [] + for i, spec in enumerate(images, start=1): + if not isinstance(spec, dict): + errors.append(f"images[{i}] must be a dict with file_path and name") + continue + + file_path = str(spec.get("file_path") or "").strip() + name = str(spec.get("name") or "").strip() + + item_errors, item_meta = _validate_image_file(file_path, name, i) + if item_errors: + errors.extend(item_errors) + continue + validated.append(item_meta) + + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="upload_image_asset", + entity_type="asset", + customer_id=customer_id, + changes={"images": validated}, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_asset_group_signal( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_group_id: str = "", + search_theme: str = "", + audience_resource_name: str = "", +) -> dict: + """Draft a single new signal (search theme OR audience) on an asset group. + + Search themes are immutable once created — to "edit" one, remove the old + signal and add a new one. Audiences must already exist as Audience + resources; pass the resource_name (``customers/.../audiences/...``). + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_asset_group_signal", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors: list[str] = [] + if not asset_group_id: + errors.append("asset_group_id is required") + if not search_theme and not audience_resource_name: + errors.append("Either search_theme or audience_resource_name is required") + if search_theme and audience_resource_name: + errors.append( + "Pass only one of search_theme or audience_resource_name " + "per call — Google creates one signal per AssetGroupSignal." + ) + if audience_resource_name and not audience_resource_name.startswith("customers/"): + errors.append( + f"audience_resource_name must look like " + f"'customers/.../audiences/...', got '{audience_resource_name}'" + ) + + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_asset_group_signal", + entity_type="asset_group", + entity_id=asset_group_id, + customer_id=customer_id, + changes={ + "asset_group_id": asset_group_id, + "search_theme": search_theme, + "audience_resource_name": audience_resource_name, + }, + ) + store_plan(plan) + return plan.to_preview() + + +# --------------------------------------------------------------------------- +# Validation helpers +# --------------------------------------------------------------------------- + + +def _validate_pmax_campaign( + *, + campaign_name: str, + daily_budget: float, + bidding_strategy: str, + target_cpa: float, + target_roas: float, + geo_target_ids: list[str] | None, + language_ids: list[str] | None, + asset_group: dict | None, +) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + + if not campaign_name or not campaign_name.strip(): + errors.append("campaign_name is required") + if daily_budget <= 0: + errors.append("daily_budget must be greater than 0") + if not geo_target_ids: + errors.append( + "geo_target_ids is required — PMax campaigns must target at " + "least one country/region" + ) + if not language_ids: + errors.append( + "language_ids is required — PMax campaigns must target at " + "least one language" + ) + + bs = bidding_strategy.upper() + if bs not in _PMAX_VALID_BIDDING: + errors.append( + f"bidding_strategy must be one of {sorted(_PMAX_VALID_BIDDING)} " + f"for PMax (no MANUAL_CPC or TARGET_SPEND), got '{bidding_strategy}'" + ) + if bs == "TARGET_CPA" and not target_cpa: + errors.append("target_cpa is required when bidding_strategy is TARGET_CPA") + if bs == "TARGET_ROAS" and not target_roas: + errors.append("target_roas is required when bidding_strategy is TARGET_ROAS") + + if target_cpa > 0 and daily_budget < 5 * target_cpa: + warnings.append( + f"Daily budget {daily_budget:.2f} is less than 5x target CPA " + f"{target_cpa:.2f}. Google recommends at least 5x for PMax " + f"learning to converge." + ) + + if not asset_group: + errors.append("asset_group is required for PMax campaign creation") + else: + errors.extend(_validate_asset_group(asset_group)) + + return errors, warnings + + +def _validate_asset_group(asset_group: dict) -> list[str]: + """Validate the asset_group dict against PMax minimums and char limits.""" + errors: list[str] = [] + + name = (asset_group.get("name") or "").strip() + if not name: + errors.append("asset_group.name is required") + + final_urls = asset_group.get("final_urls") or [] + if not final_urls: + errors.append("asset_group.final_urls must contain at least one URL") + for url in final_urls: + if not isinstance(url, str) or not url.startswith(("http://", "https://")): + errors.append( + f"asset_group.final_urls must be http(s) URLs — got '{url}'" + ) + + path1 = asset_group.get("path1") or "" + if path1 and len(path1) > 15: + errors.append(f"asset_group.path1 exceeds 15 chars ({len(path1)}): '{path1}'") + path2 = asset_group.get("path2") or "" + if path2 and len(path2) > 15: + errors.append(f"asset_group.path2 exceeds 15 chars ({len(path2)}): '{path2}'") + + business_name = asset_group.get("business_name") or "" + # Always include BUSINESS_NAME — even when omitted — so the minimum check + # fires. Otherwise an empty business_name skips validation and the + # asset group fails apply-time API validation instead of draft-time. + text_groups = { + "HEADLINE": asset_group.get("headlines") or [], + "LONG_HEADLINE": asset_group.get("long_headlines") or [], + "DESCRIPTION": asset_group.get("descriptions") or [], + "BUSINESS_NAME": [business_name] if business_name else [], + } + + for ftype, items in text_groups.items(): + for i, text in enumerate(items, start=1): + errors.extend(_validate_asset_text(ftype, text, i)) + + minimum = ASSET_MINIMUMS.get(ftype, 0) + if len(items) < minimum: + errors.append( + f"asset_group needs at least {minimum} {ftype} asset(s), " + f"got {len(items)}" + ) + maximum = ASSET_MAXIMUMS.get(ftype, 999) + if len(items) > maximum: + errors.append( + f"asset_group accepts at most {maximum} {ftype} asset(s), " + f"got {len(items)}" + ) + + image_keys = { + "MARKETING_IMAGE": asset_group.get("marketing_image_assets") or [], + "SQUARE_MARKETING_IMAGE": asset_group.get("square_marketing_image_assets") or [], + "LOGO": asset_group.get("logo_assets") or [], + } + for ftype, items in image_keys.items(): + for rn in items: + if not isinstance(rn, str) or not rn.startswith("customers/"): + errors.append( + f"{ftype.lower()}_assets entries must be Asset resource_names " + f"like 'customers/123/assets/456' — got '{rn}'. Upload local " + f"files via draft_image_asset to obtain resource_names." + ) + minimum = ASSET_MINIMUMS.get(ftype, 0) + if len(items) < minimum: + errors.append( + f"asset_group requires at least {minimum} pre-uploaded " + f"{ftype} asset resource_name(s) — call draft_image_asset to " + f"upload local files, or paste resource_names of assets already " + f"in the account. Got {len(items)}." + ) + maximum = ASSET_MAXIMUMS.get(ftype, 999) + if len(items) > maximum: + errors.append( + f"asset_group accepts at most {maximum} {ftype} " + f"asset resource_name(s), got {len(items)}." + ) + + return errors + + +def _validate_asset_text(field_type: str, text: str, index: int) -> list[str]: + """Validate a single text asset's char limit and non-emptiness.""" + errors: list[str] = [] + if not text or not str(text).strip(): + errors.append(f"{field_type} #{index} is empty") + return errors + limit = _LIMITS.get(field_type) + if limit is not None and len(text) > limit: + errors.append( + f"{field_type} #{index} exceeds {limit} chars ({len(text)}): '{text}'" + ) + return errors + + +def _validate_image_file( + file_path: str, name: str, index: int +) -> tuple[list[str], dict]: + """Validate a local image file path for upload. + + Returns (errors, metadata). Metadata is empty when errors are present. + Validates path exists, extension is JPG/PNG/GIF, file size is within + Google Ads's 5 MB limit, and the magic bytes match the declared format. + """ + errors: list[str] = [] + if not file_path: + errors.append(f"images[{index}].file_path is required") + if not name: + errors.append(f"images[{index}].name is required") + if errors: + return errors, {} + + if not os.path.isabs(file_path): + errors.append( + f"images[{index}].file_path must be an absolute path, got '{file_path}'" + ) + return errors, {} + + if not os.path.isfile(file_path): + errors.append(f"images[{index}].file_path does not exist: '{file_path}'") + return errors, {} + + ext = os.path.splitext(file_path)[1].lower() + mime_type = _IMAGE_EXT_TO_MIME.get(ext) + if mime_type is None: + errors.append( + f"images[{index}].file_path has unsupported extension '{ext}' — " + f"Google Ads accepts {sorted(_IMAGE_EXT_TO_MIME)}" + ) + return errors, {} + + file_size = os.path.getsize(file_path) + if file_size == 0: + errors.append(f"images[{index}].file_path is empty: '{file_path}'") + return errors, {} + if file_size > _IMAGE_MAX_BYTES: + errors.append( + f"images[{index}].file_path is {file_size / 1024 / 1024:.2f} MB — " + f"Google Ads max is 5 MB" + ) + return errors, {} + + with open(file_path, "rb") as f: + data = f.read() + if not any(data.startswith(sig) for sig in _IMAGE_MAGIC_BYTES[mime_type]): + errors.append( + f"images[{index}].file_path extension '{ext}' does not match the " + f"actual file content — magic bytes mismatch" + ) + return errors, {} + + # Hash so we can detect content-substitution between draft and apply + # (same byte count, different bytes — file_size alone would miss it). + sha256 = hashlib.sha256(data).hexdigest() + + return [], { + "file_path": file_path, + "name": name, + "mime_type": mime_type, + "file_size": file_size, + "sha256": sha256, + } + + +# --------------------------------------------------------------------------- +# Apply helpers — wired into _execute_plan via PMAX_OPERATIONS +# --------------------------------------------------------------------------- + + +def _apply_create_pmax_campaign( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Create a full PMax campaign in one bulk mutate. + + Order of operations (Google Ads docs say AssetOperations must be + consecutive and precede their AssetGroupAsset links): + + 1. CampaignBudgetOperation.create (temp -1) + 2. CampaignOperation.create (temp -2, references budget -1) + - PMax: NO network_settings, no advertising_channel_sub_type + 3. CampaignCriterionOperation.create x N (geo + language, references -2) + 4. AssetOperation.create x N (text + youtube_video assets, temp -10..) + 5. AssetGroupOperation.create (temp -100, references campaign -2) + 6. AssetGroupAssetOperation.create x N (links assets to asset group) + 7. AssetGroupSignalOperation.create x N (search themes + audiences) + """ + service = client.get_service("GoogleAdsService") + campaign_service = client.get_service("CampaignService") + budget_service = client.get_service("CampaignBudgetService") + + operations: list = [] + asset_group_data = changes["asset_group"] + + # --- 1. CampaignBudget (temp -1) --- + budget_op = client.get_type("MutateOperation") + budget = budget_op.campaign_budget_operation.create + budget.resource_name = budget_service.campaign_budget_path(cid, "-1") + budget.name = f"Budget - {changes['campaign_name']}" + budget.amount_micros = int(changes["daily_budget"] * 1_000_000) + budget.delivery_method = client.enums.BudgetDeliveryMethodEnum.STANDARD + budget.explicitly_shared = False + operations.append(budget_op) + + # --- 2. Campaign (temp -2) — PMax: omit network_settings entirely --- + campaign_op = client.get_type("MutateOperation") + campaign = campaign_op.campaign_operation.create + campaign.resource_name = campaign_service.campaign_path(cid, "-2") + campaign.name = changes["campaign_name"] + campaign.campaign_budget = budget_service.campaign_budget_path(cid, "-1") + campaign.status = client.enums.CampaignStatusEnum.PAUSED + campaign.advertising_channel_type = ( + client.enums.AdvertisingChannelTypeEnum.PERFORMANCE_MAX + ) + + bs = changes["bidding_strategy"] + if bs == "MAXIMIZE_CONVERSIONS": + campaign.maximize_conversions.target_cpa_micros = 0 + if changes.get("target_cpa"): + campaign.maximize_conversions.target_cpa_micros = int( + changes["target_cpa"] * 1_000_000 + ) + elif bs == "TARGET_CPA": + campaign.maximize_conversions.target_cpa_micros = int( + changes["target_cpa"] * 1_000_000 + ) + elif bs == "MAXIMIZE_CONVERSION_VALUE": + campaign.maximize_conversion_value.target_roas = 0 + if changes.get("target_roas"): + campaign.maximize_conversion_value.target_roas = changes["target_roas"] + elif bs == "TARGET_ROAS": + campaign.maximize_conversion_value.target_roas = changes["target_roas"] + + # PMax does NOT accept network_settings — omitted intentionally. + + campaign.contains_eu_political_advertising = ( + client.enums.EuPoliticalAdvertisingStatusEnum.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING + ) + + if changes.get("brand_guidelines_enabled"): + campaign.brand_guidelines_enabled = True + else: + campaign.brand_guidelines_enabled = False + + if changes.get("final_url_suffix"): + campaign.final_url_suffix = changes["final_url_suffix"] + + operations.append(campaign_op) + + # --- 3. Geo/language targeting (CampaignCriterion, references campaign -2) --- + campaign_path = campaign_service.campaign_path(cid, "-2") + for geo_id in changes.get("geo_target_ids") or []: + geo_op = client.get_type("MutateOperation") + geo = geo_op.campaign_criterion_operation.create + geo.campaign = campaign_path + geo.location.geo_target_constant = f"geoTargetConstants/{geo_id}" + operations.append(geo_op) + + for lang_id in changes.get("language_ids") or []: + lang_op = client.get_type("MutateOperation") + lang = lang_op.campaign_criterion_operation.create + lang.campaign = campaign_path + lang.language.language_constant = f"languageConstants/{lang_id}" + operations.append(lang_op) + + # --- 4-7. Asset group + assets + signals --- + ag_operations, exposed = _build_asset_group_operations( + client=client, + cid=cid, + campaign_resource_name=campaign_path, + asset_group_data=asset_group_data, + asset_temp_id_start=-10, + asset_group_temp_id="-100", + ) + operations.extend(ag_operations) + + # --- 8. CampaignAsset links for Brand Guidelines --- + # New PMax campaigns default to brand_guidelines_enabled=True on Google's + # side. With that flag on, BUSINESS_NAME and LOGO assets MUST be linked + # at the campaign level via CampaignAsset (the asset_group-level link is + # not sufficient). The API otherwise rejects the mutate with + # REQUIRED_BUSINESS_NAME_ASSET_NOT_LINKED / REQUIRED_LOGO_ASSET_NOT_LINKED. + if changes.get("brand_guidelines_enabled"): + business_name_rn = exposed.get("business_name_asset") + if business_name_rn: + bn_link_op = client.get_type("MutateOperation") + bn_link = bn_link_op.campaign_asset_operation.create + bn_link.asset = business_name_rn + bn_link.campaign = campaign_path + bn_link.field_type = client.enums.AssetFieldTypeEnum.BUSINESS_NAME + operations.append(bn_link_op) + + logo_assets = asset_group_data.get("logo_assets") or [] + if logo_assets: + logo_link_op = client.get_type("MutateOperation") + logo_link = logo_link_op.campaign_asset_operation.create + logo_link.asset = logo_assets[0] + logo_link.campaign = campaign_path + logo_link.field_type = client.enums.AssetFieldTypeEnum.LOGO + operations.append(logo_link_op) + + response = service.mutate( + request={ + "customer_id": cid, + "mutate_operations": operations, + "validate_only": validate_only, + } + ) + + if validate_only: + return {"status": "validated", "operation_count": len(operations)} + + results: dict = { + "campaign_budget": None, + "campaign": None, + "asset_group": None, + "asset_count": 0, + "asset_group_assets": [], + "asset_group_signals": [], + "campaign_assets": [], + } + for resp in response.mutate_operation_responses: + # proto-plus's wrapper of MutateOperationResponse does not always + # expose WhichOneof as a method — calling it on the wrapper raises + # "Unknown field for MutateOperationResponse: WhichOneof". Drop to + # the underlying protobuf via type(resp).pb(resp). + resp_type = type(resp).pb(resp).WhichOneof("response") + if not resp_type: + continue + rn = getattr(getattr(resp, resp_type), "resource_name", None) + if not rn: + continue + if resp_type == "campaign_budget_result": + results["campaign_budget"] = rn + elif resp_type == "campaign_result": + results["campaign"] = rn + elif resp_type == "asset_group_result": + results["asset_group"] = rn + elif resp_type == "asset_result": + results["asset_count"] += 1 + elif resp_type == "asset_group_asset_result": + results["asset_group_assets"].append(rn) + elif resp_type == "asset_group_signal_result": + results["asset_group_signals"].append(rn) + elif resp_type == "campaign_asset_result": + results["campaign_assets"].append(rn) + + return results + + +def _apply_create_asset_group( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Add an asset group (with assets + signals) to an existing PMax campaign.""" + service = client.get_service("GoogleAdsService") + campaign_service = client.get_service("CampaignService") + + campaign_path = campaign_service.campaign_path(cid, changes["campaign_id"]) + # Brand-guidelines CampaignAsset links already live on the parent + # campaign — adding a new asset group does not require re-creating them. + operations, _exposed = _build_asset_group_operations( + client=client, + cid=cid, + campaign_resource_name=campaign_path, + asset_group_data=changes["asset_group"], + asset_temp_id_start=-10, + asset_group_temp_id="-100", + ) + + response = service.mutate( + request={ + "customer_id": cid, + "mutate_operations": operations, + "validate_only": validate_only, + } + ) + + if validate_only: + return {"status": "validated", "operation_count": len(operations)} + + results: dict = {"asset_group": None, "asset_count": 0, "links": [], "signals": []} + for resp in response.mutate_operation_responses: + resp_type = type(resp).pb(resp).WhichOneof("response") + if not resp_type: + continue + rn = getattr(getattr(resp, resp_type), "resource_name", None) + if not rn: + continue + if resp_type == "asset_group_result": + results["asset_group"] = rn + elif resp_type == "asset_result": + results["asset_count"] += 1 + elif resp_type == "asset_group_asset_result": + results["links"].append(rn) + elif resp_type == "asset_group_signal_result": + results["signals"].append(rn) + return results + + +def _apply_create_asset_group_assets( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Add assets (text/video inline + image refs) to an existing asset group. + + Per Google's bulk-mutate ordering rules, all Asset.create operations are + emitted first (consecutive), then all AssetGroupAsset.create links. + """ + service = client.get_service("GoogleAdsService") + asset_service = client.get_service("AssetService") + asset_group_service = client.get_service("AssetGroupService") + + asset_group_path = asset_group_service.asset_group_path( + cid, changes["asset_group_id"] + ) + field_type_enum = client.enums.AssetFieldTypeEnum + + # Plan all the new Asset.create operations first; record the resulting + # temp resource_names so AssetGroupAsset.create operations can reference + # them after all Asset operations are emitted. + asset_ops: list = [] + link_specs: list[tuple[str, str]] = [] # (asset_resource_name, field_type) + next_temp_id = -1 + + # Inline text assets. + for ftype, texts in (changes.get("text_assets_by_type") or {}).items(): + for text in texts: + op = client.get_type("MutateOperation") + asset = op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, str(next_temp_id)) + asset.text_asset.text = text + asset_ops.append(op) + link_specs.append((asset.resource_name, ftype)) + next_temp_id -= 1 + + # Inline YouTube video assets. + for video_id in changes.get("youtube_video_ids") or []: + op = client.get_type("MutateOperation") + asset = op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, str(next_temp_id)) + asset.youtube_video_asset.youtube_video_id = video_id + asset_ops.append(op) + link_specs.append((asset.resource_name, "YOUTUBE_VIDEO")) + next_temp_id -= 1 + + # Pre-uploaded image/logo assets — link-only. + for ftype, resource_names in (changes.get("resource_assets_by_type") or {}).items(): + for rn in resource_names: + link_specs.append((rn, ftype)) + + if not asset_ops and not link_specs: + return {"message": "No assets to add"} + + operations: list = list(asset_ops) + for asset_rn, ftype in link_specs: + op = client.get_type("MutateOperation") + link = op.asset_group_asset_operation.create + link.asset = asset_rn + link.asset_group = asset_group_path + link.field_type = getattr(field_type_enum, ftype) + operations.append(op) + + response = service.mutate( + request={ + "customer_id": cid, + "mutate_operations": operations, + "validate_only": validate_only, + } + ) + + if validate_only: + return {"status": "validated", "operation_count": len(operations)} + + results: dict = {"assets": [], "links": []} + for resp in response.mutate_operation_responses: + resp_type = type(resp).pb(resp).WhichOneof("response") + if not resp_type: + continue + rn = getattr(getattr(resp, resp_type), "resource_name", None) + if not rn: + continue + if resp_type == "asset_result": + results["assets"].append(rn) + elif resp_type == "asset_group_asset_result": + results["links"].append(rn) + return results + + +def _read_image_unchanged(spec: dict) -> bytes: + """Re-read an image at apply time, verifying it has not changed since draft. + + Raises FileNotFoundError if the file was moved, or ValueError if the bytes + were modified (size or sha256 mismatch). Returns the file bytes so the + caller can hand them straight to AssetService.MutateAssets. + """ + path = spec["file_path"] + if not os.path.isfile(path): + raise FileNotFoundError( + f"Image '{spec['name']}' is no longer at '{path}'. The file was " + f"removed or moved between draft and confirm_and_apply. Re-draft " + f"with the current path." + ) + size_now = os.path.getsize(path) + if size_now != spec["file_size"]: + raise ValueError( + f"Image '{spec['name']}' at '{path}' changed size between draft " + f"({spec['file_size']} bytes) and confirm_and_apply ({size_now} " + f"bytes). Re-draft to upload the current bytes." + ) + with open(path, "rb") as f: + data = f.read() + sha_now = hashlib.sha256(data).hexdigest() + if sha_now != spec["sha256"]: + raise ValueError( + f"Image '{spec['name']}' at '{path}' changed content between " + f"draft and confirm_and_apply (sha256 mismatch — same byte " + f"count, different bytes). Re-draft to upload the current " + f"bytes." + ) + return data + + +def _apply_upload_image_asset( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Upload one or more local image files as Google Ads Assets. + + File bytes are read at apply time (not at draft time) so that large + images do not bloat the in-memory plan. If a file was modified or moved + between draft and apply, the apply fails with a clear error before any + Google Ads mutate runs. + """ + image_specs = changes.get("images") or [] + # Verify every image before reaching the API so a partial mutate cannot + # leave half of a batch uploaded. + verified = [(spec, _read_image_unchanged(spec)) for spec in image_specs] + + service = client.get_service("AssetService") + mime_type_enum = client.enums.MimeTypeEnum + + operations: list = [] + image_names: list[str] = [] + for spec, data in verified: + size_now = len(data) + + op = client.get_type("AssetOperation") + asset = op.create + asset.name = spec["name"] + asset.type_ = client.enums.AssetTypeEnum.IMAGE + asset.image_asset.data = data + asset.image_asset.file_size = size_now + asset.image_asset.mime_type = getattr(mime_type_enum, spec["mime_type"]) + operations.append(op) + image_names.append(spec["name"]) + + response = service.mutate_assets( + request={ + "customer_id": cid, + "operations": operations, + "validate_only": validate_only, + } + ) + + if validate_only: + return {"status": "validated", "image_count": len(operations)} + + uploaded = [ + {"name": image_names[i], "resource_name": r.resource_name} + for i, r in enumerate(response.results) + ] + return {"uploaded": uploaded, "image_count": len(uploaded)} + + +def _apply_create_asset_group_signal( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Add a single signal (search theme or audience) to an asset group.""" + service = client.get_service("AssetGroupSignalService") + asset_group_service = client.get_service("AssetGroupService") + + operation = client.get_type("AssetGroupSignalOperation") + signal = operation.create + signal.asset_group = asset_group_service.asset_group_path( + cid, changes["asset_group_id"] + ) + + if changes.get("search_theme"): + signal.search_theme.text = changes["search_theme"] + elif changes.get("audience_resource_name"): + signal.audience.audience = changes["audience_resource_name"] + + response = service.mutate_asset_group_signals( + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } + ) + if validate_only: + return {"status": "validated"} + return {"resource_name": response.results[0].resource_name} + + +# --------------------------------------------------------------------------- +# Internal: build the AssetGroup + Asset + AssetGroupAsset + Signal operations +# --------------------------------------------------------------------------- + + +def _build_asset_group_operations( + *, + client: object, + cid: str, + campaign_resource_name: str, + asset_group_data: dict, + asset_temp_id_start: int, + asset_group_temp_id: str, +) -> tuple[list, dict]: + """Build the slice of MutateOperations that creates an asset group. + + The order follows Google's "AssetOperations consecutive, before + AssetGroupAssets" requirement: all Asset.create ops first, then + AssetGroup.create, then AssetGroupAsset.create links, then + AssetGroupSignal.create. + + Returns ``(operations, exposed_resource_names)``. ``exposed_resource_names`` + keys the BUSINESS_NAME text asset's temp resource_name so the caller can + link it as a CampaignAsset when Brand Guidelines is enabled (the API + requires BUSINESS_NAME and LOGO to live at the campaign level, not just + at the asset-group level). + """ + asset_service = client.get_service("AssetService") + asset_group_service = client.get_service("AssetGroupService") + + operations: list = [] + field_type_enum = client.enums.AssetFieldTypeEnum + + # Track temp resource names by the field_type they're linked to so we can + # build AssetGroupAsset links after the AssetGroup itself is created. + text_assets: list[tuple[str, str]] = [] # (resource_name, field_type) + video_assets: list[str] = [] # resource_names + business_name_resource: str | None = None + next_temp = asset_temp_id_start + + text_groups = { + "HEADLINE": asset_group_data.get("headlines") or [], + "LONG_HEADLINE": asset_group_data.get("long_headlines") or [], + "DESCRIPTION": asset_group_data.get("descriptions") or [], + } + if asset_group_data.get("business_name"): + text_groups["BUSINESS_NAME"] = [asset_group_data["business_name"]] + + # --- 4a. Text Asset operations (one per text) --- + for field_type, texts in text_groups.items(): + for text in texts: + asset_op = client.get_type("MutateOperation") + asset = asset_op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, str(next_temp)) + asset.text_asset.text = text + operations.append(asset_op) + text_assets.append((asset.resource_name, field_type)) + if field_type == "BUSINESS_NAME": + business_name_resource = asset.resource_name + next_temp -= 1 + + # --- 4b. YouTube video Asset operations --- + for video_id in asset_group_data.get("youtube_video_ids") or []: + asset_op = client.get_type("MutateOperation") + asset = asset_op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, str(next_temp)) + asset.youtube_video_asset.youtube_video_id = video_id + operations.append(asset_op) + video_assets.append(asset.resource_name) + next_temp -= 1 + + # --- 5. AssetGroup operation --- + ag_resource_name = asset_group_service.asset_group_path(cid, asset_group_temp_id) + ag_op = client.get_type("MutateOperation") + ag = ag_op.asset_group_operation.create + ag.resource_name = ag_resource_name + ag.name = asset_group_data["name"] + ag.campaign = campaign_resource_name + for url in asset_group_data["final_urls"]: + ag.final_urls.append(url) + if asset_group_data.get("path1"): + ag.path1 = asset_group_data["path1"] + if asset_group_data.get("path2"): + ag.path2 = asset_group_data["path2"] + ag.status = client.enums.AssetGroupStatusEnum.PAUSED + operations.append(ag_op) + + # --- 6. AssetGroupAsset link operations --- + for asset_rn, field_type in text_assets: + link_op = client.get_type("MutateOperation") + link = link_op.asset_group_asset_operation.create + link.asset = asset_rn + link.asset_group = ag_resource_name + link.field_type = getattr(field_type_enum, field_type) + operations.append(link_op) + + for video_rn in video_assets: + link_op = client.get_type("MutateOperation") + link = link_op.asset_group_asset_operation.create + link.asset = video_rn + link.asset_group = ag_resource_name + link.field_type = field_type_enum.YOUTUBE_VIDEO + operations.append(link_op) + + image_keys = { + "MARKETING_IMAGE": asset_group_data.get("marketing_image_assets") or [], + "SQUARE_MARKETING_IMAGE": asset_group_data.get("square_marketing_image_assets") or [], + "LOGO": asset_group_data.get("logo_assets") or [], + } + for field_type, resource_names in image_keys.items(): + for rn in resource_names: + link_op = client.get_type("MutateOperation") + link = link_op.asset_group_asset_operation.create + link.asset = rn + link.asset_group = ag_resource_name + link.field_type = getattr(field_type_enum, field_type) + operations.append(link_op) + + # --- 7. AssetGroupSignal operations --- + for theme in asset_group_data.get("search_themes") or []: + sig_op = client.get_type("MutateOperation") + signal = sig_op.asset_group_signal_operation.create + signal.asset_group = ag_resource_name + signal.search_theme.text = theme + operations.append(sig_op) + + for audience_rn in asset_group_data.get("audience_resource_names") or []: + sig_op = client.get_type("MutateOperation") + signal = sig_op.asset_group_signal_operation.create + signal.asset_group = ag_resource_name + signal.audience.audience = audience_rn + operations.append(sig_op) + + return operations, {"business_name_asset": business_name_resource} + + +# --------------------------------------------------------------------------- +# Dispatch table — imported by ads/write.py's _execute_plan +# --------------------------------------------------------------------------- + + +PMAX_OPERATIONS = { + "create_pmax_campaign": _apply_create_pmax_campaign, + "create_asset_group": _apply_create_asset_group, + "create_asset_group_assets": _apply_create_asset_group_assets, + "create_asset_group_signal": _apply_create_asset_group_signal, + "upload_image_asset": _apply_upload_image_asset, +} diff --git a/src/adloop/ads/read.py b/src/adloop/ads/read.py index 681f9dc..95fd6e4 100644 --- a/src/adloop/ads/read.py +++ b/src/adloop/ads/read.py @@ -1,4 +1,4 @@ -"""Google Ads read tools — campaign, ad, keyword, and search term performance.""" +"""Google Ads read tools — campaign, ad, keyword, search term, and insights performance.""" from __future__ import annotations @@ -257,7 +257,8 @@ def get_keyword_performance( date_clause = _date_clause(date_range_start, date_range_end) query = f""" - SELECT campaign.name, ad_group.name, + SELECT campaign.name, ad_group.name, ad_group.id, + ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.quality_info.quality_score, @@ -337,45 +338,37 @@ def get_search_terms( customer_id: str = "", date_range_start: str = "", date_range_end: str = "", + campaign_id: str = "", compact: bool = False, ) -> dict: """Get search terms report — what users actually typed before clicking ads.""" from adloop.ads.gaql import execute_query - date_clause = _date_clause(date_range_start, date_range_end) + campaign_filter = _campaign_filter_clause(campaign_id) - query = f""" - SELECT search_term_view.search_term, - campaign.name, ad_group.name, - metrics.impressions, metrics.clicks, - metrics.cost_micros, metrics.conversions - FROM search_term_view - WHERE segments.date DURING LAST_30_DAYS - {f"AND segments.date BETWEEN '{date_range_start}' AND '{date_range_end}'" if date_range_start and date_range_end else ""} - ORDER BY metrics.clicks DESC - LIMIT 200 - """ # search_term_view requires an explicit date segment, so we always # include DURING LAST_30_DAYS as baseline and override if dates given. if date_range_start and date_range_end: query = f""" SELECT search_term_view.search_term, - campaign.name, ad_group.name, + campaign.id, campaign.name, ad_group.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM search_term_view WHERE segments.date BETWEEN '{date_range_start}' AND '{date_range_end}' + {campaign_filter} ORDER BY metrics.clicks DESC LIMIT 200 """ else: - query = """ + query = f""" SELECT search_term_view.search_term, - campaign.name, ad_group.name, + campaign.id, campaign.name, ad_group.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM search_term_view WHERE segments.date DURING LAST_30_DAYS + {campaign_filter} ORDER BY metrics.clicks DESC LIMIT 200 """ @@ -893,6 +886,466 @@ def get_demographic_targeting( } +# --------------------------------------------------------------------------- +# Impression Share & Insights Tools +# --------------------------------------------------------------------------- + + +def get_impression_share( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + level: str = "campaign", +) -> dict: + """Get impression share metrics segmented by campaign, ad group, or keyword.""" + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + share_metrics = """metrics.impressions, metrics.clicks, metrics.cost_micros, + metrics.search_impression_share, + metrics.search_budget_lost_impression_share, + metrics.search_rank_lost_impression_share, + metrics.search_exact_match_impression_share, + metrics.search_top_impression_share, + metrics.search_absolute_top_impression_share""" + + if level == "ad_group": + query = f""" + SELECT campaign.name, ad_group.id, ad_group.name, + {share_metrics} + FROM ad_group + WHERE ad_group.status != 'REMOVED' + {date_clause} + ORDER BY metrics.impressions DESC + """ + elif level == "keyword": + query = f""" + SELECT campaign.name, ad_group.name, + ad_group_criterion.keyword.text, + ad_group_criterion.keyword.match_type, + {share_metrics} + FROM keyword_view + WHERE ad_group_criterion.status != 'REMOVED' + {date_clause} + ORDER BY metrics.impressions DESC + """ + else: + query = f""" + SELECT campaign.id, campaign.name, campaign.status, + {share_metrics} + FROM campaign + WHERE campaign.status != 'REMOVED' + {date_clause} + ORDER BY metrics.impressions DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_impression_share_fields(rows) + + return {"impression_share": rows, "total_rows": len(rows), "level": level} + + +def get_change_history( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + resource_type: str = "", + operation_type: str = "", + limit: int = 100, +) -> dict: + """Get account change history from the change_event resource.""" + from adloop.ads.gaql import execute_query + + # change_event has a hard API max of 10,000 rows. + limit = max(1, min(limit, 10_000)) + + resource_filter = "" + if resource_type: + resource_filter = ( + f"AND change_event.change_resource_type = '{resource_type}'" + ) + + operation_filter = "" + if operation_type: + operation_filter = ( + f"AND change_event.resource_change_operation = '{operation_type}'" + ) + + # change_event uses change_date_time (a timestamp), not segments.date. + # A bare date like '2026-03-27' means midnight, which misses the rest + # of that day. Append end-of-day time when only a date is provided. + if date_range_start and date_range_end: + end = date_range_end + if "T" not in end and " " not in end: + end = f"{end} 23:59:59" + date_where = ( + f"change_event.change_date_time >= '{date_range_start}'" + f" AND change_event.change_date_time <= '{end}'" + ) + else: + date_where = "change_event.change_date_time DURING LAST_14_DAYS" + + query = f""" + SELECT change_event.change_date_time, + change_event.user_email, + change_event.change_resource_type, + change_event.resource_change_operation, + change_event.changed_fields, + change_event.old_resource, + change_event.new_resource, + change_event.resource_name + FROM change_event + WHERE {date_where} + {resource_filter} + {operation_filter} + ORDER BY change_event.change_date_time DESC + LIMIT {limit} + """ + + rows = execute_query(config, customer_id, query) + return {"changes": rows, "total_changes": len(rows)} + + +def get_device_performance( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + level: str = "campaign", +) -> dict: + """Get performance segmented by device (MOBILE, DESKTOP, TABLET).""" + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + if level == "ad_group": + query = f""" + SELECT campaign.name, ad_group.id, ad_group.name, + segments.device, + metrics.impressions, metrics.clicks, metrics.ctr, + metrics.cost_micros, metrics.average_cpc, + metrics.conversions, metrics.conversions_value + FROM ad_group + WHERE ad_group.status != 'REMOVED' + {date_clause} + ORDER BY ad_group.name, metrics.cost_micros DESC + """ + else: + query = f""" + SELECT campaign.id, campaign.name, + segments.device, + metrics.impressions, metrics.clicks, metrics.ctr, + metrics.cost_micros, metrics.average_cpc, + metrics.conversions, metrics.conversions_value + FROM campaign + WHERE campaign.status != 'REMOVED' + {date_clause} + ORDER BY campaign.name, metrics.cost_micros DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_conversion_rate(rows) + + return {"device_performance": rows, "total_rows": len(rows), "level": level} + + +def get_location_performance( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get performance segmented by geographic location.""" + from adloop.ads.gaql import execute_query + + # geographic_view requires segments.date in WHERE, like search_term_view. + if date_range_start and date_range_end: + query = f""" + SELECT geographic_view.country_criterion_id, + geographic_view.location_type, + campaign.name, + metrics.impressions, metrics.clicks, metrics.ctr, + metrics.cost_micros, metrics.conversions, + metrics.conversions_value + FROM geographic_view + WHERE segments.date BETWEEN '{date_range_start}' AND '{date_range_end}' + ORDER BY metrics.cost_micros DESC + LIMIT 200 + """ + else: + query = """ + SELECT geographic_view.country_criterion_id, + geographic_view.location_type, + campaign.name, + metrics.impressions, metrics.clicks, metrics.ctr, + metrics.cost_micros, metrics.conversions, + metrics.conversions_value + FROM geographic_view + WHERE segments.date DURING LAST_30_DAYS + ORDER BY metrics.cost_micros DESC + LIMIT 200 + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_conversion_rate(rows) + + return {"locations": rows, "total_locations": len(rows)} + + +def get_quality_score_details( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get keyword-level Quality Score with component breakdowns.""" + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + campaign_filter = "" + if campaign_id: + campaign_filter = f"AND campaign.id = {campaign_id}" + + query = f""" + SELECT campaign.name, campaign.id, ad_group.name, + ad_group_criterion.keyword.text, + ad_group_criterion.keyword.match_type, + ad_group_criterion.quality_info.quality_score, + ad_group_criterion.quality_info.creative_quality_score, + ad_group_criterion.quality_info.post_click_quality_score, + ad_group_criterion.quality_info.search_predicted_ctr, + metrics.impressions, metrics.clicks, metrics.cost_micros, + metrics.conversions + FROM keyword_view + WHERE ad_group_criterion.status != 'REMOVED' + {date_clause} + {campaign_filter} + ORDER BY metrics.cost_micros DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + + return {"quality_scores": rows, "total_keywords": len(rows)} + + +def get_bid_strategy_status( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", +) -> dict: + """Get bid strategy type, learning status, and budget for each campaign.""" + from adloop.ads.gaql import execute_query + + campaign_filter = "" + if campaign_id: + campaign_filter = f"AND campaign.id = {campaign_id}" + + query = f""" + SELECT campaign.id, campaign.name, campaign.status, + campaign.bidding_strategy_type, + campaign.bidding_strategy_system_status, + campaign_budget.amount_micros, + metrics.conversions, metrics.cost_micros + FROM campaign + WHERE campaign.status != 'REMOVED' + {campaign_filter} + AND segments.date DURING LAST_30_DAYS + ORDER BY metrics.cost_micros DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_budget_fields(rows) + + return {"strategies": rows, "total_campaigns": len(rows)} + + +def get_budget_pacing( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", +) -> dict: + """Get monthly budget pacing — spend-to-date, projected spend, pace %.""" + import calendar + from datetime import date + + from adloop.ads.gaql import execute_query + + campaign_filter = "" + if campaign_id: + campaign_filter = f"AND campaign.id = {campaign_id}" + + # Query 1: budget settings + budget_query = f""" + SELECT campaign.id, campaign.name, campaign.status, + campaign_budget.amount_micros + FROM campaign + WHERE campaign.status != 'REMOVED' + {campaign_filter} + """ + + # Query 2: month-to-date spend (segments.date breaks down by day; we sum) + spend_query = f""" + SELECT campaign.id, metrics.cost_micros + FROM campaign + WHERE campaign.status != 'REMOVED' + AND segments.date DURING THIS_MONTH + {campaign_filter} + """ + + budget_rows = execute_query(config, customer_id, budget_query) + spend_rows = execute_query(config, customer_id, spend_query) + + # Aggregate daily spend per campaign + spend_by_campaign: dict[str, int] = {} + for row in spend_rows: + cid = row.get("campaign.id") + cost = row.get("metrics.cost_micros", 0) or 0 + spend_by_campaign[cid] = spend_by_campaign.get(cid, 0) + cost + + today = date.today() + days_in_month = calendar.monthrange(today.year, today.month)[1] + days_elapsed = today.day + days_remaining = days_in_month - days_elapsed + + pacing = [] + for row in budget_rows: + cid = row.get("campaign.id") + budget_micros = row.get("campaign_budget.amount_micros", 0) or 0 + daily_budget = round(budget_micros / 1_000_000, 2) + month_budget = round(daily_budget * days_in_month, 2) + + month_spend_micros = spend_by_campaign.get(cid, 0) + month_spend = round(month_spend_micros / 1_000_000, 2) + + daily_avg = round(month_spend / days_elapsed, 2) if days_elapsed > 0 else 0 + projected = round(daily_avg * days_in_month, 2) + pace_pct = round(projected / month_budget * 100, 1) if month_budget > 0 else 0 + + pacing.append({ + "campaign.id": cid, + "campaign.name": row.get("campaign.name"), + "campaign.status": row.get("campaign.status"), + "daily_budget": daily_budget, + "month_budget": month_budget, + "month_spend": month_spend, + "daily_avg_spend": daily_avg, + "projected_month_spend": projected, + "days_elapsed": days_elapsed, + "days_remaining": days_remaining, + "pace_pct": pace_pct, + }) + + return {"pacing": pacing, "total_campaigns": len(pacing)} + + +def get_ad_schedule_performance( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get performance by hour of day and day of week.""" + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + campaign_filter = "" + if campaign_id: + campaign_filter = f"AND campaign.id = {campaign_id}" + + query = f""" + SELECT campaign.name, campaign.id, + segments.day_of_week, segments.hour, + metrics.impressions, metrics.clicks, metrics.ctr, + metrics.cost_micros, metrics.conversions + FROM campaign + WHERE campaign.status != 'REMOVED' + {date_clause} + {campaign_filter} + ORDER BY segments.day_of_week, segments.hour + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_conversion_rate(rows) + + return {"schedule_performance": rows, "total_rows": len(rows)} + + +def get_auction_insights( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get auction insights — competitor overlap, outranking share, position data. + + Note: only available for allowlisted accounts. Returns a helpful error + message if the account does not have access. + """ + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + campaign_filter = "" + if campaign_id: + campaign_filter = f"AND campaign.id = {campaign_id}" + + query = f""" + SELECT campaign.name, campaign.id, + segments.auction_insight_domain, + metrics.auction_insight_search_impression_share, + metrics.auction_insight_search_overlap_rate, + metrics.auction_insight_search_outranking_share, + metrics.auction_insight_search_position_above_rate, + metrics.auction_insight_search_top_impression_percentage, + metrics.auction_insight_search_absolute_top_impression_percentage + FROM campaign + WHERE campaign.status != 'REMOVED' + {date_clause} + {campaign_filter} + ORDER BY metrics.auction_insight_search_impression_share DESC + """ + + try: + rows = execute_query(config, customer_id, query) + except Exception as exc: + err = str(exc) + if "QUERY_NOT_ALLOWED" in err or "not allowed" in err.lower(): + return { + "error": "Auction insights are not available for this account.", + "hint": ( + "Auction insights via GAQL require an allowlisted account. " + "Contact your Google account manager to request access, or " + "view auction insights in the Google Ads UI instead." + ), + } + raise + + return {"auction_insights": rows, "total_rows": len(rows)} + + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- @@ -923,6 +1376,16 @@ def _date_clause(start: str, end: str) -> str: return "AND segments.date DURING LAST_30_DAYS" +def _campaign_filter_clause(campaign_id: str) -> str: + """Build a GAQL campaign filter, validating the ID is numeric.""" + if not campaign_id: + return "" + stripped = campaign_id.replace("-", "").strip() + if not stripped.isdigit(): + raise ValueError(f"Invalid campaign_id: {campaign_id!r} — must be numeric") + return f"AND campaign.id = {stripped}" + + def _compact_totals(rows: list[dict], currency_code: str) -> dict: """Deterministic account-level aggregates over enriched metric rows.""" cost = sum((r.get("metrics.cost_micros") or 0) for r in rows) / 1_000_000 @@ -974,3 +1437,36 @@ def _enrich_cost_fields(rows: list[dict], currency_code: str = "EUR") -> None: row["metrics.average_cpc_amount"] = round(avg_cpc_micros / 1_000_000, 2) row["metrics.currency"] = currency_code + + +def _enrich_impression_share_fields(rows: list[dict]) -> None: + """Convert impression share fractions (0.0-1.0) to readable percentages.""" + share_fields = [ + "metrics.search_impression_share", + "metrics.search_budget_lost_impression_share", + "metrics.search_rank_lost_impression_share", + "metrics.search_exact_match_impression_share", + "metrics.search_top_impression_share", + "metrics.search_absolute_top_impression_share", + ] + for row in rows: + for field in share_fields: + val = row.get(field) + if val is not None and isinstance(val, (int, float)): + row[field + "_pct"] = f"{val * 100:.1f}%" + + +def _enrich_conversion_rate(rows: list[dict]) -> None: + """Compute conversion rate percentage from clicks and conversions.""" + for row in rows: + clicks = row.get("metrics.clicks", 0) or 0 + conversions = row.get("metrics.conversions", 0) or 0 + row["metrics.conversion_rate"] = round(conversions / clicks * 100, 2) if clicks > 0 else 0.0 + + +def _enrich_budget_fields(rows: list[dict]) -> None: + """Add human-readable budget amount from budget_micros.""" + for row in rows: + budget_micros = row.get("campaign_budget.amount_micros", 0) or 0 + if budget_micros: + row["campaign_budget.amount"] = round(budget_micros / 1_000_000, 2) diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index dfd1763..570f09d 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -443,6 +443,43 @@ def _detect_image_type_and_size(data: bytes) -> tuple[str, int, int]: ) +# --------------------------------------------------------------------------- +# Fetch existing ad — used by replacement tools +# --------------------------------------------------------------------------- + + +def _fetch_existing_rsa( + config: AdLoopConfig, + customer_id: str, + ad_id: str, +) -> dict | None: + """Fetch an existing RSA's headlines, descriptions, URLs, paths, and ad_group_id. + + Returns a flat dict from GAQL, or ``None`` if the ad is not found or not an RSA. + """ + from adloop.ads.gaql import execute_query + + query = ( + "SELECT ad_group.id, ad_group_ad.ad.id, ad_group_ad.ad.type, " + "ad_group_ad.ad.responsive_search_ad.headlines, " + "ad_group_ad.ad.responsive_search_ad.descriptions, " + "ad_group_ad.ad.final_urls, " + "ad_group_ad.ad.responsive_search_ad.path1, " + "ad_group_ad.ad.responsive_search_ad.path2, " + "ad_group_ad.status " + f"FROM ad_group_ad WHERE ad_group_ad.ad.id = {ad_id} LIMIT 1" + ) + rows = execute_query(config, customer_id, query) + if not rows: + return None + + row = rows[0] + if row.get("ad_group_ad.ad.type") != "RESPONSIVE_SEARCH_AD": + return None + + return row + + # --------------------------------------------------------------------------- # Draft tools — validate inputs, create a ChangePlan, return preview # --------------------------------------------------------------------------- @@ -482,8 +519,8 @@ def draft_responsive_search_ad( except SafetyViolation as e: return {"error": str(e)} - headlines = headlines or [] - descriptions = descriptions or [] + headlines = _normalize_assets(headlines or []) + descriptions = _normalize_assets(descriptions or []) try: headlines = _normalize_rsa_assets(headlines) @@ -539,6 +576,149 @@ def draft_responsive_search_ad( return preview +def draft_rsa_replacement( + config: AdLoopConfig, + *, + customer_id: str = "", + ad_id: str = "", + headlines: list[str | dict] | None = None, + descriptions: list[str | dict] | None = None, + final_url: str = "", + path1: str = "", + path2: str = "", + remove_old: bool = True, +) -> dict: + """Draft an RSA replacement — creates new ad and removes the old one. + + Use this to **fix issues** with an existing RSA (wrong copy, character + errors, data inconsistencies, truncated city names, etc.). The old ad + is removed by default so it cannot be accidentally re-enabled. + + For A/B testing or creating ad variants, use ``draft_responsive_search_ad`` + to add a new ad alongside the existing one instead. + + Fetches the existing ad's details, validates the new copy, and returns a + preview with an old-vs-new diff. Does NOT execute until confirmed. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("replace_responsive_search_ad", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + headlines = _normalize_assets(headlines or []) + descriptions = _normalize_assets(descriptions or []) + + if not ad_id: + return {"error": "Validation failed", "details": ["ad_id is required."]} + + # Fetch current ad ------------------------------------------------------- + existing = _fetch_existing_rsa(config, customer_id, ad_id) + if existing is None: + return { + "error": f"Ad ID {ad_id} not found or is not a Responsive Search Ad." + } + + old_status = existing.get("ad_group_ad.status", "") + if old_status == "REMOVED": + return {"error": f"Ad ID {ad_id} has already been removed."} + + ad_group_id = str(existing.get("ad_group.id", "")) + + # Inherit final_url from old ad if not provided -------------------------- + if not final_url: + old_urls = existing.get("ad_group_ad.ad.final_urls", []) + if isinstance(old_urls, list) and old_urls: + final_url = old_urls[0] if isinstance(old_urls[0], str) else str(old_urls[0]) + + # Validate new copy ------------------------------------------------------ + errors = _validate_rsa(ad_group_id, headlines, descriptions, final_url) + if errors: + return {"error": "Validation failed", "details": errors} + + url_check, _url_warnings = _validate_urls([final_url]) + if url_check.get(final_url): + return { + "error": "URL validation failed", + "details": [ + f"final_url '{final_url}' is not reachable: {url_check[final_url]}. " + f"Ads MUST point to working URLs." + ], + } + + warnings: list[str] = [] + if len(headlines) < 8: + warnings.append( + f"Only {len(headlines)} headlines provided. Google recommends 8-15 " + "diverse headlines for optimal RSA performance." + ) + if len(descriptions) < 3: + warnings.append( + f"Only {len(descriptions)} descriptions provided. Google recommends " + "3-4 descriptions for optimal RSA performance." + ) + + # Build old copy for diff preview ---------------------------------------- + old_headlines = existing.get( + "ad_group_ad.ad.responsive_search_ad.headlines", [] + ) + old_descriptions = existing.get( + "ad_group_ad.ad.responsive_search_ad.descriptions", [] + ) + # Normalize old assets to the same dict format used for new copy so + # the diff preview consistently shows pinning info. + old_headlines = _normalize_assets(old_headlines) + old_descriptions = _normalize_assets(old_descriptions) + + old_final_urls = existing.get("ad_group_ad.ad.final_urls", []) + old_copy = { + "headlines": old_headlines, + "descriptions": old_descriptions, + "final_url": old_final_urls[0] if old_final_urls else "", + "path1": existing.get("ad_group_ad.ad.responsive_search_ad.path1", ""), + "path2": existing.get("ad_group_ad.ad.responsive_search_ad.path2", ""), + } + + plan = ChangePlan( + operation="replace_responsive_search_ad", + entity_type="ad", + entity_id=ad_id, + customer_id=customer_id, + changes={ + "old_ad_id": ad_id, + "ad_group_id": ad_group_id, + "headlines": headlines, + "descriptions": descriptions, + "final_url": final_url, + "path1": path1, + "path2": path2, + "remove_old": remove_old, + "old_copy": old_copy, + }, + ) + if remove_old: + plan.requires_double_confirm = True + store_plan(plan) + + preview = plan.to_preview() + preview["diff"] = { + "old": old_copy, + "new": { + "headlines": headlines, + "descriptions": descriptions, + "final_url": final_url, + "path1": path1, + "path2": path2, + }, + "old_ad_action": "REMOVE" if remove_old else "PAUSE", + } + if warnings: + preview["warnings"] = warnings + return preview + + def draft_keywords( config: AdLoopConfig, *, @@ -1165,11 +1345,12 @@ def remove_entity( entity_type: str = "", entity_id: str = "", ) -> dict: - """Draft removing an entity — returns preview. + """Draft removing an entity — DESTRUCTIVE, returns a preview. Supported ``entity_type`` values: ``campaign``, ``ad_group``, ``ad``, ``keyword``, ``negative_keyword``, ``shared_criterion``, ``campaign_asset``, - ``asset``, ``customer_asset``. + ``asset``, ``customer_asset``, ``asset_group``, ``asset_group_signal``, + ``label``. Composite ``entity_id`` formats: @@ -1181,10 +1362,12 @@ def remove_entity( - ``campaign_asset``: ``campaignId~assetId~fieldType`` - ``customer_asset``: ``assetId~fieldType`` - ``asset``: bare asset ID + - ``asset_group_signal``: ``assetGroupId~criterionId`` (from + ``get_asset_group_signals``) - This is a DESTRUCTIVE operation — removed entities cannot be re-enabled. - Prefer ``pause_entity`` unless the user explicitly wants permanent removal. - Call ``confirm_and_apply`` with the returned plan_id to execute. + Removed entities cannot be re-enabled. Prefer ``pause_entity`` unless the + user explicitly wants permanent removal. Call ``confirm_and_apply`` with the + returned plan_id to execute. """ from adloop.safety.guards import SafetyViolation, check_blocked_operation from adloop.safety.preview import ChangePlan, store_plan @@ -1234,21 +1417,29 @@ def draft_campaign( keywords: list[dict] | None = None, geo_target_ids: list[str] | None = None, language_ids: list[str] | None = None, + final_url_suffix: str | None = None, search_partners_enabled: bool = False, display_network_enabled: bool | None = None, display_expansion_enabled: bool | None = None, max_cpc: float = 0, ) -> dict: - """Draft a full campaign structure — returns preview, does NOT execute. + """Draft a full Search campaign structure — returns preview, does NOT execute. Creates: CampaignBudget + Campaign (PAUSED) + AdGroup + optional Keywords + geo targeting + language targeting. Ads are NOT included — use draft_responsive_search_ad separately. + For Performance Max campaigns, use ``draft_pmax_campaign`` instead — the + PMax structure (no ad groups, asset_groups + assets + signals must be + created in the same mutate as the campaign) is incompatible with this + Search-shaped draft. + geo_target_ids: list of geo target constant IDs (e.g. ["2276"] for Germany, ["2840"] for USA). REQUIRED — campaigns must target specific countries. language_ids: list of language constant IDs (e.g. ["1001"] for German, ["1000"] for English). REQUIRED — campaigns must target specific languages. + final_url_suffix: UTM suffix auto-applied to SEARCH campaigns. Pass "" to + disable. Defaults to standard UTM tracking with ValueTrack parameters. """ from adloop.safety.guards import ( SafetyViolation, @@ -1257,6 +1448,16 @@ def draft_campaign( ) from adloop.safety.preview import ChangePlan, store_plan + if channel_type.upper() == "PERFORMANCE_MAX": + return { + "error": ( + "draft_campaign cannot create Performance Max campaigns. PMax " + "campaigns have no ad groups, no keywords, and require an " + "asset_group with assets + signals to be created in the same " + "API call. Use draft_pmax_campaign instead." + ), + } + try: check_blocked_operation("create_campaign", config.safety) except SafetyViolation as e: @@ -1295,6 +1496,10 @@ def draft_campaign( except SafetyViolation as e: return {"error": str(e)} + # Resolve final_url_suffix: explicit param > hardcoded default (SEARCH only) + if final_url_suffix is None and channel_type.upper() == "SEARCH": + final_url_suffix = _DEFAULT_FINAL_URL_SUFFIX + plan = ChangePlan( operation="create_campaign", entity_type="campaign", @@ -1310,6 +1515,7 @@ def draft_campaign( "keywords": keywords, "geo_target_ids": geo_target_ids or [], "language_ids": language_ids or [], + "final_url_suffix": final_url_suffix or "", "search_partners_enabled": search_partners_enabled, "display_network_enabled": normalized_display_network_enabled, "max_cpc": max_cpc if max_cpc else None, @@ -1393,6 +1599,7 @@ def update_campaign( daily_budget: float = 0, geo_target_ids: list[str] | None = None, language_ids: list[str] | None = None, + final_url_suffix: str | None = None, search_partners_enabled: bool | None = None, display_network_enabled: bool | None = None, display_expansion_enabled: bool | None = None, @@ -1402,6 +1609,7 @@ def update_campaign( All parameters except campaign_id are optional — only include what you want to change. Geo/language targets are REPLACED entirely (not appended). + final_url_suffix: set or change the campaign's Final URL suffix. Pass "" to clear. """ from adloop.safety.guards import ( SafetyViolation, @@ -1465,6 +1673,7 @@ def update_campaign( daily_budget, geo_target_ids is not None, language_ids is not None, + final_url_suffix is not None, search_partners_enabled is not None, normalized_display_network_enabled is not None, max_cpc, @@ -1523,6 +1732,8 @@ def update_campaign( changes["geo_target_ids"] = geo_target_ids if language_ids is not None: changes["language_ids"] = language_ids + if final_url_suffix is not None: + changes["final_url_suffix"] = final_url_suffix if search_partners_enabled is not None: changes["search_partners_enabled"] = search_partners_enabled if normalized_display_network_enabled is not None: @@ -1959,7 +2170,7 @@ def confirm_and_apply( # --------------------------------------------------------------------------- _VALID_MATCH_TYPES = {"EXACT", "PHRASE", "BROAD"} -_VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"} +_VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword", "asset_group"} _REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | { "negative_keyword", "shared_criterion", @@ -1968,8 +2179,17 @@ def confirm_and_apply( "campaign_asset", "asset", "customer_asset", + "asset_group_signal", + "label", } +_DEFAULT_FINAL_URL_SUFFIX = ( + "utm_source=google&utm_medium=cpc" + "&utm_campaign={campaignid}" + "&utm_content={adgroupid}" + "&utm_term={keyword}" +) + _SMART_BIDDING_STRATEGIES = { "MAXIMIZE_CONVERSIONS", "MAXIMIZE_CONVERSION_VALUE", @@ -2194,6 +2414,23 @@ def _check_broad_match_safety( return [] +def _normalize_assets(items: list[str | dict]) -> list[dict]: + """Normalize a mixed ``str | dict`` asset list to uniform dicts. + + Each returned dict has ``{"text": str, "pinned_field": str | None}``. + """ + result: list[dict] = [] + for item in items: + if isinstance(item, str): + result.append({"text": item, "pinned_field": None}) + elif isinstance(item, dict): + raw_text = item.get("text", "") + result.append({"text": str(raw_text) if raw_text is not None else "", "pinned_field": item.get("pinned_field")}) + else: + result.append({"text": str(item), "pinned_field": None}) + return result + + def _validate_rsa( ad_group_id: str, headlines: list[dict], @@ -2218,7 +2455,9 @@ def _validate_rsa( for i, h in enumerate(headlines): text = h["text"] pin = h["pinned_field"] - if len(text) > 30: + if not text: + errors.append(f"Headline {i + 1} is missing required 'text' field.") + elif len(text) > 30: errors.append( f"Headline {i + 1} exceeds 30 chars ({len(text)}): '{text}'" ) @@ -2238,7 +2477,9 @@ def _validate_rsa( for i, d in enumerate(descriptions): text = d["text"] pin = d["pinned_field"] - if len(text) > 90: + if not text: + errors.append(f"Description {i + 1} is missing required 'text' field.") + elif len(text) > 90: errors.append( f"Description {i + 1} exceeds 90 chars ({len(text)}): '{text}'" ) @@ -2266,7 +2507,7 @@ def _validate_rsa( "MANUAL_CPC", } -_VALID_CHANNEL_TYPES = {"SEARCH", "DISPLAY", "SHOPPING", "VIDEO", "PERFORMANCE_MAX"} +_VALID_CHANNEL_TYPES = {"SEARCH", "DISPLAY", "SHOPPING", "VIDEO"} def _validate_campaign( @@ -2591,6 +2832,8 @@ def _extract_resource_name(resp: object) -> str: def _execute_plan(config: AdLoopConfig, plan: object) -> dict: """Dispatch to the right Google API call based on plan.operation.""" from adloop.ads.client import get_ads_client, normalize_customer_id + from adloop.ads.labels import LABEL_OPERATIONS + from adloop.ads.pmax_write import PMAX_OPERATIONS # GA4 plans dispatch before Ads client construction so they work for # GA4-only setups (no Ads credentials/developer token required). @@ -2608,6 +2851,7 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict: "update_campaign": _apply_update_campaign, "update_ad_group": _apply_update_ad_group, "create_responsive_search_ad": _apply_create_rsa, + "replace_responsive_search_ad": _apply_replace_rsa, "add_keywords": _apply_add_keywords, "add_negative_keywords": _apply_add_negative_keywords, "add_negative_locations": _apply_add_negative_locations, @@ -2623,6 +2867,8 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict: "create_structured_snippets": _apply_create_structured_snippets, "create_image_assets": _apply_create_image_assets, "create_sitelinks": _apply_create_sitelinks, + **PMAX_OPERATIONS, + **LABEL_OPERATIONS, } handler = dispatch.get(plan.operation) @@ -2639,12 +2885,23 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict: ) if plan.operation == "remove_entity": - return handler(client, cid, plan.entity_type, plan.entity_id) + return handler( + client, + cid, + plan.entity_type, + plan.entity_id, + ) return handler(client, cid, plan.changes) -def _apply_update_ad_group(client: object, cid: str, changes: dict) -> dict: +def _apply_update_ad_group( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: """Update an ad group's name and/or manual CPC bid.""" from google.protobuf import field_mask_pb2 @@ -2740,6 +2997,12 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: client.enums.EuPoliticalAdvertisingStatusEnum.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING ) + # Final URL suffix (UTM tracking) — auto-set for SEARCH by draft_campaign; + # an empty string means the caller explicitly disabled it, so only apply + # when non-empty. + if changes.get("final_url_suffix"): + campaign.final_url_suffix = changes["final_url_suffix"] + operations.append(campaign_op) # 3. AdGroup (temp ID: -3, references campaign -2) @@ -2878,6 +3141,7 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: or search_partners_enabled is not None or display_network_enabled is not None or changes.get("max_cpc") + or changes.get("final_url_suffix") is not None ): campaign_op = client.get_type("MutateOperation") campaign = campaign_op.campaign_operation.update @@ -2925,6 +3189,12 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: campaign.network_settings.target_content_network = display_network_enabled field_paths.append("network_settings.target_content_network") + # Final URL suffix (UTM) — empty string clears it; None = no change. + new_suffix = changes.get("final_url_suffix") + if new_suffix is not None: + campaign.final_url_suffix = new_suffix + field_paths.append("final_url_suffix") + if field_paths: campaign_op.campaign_operation.update_mask.CopyFrom( field_mask_pb2.FieldMask(paths=field_paths) @@ -3025,7 +3295,13 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: return results -def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict: +def _apply_create_rsa( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: service = client.get_service("AdGroupAdService") operation = client.get_type("AdGroupAdOperation") ad_group_ad = operation.create @@ -3063,12 +3339,85 @@ def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict: ad.responsive_search_ad.path2 = changes["path2"] response = service.mutate_ad_group_ads( - customer_id=cid, operations=[operation] + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) + if validate_only: + return {"status": "validated"} return {"resource_name": response.results[0].resource_name} -def _apply_add_keywords(client: object, cid: str, changes: dict) -> dict: +def _apply_replace_rsa( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Create a new RSA and pause/remove the old one.""" + # Step 1: Create the replacement ad + create_changes = { + "ad_group_id": changes["ad_group_id"], + "headlines": changes["headlines"], + "descriptions": changes["descriptions"], + "final_url": changes["final_url"], + "path1": changes.get("path1", ""), + "path2": changes.get("path2", ""), + } + new_ad_result = _apply_create_rsa( + client, cid, create_changes, validate_only=validate_only + ) + + # Step 2: Pause or remove the old ad + old_ad_id = changes["old_ad_id"] + try: + if changes.get("remove_old"): + old_ad_result = _apply_remove( + client, cid, "ad", old_ad_id, validate_only=validate_only + ) + old_action = "REMOVED" + else: + old_ad_result = _apply_status_change( + client, cid, "ad", old_ad_id, "PAUSED", + validate_only=validate_only, + ) + old_action = "PAUSED" + except Exception as exc: + # New ad was already created — report partial success so user can + # pause the old ad manually. + return { + "new_ad": new_ad_result, + "old_ad": { + "ad_id": old_ad_id, + "action": "FAILED", + "error": ( + f"New ad created successfully but failed to " + f"{'remove' if changes.get('remove_old') else 'pause'} " + f"old ad: {exc}" + ), + }, + } + + return { + "new_ad": new_ad_result, + "old_ad": { + "ad_id": old_ad_id, + "action": old_action, + "result": old_ad_result, + }, + } + + +def _apply_add_keywords( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: service = client.get_service("AdGroupCriterionService") ad_group_path = client.get_service("AdGroupService").ad_group_path( cid, changes["ad_group_id"] @@ -3086,12 +3435,24 @@ def _apply_add_keywords(client: object, cid: str, changes: dict) -> dict: operations.append(operation) response = service.mutate_ad_group_criteria( - customer_id=cid, operations=operations + request={ + "customer_id": cid, + "operations": operations, + "validate_only": validate_only, + } ) + if validate_only: + return {"status": "validated", "operation_count": len(operations)} return {"resource_names": [r.resource_name for r in response.results]} -def _apply_add_negative_keywords(client: object, cid: str, changes: dict) -> dict: +def _apply_add_negative_keywords( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: service = client.get_service("CampaignCriterionService") campaign_path = client.get_service("CampaignService").campaign_path( cid, changes["campaign_id"] @@ -3110,8 +3471,14 @@ def _apply_add_negative_keywords(client: object, cid: str, changes: dict) -> dic operations.append(operation) response = service.mutate_campaign_criteria( - customer_id=cid, operations=operations + request={ + "customer_id": cid, + "operations": operations, + "validate_only": validate_only, + } ) + if validate_only: + return {"status": "validated", "operation_count": len(operations)} return {"resource_names": [r.resource_name for r in response.results]} @@ -3375,8 +3742,10 @@ def _apply_status_change( entity_type: str, entity_id: str, status: str, + *, + validate_only: bool = False, ) -> dict: - """Update the status of a campaign, ad group, ad, or keyword.""" + """Update the status of a campaign, ad group, ad, asset group, or keyword.""" if entity_type == "campaign": service = client.get_service("CampaignService") operation = client.get_type("CampaignOperation") @@ -3412,6 +3781,14 @@ def _apply_status_change( ) mutate = service.mutate_ad_group_criteria + elif entity_type == "asset_group": + service = client.get_service("AssetGroupService") + operation = client.get_type("AssetGroupOperation") + entity = operation.update + entity.resource_name = service.asset_group_path(cid, entity_id) + entity.status = getattr(client.enums.AssetGroupStatusEnum, status) + mutate = service.mutate_asset_groups + else: raise ValueError(f"Unknown entity_type: {entity_type}") @@ -3420,7 +3797,15 @@ def _apply_status_change( operation.update_mask = field_mask_pb2.FieldMask(paths=["status"]) - response = mutate(customer_id=cid, operations=[operation]) + response = mutate( + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } + ) + if validate_only: + return {"status": "validated"} return {"resource_name": response.results[0].resource_name} diff --git a/src/adloop/cli.py b/src/adloop/cli.py index fc4c1ae..a7ab387 100644 --- a/src/adloop/cli.py +++ b/src/adloop/cli.py @@ -214,7 +214,10 @@ def _generate_config_yaml( def _mcp_json_snippet(toolsets: str = "") -> str: """mcpServers JSON block (Cursor and Claude Code use the same shape).""" - python_path = sys.executable + import json + + # json.dumps escapes the path so Windows backslashes stay valid JSON. + python_path = json.dumps(sys.executable) env_line = ( f'\n "env": {{ "ADLOOP_TOOLSETS": "{toolsets}" }},' if toolsets else "" ) @@ -222,7 +225,7 @@ def _mcp_json_snippet(toolsets: str = "") -> str: {{ "mcpServers": {{ "adloop": {{{env_line} - "command": "{python_path}", + "command": {python_path}, "args": ["-m", "adloop"] }} }} diff --git a/src/adloop/crossref.py b/src/adloop/crossref.py index 8484d95..17d5d48 100644 --- a/src/adloop/crossref.py +++ b/src/adloop/crossref.py @@ -133,7 +133,12 @@ def analyze_campaign_conversions( conv_rate = _safe_div(ga4_conversions, ga4_sessions) cost_per_conv = _safe_div(ads_cost, ga4_conversions) + # Conversion discrepancy between Ads and GA4 + denom = max(ads_conversions, ga4_conversions, 1) + discrepancy = round(abs(ads_conversions - ga4_conversions) / denom * 100, 1) + entry = { + "campaign_id": str(camp.get("campaign.id", "")), "campaign_name": name, "campaign_status": camp.get("campaign.status", ""), "ads_clicks": ads_clicks, @@ -144,6 +149,7 @@ def analyze_campaign_conversions( "click_to_session_ratio": ratio, "ga4_conversion_rate": conv_rate, "cost_per_ga4_conversion": cost_per_conv, + "conversion_discrepancy_pct": discrepancy, } campaigns.append(entry) @@ -252,6 +258,7 @@ def landing_page_analysis( if source != "google" or medium != "cpc": continue path = row.get("pagePath", "/") + path = path.rstrip("/") or "/" bucket = ga4_by_path.setdefault(path, { "sessions": 0, "conversions": 0, "engaged": 0, "bounce_rate_sum": 0.0, "count": 0, }) @@ -513,6 +520,285 @@ def attribution_check( } +# --------------------------------------------------------------------------- +# Tool 4: analyze_pmax_performance +# --------------------------------------------------------------------------- + + +def analyze_pmax_performance( + config: AdLoopConfig, + *, + customer_id: str = "", + property_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Performance Max diagnostic — campaign + asset groups + assets + channels + GA4. + + Aggregates everything you can see about a PMax campaign in one place so the + AI can reason about it as a whole. Pulls campaign metrics, asset group ad + strength, asset counts and missing-minimum diagnostics, channel breakdown, + and (when a property is configured) GA4 paid sessions/conversions. + + Returns auto-generated insights[] flagging: + - Asset groups with POOR or AVERAGE ad strength + - Asset groups below the documented PMax asset-type minimums + - Channel skew (e.g. 90%+ of spend going to a single surface) + - Zero-conversion campaigns despite spend + - GDPR consent gaps (click-to-session ratio > 2:1) + - Pre-2025-06-01 channel breakdown caveats + """ + from adloop.ads.pmax_read import ( + get_asset_group_assets, + get_asset_groups, + get_pmax_campaigns, + get_pmax_channel_breakdown, + ) + from adloop.ads.pmax_write import ASSET_MINIMUMS + from adloop.ga4.reports import run_ga4_report + + start, end = _default_date_range(date_range_start, date_range_end) + + campaigns_result = get_pmax_campaigns( + config, customer_id=customer_id, + date_range_start=start, date_range_end=end, + ) + if "error" in campaigns_result: + return campaigns_result + + pmax_campaigns = campaigns_result.get("campaigns", []) + if campaign_id: + pmax_campaigns = [ + c for c in pmax_campaigns if str(c.get("campaign.id", "")) == str(campaign_id) + ] + if not pmax_campaigns: + return { + "error": f"No PMax campaign found with id {campaign_id}.", + "hint": "Use get_pmax_campaigns to list available campaigns.", + } + + asset_groups_result = get_asset_groups( + config, customer_id=customer_id, campaign_id=campaign_id, + date_range_start=start, date_range_end=end, + ) + asset_groups = asset_groups_result.get("asset_groups", []) + + assets_result = get_asset_group_assets( + config, customer_id=customer_id, campaign_id=campaign_id, + ) + assets = assets_result.get("assets", []) + + channels_result = get_pmax_channel_breakdown( + config, customer_id=customer_id, campaign_id=campaign_id, + date_range_start=start, date_range_end=end, + ) + channels = channels_result.get("channel_breakdown", []) + + ga4_paid_by_campaign: dict[str, dict] = {} + ga4_warning: str | None = None + if property_id: + try: + ga4_result = run_ga4_report( + config, property_id=property_id, + dimensions=["sessionCampaignName", "sessionSource", "sessionMedium"], + metrics=["sessions", "conversions", "engagedSessions"], + date_range_start=start, date_range_end=end, + limit=1000, + ) + if "error" in ga4_result: + ga4_warning = ( + f"GA4 data could not be fetched ({ga4_result['error']}) — " + f"PMax metrics still shown but click-to-session and conversion " + f"comparisons are unavailable." + ) + else: + for row in ga4_result.get("rows", []): + source = row.get("sessionSource", "") + medium = row.get("sessionMedium", "") + if source != "google" or medium != "cpc": + continue + name = row.get("sessionCampaignName", "") + bucket = ga4_paid_by_campaign.setdefault( + name, {"sessions": 0, "conversions": 0, "engaged": 0} + ) + bucket["sessions"] += _safe_int(row.get("sessions", 0)) + bucket["conversions"] += _safe_int(row.get("conversions", 0)) + bucket["engaged"] += _safe_int(row.get("engagedSessions", 0)) + except Exception as exc: + ga4_warning = ( + f"GA4 query failed ({exc}) — PMax metrics still shown but " + f"click-to-session and conversion comparisons are unavailable." + ) + + assets_by_group: dict[str, list[dict]] = {} + for asset in assets: + ag_id = str(asset.get("asset_group.id", "")) + assets_by_group.setdefault(ag_id, []).append(asset) + + channels_by_campaign: dict[str, list[dict]] = {} + for ch in channels: + cmp_id = str(ch.get("campaign.id", "")) + channels_by_campaign.setdefault(cmp_id, []).append(ch) + + summaries = [] + insights = [] + + for camp in pmax_campaigns: + cmp_id = str(camp.get("campaign.id", "")) + cmp_name = camp.get("campaign.name", "") + + cmp_clicks = _safe_int(camp.get("metrics.clicks", 0)) + cmp_cost = _safe_float(camp.get("metrics.cost", 0)) + cmp_conv = _safe_float(camp.get("metrics.conversions", 0)) + cmp_value = _safe_float(camp.get("metrics.conversions_value", 0)) + + ga4 = ga4_paid_by_campaign.get(cmp_name, {"sessions": 0, "conversions": 0}) + click_session_ratio = _safe_div(cmp_clicks, ga4["sessions"]) + + cmp_groups = [ + ag for ag in asset_groups + if str(ag.get("campaign.id", "")) == cmp_id + ] + weak_groups = [ + ag for ag in cmp_groups + if ag.get("asset_group.ad_strength") in ("POOR", "AVERAGE") + ] + + # When brand_guidelines_enabled is on, BUSINESS_NAME and LOGO assets + # live at the campaign level rather than the asset group, so checking + # the asset group for them produces false-positive "missing" warnings. + brand_guidelines = bool(camp.get("campaign.brand_guidelines_enabled")) + applicable_minimums = { + ftype: minimum + for ftype, minimum in ASSET_MINIMUMS.items() + if not (brand_guidelines and ftype in ("BUSINESS_NAME", "LOGO")) + } + + group_summaries = [] + for ag in cmp_groups: + ag_id = str(ag.get("asset_group.id", "")) + ag_assets = assets_by_group.get(ag_id, []) + + counts: dict[str, int] = {} + for a in ag_assets: + ftype = a.get("asset_group_asset.field_type", "UNKNOWN") + counts[ftype] = counts.get(ftype, 0) + 1 + + missing_minimums = [ + f"{ftype} (have {counts.get(ftype, 0)}, need {minimum})" + for ftype, minimum in applicable_minimums.items() + if counts.get(ftype, 0) < minimum + ] + + group_summaries.append({ + "asset_group_id": ag_id, + "asset_group_name": ag.get("asset_group.name", ""), + "ad_strength": ag.get("asset_group.ad_strength", ""), + "asset_counts_by_type": counts, + "missing_asset_minimums": missing_minimums, + "metrics": { + "cost": _safe_float(ag.get("metrics.cost", 0)), + "clicks": _safe_int(ag.get("metrics.clicks", 0)), + "conversions": _safe_float(ag.get("metrics.conversions", 0)), + }, + }) + + if missing_minimums: + insights.append( + f"{cmp_name} / {ag.get('asset_group.name', '')}: " + f"asset group is below minimums for {', '.join(missing_minimums)} " + f"— add the missing assets via draft_asset_group_assets" + ) + + if ag.get("asset_group.ad_strength") in ("POOR", "AVERAGE"): + insights.append( + f"{cmp_name} / {ag.get('asset_group.name', '')}: " + f"ad strength is {ag.get('asset_group.ad_strength')} — " + f"add more headlines/descriptions/images to improve" + ) + + cmp_channels = channels_by_campaign.get(cmp_id, []) + channel_summary = [] + total_channel_cost = sum( + _safe_float(c.get("metrics.cost", 0)) for c in cmp_channels + ) + for ch in cmp_channels: + ch_cost = _safe_float(ch.get("metrics.cost", 0)) + share = _safe_div(ch_cost, total_channel_cost) + channel_summary.append({ + "ad_network_type": ch.get("segments.ad_network_type", ""), + "cost": ch_cost, + "clicks": _safe_int(ch.get("metrics.clicks", 0)), + "conversions": _safe_float(ch.get("metrics.conversions", 0)), + "spend_share": share, + }) + + if total_channel_cost > 0: + top = max(channel_summary, key=lambda c: c["cost"]) + if top["spend_share"] is not None and top["spend_share"] > 0.90: + insights.append( + f"{cmp_name}: {top['spend_share']:.0%} of spend going to " + f"{top['ad_network_type']} — channel mix is heavily skewed, " + f"consider whether other surfaces are being suppressed" + ) + + if cmp_cost > 0 and cmp_conv == 0: + insights.append( + f"{cmp_name}: €{cmp_cost:.2f} spend with 0 conversions — " + f"check that conversion goals are linked and tracking fires" + ) + + if click_session_ratio is not None and click_session_ratio > 2.0 and cmp_clicks > 5: + lost_pct = round((1 - 1 / click_session_ratio) * 100) + insights.append( + f"{cmp_name}: click-to-session ratio is {click_session_ratio:.1f}:1 " + f"— ~{lost_pct}% of paid clicks not in GA4 (likely GDPR consent)" + ) + + summaries.append({ + "campaign_id": cmp_id, + "campaign_name": cmp_name, + "campaign_status": camp.get("campaign.status", ""), + "bidding_strategy_type": camp.get("campaign.bidding_strategy_type", ""), + "brand_guidelines_enabled": camp.get("campaign.brand_guidelines_enabled"), + "daily_budget": camp.get("campaign_budget.amount"), + "metrics": { + "clicks": cmp_clicks, + "cost": cmp_cost, + "conversions": cmp_conv, + "conversions_value": cmp_value, + "cpa": camp.get("metrics.cpa"), + "roas": camp.get("metrics.roas"), + }, + "ga4_paid": { + "sessions": ga4["sessions"], + "conversions": ga4["conversions"], + "click_to_session_ratio": click_session_ratio, + } if property_id and ga4_warning is None else None, + "asset_groups": group_summaries, + "weak_asset_groups": len(weak_groups), + "channel_breakdown": channel_summary, + }) + + insights.extend(channels_result.get("insights", [])) + if ga4_warning: + insights.append(ga4_warning) + + if not pmax_campaigns: + insights.append( + "No Performance Max campaigns found in this account for the date range. " + "If you expected to see campaigns, check campaign.status filters or date range." + ) + + return { + "campaigns": summaries, + "total_campaigns": len(summaries), + "insights": insights, + "date_range": {"start": start, "end": end}, + } + + # --------------------------------------------------------------------------- # Tool 4: audit_event_coverage — three-way join across codebase, GTM, and GA4 # --------------------------------------------------------------------------- diff --git a/src/adloop/ga4/reports.py b/src/adloop/ga4/reports.py index 6425d8f..9b7f636 100644 --- a/src/adloop/ga4/reports.py +++ b/src/adloop/ga4/reports.py @@ -45,11 +45,20 @@ def run_ga4_report( date_range_start: str = "7daysAgo", date_range_end: str = "today", limit: int = 100, + dimension_filter: dict[str, str] | None = None, ) -> dict: - """Run a GA4 report with specified dimensions, metrics, and date range.""" + """Run a GA4 report with specified dimensions, metrics, and date range. + + dimension_filter: optional dict of dimension_name -> exact match value. + Multiple entries are combined with AND logic. Example: + {"sessionSource": "google", "sessionMedium": "cpc"} filters to paid search. + """ from google.analytics.data_v1beta.types import ( DateRange, Dimension, + Filter, + FilterExpression, + FilterExpressionList, Metric, RunReportRequest, ) @@ -69,6 +78,27 @@ def run_ga4_report( limit=limit, ) + if dimension_filter: + filter_exprs = [] + for field_name, value in dimension_filter.items(): + filter_exprs.append( + FilterExpression( + filter=Filter( + field_name=field_name, + string_filter=Filter.StringFilter( + value=value, + match_type=Filter.StringFilter.MatchType.EXACT, + ), + ) + ) + ) + if len(filter_exprs) == 1: + request.dimension_filter = filter_exprs[0] + else: + request.dimension_filter = FilterExpression( + and_group=FilterExpressionList(expressions=filter_exprs) + ) + response = client.run_report(request) dim_headers = [h.name for h in response.dimension_headers] diff --git a/src/adloop/rules/adloop.md b/src/adloop/rules/adloop.md index f875ebe..280ee63 100644 --- a/src/adloop/rules/adloop.md +++ b/src/adloop/rules/adloop.md @@ -1,5 +1,5 @@ --- -description: AdLoop MCP orchestration — Google Ads + GA4 + codebase intelligence +description: AdLoop MCP orchestration — Google Ads + GA4 + codebase intelligence --- # AdLoop — AI Orchestration Rules @@ -21,10 +21,12 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | Tool | When to Use | Key Parameters | |------|-------------|----------------| | `get_account_summaries` | First-time discovery — find which GA4 properties exist | (none — uses config) | -| `run_ga4_report` | Any analytics question — sessions, users, conversions, page performance | `dimensions`, `metrics`, `date_range_start`, `date_range_end`, `limit` | +| `run_ga4_report` | Any analytics question — sessions, users, conversions, page performance | `dimensions`, `metrics`, `date_range_start`, `date_range_end`, `limit`, `dimension_filter` | | `run_realtime_report` | After code deploys — verify tracking fires correctly | `dimensions`, `metrics` | | `get_tracking_events` | Understanding what events are configured and their volume | `date_range_start`, `date_range_end` | +**GA4 report filtering:** `run_ga4_report` supports a `dimension_filter` parameter — a dict of `dimension_name -> exact_value` pairs combined with AND logic. Use `{"sessionSource": "google", "sessionMedium": "cpc"}` to isolate paid search traffic server-side instead of pulling all sources and filtering in post-processing. + ### Google Ads Read Tools | Tool | When to Use | Key Parameters | @@ -33,7 +35,7 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | `get_campaign_performance` | Campaign-level metrics — impressions, clicks, cost, conversions | `date_range_start`, `date_range_end` | | `get_ad_performance` | Ad copy analysis — which headlines/descriptions work | `date_range_start`, `date_range_end` | | `get_keyword_performance` | Keyword analysis — quality scores, competitive metrics | `date_range_start`, `date_range_end` | -| `get_search_terms` | Find negative keyword opportunities and understand user intent | `date_range_start`, `date_range_end` | +| `get_search_terms` | Find negative keyword opportunities and understand user intent | `date_range_start`, `date_range_end`, `campaign_id` (optional) | | `get_negative_keywords` | List direct campaign-level negative keywords (not inside SharedSets) | `campaign_id` (optional) | | `get_negative_keyword_lists` | List all shared negative keyword lists — names, IDs, status, keyword count | (none) | | `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list | `shared_set_id` (required) | @@ -50,6 +52,33 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics - Ads read tools automatically compute `metrics.cost` and `metrics.cpa` from `metrics.cost_micros` — no manual division needed. `metrics.currency` contains the account's currency code (auto-detected). - `metrics.average_cpc_amount` is also pre-computed where available. - `get_ad_performance` returns full `headlines` and `descriptions` lists for RSAs. +- `get_keyword_performance` returns `ad_group.id`, `ad_group.name`, and `ad_group_criterion.criterion_id` — use these to construct `entity_id` strings (e.g. `"adGroupId~criterionId"`) for `pause_entity` calls, and `ad_group.name` for human-readable reporting. +- `get_search_terms` returns `campaign.id` and `metrics.cost` per search term — use `campaign.id` for `add_negative_keywords`, and cost for negative keyword analysis (flag terms spending > 2-3x CPA with zero conversions). + +### Performance Max Read Tools + +Performance Max (PMax) campaigns have a different structure than Search: +- **No ad groups, no keywords, no individual ads.** Instead, an `asset_group` bundles assets (headlines, descriptions, images, logos, videos) that Google assembles dynamically per impression. +- **Channel is decided at serve time.** A PMax campaign can serve on Search, Display, YouTube, Gmail, Discover, Maps, or Shopping — Google chooses based on signals. +- **Search terms are not exposed individually.** PMax surfaces aggregated category-level insights only. + +| Tool | When to Use | Key Parameters | +|------|-------------|----------------| +| `get_pmax_campaigns` | Discover PMax campaigns and their PMax-specific settings (brand guidelines) | `date_range_start`, `date_range_end` | +| `get_pmax_channel_breakdown` | "Where is my PMax spend going?" — Search vs YouTube vs Display etc. | `date_range_start`, `date_range_end`, `campaign_id` (optional) | +| `get_asset_groups` | List asset groups in a PMax campaign with ad strength + metrics | `campaign_id` (optional), `date_range_start`, `date_range_end` | +| `get_asset_group_assets` | Inspect every asset's `field_type`, status, and policy `review_status` | `asset_group_id` OR `campaign_id` | +| `get_asset_group_signals` | List search themes and audience signals attached to an asset group | `asset_group_id` OR `campaign_id` | +| `get_asset_group_top_combinations` | See which headline+description+image combos Google assembled (no metrics in v24) | `asset_group_id` OR `campaign_id`, `date_range_start`, `date_range_end` | +| `get_pmax_search_terms` | Get aggregated category-level insights (not individual queries) for a PMax campaign | `campaign_id` (REQUIRED), `date_range_start`, `date_range_end` | + +**PMax read tool notes:** +- `get_pmax_campaigns` returns `metrics.cost`, `metrics.cpa`, `metrics.roas`, and `campaign_budget.amount` pre-computed. The Search-style `bidding_strategy_type` is shown — for PMax it's typically `MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE` (Smart Bidding only). +- `get_pmax_channel_breakdown` is only reliable from **2025-06-01 onwards**. Earlier rows return `MIXED` for `segments.ad_network_type` because Google could not attribute. The tool emits a warning in `insights[]` when the date range overlaps that period. +- `get_asset_group_assets` no longer returns the `performance_label` (LOW/GOOD/BEST/PENDING) field — Google removed it from `asset_group_asset` in API v24. To judge per-asset performance now, look at `asset_group_top_combinations` (which assets actually serve together) or query `asset_field_type_view` for per-field-type metrics. +- `get_asset_group_signals` returns `signal_type = SEARCH_THEME | AUDIENCE | UNKNOWN`. Search themes are immutable once created — to "edit", you remove and re-create. +- `get_asset_group_top_combinations` does NOT return any `metrics.*` fields in v24 — the API rejects them with PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE. Combinations come pre-ordered by Google by serving frequency. +- `get_pmax_search_terms` requires API v23.2+. Returns `metrics.impressions` and `metrics.clicks` only — `metrics.cost_micros`, `metrics.conversions`, `metrics.conversions_value` are not selectable on `campaign_search_term_insight`. Google deliberately does NOT expose individual search queries for PMax campaigns. Don't tell users they can see exactly what someone typed, or that this tool surfaces per-category cost. - `get_recommendations` returns `estimated_improvement` per recommendation (potential minus base metrics) and `insights[]` that flag self-serving budget recommendations. - PMax tools: `get_pmax_performance` returns `insights[]` flagging weak ad strength and zero-conversion asset groups. `segments.ad_network_type` includes MIXED — a Google catch-all for most PMax traffic. Full channel splits (Search vs YouTube vs Display vs Discover) are not available via the API. - `get_asset_performance` returns `by_status` and `by_field_type` summaries. Note: per-asset performance labels (BEST/GOOD/LOW) are not available for PMax assets in the Google Ads API. Use `get_detailed_asset_performance` for quality signals via top combinations. @@ -64,14 +93,19 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | `analyze_campaign_conversions` | "What's my real CPA?", paid vs organic comparison, GDPR gap analysis | `date_range_start`, `date_range_end`, `campaign_name` (optional filter) | | `landing_page_analysis` | "Which landing pages convert?", identify pages with traffic but no conversions | `date_range_start`, `date_range_end` | | `attribution_check` | "Are my conversions tracked correctly?", Ads vs GA4 conversion discrepancies | `date_range_start`, `date_range_end`, `conversion_events` (optional GA4 event names) | +| `analyze_pmax_performance` | One-call PMax diagnostic — campaign + asset groups + asset counts + channel mix + GA4 | `date_range_start`, `date_range_end`, `campaign_id` (optional filter) | These tools call both APIs internally and return unified results with computed `insights[]`. They are read-only — no mutations. Each returns a `date_range` and auto-generates conditional warnings (GDPR gaps, zero conversions, attribution mismatches, orphaned URLs). +**`analyze_campaign_conversions` details:** Returns one row per campaign (with `campaign_id`) including `conversion_discrepancy_pct` between Ads and GA4. When `campaign_name` is omitted, all campaigns are returned — no need to call once per campaign. + +**`analyze_pmax_performance` details:** Aggregates everything you can see about Performance Max in one call: campaign metrics + bidding/brand-guidelines settings, every asset group with its `ad_strength` and asset counts, the channel-mix breakdown, and (when a GA4 property is configured) GA4 paid sessions/conversions per campaign. The `insights[]` flag POOR/AVERAGE asset groups, asset groups missing the documented PMax minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE, 1+ SQUARE_MARKETING_IMAGE, 1+ LOGO), channel-spend skew (>90% on a single surface), zero-conversion campaigns, GDPR consent gaps, and pre-2025-06-01 data caveats. Use this as the FIRST call when the user asks about PMax performance — it eliminates 4-5 separate read tool calls. + ### Tracking Tools | Tool | When to Use | Key Parameters | |------|-------------|----------------| -| `validate_tracking` | Compare codebase event code against actual GA4 events — find missing/broken tracking | `expected_events` (list of event names found in code), `date_range_start`, `date_range_end` | +| `validate_tracking` | Compare codebase event code against actual GA4 events — find missing/broken tracking | `expected_events` (list of event names found in code), `date_range_start`, `date_range_end`, `customer_id` (optional — cross-refs Ads conversion actions) | | `generate_tracking_code` | Generate ready-to-paste GA4 gtag JavaScript for an event | `event_name`, `event_params` (optional), `trigger` (form_submit/button_click/page_load) | `validate_tracking` requires the AI to first search the codebase for `gtag('event', ...)` or `dataLayer.push({event: ...})` calls, extract event names, then pass them to the tool. The tool queries GA4 and returns a structured comparison (matched, missing, unexpected, auto-collected). @@ -144,7 +178,7 @@ These tools call both APIs internally and return unified results with computed ` | Tool | When to Use | Key Parameters | |------|-------------|----------------| | `discover_keywords` | Discover new keyword ideas from seed keywords and/or a URL — returns avg monthly searches, competition, and bid range | `seed_keywords` (list, optional), `url` (optional), `geo_target_id`, `language_id`, `page_size`, `include_monthly_volumes` (per-month history + seasonality insight — use for "when does demand peak" questions) | -| `estimate_budget` | Budget planning before launching a campaign — forecasts clicks, impressions, cost for a set of keywords | `keywords` (list of {text, match_type, max_cpc}), `daily_budget` (optional), `geo_target_id`, `language_id`, `forecast_days` | +| `estimate_budget` | Budget planning before launching a campaign — forecasts clicks, cost, and conversions for a set of keywords | `keywords` (list of {text, match_type, max_cpc}), `daily_budget` (optional), `geo_target_id`, `language_id`, `forecast_days` | `estimate_budget` calls the Google Ads Keyword Planner API (read-only — creates nothing). Returns forecast metrics for the specified keywords and optional budget, including daily estimates and insights about budget sufficiency. Common geo targets: 2276=Germany, 2840=USA, 2826=UK. Common languages: 1000=English, 1001=German, 1002=French. @@ -152,11 +186,17 @@ These tools call both APIs internally and return unified results with computed ` | Tool | What It Does | Validation | |------|-------------|------------| -| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), optional `search_partners_enabled`, `display_network_enabled`, `display_expansion_enabled`, optional `max_cpc` for MANUAL_CPC ad-group bids or TARGET_SPEND CPC caps | -| `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | -| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` | +| `draft_campaign` | Create a SEARCH campaign (budget + campaign + ad group + keywords + geo/language targeting). Auto-sets Final URL Suffix with UTM tracking. **Rejects channel_type=PERFORMANCE_MAX** — use `draft_pmax_campaign` instead. | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated, `final_url_suffix` (auto-set for SEARCH, pass "" to disable), optional `search_partners_enabled`, `display_network_enabled`, `max_cpc` (MANUAL_CPC ad-group bid or TARGET_SPEND CPC cap) | +| `draft_pmax_campaign` | Create a Performance Max campaign with its first asset group + assets + signals atomically. PMax has no ad groups, no keywords, no `network_settings`. | `campaign_name`, `daily_budget`, `bidding_strategy` (Smart Bidding only), `geo_target_ids`, `language_ids`, `asset_group` dict (see PMax Write Tools section), `brand_guidelines_enabled` (default True — auto-links BUSINESS_NAME + first LOGO as CampaignAsset) | +| `draft_asset_group` | Add a new asset group (with assets + signals) to an existing PMax campaign | `campaign_id` (REQUIRED), `asset_group` dict | +| `draft_asset_group_assets` | Add headlines / long_headlines / descriptions / business_name / image refs / YouTube videos to an existing asset group | `asset_group_id` (REQUIRED), plus any of the asset arrays | +| `draft_asset_group_signal` | Add a single signal (search theme OR audience) to an asset group | `asset_group_id` (REQUIRED), plus exactly one of `search_theme` / `audience_resource_name` | +| `draft_image_asset` | Upload one or more local JPG/PNG/GIF images to the account as Google Ads Assets. Returns resource_names you can pass into `draft_pmax_campaign` / `draft_asset_group` / `draft_asset_group_assets`. | `images` list of `{file_path, name}` dicts. Absolute paths, ≤5 MB each, JPG/PNG/GIF only. Bytes are read at apply time. | +| `draft_ad_group` | Create a new ad group within an existing SEARCH campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo/language targets, Final URL suffix, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` | | `update_ad_group` | Update ad group name and/or MANUAL_CPC `max_cpc` | `ad_group_id`, optional `ad_group_name`, optional `max_cpc` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`. Google permits ≤2 headlines per slot, ≤1 description per slot. | +| `draft_rsa_replacement` | **Fix** an existing RSA — creates corrected replacement and removes the old ad. Use for copy errors, not A/B testing. For testing variants, use `draft_responsive_search_ad` instead. | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default true). Supports **pinning** — same format as `draft_responsive_search_ad`. | | `draft_callouts` | Create callout assets for a campaign (does NOT publish) | `campaign_id`, `callouts` list with 1-25 chars each | | `draft_structured_snippets` | Create structured snippet assets for a campaign (does NOT publish) | `campaign_id`, `snippets` list of `{header, values}` with official header values and 3-10 values | | `draft_image_assets` | Create image assets for a campaign from local files (does NOT publish) | `campaign_id`, `image_paths` list of local PNG/JPEG/GIF files | @@ -164,16 +204,20 @@ These tools call both APIs internally and return unified results with computed ` | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | | `add_negative_keywords` | Propose negative keywords directly on a campaign (does NOT add) | `campaign_id`, keyword list, `match_type` | | `add_negative_locations` | Propose negative geo exclusions on a campaign (does NOT add) — exclude cities/regions while keeping broader positive targets | `campaign_id`, `geo_target_ids` (numeric geo target constant IDs) | -| `draft_key_event` | Mark a GA4 event as a key event/conversion (does NOT apply) — closes the tracking loop after attribution_check finds an untracked conversion | `event_name`, `counting_method` (ONCE_PER_EVENT for purchases / ONCE_PER_SESSION for sign-ups), `property_id` (falls back to config) | | `propose_negative_keyword_list` | Draft a shared negative keyword list and attach it to a campaign (does NOT create) | `campaign_id`, `list_name`, keyword list, `match_type` | | `add_to_negative_keyword_list` | Append keywords to an EXISTING shared negative keyword list (does NOT add) | `shared_set_id` (from `get_negative_keyword_lists`), keyword list, `match_type` | | `attach_shared_set_to_campaigns` | Attach an EXISTING shared set (e.g. shared negative keyword list) to one or more campaigns. Use after creating a campaign to inherit pre-built negatives. | `shared_set_id` (from `get_negative_keyword_lists`), `campaign_ids` list | | `detach_shared_set_from_campaigns` | Detach a shared set from one or more campaigns. Removes only the linkage; the shared set and its keywords stay intact. | `shared_set_id`, `campaign_ids` list | | `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income range) at ad group or campaign level. Defaults to EXCLUSION (`negative=True`). | exactly one of `ad_group_id` or `campaign_id`, at least one of `age_ranges`/`genders`/`parental_statuses`/`income_ranges`, optional `negative` (default True) | -| `pause_entity` | Propose pausing campaign/ad group/ad/keyword | `entity_type`, `entity_id` | -| `enable_entity` | Propose enabling paused entity | `entity_type`, `entity_id` | -| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset"), `entity_id` | -| `confirm_and_apply` | Execute a previously previewed change | `plan_id` from a draft tool, `dry_run` (default true) | +| `draft_key_event` | Mark a GA4 event as a key event/conversion (does NOT apply) — closes the tracking loop after attribution_check finds an untracked conversion | `event_name`, `counting_method` (ONCE_PER_EVENT for purchases / ONCE_PER_SESSION for sign-ups), `property_id` (falls back to config) | +| `list_labels` | Read tool — list all labels in the account | (none) | +| `draft_label` | Create a Label resource | `name` (REQUIRED), `description`, `background_color` (hex like '#FF5733') | +| `apply_label` | Attach an existing Label to a campaign / ad_group / ad / keyword | `entity_type`, `entity_id`, `label_id` | +| `unapply_label` | Detach a Label from one entity (does NOT delete the Label itself) | same as `apply_label` | +| `pause_entity` | Propose pausing a campaign / ad group / ad / keyword / asset_group | `entity_type`, `entity_id` | +| `enable_entity` | Propose enabling a paused entity (campaign / ad group / ad / keyword / asset_group) | `entity_type`, `entity_id` | +| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset", "asset_group", "asset_group_signal", "label"), `entity_id` | +| `confirm_and_apply` | Execute a previously previewed change. With `dry_run=true` (default), runs the plan against the Google Ads API with `validate_only=True` — full server-side validation, no changes committed. | `plan_id` from a draft tool, `dry_run` (default true) | **Write tool workflow:** 1. Call a `draft_*` tool → returns a preview with a `plan_id` @@ -182,8 +226,15 @@ These tools call both APIs internally and return unified results with computed ` 4. Only call with `dry_run=false` after explicit user confirmation **Safety behaviors:** -- New campaigns and RSAs are created as PAUSED — user must explicitly enable them after review. +- New campaigns, asset groups, and RSAs are created as PAUSED — user must explicitly enable them after review. - `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting. +- `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, and warns if budget is below 5x target CPA. +- `draft_campaign` rejects `channel_type=PERFORMANCE_MAX` — PMax requires the asset_group + assets + signals to be created in the same mutate as the campaign, which the Search-shaped draft cannot produce. Use `draft_pmax_campaign` for PMax. +- `draft_pmax_campaign` enforces Smart Bidding (rejects MANUAL_CPC and TARGET_SPEND) and PMax asset minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO). Image and logo assets must be uploaded before the campaign mutate — either via `draft_image_asset` (point at local JPG/PNG/GIF paths) or via the Google Ads UI — then pass the resulting resource_names. The Google Ads API rejects an asset_group create that's missing any image / logo minimum (`ASSET_GROUP_NOT_ENOUGH_MARKETING_IMAGE_ASSET` etc.), so there is no "text-only PMax draft" workflow. +- `update_campaign` replaces geo/language targets entirely (not append). Pass the full desired list. +- `remove_entity` is IRREVERSIBLE — always prefer `pause_entity` unless the user explicitly wants permanent removal. Removal triggers double confirmation in the safety layer. +- `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "campaign_asset", "asset_group", "asset_group_signal", "label". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "asset_group_signal" to remove a search-theme or audience signal — entity_id is the composite `assetGroupId~criterionId` returned by `get_asset_group_signals`. Use "label" to delete a Label resource (cascade-removes all assignments — to detach a single assignment, use `unapply_label` instead). +- `confirm_and_apply` with `dry_run=true` runs the plan against the Google Ads API with `validate_only=True`. The API performs full validation server-side and returns errors if the plan is malformed (e.g. PMax with `network_settings`, invalid bidding strategy, dangling resource references) — but commits nothing. A passing dry run means the real apply will pass the same validation. A failing dry run returns `status: DRY_RUN_VALIDATION_FAILED` with the actual API error. - `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, warns if budget is below 5x target CPA, and interprets `max_cpc` by bidding strategy: MANUAL_CPC seeds the initial ad-group bid, TARGET_SPEND sets the Maximize Clicks CPC ceiling. - `display_network_enabled` is the canonical Search display-expansion flag. `display_expansion_enabled` is only a compatibility alias and should be normalized away before presenting the plan to the user. - `update_ad_group` is the right tool for later MANUAL_CPC bid changes. Use `update_campaign` for TARGET_SPEND (Maximize Clicks) `max_cpc` changes. @@ -192,6 +243,7 @@ These tools call both APIs internally and return unified results with computed ` - `remove_entity` is IRREVERSIBLE — always prefer `pause_entity` unless the user explicitly wants permanent removal. Removal triggers double confirmation in the safety layer. - `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset". Use "negative_keyword" to remove campaign-level negative keywords. Use "shared_criterion" to remove a keyword from a shared negative keyword list — the `entity_id` format is "sharedSetId~criterionId" (use the `resource_id` field from `get_negative_keyword_list_keywords`). Use "ad_group_criterion" or "campaign_criterion" to remove demographic targeting (age/gender/parental/income) — pass the `remove_id` returned by `get_demographic_targeting`. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "asset" to remove a standalone asset. Use "customer_asset" to remove a customer-level asset link. - `require_dry_run: true` in config overrides `dry_run=false` — the user must change the config to allow real mutations. +- `two_phase_apply: true` in config (always on for AdLoop Cloud) refuses `dry_run=false` with status `DRY_RUN_REQUIRED` until that plan_id has completed one `dry_run=true` pass. Run the dry run, show the user, then apply — do not retry `dry_run=false` in a loop. - All operations (including dry runs) are logged to `~/.adloop/audit.log`. ## Safety Rules (CRITICAL — always follow) @@ -247,6 +299,17 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 7. Compare against best practices (see Marketing Best Practices section) 8. If the account has active Google recommendations, mention that `get_recommendations` can surface Google's suggestions for improvement +### When user asks about Performance Max performance + +PMax is structurally different from Search — different tools, different diagnostics, different levers. + +1. **Default to `analyze_pmax_performance`.** It pulls campaign + asset groups + assets + channel breakdown + GA4 in one call and returns auto-generated `insights[]`. Don't manually chain `get_pmax_campaigns` + `get_asset_groups` + `get_asset_group_assets` unless you need data the cross-ref tool doesn't surface. +2. If the user asks specifically about creative quality: present the asset groups sorted by `ad_strength`, then look at `asset_counts_by_type` and `missing_asset_minimums` to identify groups under-supplied with assets. The per-asset `performance_label` was removed in v24, so individual LOW asset diagnosis is no longer available — `get_asset_group_top_combinations` (which assets actually serve together) is the closest replacement. +3. If the user asks "where is my budget going?": use the `channel_breakdown` array and emphasize that PMax decides channel mix at serve time. If one channel dominates (>90%), that's worth flagging — Google may be suppressing other surfaces due to weak creative for those formats. +4. **Do NOT compare PMax CPA to Search CPA directly.** PMax includes Display, YouTube, and Discovery surfaces that have intrinsically different conversion dynamics. Compare PMax CPA to the campaign's `target_cpa` (if set) or to historical PMax CPA, not to a Search benchmark. +5. **Search terms work differently for PMax.** When users ask "what are people searching for" — explain that PMax does NOT expose individual queries (Google's design), only aggregated category labels via `get_pmax_search_terms`. Don't promise data the API doesn't return. +6. **GDPR consent gaps apply identically** — the click-to-session ratio insight in `analyze_pmax_performance` factors this in. + ### When user asks about conversions or conversion drops 1. Call `attribution_check` with relevant date range and `conversion_events` if the user mentions specific events (e.g. sign_up, purchase) — this does the Ads vs GA4 comparison in one call and auto-generates insights @@ -277,6 +340,27 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 10. After the ad is created, **suggest sitelinks** if the campaign doesn't have any. Use `draft_sitelinks` with at least 4 relevant links (key pages like pricing, features, signup, etc.). Sitelinks increase ad real estate and CTR. 11. Wait for explicit user approval before calling `confirm_and_apply` +### When user wants to fix issues with an existing RSA + +**Use `draft_rsa_replacement` when an ad has problems that need correcting** — wrong data (e.g. "200+ reviews" should be "300+"), truncated city names, mixed A/B messaging, character limit violations, broken display paths, etc. This tool removes the broken ad and creates a corrected replacement. + +**Do NOT use this for A/B testing or creating ad variants.** For that, use `draft_responsive_search_ad` to add a new ad alongside the existing one, and optionally `pause_entity` to pause the old one. + +1. Call `get_ad_performance` to find the ad ID and see current copy +2. **Pre-write checks (same as ad creation):** + - Is the campaign's bidding strategy appropriate? + - Does the campaign have conversions? + - What are quality scores? +3. Review the current headlines and descriptions — identify exactly what needs fixing +4. Write the complete corrected set of headlines (3-15) and descriptions (2-4), following the "Ad Copy Character Limits" section. Count characters for every headline before generating. +5. If the user hasn't specified a `final_url`, the tool inherits it from the old ad — no need to provide it +6. Call `draft_rsa_replacement` with the old `ad_id` and the corrected copy +7. Present the diff preview (old vs new) to the user — the preview shows both old and new copy side-by-side +8. Wait for explicit user approval before calling `confirm_and_apply` +9. The old ad is **permanently removed** by default (so it can't be accidentally re-enabled). The new ad is created as PAUSED. +10. Remind the user to enable the new ad via `enable_entity` after reviewing in Google Ads UI +11. Only pass `remove_old=false` if the user explicitly wants to keep the old ad around (paused) for reference + ### When user wants to add keywords 1. Call `get_campaign_performance` to identify the target campaign and its **bidding strategy** @@ -307,6 +391,55 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has - Add ads via `draft_responsive_search_ad` (with display paths set) - Add sitelinks via `draft_sitelinks` (at least 4 recommended) - If the user needs multiple ad groups (e.g., different keyword themes), use `draft_ad_group` to add additional ad groups after the initial campaign is created and confirmed + - Enable the campaign via `enable_entity` only after ads and sitelinks are in place +8. Wait for explicit user approval before calling `confirm_and_apply` + +### When user wants to create a new Performance Max campaign + +PMax is structurally different — there is no `draft_campaign` path for it. PMax requires the campaign + asset group + assets + signals to be created in the same atomic mutate. There is no "text-only" or "draft-and-add-images-later" path: the Google Ads API rejects an asset_group create with any missing image/logo minimum (`ASSET_GROUP_NOT_ENOUGH_MARKETING_IMAGE_ASSET`, `...SQUARE_MARKETING_IMAGE_ASSET`, `...LOGO_ASSET`). Images and logos MUST exist as Asset resources before `draft_pmax_campaign` is called. + +1. Call `get_pmax_campaigns` to see existing PMax campaigns and avoid name duplicates +2. **Get the image/logo resource_names ready.** PMax needs at least one MARKETING_IMAGE (1.91:1, ≥600x314), one SQUARE_MARKETING_IMAGE (1:1, ≥300x300), and one LOGO (1:1, ≥128x128) before the campaign can be created. Two options: + - **Upload via the MCP**: call `draft_image_asset(images=[{file_path, name}, ...])`, confirm, and use the returned resource_names. JPG/PNG/GIF, ≤5 MB each, absolute paths. + - **Upload via the Google Ads UI**: ask the user for the resource_names of assets they've already uploaded (format `customers/.../assets/...`). +3. **Pre-write checks (CRITICAL):** + - **Bidding strategy**: PMax accepts only Smart Bidding — `MAXIMIZE_CONVERSIONS`, `MAXIMIZE_CONVERSION_VALUE`, `TARGET_CPA`, `TARGET_ROAS`. The tool rejects MANUAL_CPC and TARGET_SPEND for PMax. + - **Geo targeting**: ALWAYS ask the user which countries/regions to target. + - **Language targeting**: ALWAYS ask the user which languages. + - **Asset minimums**: 3-5 headlines (≤30 chars), 1-5 long_headlines (≤90 chars), 2-5 descriptions (≤90 chars), 1 business_name (≤25 chars), at least 1 marketing_image, 1 square_marketing_image, 1 logo (resource_names from step 2). + - **Brand Guidelines**: `draft_pmax_campaign` defaults `brand_guidelines_enabled=True` because new PMax campaigns default to it on Google's side. With it on, BUSINESS_NAME and the first LOGO are auto-linked at the campaign level via CampaignAsset (the API otherwise rejects with `REQUIRED_BUSINESS_NAME_ASSET_NOT_LINKED` / `REQUIRED_LOGO_ASSET_NOT_LINKED`). Pass `brand_guidelines_enabled=False` if the user explicitly wants the legacy "no brand guidelines" behavior. + - **Conversion tracking**: PMax depends heavily on Smart Bidding signals. Call `attribution_check` — if zero conversions across the board, WARN that PMax won't optimize without tracking working first. + - **Budget**: ideally ≥ 5x target CPA; the tool warns otherwise. +4. Call `draft_pmax_campaign` with campaign details + the full `asset_group` dict (name, final_urls, headlines, long_headlines, descriptions, business_name, marketing_image_assets, square_marketing_image_assets, logo_assets, and optionally search_themes / audience_resource_names). +5. Present the complete preview to the user — emphasize the campaign will be created as PAUSED. +6. Call `confirm_and_apply(plan_id=..., dry_run=true)` first — this runs `validate_only=True` against Google Ads and surfaces any API rejections (e.g. invalid asset shapes, missing minimums) before applying for real. +7. After dry run passes and user approves, call `confirm_and_apply(plan_id=..., dry_run=false)`. +8. Remind the user to enable the PMax campaign via `enable_entity(entity_type='campaign', entity_id=...)` after reviewing in Google Ads UI. + +### When user wants to upload images / logos for PMax + +1. Confirm the user has the files locally and knows the absolute paths. +2. Group the files into one `draft_image_asset(images=[{file_path, name}, ...])` call — a single batch is cheaper than one upload per file. Names are display names that show up in the Ads UI Asset Library; pick descriptive ones the user will recognize (e.g. `"Acme Logo - Square"`). +3. Present the preview. Each image has its file_path, mime_type, and file_size; on confirm the bytes are read fresh from disk and uploaded via `AssetService.MutateAssets`. +4. Call `confirm_and_apply(plan_id=..., dry_run=true)` to let Google validate dimensions and policy. A dry run failure here usually means wrong aspect ratio for the slot the user wants to fill — surface the exact Google Ads error. +5. Then call `confirm_and_apply(plan_id=..., dry_run=false)`. The result contains `uploaded` — a list of `{name, resource_name}`. Use those resource_names in `draft_pmax_campaign` / `draft_asset_group` / `draft_asset_group_assets`. +6. The same uploaded Asset can be linked as MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, or LOGO at link time as long as its real pixel dimensions fit the slot — Google enforces the aspect ratio when the AssetGroupAsset link is created, not at upload time. + +### When user wants to add a new asset group, more assets, or signals to an existing PMax campaign + +- **New asset group**: `draft_asset_group(campaign_id, asset_group=...)` — same `asset_group` shape as `draft_pmax_campaign`. Each asset group has independent assets and is its own creative bundle. +- **More assets on an existing asset group**: `draft_asset_group_assets(asset_group_id, headlines=[...], long_headlines=[...], descriptions=[...], business_name=..., marketing_image_assets=[...], square_marketing_image_assets=[...], logo_assets=[...], youtube_video_ids=[...])` — pass only what you want to add. Text and YouTube assets are created inline; images/logos must already exist as Asset resources (upload them via `draft_image_asset` first, or paste resource_names from the Google Ads UI). +- **New signal on an existing asset group**: `draft_asset_group_signal(asset_group_id, search_theme="..." OR audience_resource_name="customers/.../audiences/...")`. Pass exactly one. Search themes are immutable — to "edit" one, remove and re-add. + +To pause/enable an asset group, use `pause_entity`/`enable_entity` with `entity_type="asset_group"`. To remove one, use `remove_entity` (irreversible). + +### When user wants to add labels or filter by label + +1. Call `list_labels` to discover existing labels. +2. To create a new label, call `draft_label(name, description=..., background_color="#RRGGBB")`, then `confirm_and_apply`. +3. To attach a label, call `apply_label(entity_type='campaign'|'ad_group'|'ad'|'keyword', entity_id=..., label_id=...)`. The label must already exist — capture the `label_id` from `list_labels` or from the result of `draft_label` + apply. +4. To detach a label from one entity, call `unapply_label` with the same args. To delete the Label resource itself (cascades), use `remove_entity(entity_type='label', entity_id=...)`. + - **If a shared negative keyword list already exists** for the account (check via `get_negative_keyword_lists`), call `attach_shared_set_to_campaigns` so the new campaign inherits historical waste-blockers. New campaigns don't auto-inherit shared lists — this step is easy to forget. - Enable the campaign via `enable_entity` only after ads and sitelinks are in place 8. Wait for explicit user approval before calling `confirm_and_apply` @@ -343,7 +476,7 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 1. Ask the user for their target keywords (or suggest some based on the business context) 2. Ask for the target geography and language (or infer from the existing account) 3. Call `estimate_budget` with the keywords, match types, and optional daily budget -4. Present the forecast: estimated clicks, impressions, cost, and avg CPC +4. Present the forecast: estimated clicks, cost, avg CPC, and conversions 5. If the user provided a daily budget, highlight whether it's sufficient to capture most available traffic 6. Use the forecast to inform `draft_campaign` decisions — the estimated daily cost guides the budget parameter @@ -517,9 +650,11 @@ LIMIT n | `campaign_budget` | Budget information | | `bidding_strategy` | Bidding strategy details | | `customer_client` | List accounts under an MCC (uses login_customer_id) | -| `asset_group` | PMax asset group data (ad strength, status) | -| `asset_group_asset` | PMax per-asset performance labels | -| `asset_group_top_combination_view` | PMax top asset combinations | +| `asset_group` | Performance Max asset groups (PMax equivalent of ad groups) | +| `asset_group_asset` | Individual assets in PMax asset groups with field_type, status, policy review_status (no `performance_label` in v24) | +| `asset_group_signal` | Search themes and audience signals attached to PMax asset groups | +| `asset_group_top_combination_view` | Top serving combinations Google has assembled for PMax | +| `campaign_search_term_insight` | Aggregated PMax search-term categories (v23.2+, no individual queries) | | `recommendation` | Google's auto-generated recommendations | | `ad_group_audience_view` | Audience segment performance | @@ -554,7 +689,21 @@ LIMIT n **Segments (for time-based breakdowns):** - `segments.date` — daily breakdown - `segments.device` — MOBILE, DESKTOP, TABLET -- `segments.ad_network_type` — SEARCH, CONTENT, YOUTUBE +- `segments.ad_network_type` — SEARCH, CONTENT, YOUTUBE_WATCH, YOUTUBE_SEARCH, MIXED (PMax channel breakdown — only reliable from 2025-06-01 onwards) + +**Performance Max fields:** +- `campaign.advertising_channel_type = 'PERFORMANCE_MAX'` — filter for PMax campaigns +- `campaign.brand_guidelines_enabled` — boolean; when true, business name + logos are at campaign level not asset group +- `asset_group.id`, `asset_group.name`, `asset_group.status`, `asset_group.ad_strength` (POOR/AVERAGE/GOOD/EXCELLENT) +- `asset_group_asset.field_type` (HEADLINE, DESCRIPTION, MARKETING_IMAGE, LOGO, YOUTUBE_VIDEO, etc.) +- `asset_group_asset.policy_summary.review_status`, `asset_group_asset.status` +- `asset_group_signal.search_theme.text`, `asset_group_signal.audience.audience` + +**Removed in API v24 — DO NOT use:** +- `campaign.url_expansion_opt_out` (and `campaign.url_expansion_optimization`) — replaced by `Campaign.asset_automation_settings` with `AssetAutomationType=FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION`. +- `asset_group_asset.performance_label` — the LOW/GOOD/BEST/PENDING per-asset rating is no longer populated. Look at `asset_group_top_combination_view` (which assets actually serve) or query metrics on `asset_field_type_view` for per-field-type performance. +- `metrics.cost_micros`, `metrics.conversions`, `metrics.conversions_value` on `campaign_search_term_insight` — the API returns PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE. Only `metrics.clicks` and `metrics.impressions` are selectable on this resource. +- Any `metrics.*` on `asset_group_top_combination_view` — PROHIBITED_METRIC. The view exposes only `asset_group_top_combination_view.asset_group_top_combinations` (a repeated message). ### Date Ranges @@ -613,6 +762,31 @@ FROM ad_group WHERE campaign.id = 12345678 ``` +**Performance Max asset groups with ad strength:** +```sql +SELECT asset_group.id, asset_group.name, asset_group.ad_strength, + campaign.name, metrics.cost_micros, metrics.conversions +FROM asset_group +WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND segments.date DURING LAST_30_DAYS +ORDER BY metrics.cost_micros DESC +``` + +**Performance Max — list all assets in an asset group:** +```sql +SELECT asset_group.id, asset_group.name, + asset_group_asset.field_type, + asset_group_asset.status, + asset_group_asset.policy_summary.review_status, + asset.text_asset.text, + asset.image_asset.full_size.url +FROM asset_group_asset +WHERE asset_group.id = 6572147947 + AND asset_group_asset.status != 'REMOVED' +``` + +(`asset_group_asset.performance_label` was removed in v24 — see "Removed in API v24" above.) + ## Ad Copy Character Limits Google Ads enforces hard character limits. The `draft_responsive_search_ad` tool will reject copy that exceeds them, but you must write copy that fits on the FIRST attempt — do not generate copy and hope it fits. @@ -685,6 +859,7 @@ When advising on Google Ads: - **Display paths**: Always set `path1` and `path2` on RSAs. They cost nothing, improve ad relevance, and make the display URL informative (e.g. `example.com/Features/Pricing` instead of bare `example.com`). Derive them from the landing page path or the ad's core message. Max 15 chars each. - **Sitelinks**: Every campaign should have at least 4 sitelinks. They increase ad real estate (more screen space = higher CTR), direct users to key pages, and are free. Good candidates: pricing, features, signup/trial, about, key product pages. Use `draft_sitelinks` to create them. Link text max 25 chars, descriptions max 35 chars each. - **Clicks vs sessions gap**: Never report a clicks > sessions discrepancy as a tracking bug without first accounting for GDPR consent. In the EU, 30-70% of users may reject analytics cookies. This is normal, not broken. +- **Performance Max specifics**: PMax campaigns are Smart Bidding only (`MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE`) — no MANUAL_CPC option, so the BROAD-match-without-Smart-Bidding rule doesn't apply. Channel mix is decided by Google at serve time; you cannot directly target Search-only or YouTube-only. Asset groups should ideally have `ad_strength = GOOD` or better, and they should meet Google's documented asset-type minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, etc. — `analyze_pmax_performance` flags any below-minimum groups). PMax search-term insights expose categories only, not individual queries. **Image and logo assets must exist before the campaign mutate.** Use `draft_image_asset` to upload local JPG/PNG/GIF files (≤5 MB each) directly from the MCP, or upload via the Google Ads UI and paste the resource_names — there is no "text-only PMax" path (the API rejects asset_group creates that are missing any image / logo minimum). - **Performance Max asset diversity**: PMax campaigns need diverse assets to perform well. Minimum recommended: 5+ headlines (max 30 chars), 5+ long headlines (max 90 chars), 5+ descriptions (max 90 chars), 5+ marketing images (1200x628), 5+ square images (1200x1200), 1+ logo (1200x1200), 1+ landscape logo (1200x300). A YouTube video is strongly recommended. Ad strength below GOOD usually means missing asset types. - **PMax transparency limitations**: The Google Ads API does not provide full channel-level breakdowns for PMax. `segments.ad_network_type` returns SEARCH, CONTENT, YOUTUBE_SEARCH, YOUTUBE_WATCH, and MIXED — but MIXED is a catch-all for most traffic. Do not promise users exact Search vs Display vs YouTube splits. Be honest about what the data shows and what it doesn't. - **Google recommendations are not neutral**: Google's auto-generated recommendations optimize for Google's revenue, not necessarily the advertiser's ROI. Budget increase and Broad Match recommendations should always be cross-referenced against actual conversion data. Bid strategy recommendations (switch to Smart Bidding) are usually sound. Keyword recommendations may be too broad. Never blindly apply recommendations — evaluate each one against the account's actual performance. diff --git a/src/adloop/rules/commands/analyze-performance.md b/src/adloop/rules/commands/analyze-performance.md index c3f91a4..c20d3b5 100644 --- a/src/adloop/rules/commands/analyze-performance.md +++ b/src/adloop/rules/commands/analyze-performance.md @@ -8,21 +8,32 @@ Analyze Google Ads and GA4 performance: $ARGUMENTS ## 1. Pull data (AdLoop MCP) - `get_campaign_performance` — relevant date range (default: last 30 days) +- `get_impression_share` — visibility and lost opportunity analysis +- `get_bid_strategy_status` — check learning status and strategy health - `analyze_campaign_conversions` — cross-referenced Ads + GA4 data with GDPR gap detection - If specific campaigns mentioned, filter by name - If keywords are relevant, also pull `get_keyword_performance` and `get_search_terms` +- If budget concerns, pull `get_budget_pacing` for month-to-date pacing **Context tip:** for the account-wide pass, call the performance tools with `compact=true` — you get totals, top-10 rows, and pre-computed offender lists instead of every row. Only switch to full mode when drilling into a specific campaign/keyword. In harnesses that support subagents, this whole data-pull step can be delegated to a subagent that returns just the summary. ## 2. Analyze - Spend, Clicks, Conversions, CPA, CTR per campaign +- Impression share: search IS, budget-lost IS, rank-lost IS — identify visibility gaps +- Bid strategy status: any campaigns in learning phase? Appropriate strategy type? - Paid vs organic comparison (from non_paid_channels) - GDPR gap (clicks vs sessions ratio — 2:1 to 5:1 is normal in EU) -- Flag: zero conversions with significant spend, CPA > 3x target, QS < 5, wasteful search terms +- Flag: zero conversions with significant spend, CPA > 3x target, QS < 5, wasteful search terms, high budget-lost IS If conversion issues found: run `attribution_check` If landing page problems suspected: run `landing_page_analysis` +If quality scores are low: run `get_quality_score_details` for component breakdowns +If device performance varies: run `get_device_performance` to compare mobile vs desktop +If performance changed unexpectedly: run `get_change_history` to correlate with account changes +If geographic waste suspected: run `get_location_performance` to identify underperforming areas +If timing patterns matter: run `get_ad_schedule_performance` for hour/day analysis +If competitive context needed: run `get_auction_insights` (requires allowlisted account) ## 3. Present results diff --git a/src/adloop/server.py b/src/adloop/server.py index f36134e..d230781 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -394,6 +394,7 @@ def run_ga4_report( date_range_end: str = "today", property_id: str = "", limit: int = 100, + dimension_filter: dict[str, str] = {}, ) -> dict: """Run a custom GA4 report with specified dimensions, metrics, and date range. @@ -402,6 +403,11 @@ def run_ga4_report( Date formats: "today", "yesterday", "7daysAgo", "28daysAgo", "90daysAgo", or "YYYY-MM-DD". If property_id is empty, uses the default from config. + + dimension_filter: optional dict of dimension_name -> exact match value to + filter results server-side. Multiple entries are combined with AND logic. + Example: {"sessionSource": "google", "sessionMedium": "cpc"} returns only + paid search traffic. """ from adloop.ga4.reports import run_ga4_report as _impl @@ -413,6 +419,7 @@ def run_ga4_report( date_range_start=date_range_start, date_range_end=date_range_end, limit=limit, + dimension_filter=dimension_filter, ) @@ -667,8 +674,10 @@ def get_keyword_performance( ) -> dict: """Get keyword metrics including quality scores and competitive data. - Returns: keyword text, match type, quality score, impressions, - clicks, CTR, CPC, conversions for each keyword. + Returns: keyword text, match type, quality score, ad_group.id, ad_group.name, + ad_group_criterion.criterion_id, impressions, clicks, CTR, CPC, cost, + conversions for each keyword. Use ad_group.id and criterion_id to + construct entity_id strings (e.g. "adGroupId~criterionId") for pause_entity. Set compact=true for audits/overviews: returns totals, match-type distribution, the top-10 spenders, low-quality-score keywords, and @@ -691,12 +700,18 @@ def get_search_terms( customer_id: str = "", date_range_start: str = "", date_range_end: str = "", + campaign_id: str = "", compact: bool = False, ) -> dict: """Get search terms report — what users actually typed before clicking your ads. Critical for finding negative keyword opportunities and understanding user intent. - Returns: search term, campaign, ad group, impressions, clicks, conversions. + Returns: search term, campaign_id, campaign_name, ad group, impressions, + clicks, cost, conversions. Each row includes campaign.id so you can pass + it directly to add_negative_keywords. + + campaign_id: optional filter to a specific campaign. When omitted, returns + search terms across all campaigns. Set compact=true for audits/overviews: returns totals, the top-10 terms by clicks, ready-made negative-keyword waste candidates @@ -709,6 +724,7 @@ def get_search_terms( customer_id=customer_id or current_config().ads.customer_id, date_range_start=date_range_start, date_range_end=date_range_end, + campaign_id=campaign_id, compact=compact, ) @@ -733,6 +749,152 @@ def get_negative_keywords( ) +# --------------------------------------------------------------------------- +# Google Ads Insights Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_impression_share( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + level: str = "campaign", +) -> dict: + """Get impression share metrics — how much of available search traffic you're capturing. + + Shows search impression share, budget-lost share, rank-lost share, + top impression share, and absolute top impression share. + + level: "campaign" (default), "ad_group", or "keyword" + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.read import get_impression_share as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + level=level, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_change_history( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + resource_type: str = "", + operation_type: str = "", + limit: int = 100, +) -> dict: + """Get recent account change history — who changed what and when. + + Critical for correlating performance shifts with account changes. + Goes back up to 30 days (API limit). Default shows last 14 days. + + resource_type: filter by type — "CAMPAIGN", "AD_GROUP", "AD", + "AD_GROUP_CRITERION", "CAMPAIGN_BUDGET", "BIDDING_STRATEGY" + operation_type: filter by action — "CREATE", "UPDATE", "REMOVE" + Date format: "YYYY-MM-DD". Empty = last 14 days. + """ + from adloop.ads.read import get_change_history as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + resource_type=resource_type, + operation_type=operation_type, + limit=limit, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_device_performance( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + level: str = "campaign", +) -> dict: + """Get performance segmented by device — MOBILE, DESKTOP, TABLET. + + Essential for businesses where mobile intent differs + dramatically from desktop. Shows clicks, cost, conversions, and + conversion rate per device. + + level: "campaign" (default) or "ad_group" + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.read import get_device_performance as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + level=level, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_location_performance( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get performance segmented by geographic location. + + Shows impressions, clicks, cost, and conversions per location. + Useful for identifying underperforming service areas or wasted spend + outside the target service radius. + + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.read import get_location_performance as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_quality_score_details( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get keyword Quality Score with component breakdowns. + + Returns quality_score (1-10), creative_quality_score (ad relevance), + post_click_quality_score (landing page), and search_predicted_ctr + (expected CTR). Sorted by spend so high-cost low-QS keywords surface first. + + campaign_id: optional filter to a specific campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.read import get_quality_score_details as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + campaign_id=campaign_id, + ) + + @mcp.tool(annotations=_READONLY, tags={"ads"}) @_safe def get_negative_keyword_lists( @@ -774,285 +936,952 @@ def get_negative_keyword_list_campaigns( shared_set_id: str = "", customer_id: str = "", ) -> dict: - """List which campaigns a shared negative keyword list is attached to. + """List which campaigns a shared negative keyword list is attached to. + + shared_set_id: numeric ID from get_negative_keyword_lists. Omit to see + all list-to-campaign attachments across the account. + """ + from adloop.ads.read import get_negative_keyword_list_campaigns as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + shared_set_id=shared_set_id, + ) + + +# --------------------------------------------------------------------------- +# Google Ads — Recommendations, Performance Max & Audience Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_recommendations( + customer_id: str = "", + recommendation_types: _StrListOpt = None, + campaign_id: str = "", +) -> dict: + """Retrieve Google's auto-generated recommendations with estimated impact. + + Returns each recommendation's type, associated campaign/ad group, current + (base) and projected (potential) metrics, and the estimated improvement. + + recommendation_types: optional filter — e.g. ["KEYWORD", "TARGET_CPA_OPT_IN", + "MAXIMIZE_CONVERSIONS_OPT_IN", "RESPONSIVE_SEARCH_AD"]. Empty = all types. + campaign_id: optional — scope to a single campaign. + + Includes insights that flag budget-increase recommendations (often self-serving) + and highlight high-impact suggestions worth investigating. + """ + from adloop.ads.read import get_recommendations as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + recommendation_types=recommendation_types, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_pmax_performance( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get Performance Max campaign and asset group performance. + + Returns two result sets: + - campaigns: PMax campaign metrics broken down by ad_network_type (SEARCH, + CONTENT, YOUTUBE_SEARCH, YOUTUBE_WATCH, MIXED). Note: MIXED is a catch-all + that Google uses for most PMax traffic — full channel splits are not + available via the API. + - asset_groups: per-asset-group metrics including ad_strength (EXCELLENT, + GOOD, AVERAGE, POOR). + + Includes insights flagging weak ad strength, zero-conversion asset groups, + and network type distribution. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.pmax import get_pmax_performance as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_asset_performance( + customer_id: str = "", + campaign_id: str = "", +) -> dict: + """Get per-asset details for Performance Max campaigns. + + Returns each asset's field_type (HEADLINE, DESCRIPTION, MARKETING_IMAGE, + YOUTUBE_VIDEO, etc.), primary_status (ELIGIBLE, NOT_ELIGIBLE, PAUSED, + PENDING), and content (text or image URL). + + Note: per-asset performance labels (BEST/GOOD/LOW) are not available for + PMax assets in the Google Ads API. Use get_detailed_asset_performance to + see which asset combinations Google selects most — the closest proxy for + individual asset quality. + + campaign_id: optional filter to a single PMax campaign. + Includes by_status and by_field_type summaries. + """ + from adloop.ads.pmax import get_asset_performance as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_detailed_asset_performance( + customer_id: str = "", + campaign_id: str = "", +) -> dict: + """Get top-performing asset combinations for Performance Max campaigns. + + Shows which headline + description + image combinations Google selects + most often. Each combination lists the assets used and their field types. + This data helps identify which creative elements work well together. + + campaign_id: optional filter to a single PMax campaign. + """ + from adloop.ads.pmax import get_detailed_asset_performance as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_audience_performance( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get audience segment performance metrics. + + Returns performance by audience type — remarketing lists (USER_LIST), + in-market segments (USER_INTEREST), affinity, demographics (AGE_RANGE, + GENDER), etc. Shows display_name, impressions, clicks, cost, conversions, + CTR, and CPC for each audience. + + Works for campaigns with explicit audience targeting (Search, Display). + PMax audience targeting is automatic and may not appear in this report. + campaign_id: optional filter to a single campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.read import get_audience_performance as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_bid_strategy_status( + customer_id: str = "", + campaign_id: str = "", +) -> dict: + """Get bid strategy type, system status, and learning state per campaign. + + Shows bidding_strategy_type (MAXIMIZE_CONVERSIONS, TARGET_CPA, etc.), + bidding_strategy_system_status (LEARNING, ELIGIBLE, LIMITED, etc.), + daily budget, and last-30-day metrics. + + Use this before recommending changes — don't edit campaigns in a learning phase. + campaign_id: optional filter to a specific campaign. + """ + from adloop.ads.read import get_bid_strategy_status as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_budget_pacing( + customer_id: str = "", + campaign_id: str = "", +) -> dict: + """Get monthly budget pacing — spend-to-date, projected spend, pace percentage. + + Shows daily budget, month-to-date spend, daily average spend, + projected month-end spend, and whether each campaign is over or under pace. + + campaign_id: optional filter to a specific campaign. + """ + from adloop.ads.read import get_budget_pacing as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_ad_schedule_performance( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get performance by hour of day and day of week. + + Identifies peak and off-peak patterns. Important for service + businesses (e.g. emergency plumber at 2am vs 2pm). + + Returns: campaign, day_of_week, hour, impressions, clicks, CTR, cost, + conversions, conversion_rate, CPA for each time slot. + + campaign_id: optional filter to a specific campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.read import get_ad_schedule_performance as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_auction_insights( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get auction insights — competitor overlap rate, outranking share, position data. + + Shows which competitors appear alongside your ads and how often you + outrank them. Requires an allowlisted Google Ads account — returns a + helpful error if access is not available. + + campaign_id: optional filter to a specific campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.read import get_auction_insights as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_demographic_targeting( + ad_group_id: str = "", + campaign_id: str = "", + customer_id: str = "", +) -> dict: + """List demographic targeting criteria (age, gender, parental status, income). + + Provide exactly one of `ad_group_id` or `campaign_id`. Returns each + criterion's value, whether it's negative (excluded) or positive + (narrowing), status, and a `remove_id` (composite resource ID) that + can be passed directly to `remove_entity` with + entity_type='ad_group_criterion' or 'campaign_criterion'. + + By default, Google Ads serves ads to ALL demographic segments — a + criterion only appears here once you've actively excluded or narrowed. + """ + from adloop.ads.read import get_demographic_targeting as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + ad_group_id=ad_group_id, + campaign_id=campaign_id, + ) + + +# --------------------------------------------------------------------------- +# Cross-Reference Tools (GA4 + Ads Combined) +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY, tags={"tracking"}) +@_safe +def analyze_campaign_conversions( + date_range_start: str = "", + date_range_end: str = "", + customer_id: str = "", + property_id: str = "", + campaign_name: str = "", +) -> dict: + """Campaign clicks → GA4 conversions mapping — the real cost-per-conversion. + + Combines Google Ads campaign metrics with GA4 session/conversion data to + reveal click-to-session ratios (GDPR indicator), compare Ads-reported vs + GA4-reported conversions, and compute cost-per-GA4-conversion. + + Returns one row per campaign (with campaign_id) including + conversion_discrepancy_pct between Ads and GA4. When campaign_name is + provided, filters to matching campaigns. + + Also returns non-paid channel conversion rates for comparison context. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.crossref import analyze_campaign_conversions as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + property_id=property_id or current_config().ga4.property_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + campaign_name=campaign_name, + ) + + +@mcp.tool(annotations=_READONLY, tags={"tracking"}) +@_safe +def landing_page_analysis( + date_range_start: str = "", + date_range_end: str = "", + customer_id: str = "", + property_id: str = "", +) -> dict: + """Analyze which landing pages convert and which don't. + + Combines ad final URLs with GA4 page-level data to show paid traffic + sessions, conversion rates, bounce rates, and engagement per landing page. + Identifies pages that get ad clicks but zero conversions and orphaned URLs. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.crossref import landing_page_analysis as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + property_id=property_id or current_config().ga4.property_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + ) + + +@mcp.tool(annotations=_READONLY, tags={"tracking"}) +@_safe +def attribution_check( + date_range_start: str = "", + date_range_end: str = "", + customer_id: str = "", + property_id: str = "", + conversion_events: _StrListOpt = None, +) -> dict: + """Compare Ads-reported conversions vs GA4 — find tracking discrepancies. + + Checks whether conversions reported by Google Ads match what GA4 records, + diagnoses GDPR consent gaps, attribution model differences, and missing + conversion event configuration. + + conversion_events: optional list of GA4 event names to specifically check + (e.g. ["sign_up", "purchase"]). If omitted, compares aggregate totals only. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.crossref import attribution_check as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + property_id=property_id or current_config().ga4.property_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + conversion_events=conversion_events, + ) + + +# --------------------------------------------------------------------------- +# Performance Max Read Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_pmax_campaigns( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get Performance Max campaigns with PMax-specific settings and metrics. + + Returns: campaign id/name/status, bidding strategy, brand guidelines flag, + daily budget, impressions, clicks, cost, conversions, conversions_value, + CPA, and ROAS for each PMax campaign. + + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.pmax_read import get_pmax_campaigns as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_pmax_channel_breakdown( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get PMax spend/clicks/conversions per serving surface (Search/Display/YouTube/etc.). + + Uses segments.ad_network_type to break down where PMax actually served. + Channel-level data is only reliable from 2025-06-01 onwards — earlier + rows return MIXED. The tool emits a warning in insights when the date + range overlaps that period. + + campaign_id: optional filter to a single campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.pmax_read import get_pmax_channel_breakdown as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_asset_groups( + customer_id: str = "", + campaign_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """List PMax asset groups with their final URLs, paths, ad strength, and metrics. + + Asset groups are the PMax equivalent of ad groups — each contains a bundle + of assets (headlines, descriptions, images, logos, videos) that Google + assembles dynamically. Ad strength values: POOR | AVERAGE | GOOD | EXCELLENT. + + campaign_id: optional filter to a single campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.pmax_read import get_asset_groups as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + campaign_id=campaign_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_asset_group_assets( + customer_id: str = "", + asset_group_id: str = "", + campaign_id: str = "", +) -> dict: + """List individual assets in PMax asset groups with field type and policy review. + + Returns asset id/type, field_type (HEADLINE, DESCRIPTION, MARKETING_IMAGE, + LOGO, YOUTUBE_VIDEO, etc.), status, policy_summary.review_status, and the + text content, image URL, or YouTube video id/title/url depending on type. + + Note: the LOW/GOOD/BEST/PENDING performance_label was removed from + asset_group_asset in Google Ads API v24. To judge per-asset performance + now, query metrics directly via asset_field_type_view, or use + get_asset_group_top_combinations to see which combinations actually serve. + + Provide either asset_group_id (single group) or campaign_id (all groups in + the campaign). With both empty, returns all assets across all PMax campaigns. + """ + from adloop.ads.pmax_read import get_asset_group_assets as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + asset_group_id=asset_group_id, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_asset_group_signals( + customer_id: str = "", + asset_group_id: str = "", + campaign_id: str = "", +) -> dict: + """List audience and search-theme signals attached to PMax asset groups. + + Signals are not hard targeting — they are hints to Google's algorithm about + who and what kind of search intent the asset group should match. Each row + has signal_type = SEARCH_THEME or AUDIENCE. + + Provide either asset_group_id or campaign_id. Both empty returns all signals + across all PMax campaigns. + """ + from adloop.ads.pmax_read import get_asset_group_signals as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + asset_group_id=asset_group_id, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY, tags={"ads"}) +@_safe +def get_asset_group_top_combinations( + customer_id: str = "", + asset_group_id: str = "", + campaign_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get the asset combinations Google has assembled at serve time for PMax. - shared_set_id: numeric ID from get_negative_keyword_lists. Omit to see - all list-to-campaign attachments across the account. + Each row's asset_group_top_combinations field is a repeated message of + the assets that served together (headlines, descriptions, images, optional + video). The view does NOT expose metrics in v24 — the API rejects any + metrics.* field on this resource. Combinations come pre-ordered by Google + by serving frequency. + + Provide either asset_group_id or campaign_id. Returns up to 50 rows. + Date format: "YYYY-MM-DD". Empty = last 30 days. """ - from adloop.ads.read import get_negative_keyword_list_campaigns as _impl + from adloop.ads.pmax_read import get_asset_group_top_combinations as _impl return _impl( current_config(), customer_id=customer_id or current_config().ads.customer_id, - shared_set_id=shared_set_id, + asset_group_id=asset_group_id, + campaign_id=campaign_id, + date_range_start=date_range_start, + date_range_end=date_range_end, ) -# --------------------------------------------------------------------------- -# Google Ads — Recommendations, Performance Max & Audience Tools -# --------------------------------------------------------------------------- - - @mcp.tool(annotations=_READONLY, tags={"ads"}) @_safe -def get_recommendations( +def get_pmax_search_terms( + campaign_id: str, customer_id: str = "", - recommendation_types: _StrListOpt = None, - campaign_id: str = "", + date_range_start: str = "", + date_range_end: str = "", ) -> dict: - """Retrieve Google's auto-generated recommendations with estimated impact. - - Returns each recommendation's type, associated campaign/ad group, current - (base) and projected (potential) metrics, and the estimated improvement. + """Get aggregated search-term category insights for a Performance Max campaign. - recommendation_types: optional filter — e.g. ["KEYWORD", "TARGET_CPA_OPT_IN", - "MAXIMIZE_CONVERSIONS_OPT_IN", "RESPONSIVE_SEARCH_AD"]. Empty = all types. - campaign_id: optional — scope to a single campaign. + Note: PMax does NOT expose individual search terms (Google's design choice). + This returns category labels (e.g. "Buy women's running shoes") aggregated + across many real queries, with impression and click counts. The Google Ads + API does NOT expose cost, conversions, or conversions_value on + campaign_search_term_insight (PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE), + so per-category cost is not available. - Includes insights that flag budget-increase recommendations (often self-serving) - and highlight high-impact suggestions worth investigating. + campaign_id is REQUIRED — these insights are queried per-campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. """ - from adloop.ads.read import get_recommendations as _impl + from adloop.ads.pmax_read import get_pmax_search_terms as _impl return _impl( current_config(), customer_id=customer_id or current_config().ads.customer_id, - recommendation_types=recommendation_types, campaign_id=campaign_id, + date_range_start=date_range_start, + date_range_end=date_range_end, ) @mcp.tool(annotations=_READONLY, tags={"ads"}) @_safe -def get_pmax_performance( - customer_id: str = "", +def analyze_pmax_performance( date_range_start: str = "", date_range_end: str = "", + customer_id: str = "", + property_id: str = "", + campaign_id: str = "", ) -> dict: - """Get Performance Max campaign and asset group performance. - - Returns two result sets: - - campaigns: PMax campaign metrics broken down by ad_network_type (SEARCH, - CONTENT, YOUTUBE_SEARCH, YOUTUBE_WATCH, MIXED). Note: MIXED is a catch-all - that Google uses for most PMax traffic — full channel splits are not - available via the API. - - asset_groups: per-asset-group metrics including ad_strength (EXCELLENT, - GOOD, AVERAGE, POOR). - - Includes insights flagging weak ad strength, zero-conversion asset groups, - and network type distribution. + """Comprehensive PMax diagnostic — campaign + asset groups + assets + channels + GA4. + + Pulls everything you can inspect about Performance Max in one call: + campaign metrics + bidding/brand-guidelines settings, every asset group + with its ad strength and asset counts, channel-mix breakdown, and (when + property_id is configured) GA4 paid sessions/conversions per campaign. + + Returns auto-generated insights[] flagging: + - Asset groups with POOR or AVERAGE ad strength + - Asset groups below the documented PMax asset-type minimums + - Channel skew (e.g. >90% of spend on a single surface) + - Zero-conversion campaigns despite spend + - GDPR consent gaps (click-to-session ratio > 2:1) + - Pre-2025-06-01 channel breakdown caveats + + campaign_id: optional filter — when provided, returns only that PMax campaign. Date format: "YYYY-MM-DD". Empty = last 30 days. """ - from adloop.ads.pmax import get_pmax_performance as _impl + from adloop.crossref import analyze_pmax_performance as _impl return _impl( current_config(), customer_id=customer_id or current_config().ads.customer_id, + property_id=property_id or current_config().ga4.property_id, date_range_start=date_range_start, date_range_end=date_range_end, + campaign_id=campaign_id, ) -@mcp.tool(annotations=_READONLY, tags={"ads"}) +# --------------------------------------------------------------------------- +# Performance Max Write Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_WRITE, tags={"ads"}) @_safe -def get_asset_performance( +def draft_pmax_campaign( + campaign_name: str, + daily_budget: float, + bidding_strategy: str, + geo_target_ids: list[str], + language_ids: list[str], + asset_group: dict, customer_id: str = "", - campaign_id: str = "", + target_cpa: float = 0, + target_roas: float = 0, + final_url_suffix: str | None = None, + brand_guidelines_enabled: bool = True, ) -> dict: - """Get per-asset details for Performance Max campaigns. + """Draft a Performance Max campaign with its first asset group — returns PREVIEW. + + Creates: CampaignBudget + Campaign (PAUSED, no network_settings) + geo + + language + AssetGroup (PAUSED) + Assets + AssetGroupAsset links + Signals + in one atomic mutate. PMax requires this all-in-one shape. + + bidding_strategy: PMax accepts only Smart Bidding — + MAXIMIZE_CONVERSIONS | MAXIMIZE_CONVERSION_VALUE | TARGET_CPA | TARGET_ROAS + target_cpa / target_roas: required when bidding_strategy is the matching name. + geo_target_ids / language_ids: REQUIRED — same constants as draft_campaign. + + brand_guidelines_enabled: defaults to True (matches Google's new PMax + default). When True, BUSINESS_NAME and the first LOGO are also linked + at the campaign level via CampaignAsset — required for the mutate to + succeed on Brand-Guidelines-defaulted accounts. Pass False to opt + out (assets stay at the asset-group level only). + + asset_group dict: see draft_pmax_campaign in pmax_write.py. Keys: + - name (str): asset group name + - final_urls (list[str]): at least one + - path1, path2 (str, optional, <=15 chars) + - headlines (list[str], 3-5, <=30 chars) + - long_headlines (list[str], 1-5, <=90 chars) + - descriptions (list[str], 2-5, <=90 chars) + - business_name (str, <=25 chars) + - marketing_image_assets (list[str]): resource_names of pre-uploaded + 1.91:1 images. PMax requires at least one. + - square_marketing_image_assets (list[str]): resource_names of pre- + uploaded 1:1 images. At least one required. + - logo_assets (list[str]): resource_names of pre-uploaded logos. At + least one required. + - youtube_video_ids (list[str], optional) + - search_themes (list[str], optional): SearchTheme signal phrases + - audience_resource_names (list[str], optional): Audience resource_names + + NOTE: image/logo assets cannot be created inline through this MCP — use + draft_image_asset to upload local JPG/PNG/GIF files, or paste resource_names + of assets already uploaded via the Google Ads UI. + + Call confirm_and_apply with the returned plan_id to execute. The new + campaign is created as PAUSED — enable_entity it after review. + """ + from adloop.ads.pmax_write import draft_pmax_campaign as _impl - Returns each asset's field_type (HEADLINE, DESCRIPTION, MARKETING_IMAGE, - YOUTUBE_VIDEO, etc.), primary_status (ELIGIBLE, NOT_ELIGIBLE, PAUSED, - PENDING), and content (text or image URL). + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + campaign_name=campaign_name, + daily_budget=daily_budget, + bidding_strategy=bidding_strategy, + target_cpa=target_cpa, + target_roas=target_roas, + geo_target_ids=geo_target_ids, + language_ids=language_ids, + final_url_suffix=final_url_suffix, + brand_guidelines_enabled=brand_guidelines_enabled, + asset_group=asset_group, + ) - Note: per-asset performance labels (BEST/GOOD/LOW) are not available for - PMax assets in the Google Ads API. Use get_detailed_asset_performance to - see which asset combinations Google selects most — the closest proxy for - individual asset quality. - campaign_id: optional filter to a single PMax campaign. - Includes by_status and by_field_type summaries. +@mcp.tool(annotations=_WRITE, tags={"ads"}) +@_safe +def draft_asset_group( + campaign_id: str, + asset_group: dict, + customer_id: str = "", +) -> dict: + """Draft a new asset group inside an existing PMax campaign — returns PREVIEW. + + asset_group has the same shape as draft_pmax_campaign's asset_group field. + See that tool's docstring for the full schema. + + Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.pmax import get_asset_performance as _impl + from adloop.ads.pmax_write import draft_asset_group as _impl return _impl( current_config(), customer_id=customer_id or current_config().ads.customer_id, campaign_id=campaign_id, + asset_group=asset_group, ) -@mcp.tool(annotations=_READONLY, tags={"ads"}) +@mcp.tool(annotations=_WRITE, tags={"ads"}) @_safe -def get_detailed_asset_performance( +def draft_asset_group_assets( + asset_group_id: str, customer_id: str = "", - campaign_id: str = "", + headlines: list[str] = [], + long_headlines: list[str] = [], + descriptions: list[str] = [], + business_name: str = "", + marketing_image_assets: list[str] = [], + square_marketing_image_assets: list[str] = [], + logo_assets: list[str] = [], + youtube_video_ids: list[str] = [], ) -> dict: - """Get top-performing asset combinations for Performance Max campaigns. + """Draft attaching new assets to an existing asset group — returns PREVIEW. - Shows which headline + description + image combinations Google selects - most often. Each combination lists the assets used and their field types. - This data helps identify which creative elements work well together. + Use this to add more headlines, descriptions, images, etc. to an asset + group that already exists. Each text/youtube asset is created inline (one + Asset.create + one AssetGroupAsset.create per item). Image and logo assets + must already exist in the account — pass their resource_names. - campaign_id: optional filter to a single PMax campaign. + Char limits: headlines <=30, long_headlines <=90, descriptions <=90, + business_name <=25. + + Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.pmax import get_detailed_asset_performance as _impl + from adloop.ads.pmax_write import draft_asset_group_assets as _impl return _impl( current_config(), customer_id=customer_id or current_config().ads.customer_id, - campaign_id=campaign_id, + asset_group_id=asset_group_id, + headlines=headlines, + long_headlines=long_headlines, + descriptions=descriptions, + business_name=business_name, + marketing_image_assets=marketing_image_assets, + square_marketing_image_assets=square_marketing_image_assets, + logo_assets=logo_assets, + youtube_video_ids=youtube_video_ids, ) -@mcp.tool(annotations=_READONLY, tags={"ads"}) +@mcp.tool(annotations=_WRITE, tags={"ads"}) @_safe -def get_audience_performance( +def draft_image_asset( + images: list[dict], customer_id: str = "", - date_range_start: str = "", - date_range_end: str = "", - campaign_id: str = "", ) -> dict: - """Get audience segment performance metrics. + """Draft uploading one or more local image files as Google Ads Assets — returns PREVIEW. - Returns performance by audience type — remarketing lists (USER_LIST), - in-market segments (USER_INTEREST), affinity, demographics (AGE_RANGE, - GENDER), etc. Shows display_name, impressions, clicks, cost, conversions, - CTR, and CPC for each audience. + PMax campaigns require pre-uploaded MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, + and LOGO assets that are referenced by resource_name. This tool reads + local JPG / PNG / GIF files, validates them, and produces a ChangePlan that + uploads bytes via AssetService.MutateAssets when confirm_and_apply is + called. On apply, returns the new Asset resource_names so they can be + passed to draft_pmax_campaign / draft_asset_group / draft_asset_group_assets. - Works for campaigns with explicit audience targeting (Search, Display). - PMax audience targeting is automatic and may not appear in this report. - campaign_id: optional filter to a single campaign. - Date format: "YYYY-MM-DD". Empty = last 30 days. + The same uploaded Asset can be linked as MARKETING_IMAGE (1.91:1, min + 600x314), SQUARE_MARKETING_IMAGE (1:1, min 300x300), or LOGO (1:1, min + 128x128) — Google checks the pixel dimensions against the slot at link + time. + + Accepted formats: JPG (.jpg/.jpeg), PNG (.png), static GIF (.gif). + Max file size: 5 MB per image. File bytes are read at apply time; the file + must still exist when confirm_and_apply runs. + + images: list of dicts, each with: + - file_path (str, REQUIRED): absolute path to a local image file + - name (str, REQUIRED): the Asset display name in Google Ads + + Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.read import get_audience_performance as _impl + from adloop.ads.pmax_write import draft_image_asset as _impl return _impl( current_config(), customer_id=customer_id or current_config().ads.customer_id, - date_range_start=date_range_start, - date_range_end=date_range_end, - campaign_id=campaign_id, + images=images, ) -@mcp.tool(annotations=_READONLY, tags={"ads"}) +@mcp.tool(annotations=_WRITE, tags={"ads"}) @_safe -def get_demographic_targeting( - ad_group_id: str = "", - campaign_id: str = "", +def draft_asset_group_signal( + asset_group_id: str, customer_id: str = "", + search_theme: str = "", + audience_resource_name: str = "", ) -> dict: - """List demographic targeting criteria (age, gender, parental status, income). + """Draft a new signal (search theme OR audience) on an asset group — returns PREVIEW. - Provide exactly one of `ad_group_id` or `campaign_id`. Returns each - criterion's value, whether it's negative (excluded) or positive - (narrowing), status, and a `remove_id` (composite resource ID) that - can be passed directly to `remove_entity` with - entity_type='ad_group_criterion' or 'campaign_criterion'. + Pass exactly one of search_theme (a phrase) or audience_resource_name + (a 'customers/.../audiences/...' resource name). Search themes are + immutable once created — to "edit", remove the old signal and add a new + one. - By default, Google Ads serves ads to ALL demographic segments — a - criterion only appears here once you've actively excluded or narrowed. + Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.read import get_demographic_targeting as _impl + from adloop.ads.pmax_write import draft_asset_group_signal as _impl return _impl( current_config(), customer_id=customer_id or current_config().ads.customer_id, - ad_group_id=ad_group_id, - campaign_id=campaign_id, + asset_group_id=asset_group_id, + search_theme=search_theme, + audience_resource_name=audience_resource_name, ) # --------------------------------------------------------------------------- -# Cross-Reference Tools (GA4 + Ads Combined) +# Label Tools # --------------------------------------------------------------------------- -@mcp.tool(annotations=_READONLY, tags={"tracking"}) +@mcp.tool(annotations=_READONLY, tags={"ads"}) @_safe -def analyze_campaign_conversions( - date_range_start: str = "", - date_range_end: str = "", +def list_labels(customer_id: str = "") -> dict: + """List all labels in the Google Ads account. + + Returns each label's id, name, status, description, and background_color. + Use the IDs returned here with apply_label / unapply_label / remove_entity. + """ + from adloop.ads.labels import list_labels as _impl + + return _impl(current_config(), customer_id=customer_id or current_config().ads.customer_id) + + +@mcp.tool(annotations=_WRITE, tags={"ads"}) +@_safe +def draft_label( + name: str, customer_id: str = "", - property_id: str = "", - campaign_name: str = "", + description: str = "", + background_color: str = "", ) -> dict: - """Campaign clicks → GA4 conversions mapping — the real cost-per-conversion. + """Draft creating a new Label — returns PREVIEW. - Combines Google Ads campaign metrics with GA4 session/conversion data to - reveal click-to-session ratios (GDPR indicator), compare Ads-reported vs - GA4-reported conversions, and compute cost-per-GA4-conversion. + name: required. Must be unique in the account. + description: optional human description. + background_color: optional hex color string like '#FF5733'. - Also returns non-paid channel conversion rates for comparison context. - Date format: "YYYY-MM-DD". Empty = last 30 days. + Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.crossref import analyze_campaign_conversions as _impl + from adloop.ads.labels import draft_label as _impl return _impl( current_config(), customer_id=customer_id or current_config().ads.customer_id, - property_id=property_id or current_config().ga4.property_id, - date_range_start=date_range_start, - date_range_end=date_range_end, - campaign_name=campaign_name, + name=name, + description=description, + background_color=background_color, ) -@mcp.tool(annotations=_READONLY, tags={"tracking"}) +@mcp.tool(annotations=_WRITE, tags={"ads"}) @_safe -def landing_page_analysis( - date_range_start: str = "", - date_range_end: str = "", +def apply_label( + entity_type: str, + entity_id: str, + label_id: str, customer_id: str = "", - property_id: str = "", ) -> dict: - """Analyze which landing pages convert and which don't. + """Draft attaching a label to a campaign/ad_group/ad/keyword — returns PREVIEW. - Combines ad final URLs with GA4 page-level data to show paid traffic - sessions, conversion rates, bounce rates, and engagement per landing page. - Identifies pages that get ad clicks but zero conversions and orphaned URLs. - Date format: "YYYY-MM-DD". Empty = last 30 days. + entity_type: 'campaign', 'ad_group', 'ad', or 'keyword'. + entity_id: bare ID for campaign/ad_group, 'adGroupId~adId' for ad, + 'adGroupId~criterionId' for keyword. + label_id: the ID of an existing Label (use list_labels to discover them). + + Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.crossref import landing_page_analysis as _impl + from adloop.ads.labels import draft_apply_label as _impl return _impl( current_config(), customer_id=customer_id or current_config().ads.customer_id, - property_id=property_id or current_config().ga4.property_id, - date_range_start=date_range_start, - date_range_end=date_range_end, + entity_type=entity_type, + entity_id=entity_id, + label_id=label_id, ) -@mcp.tool(annotations=_READONLY, tags={"tracking"}) +@mcp.tool(annotations=_WRITE, tags={"ads"}) @_safe -def attribution_check( - date_range_start: str = "", - date_range_end: str = "", +def unapply_label( + entity_type: str, + entity_id: str, + label_id: str, customer_id: str = "", - property_id: str = "", - conversion_events: _StrListOpt = None, ) -> dict: - """Compare Ads-reported conversions vs GA4 — find tracking discrepancies. + """Draft detaching a label from an entity (does NOT delete the Label itself). - Checks whether conversions reported by Google Ads match what GA4 records, - diagnoses GDPR consent gaps, attribution model differences, and missing - conversion event configuration. + To delete the Label resource itself, use remove_entity with + entity_type='label'. - conversion_events: optional list of GA4 event names to specifically check - (e.g. ["sign_up", "purchase"]). If omitted, compares aggregate totals only. - Date format: "YYYY-MM-DD". Empty = last 30 days. + Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.crossref import attribution_check as _impl + from adloop.ads.labels import draft_unapply_label as _impl return _impl( current_config(), customer_id=customer_id or current_config().ads.customer_id, - property_id=property_id or current_config().ga4.property_id, - date_range_start=date_range_start, - date_range_end=date_range_end, - conversion_events=conversion_events, + entity_type=entity_type, + entity_id=entity_id, + label_id=label_id, ) @@ -1354,6 +2183,11 @@ def get_gtm_version( ) +# --------------------------------------------------------------------------- +# Custom GAQL +# --------------------------------------------------------------------------- + + @mcp.tool(annotations=_READONLY, tags={"ads"}) @_safe def run_gaql( @@ -1397,6 +2231,7 @@ def draft_campaign( channel_type: str = "SEARCH", ad_group_name: str = "", keywords: _DictListOpt = None, + final_url_suffix: str | None = None, search_partners_enabled: bool = False, display_network_enabled: bool | None = None, display_expansion_enabled: bool | None = None, @@ -1425,6 +2260,9 @@ def draft_campaign( language_ids: REQUIRED list of language constant IDs Common: "1001" German, "1000" English, "1002" French, "1004" Spanish, "1014" Portuguese. Full list: Google Ads API language constants. + final_url_suffix: UTM suffix auto-applied to SEARCH campaigns. Pass "" to + disable. Defaults to standard UTM tracking with ValueTrack parameters: + utm_source=google&utm_medium=cpc&utm_campaign={campaignid}&utm_content={adgroupid}&utm_term={keyword} Call confirm_and_apply with the returned plan_id to execute. """ @@ -1443,6 +2281,7 @@ def draft_campaign( keywords=keywords, geo_target_ids=geo_target_ids, language_ids=language_ids, + final_url_suffix=final_url_suffix, search_partners_enabled=search_partners_enabled, display_network_enabled=display_network_enabled, display_expansion_enabled=display_expansion_enabled, @@ -1494,6 +2333,7 @@ def update_campaign( daily_budget: float = 0, geo_target_ids: _StrListOpt = None, language_ids: _StrListOpt = None, + final_url_suffix: str | None = None, search_partners_enabled: bool | None = None, display_network_enabled: bool | None = None, display_expansion_enabled: bool | None = None, @@ -1513,6 +2353,7 @@ def update_campaign( "2040" Austria, "2756" Switzerland, "2840" USA, "2826" UK language_ids: REPLACES all language targets. Common IDs: "1001" German, "1000" English, "1002" French, "1004" Spanish + final_url_suffix: set or change the campaign's Final URL suffix. Pass "" to clear. search_partners_enabled: include ads on Search partners display_network_enabled: enable Search campaign display expansion display_expansion_enabled: alias for display_network_enabled @@ -1533,6 +2374,7 @@ def update_campaign( daily_budget=daily_budget, geo_target_ids=geo_target_ids, language_ids=language_ids, + final_url_suffix=final_url_suffix, search_partners_enabled=search_partners_enabled, display_network_enabled=display_network_enabled, display_expansion_enabled=display_expansion_enabled, @@ -1583,6 +2425,52 @@ def draft_responsive_search_ad( ) +@mcp.tool(annotations=_WRITE, tags={"ads"}) +@_safe +def draft_rsa_replacement( + ad_id: str, + headlines: list[str | dict], + descriptions: list[str | dict], + final_url: str = "", + customer_id: str = "", + path1: str = "", + path2: str = "", + remove_old: bool = True, +) -> dict: + """Fix an existing RSA — creates a corrected replacement and removes the old ad. + + Use this to fix issues with an existing RSA: wrong copy, character errors, + data inconsistencies, truncated names, etc. The old ad is REMOVED by default + so it cannot be accidentally re-enabled. + + For A/B testing or adding ad variants, use draft_responsive_search_ad instead. + + Provide the ad_id of the RSA to fix, plus the complete corrected copy. + The tool fetches the old ad's details and shows a side-by-side diff preview. + The new ad inherits the ad group from the old one and is created as PAUSED. + If final_url is omitted, the old ad's URL is reused. + Call confirm_and_apply with the returned plan_id to execute. + + Each headline/description can be a plain string (unpinned) or a dict with + optional pinning: {"text": "...", "pinned_field": "HEADLINE_1"}. + Valid headline pins: HEADLINE_1, HEADLINE_2, HEADLINE_3. + Valid description pins: DESCRIPTION_1, DESCRIPTION_2. + """ + from adloop.ads.write import draft_rsa_replacement as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + ad_id=ad_id, + headlines=headlines, + descriptions=descriptions, + final_url=final_url, + path1=path1, + path2=path2, + remove_old=remove_old, + ) + + @mcp.tool(annotations=_WRITE, tags={"ads"}) @_safe def draft_keywords( @@ -1912,12 +2800,13 @@ def pause_entity( ) -> dict: """Draft pausing a campaign, ad group, ad, or keyword — returns a PREVIEW. - entity_type: "campaign", "ad_group", "ad", or "keyword" + entity_type: "campaign", "ad_group", "ad", "keyword", or "asset_group" entity_id format by type: - campaign: campaign ID (e.g. "12345678") - ad_group: ad group ID (e.g. "12345678") - ad: "adGroupId~adId" (e.g. "12345678~987654") - keyword: "adGroupId~criterionId" (e.g. "12345678~987654") + - asset_group: asset group ID (e.g. "6572147947") Call confirm_and_apply with the returned plan_id to execute. """ @@ -1940,12 +2829,13 @@ def enable_entity( ) -> dict: """Draft enabling a paused campaign, ad group, ad, or keyword — returns a PREVIEW. - entity_type: "campaign", "ad_group", "ad", or "keyword" + entity_type: "campaign", "ad_group", "ad", "keyword", or "asset_group" entity_id format by type: - campaign: campaign ID (e.g. "12345678") - ad_group: ad group ID (e.g. "12345678") - ad: "adGroupId~adId" (e.g. "12345678~987654") - keyword: "adGroupId~criterionId" (e.g. "12345678~987654") + - asset_group: asset group ID (e.g. "6572147947") Call confirm_and_apply with the returned plan_id to execute. """ @@ -1969,19 +2859,28 @@ def remove_entity( """Draft REMOVING an entity — returns a PREVIEW. This is IRREVERSIBLE. entity_type: "campaign", "ad_group", "ad", "keyword", "negative_keyword", - "shared_criterion", "campaign_asset", "asset", or "customer_asset" + "shared_criterion", "ad_group_criterion", "campaign_criterion", + "campaign_asset", "asset", "customer_asset", "asset_group", + "asset_group_signal", or "label" entity_id: The resource ID. For keywords: "adGroupId~criterionId" For negative_keywords: "campaignId~criterionId" (use the resource_id field from get_negative_keywords) For shared_criterion: "sharedSetId~criterionId" (use the resource_id field from get_negative_keyword_list_keywords) + For ad_group_criterion / campaign_criterion: the remove_id from + get_demographic_targeting For campaign_asset: "campaignId~assetId~fieldType" For asset: simple asset ID For customer_asset: "assetId~fieldType" + For asset_groups: the asset group ID + For asset_group_signals: "assetGroupId~criterionId" (the + format get_asset_group_signals returns) + For labels: the label ID (cascades to all assignments) WARNING: Removed entities cannot be re-enabled. Use pause_entity instead - if you just want to temporarily disable something. + if you just want to temporarily disable something. To detach a label from + a single entity (without deleting the Label itself), use unapply_label. Call confirm_and_apply with the returned plan_id to execute. """ @@ -2097,6 +2996,7 @@ def validate_tracking( property_id: str = "", date_range_start: str = "28daysAgo", date_range_end: str = "today", + customer_id: str = "", ) -> dict: """Compare tracking events found in the codebase against actual GA4 data. @@ -2106,6 +3006,10 @@ def validate_tracking( Returns: matched events, events missing from GA4, unexpected GA4 events, and auto-collected events (page_view, session_start, etc.). + + customer_id: optional — when provided, also pulls Google Ads conversion + actions and checks which expected events have matching Ads conversion + actions configured. """ from adloop.tracking import validate_tracking as _impl @@ -2115,6 +3019,7 @@ def validate_tracking( property_id=property_id or current_config().ga4.property_id, date_range_start=date_range_start, date_range_end=date_range_end, + customer_id=customer_id, ) diff --git a/src/adloop/tracking.py b/src/adloop/tracking.py index 083c9eb..f24484e 100644 --- a/src/adloop/tracking.py +++ b/src/adloop/tracking.py @@ -72,12 +72,17 @@ def validate_tracking( property_id: str = "", date_range_start: str = "28daysAgo", date_range_end: str = "today", + customer_id: str = "", ) -> dict: """Compare expected tracking events (from codebase) against actual GA4 data. The AI searches the user's codebase for gtag/dataLayer event calls, extracts event names, and passes them here. This tool queries GA4 for actual events and returns a structured comparison. + + When customer_id is provided, also pulls Google Ads conversion actions and + includes them in the comparison — showing which expected events have a + matching Ads conversion action and which are missing. """ from adloop.ga4.tracking import get_tracking_events @@ -135,7 +140,49 @@ def validate_tracking( f"zero count — it may not be triggering for real users." ) - return { + # Optionally cross-reference with Google Ads conversion actions + ads_conversion_actions: list[dict] = [] + if customer_id: + from adloop.ads.gaql import execute_query + + conv_query = """ + SELECT conversion_action.name, conversion_action.type, + conversion_action.status + FROM conversion_action + WHERE conversion_action.status = 'ENABLED' + """ + try: + conv_rows = execute_query(config, customer_id, conv_query) + ads_conversion_names: set[str] = set() + for row in conv_rows: + name = row.get("conversion_action.name", "") + ads_conversion_names.add(name) + ads_conversion_actions.append({ + "name": name, + "type": row.get("conversion_action.type", ""), + }) + + matched_in_ads = sorted(expected_set & ads_conversion_names) + missing_from_ads = sorted(expected_set - ads_conversion_names) + + if missing_from_ads: + insights.append( + f"{len(missing_from_ads)} expected event(s) have no matching " + f"Google Ads conversion action: {', '.join(missing_from_ads)}. " + f"These events may fire in GA4 but are not imported as Ads conversions." + ) + if matched_in_ads: + insights.append( + f"{len(matched_in_ads)} expected event(s) match Ads conversion " + f"actions: {', '.join(matched_in_ads)}." + ) + except Exception: + insights.append( + "Could not retrieve Google Ads conversion actions — " + "check customer_id and API access." + ) + + result = { "matched": matched, "missing_from_ga4": missing_from_ga4, "unexpected_in_ga4": unexpected, @@ -145,6 +192,9 @@ def validate_tracking( "start": date_range_start, "end": date_range_end, }), } + if ads_conversion_actions: + result["ads_conversion_actions"] = ads_conversion_actions + return result # --------------------------------------------------------------------------- diff --git a/tests/test_crossref.py b/tests/test_crossref.py new file mode 100644 index 0000000..f74c175 --- /dev/null +++ b/tests/test_crossref.py @@ -0,0 +1,211 @@ +"""Tests for cross-reference tools (GA4 + Ads combined).""" + +from unittest.mock import patch + +import pytest + +from adloop.config import AdLoopConfig, AdsConfig, GA4Config, SafetyConfig +from adloop.crossref import analyze_campaign_conversions, landing_page_analysis + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + ga4=GA4Config(property_id="properties/123456"), + safety=SafetyConfig(max_daily_budget=50.0, require_dry_run=True), + ) + + +class TestAnalyzeCampaignConversions: + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.read.get_campaign_performance") + def test_returns_per_campaign_with_id(self, mock_ads, mock_ga4, config): + mock_ads.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "Campaign A", + "campaign.status": "ENABLED", + "metrics.clicks": 100, + "metrics.cost": 50.0, + "metrics.conversions": 10, + }, + { + "campaign.id": 222, + "campaign.name": "Campaign B", + "campaign.status": "ENABLED", + "metrics.clicks": 200, + "metrics.cost": 100.0, + "metrics.conversions": 5, + }, + ], + } + mock_ga4.return_value = { + "rows": [ + { + "sessionCampaignName": "Campaign A", + "sessionSource": "google", + "sessionMedium": "cpc", + "sessions": "80", + "conversions": "8", + "engagedSessions": "60", + "totalUsers": "75", + }, + { + "sessionCampaignName": "Campaign B", + "sessionSource": "google", + "sessionMedium": "cpc", + "sessions": "150", + "conversions": "3", + "engagedSessions": "120", + "totalUsers": "140", + }, + { + "sessionCampaignName": "(not set)", + "sessionSource": "organic", + "sessionMedium": "search", + "sessions": "500", + "conversions": "20", + "engagedSessions": "400", + "totalUsers": "480", + }, + ], + } + + result = analyze_campaign_conversions( + config, customer_id="1234567890", property_id="properties/123456" + ) + + assert len(result["campaigns"]) == 2 + + camp_a = result["campaigns"][0] + assert camp_a["campaign_id"] == "111" + assert camp_a["campaign_name"] == "Campaign A" + assert camp_a["ads_clicks"] == 100 + assert camp_a["ga4_paid_sessions"] == 80 + assert camp_a["ga4_paid_conversions"] == 8 + assert "conversion_discrepancy_pct" in camp_a + + camp_b = result["campaigns"][1] + assert camp_b["campaign_id"] == "222" + assert camp_b["campaign_name"] == "Campaign B" + + # Non-paid channels should be present + assert len(result["non_paid_channels"]) >= 1 + + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.read.get_campaign_performance") + def test_conversion_discrepancy_pct(self, mock_ads, mock_ga4, config): + mock_ads.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "Test", + "campaign.status": "ENABLED", + "metrics.clicks": 100, + "metrics.cost": 50.0, + "metrics.conversions": 10, + }, + ], + } + mock_ga4.return_value = { + "rows": [ + { + "sessionCampaignName": "Test", + "sessionSource": "google", + "sessionMedium": "cpc", + "sessions": "80", + "conversions": "6", + "engagedSessions": "60", + "totalUsers": "75", + }, + ], + } + + result = analyze_campaign_conversions( + config, customer_id="1234567890", property_id="properties/123456" + ) + + camp = result["campaigns"][0] + # Ads: 10, GA4: 6 -> discrepancy = |10-6|/10 * 100 = 40% + assert camp["conversion_discrepancy_pct"] == 40.0 + + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.read.get_campaign_performance") + def test_campaign_name_filter(self, mock_ads, mock_ga4, config): + mock_ads.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "Campaign A", + "campaign.status": "ENABLED", + "metrics.clicks": 100, + "metrics.cost": 50.0, + "metrics.conversions": 10, + }, + { + "campaign.id": 222, + "campaign.name": "Campaign B", + "campaign.status": "ENABLED", + "metrics.clicks": 200, + "metrics.cost": 100.0, + "metrics.conversions": 5, + }, + ], + } + mock_ga4.return_value = {"rows": []} + + result = analyze_campaign_conversions( + config, + customer_id="1234567890", + property_id="properties/123456", + campaign_name="Campaign A", + ) + + assert len(result["campaigns"]) == 1 + assert result["campaigns"][0]["campaign_name"] == "Campaign A" + + +class TestLandingPageAnalysis: + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.read.get_ad_performance") + def test_trailing_slash_matching(self, mock_ads, mock_ga4, config): + """Ad URLs with trailing slashes should match GA4 paths with trailing slashes.""" + mock_ads.return_value = { + "ads": [ + { + "ad_group_ad.ad.id": 1, + "ad_group_ad.ad.final_urls": ["https://example.com/locations/plano-tx/"], + "campaign.name": "City Search", + "ad_group.name": "Plano", + "metrics.clicks": 87, + "metrics.cost": 45.0, + }, + ], + } + mock_ga4.return_value = { + "rows": [ + { + "pagePath": "/locations/plano-tx/", + "sessionSource": "google", + "sessionMedium": "cpc", + "sessions": "88", + "conversions": "5", + "engagedSessions": "70", + "bounceRate": "0.2", + }, + ], + } + + result = landing_page_analysis( + config, customer_id="1234567890", property_id="properties/123456" + ) + + # Should merge into a single path, not create two separate entries + assert len(result["landing_pages"]) == 1 + page = result["landing_pages"][0] + assert page["total_ad_clicks"] == 87 + assert page["ga4_paid_sessions"] == 88 + # Should NOT be flagged as orphaned + assert len(result.get("orphaned_urls", [])) == 0 diff --git a/tests/test_draft_rsa_replacement.py b/tests/test_draft_rsa_replacement.py new file mode 100644 index 0000000..1072360 --- /dev/null +++ b/tests/test_draft_rsa_replacement.py @@ -0,0 +1,524 @@ +"""Tests for draft_rsa_replacement validation, plan creation, and pinning.""" + +from unittest.mock import patch + +import pytest + +from adloop.ads.write import ( + _normalize_assets, + _validate_rsa, + draft_responsive_search_ad, + draft_rsa_replacement, +) +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig +from adloop.safety.preview import get_plan, remove_plan + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + safety=SafetyConfig(max_daily_budget=50.0, require_dry_run=True), + ) + + +VALID_HEADLINES = [ + "Headline One Here", + "Headline Two Here", + "Headline Three Here", + "Headline Four Here", + "Headline Five Here", + "Headline Six Here", + "Headline Seven Here", + "Headline Eight Here", +] + +VALID_DESCRIPTIONS = [ + "This is a valid description that fits within the ninety character limit easily.", + "Second description for testing purposes, also well within the character limit.", + "Third description here for completeness and to meet the recommended minimum.", +] + +EXISTING_RSA = { + "ad_group.id": 777, + "ad_group_ad.ad.id": 12345, + "ad_group_ad.ad.type": "RESPONSIVE_SEARCH_AD", + "ad_group_ad.ad.responsive_search_ad.headlines": [ + "Old Headline One", + "Old Headline Two", + "Old Headline Three", + ], + "ad_group_ad.ad.responsive_search_ad.descriptions": [ + "Old description one that is short enough.", + "Old description two that is also fine.", + ], + "ad_group_ad.ad.final_urls": ["https://example.com/landing"], + "ad_group_ad.ad.responsive_search_ad.path1": "old", + "ad_group_ad.ad.responsive_search_ad.path2": "path", + "ad_group_ad.status": "ENABLED", +} + + +class TestDraftRsaReplacement: + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_happy_path_returns_preview_with_diff( + self, mock_fetch, mock_urls, config + ): + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + final_url="https://example.com/new", + path1="new", + path2="page", + ) + assert "plan_id" in result + assert result["operation"] == "replace_responsive_search_ad" + assert "diff" in result + assert result["diff"]["old"]["headlines"] == [ + {"text": h, "pinned_field": None} + for h in EXISTING_RSA["ad_group_ad.ad.responsive_search_ad.headlines"] + ] + assert result["diff"]["new"]["headlines"] == [ + {"text": h, "pinned_field": None} for h in VALID_HEADLINES + ] + assert result["diff"]["old_ad_action"] == "REMOVE" + + # Verify plan stored correctly + plan = get_plan(result["plan_id"]) + assert plan is not None + assert plan.operation == "replace_responsive_search_ad" + assert plan.entity_id == "12345" + assert plan.changes["ad_group_id"] == "777" + assert plan.changes["old_ad_id"] == "12345" + assert plan.requires_double_confirm # default is remove + remove_plan(result["plan_id"]) + + def test_missing_ad_id(self, config): + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + assert "error" in result + details = result.get("details", []) + error_text = result.get("error", "") + " ".join(details) + assert "ad_id" in error_text.lower() + + @patch("adloop.ads.write._fetch_existing_rsa", return_value=None) + def test_ad_not_found(self, mock_fetch, config): + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="99999", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + assert "error" in result + assert "99999" in result["error"] + + @patch("adloop.ads.write._fetch_existing_rsa") + def test_ad_already_removed(self, mock_fetch, config): + removed_rsa = {**EXISTING_RSA, "ad_group_ad.status": "REMOVED"} + mock_fetch.return_value = removed_rsa + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + assert "error" in result + assert "removed" in result["error"].lower() + + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_too_few_headlines(self, mock_fetch, mock_urls, config): + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=["H1", "H2"], # Need at least 3 + descriptions=VALID_DESCRIPTIONS, + final_url="https://example.com", + ) + assert "error" in result + + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_headline_over_30_chars(self, mock_fetch, mock_urls, config): + mock_fetch.return_value = EXISTING_RSA + long_headlines = [ + "This headline is way over the thirty character maximum allowed", + "Headline Two", + "Headline Three", + ] + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=long_headlines, + descriptions=VALID_DESCRIPTIONS, + final_url="https://example.com", + ) + assert "error" in result + + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_inherits_final_url_from_old_ad(self, mock_fetch, mock_urls, config): + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + # no final_url provided — should inherit from old ad + ) + assert "plan_id" in result + assert result["diff"]["new"]["final_url"] == "https://example.com/landing" + + remove_plan(result["plan_id"]) + + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_default_removes_old_with_double_confirm( + self, mock_fetch, mock_urls, config + ): + """Default behavior removes old ad, which requires double confirmation.""" + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + assert "plan_id" in result + assert result["diff"]["old_ad_action"] == "REMOVE" + + plan = get_plan(result["plan_id"]) + assert plan.requires_double_confirm is True + remove_plan(result["plan_id"]) + + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_keep_old_paused_no_double_confirm( + self, mock_fetch, mock_urls, config + ): + """When remove_old=False, old ad is paused (no double confirm needed).""" + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + remove_old=False, + ) + assert result["diff"]["old_ad_action"] == "PAUSE" + plan = get_plan(result["plan_id"]) + assert plan.requires_double_confirm is not True + remove_plan(result["plan_id"]) + + def test_blocked_operation(self, config): + config.safety.blocked_operations = ["replace_responsive_search_ad"] + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + assert "error" in result + config.safety.blocked_operations = [] + + @patch( + "adloop.ads.write._validate_urls", + return_value=({"https://broken.example.com": "Connection refused"}, {}), + ) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_url_validation_failure(self, mock_fetch, mock_urls, config): + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + final_url="https://broken.example.com", + ) + assert "error" in result + assert "not reachable" in result.get("details", [""])[0] + + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_old_copy_in_changes(self, mock_fetch, mock_urls, config): + """The plan changes should include old_copy for audit/diff purposes.""" + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + plan = get_plan(result["plan_id"]) + assert "old_copy" in plan.changes + assert plan.changes["old_copy"]["headlines"] == [ + {"text": h, "pinned_field": None} + for h in EXISTING_RSA["ad_group_ad.ad.responsive_search_ad.headlines"] + ] + remove_plan(result["plan_id"]) + + +class TestNormalizeAssets: + """Tests for the _normalize_assets helper.""" + + def test_plain_strings(self): + result = _normalize_assets(["Hello", "World"]) + assert result == [ + {"text": "Hello", "pinned_field": None}, + {"text": "World", "pinned_field": None}, + ] + + def test_dicts_with_pinning(self): + result = _normalize_assets([ + {"text": "Pinned One", "pinned_field": "HEADLINE_1"}, + {"text": "Unpinned"}, + ]) + assert result == [ + {"text": "Pinned One", "pinned_field": "HEADLINE_1"}, + {"text": "Unpinned", "pinned_field": None}, + ] + + def test_mixed_str_and_dict(self): + result = _normalize_assets([ + "Plain string", + {"text": "Pinned", "pinned_field": "HEADLINE_2"}, + {"text": "Dict no pin"}, + ]) + assert len(result) == 3 + assert result[0] == {"text": "Plain string", "pinned_field": None} + assert result[1] == {"text": "Pinned", "pinned_field": "HEADLINE_2"} + assert result[2] == {"text": "Dict no pin", "pinned_field": None} + + def test_empty_list(self): + assert _normalize_assets([]) == [] + + def test_non_string_text_coerced(self): + """Non-string text values (e.g. int) should be coerced to str.""" + result = _normalize_assets([ + {"text": 123, "pinned_field": "HEADLINE_1"}, + {"text": None}, + ]) + assert result[0] == {"text": "123", "pinned_field": "HEADLINE_1"} + assert result[1] == {"text": "", "pinned_field": None} + + +class TestValidateRsaPinning: + """Tests for pinning validation in _validate_rsa.""" + + def test_valid_headline_pins(self): + headlines = [ + {"text": "H1", "pinned_field": "HEADLINE_1"}, + {"text": "H2", "pinned_field": "HEADLINE_2"}, + {"text": "H3", "pinned_field": "HEADLINE_3"}, + ] + descs = [ + {"text": "D1 description that is long enough.", "pinned_field": None}, + {"text": "D2 description that is also fine.", "pinned_field": None}, + ] + errors = _validate_rsa("ag123", headlines, descs, "https://example.com") + assert errors == [] + + def test_valid_description_pins(self): + headlines = [ + {"text": "H1", "pinned_field": None}, + {"text": "H2", "pinned_field": None}, + {"text": "H3", "pinned_field": None}, + ] + descs = [ + {"text": "D1 description pinned to slot.", "pinned_field": "DESCRIPTION_1"}, + {"text": "D2 description pinned too.", "pinned_field": "DESCRIPTION_2"}, + ] + errors = _validate_rsa("ag123", headlines, descs, "https://example.com") + assert errors == [] + + def test_invalid_headline_pin_rejected(self): + headlines = [ + {"text": "H1", "pinned_field": "HEADLINE_4"}, + {"text": "H2", "pinned_field": None}, + {"text": "H3", "pinned_field": None}, + ] + descs = [ + {"text": "D1 description text here.", "pinned_field": None}, + {"text": "D2 description text here.", "pinned_field": None}, + ] + errors = _validate_rsa("ag123", headlines, descs, "https://example.com") + assert any("HEADLINE_4" in e for e in errors) + + def test_description_pin_on_headline_rejected(self): + headlines = [ + {"text": "H1", "pinned_field": "DESCRIPTION_1"}, + {"text": "H2", "pinned_field": None}, + {"text": "H3", "pinned_field": None}, + ] + descs = [ + {"text": "D1 description text here.", "pinned_field": None}, + {"text": "D2 description text here.", "pinned_field": None}, + ] + errors = _validate_rsa("ag123", headlines, descs, "https://example.com") + assert any("DESCRIPTION_1" in e for e in errors) + + def test_headline_pin_on_description_rejected(self): + headlines = [ + {"text": "H1", "pinned_field": None}, + {"text": "H2", "pinned_field": None}, + {"text": "H3", "pinned_field": None}, + ] + descs = [ + {"text": "D1 description text here.", "pinned_field": "HEADLINE_1"}, + {"text": "D2 description text here.", "pinned_field": None}, + ] + errors = _validate_rsa("ag123", headlines, descs, "https://example.com") + assert any("HEADLINE_1" in e for e in errors) + + def test_missing_text_rejected(self): + headlines = [ + {"text": "", "pinned_field": None}, + {"text": "H2", "pinned_field": None}, + {"text": "H3", "pinned_field": None}, + ] + descs = [ + {"text": "D1 description text here.", "pinned_field": None}, + {"text": "D2 description text here.", "pinned_field": None}, + ] + errors = _validate_rsa("ag123", headlines, descs, "https://example.com") + assert any("missing" in e.lower() for e in errors) + + +class TestDraftRsaWithPinning: + """Tests for pinning in draft_responsive_search_ad.""" + + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) + def test_pinned_headlines_stored_in_plan(self, mock_urls, config): + headlines = [ + {"text": "Pinned Headline", "pinned_field": "HEADLINE_1"}, + "Unpinned Headline Two", + "Unpinned Headline Three", + ] + descs = VALID_DESCRIPTIONS + result = draft_responsive_search_ad( + config, + customer_id="1234567890", + ad_group_id="ag123", + headlines=headlines, + descriptions=descs, + final_url="https://example.com", + ) + assert "plan_id" in result + plan = get_plan(result["plan_id"]) + stored = plan.changes["headlines"] + assert stored[0] == {"text": "Pinned Headline", "pinned_field": "HEADLINE_1"} + assert stored[1] == {"text": "Unpinned Headline Two", "pinned_field": None} + remove_plan(result["plan_id"]) + + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) + def test_pinned_descriptions_stored_in_plan(self, mock_urls, config): + headlines = VALID_HEADLINES + descs = [ + {"text": "Pinned desc stored in plan changes.", "pinned_field": "DESCRIPTION_1"}, + "Unpinned description number two for testing.", + ] + result = draft_responsive_search_ad( + config, + customer_id="1234567890", + ad_group_id="ag123", + headlines=headlines, + descriptions=descs, + final_url="https://example.com", + ) + assert "plan_id" in result + plan = get_plan(result["plan_id"]) + stored = plan.changes["descriptions"] + assert stored[0]["pinned_field"] == "DESCRIPTION_1" + assert stored[1]["pinned_field"] is None + remove_plan(result["plan_id"]) + + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) + def test_invalid_pin_rejected(self, mock_urls, config): + headlines = [ + {"text": "Bad Pin", "pinned_field": "HEADLINE_99"}, + "H2", + "H3", + ] + descs = VALID_DESCRIPTIONS + result = draft_responsive_search_ad( + config, + customer_id="1234567890", + ad_group_id="ag123", + headlines=headlines, + descriptions=descs, + final_url="https://example.com", + ) + assert "error" in result + assert "HEADLINE_99" in str(result["details"]) + + +class TestDraftRsaReplacementWithPinning: + """Tests for pinning in draft_rsa_replacement.""" + + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_pinned_new_headlines_in_diff(self, mock_fetch, mock_urls, config): + mock_fetch.return_value = EXISTING_RSA + headlines = [ + {"text": "Pinned Replacement", "pinned_field": "HEADLINE_1"}, + "Replacement Two", + "Replacement Three", + ] + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=headlines, + descriptions=VALID_DESCRIPTIONS, + ) + assert "plan_id" in result + new_h = result["diff"]["new"]["headlines"] + assert new_h[0] == {"text": "Pinned Replacement", "pinned_field": "HEADLINE_1"} + assert new_h[1] == {"text": "Replacement Two", "pinned_field": None} + remove_plan(result["plan_id"]) + + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_pinned_old_headlines_preserved(self, mock_fetch, mock_urls, config): + """When the existing RSA has pinned assets (returned as dicts from GAQL), + the old_copy in the diff should preserve the pinning info.""" + pinned_existing = dict(EXISTING_RSA) + pinned_existing["ad_group_ad.ad.responsive_search_ad.headlines"] = [ + {"text": "Pinned Old", "pinned_field": "HEADLINE_1"}, + "Unpinned Old Two", + "Unpinned Old Three", + ] + mock_fetch.return_value = pinned_existing + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + assert "plan_id" in result + old_h = result["diff"]["old"]["headlines"] + assert old_h[0] == {"text": "Pinned Old", "pinned_field": "HEADLINE_1"} + assert old_h[1] == {"text": "Unpinned Old Two", "pinned_field": None} + remove_plan(result["plan_id"]) diff --git a/tests/test_ga4_reports.py b/tests/test_ga4_reports.py new file mode 100644 index 0000000..d931504 --- /dev/null +++ b/tests/test_ga4_reports.py @@ -0,0 +1,95 @@ +"""Tests for GA4 report tools.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from adloop.config import AdLoopConfig, GA4Config, SafetyConfig +from adloop.ga4.reports import run_ga4_report + + +@pytest.fixture +def config(): + return AdLoopConfig( + ga4=GA4Config(property_id="properties/123456"), + safety=SafetyConfig(max_daily_budget=50.0, require_dry_run=True), + ) + + +def _mock_response(rows=None): + """Build a mock RunReportResponse.""" + response = MagicMock() + response.dimension_headers = [MagicMock(name="sessionSource")] + response.metric_headers = [MagicMock(name="sessions")] + response.row_count = len(rows or []) + response.rows = rows or [] + # Fix the .name property on headers (MagicMock name= is special) + response.dimension_headers[0].name = "sessionSource" + response.metric_headers[0].name = "sessions" + return response + + +class TestRunGa4Report: + @patch("adloop.ga4.client.get_data_client") + def test_no_filter(self, mock_client_fn, config): + mock_client = MagicMock() + mock_client.run_report.return_value = _mock_response() + mock_client_fn.return_value = mock_client + + run_ga4_report( + config, + property_id="properties/123456", + dimensions=["sessionSource"], + metrics=["sessions"], + ) + + request = mock_client.run_report.call_args[0][0] + # No dimension_filter should be set + assert not request.dimension_filter.filter.field_name + + @patch("adloop.ga4.client.get_data_client") + def test_single_filter(self, mock_client_fn, config): + mock_client = MagicMock() + mock_client.run_report.return_value = _mock_response() + mock_client_fn.return_value = mock_client + + run_ga4_report( + config, + property_id="properties/123456", + dimensions=["sessionSource"], + metrics=["sessions"], + dimension_filter={"sessionSource": "google"}, + ) + + request = mock_client.run_report.call_args[0][0] + dim_filter = request.dimension_filter + assert dim_filter.filter.field_name == "sessionSource" + assert dim_filter.filter.string_filter.value == "google" + + @patch("adloop.ga4.client.get_data_client") + def test_multiple_filters(self, mock_client_fn, config): + mock_client = MagicMock() + mock_client.run_report.return_value = _mock_response() + mock_client_fn.return_value = mock_client + + run_ga4_report( + config, + property_id="properties/123456", + dimensions=["sessionSource", "sessionMedium"], + metrics=["sessions"], + dimension_filter={"sessionSource": "google", "sessionMedium": "cpc"}, + ) + + request = mock_client.run_report.call_args[0][0] + dim_filter = request.dimension_filter + # Multiple filters should be wrapped in and_group + assert len(dim_filter.and_group.expressions) == 2 + field_names = { + expr.filter.field_name + for expr in dim_filter.and_group.expressions + } + assert field_names == {"sessionSource", "sessionMedium"} + + def test_no_dimensions_or_metrics(self, config): + result = run_ga4_report(config, property_id="properties/123456") + assert result == {"error": "At least one dimension or metric must be specified."} diff --git a/tests/test_labels.py b/tests/test_labels.py new file mode 100644 index 0000000..742c961 --- /dev/null +++ b/tests/test_labels.py @@ -0,0 +1,166 @@ +"""Tests for label list / draft / apply / unapply tools.""" + +from unittest.mock import patch + +import pytest + +from adloop.ads.labels import ( + draft_apply_label, + draft_label, + draft_unapply_label, + list_labels, +) +from adloop.config import AdLoopConfig, AdsConfig, GA4Config, SafetyConfig + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + ga4=GA4Config(property_id="properties/123456"), + safety=SafetyConfig(), + ) + + +# --------------------------------------------------------------------------- +# list_labels +# --------------------------------------------------------------------------- + + +class TestListLabels: + @patch("adloop.ads.gaql.execute_query") + def test_returns_labels(self, mock_query, config): + mock_query.return_value = [ + { + "label.id": 1, + "label.name": "Test Run", + "label.status": "ENABLED", + "label.text_label.description": "", + "label.text_label.background_color": "#FF0000", + } + ] + + result = list_labels(config, customer_id="1234567890") + + assert result["total_labels"] == 1 + assert result["labels"][0]["label.name"] == "Test Run" + + @patch("adloop.ads.gaql.execute_query") + def test_filters_removed(self, mock_query, config): + mock_query.return_value = [] + + list_labels(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "label.status != 'REMOVED'" in call_query + + +# --------------------------------------------------------------------------- +# draft_label +# --------------------------------------------------------------------------- + + +class TestDraftLabel: + def test_accepts_valid(self, config): + result = draft_label(config, customer_id="1234567890", name="Q2 Tests") + + assert "error" not in result + assert result["operation"] == "create_label" + + def test_requires_name(self, config): + result = draft_label(config, customer_id="1234567890", name="") + + assert "error" in result + details = " ".join(result["details"]) + assert "name is required" in details + + def test_validates_hex_color(self, config): + result = draft_label( + config, + customer_id="1234567890", + name="x", + background_color="not-hex", + ) + + assert "error" in result + + def test_accepts_valid_hex_color(self, config): + result = draft_label( + config, + customer_id="1234567890", + name="x", + background_color="#FF5733", + ) + + assert "error" not in result + + +# --------------------------------------------------------------------------- +# draft_apply_label +# --------------------------------------------------------------------------- + + +class TestDraftApplyLabel: + def test_accepts_campaign(self, config): + result = draft_apply_label( + config, + customer_id="1234567890", + entity_type="campaign", + entity_id="22488112473", + label_id="42", + ) + + assert "error" not in result + assert result["operation"] == "apply_label" + + def test_rejects_unknown_entity_type(self, config): + result = draft_apply_label( + config, + customer_id="1234567890", + entity_type="asset_group", + entity_id="6572147947", + label_id="42", + ) + + assert "error" in result + + def test_requires_label_id(self, config): + result = draft_apply_label( + config, + customer_id="1234567890", + entity_type="campaign", + entity_id="22488112473", + label_id="", + ) + + assert "error" in result + + +# --------------------------------------------------------------------------- +# draft_unapply_label +# --------------------------------------------------------------------------- + + +class TestDraftUnapplyLabel: + def test_accepts_keyword(self, config): + result = draft_unapply_label( + config, + customer_id="1234567890", + entity_type="keyword", + entity_id="111~222", + label_id="42", + ) + + assert "error" not in result + assert result["operation"] == "unapply_label" + + def test_rejects_unknown_entity_type(self, config): + result = draft_unapply_label( + config, + customer_id="1234567890", + entity_type="campaign_asset", + entity_id="x", + label_id="42", + ) + + assert "error" in result diff --git a/tests/test_pmax_read.py b/tests/test_pmax_read.py new file mode 100644 index 0000000..072f7ed --- /dev/null +++ b/tests/test_pmax_read.py @@ -0,0 +1,794 @@ +"""Tests for Performance Max read tools and analyze_pmax_performance cross-ref.""" + +from unittest.mock import patch + +import pytest + +from adloop.ads.pmax_read import ( + get_asset_group_assets, + get_asset_group_signals, + get_asset_group_top_combinations, + get_asset_groups, + get_pmax_campaigns, + get_pmax_channel_breakdown, + get_pmax_search_terms, +) +from adloop.config import AdLoopConfig, AdsConfig, GA4Config, SafetyConfig +from adloop.crossref import analyze_pmax_performance + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + ga4=GA4Config(property_id="properties/123456"), + safety=SafetyConfig(max_daily_budget=50.0, require_dry_run=True), + ) + + +# --------------------------------------------------------------------------- +# get_pmax_campaigns +# --------------------------------------------------------------------------- + + +class TestGetPmaxCampaigns: + @patch("adloop.ads.gaql.execute_query") + def test_filters_to_performance_max(self, mock_query, config): + mock_query.return_value = [] + + get_pmax_campaigns(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "campaign.advertising_channel_type = 'PERFORMANCE_MAX'" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_enriches_cost_budget_roas(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + "campaign.advertising_channel_type": "PERFORMANCE_MAX", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "campaign.brand_guidelines_enabled": True, + "campaign_budget.amount_micros": 25_000_000, + "metrics.impressions": 10_000, + "metrics.clicks": 200, + "metrics.cost_micros": 80_000_000, + "metrics.conversions": 8, + "metrics.conversions_value": 800.0, + "metrics.ctr": 0.02, + "metrics.average_cpc": 400_000, + } + ] + + result = get_pmax_campaigns(config, customer_id="1234567890") + + assert result["total_campaigns"] == 1 + row = result["campaigns"][0] + assert row["metrics.cost"] == 80.0 + assert row["metrics.cpa"] == 10.0 + assert row["metrics.roas"] == 10.0 # 800 / 80 + assert row["campaign_budget.amount"] == 25.0 + assert row["metrics.average_cpc_amount"] == 0.4 + + @patch("adloop.ads.gaql.execute_query") + def test_with_date_range(self, mock_query, config): + mock_query.return_value = [] + + get_pmax_campaigns( + config, + customer_id="1234567890", + date_range_start="2026-04-01", + date_range_end="2026-04-30", + ) + + call_query = mock_query.call_args[0][2] + assert "BETWEEN '2026-04-01' AND '2026-04-30'" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_default_date_uses_last_30_days(self, mock_query, config): + mock_query.return_value = [] + + get_pmax_campaigns(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "DURING LAST_30_DAYS" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_pmax_campaigns(config, customer_id="1234567890") + + assert result["campaigns"] == [] + assert result["total_campaigns"] == 0 + + +# --------------------------------------------------------------------------- +# get_pmax_channel_breakdown +# --------------------------------------------------------------------------- + + +class TestGetPmaxChannelBreakdown: + @patch("adloop.ads.gaql.execute_query") + def test_returns_per_channel(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "segments.ad_network_type": "SEARCH", + "metrics.impressions": 1000, + "metrics.clicks": 50, + "metrics.cost_micros": 30_000_000, + "metrics.conversions": 3, + "metrics.conversions_value": 300.0, + }, + { + "campaign.id": 111, + "campaign.name": "PMax A", + "segments.ad_network_type": "YOUTUBE_WATCH", + "metrics.impressions": 5000, + "metrics.clicks": 20, + "metrics.cost_micros": 10_000_000, + "metrics.conversions": 1, + "metrics.conversions_value": 100.0, + }, + ] + + result = get_pmax_channel_breakdown(config, customer_id="1234567890") + + assert result["total_rows"] == 2 + first = result["channel_breakdown"][0] + assert first["metrics.cost"] == 30.0 + assert first["metrics.roas"] == 10.0 # 300 / 30 + + @patch("adloop.ads.gaql.execute_query") + def test_warns_about_pre_june_2025(self, mock_query, config): + mock_query.return_value = [] + + result = get_pmax_channel_breakdown( + config, + customer_id="1234567890", + date_range_start="2025-04-01", + date_range_end="2025-04-30", + ) + + assert any("2025-06-01" in i for i in result["insights"]) + + @patch("adloop.ads.gaql.execute_query") + def test_warns_when_mixed_present(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "segments.ad_network_type": "MIXED", + "metrics.impressions": 100, + "metrics.clicks": 5, + "metrics.cost_micros": 1_000_000, + "metrics.conversions": 0, + } + ] + + result = get_pmax_channel_breakdown(config, customer_id="1234567890") + + assert any("MIXED" in i for i in result["insights"]) + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_pmax_channel_breakdown( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + def test_invalid_campaign_id_raises(self, config): + with pytest.raises(ValueError, match="must be numeric"): + get_pmax_channel_breakdown( + config, customer_id="1234567890", campaign_id="DROP TABLE" + ) + + +# --------------------------------------------------------------------------- +# get_asset_groups +# --------------------------------------------------------------------------- + + +class TestGetAssetGroups: + @patch("adloop.ads.gaql.execute_query") + def test_returns_ad_strength_and_metrics(self, mock_query, config): + mock_query.return_value = [ + { + "asset_group.id": 555, + "asset_group.name": "Group 1", + "asset_group.status": "ENABLED", + "asset_group.final_urls": ["https://example.com/a"], + "asset_group.path1": "products", + "asset_group.path2": "shoes", + "asset_group.ad_strength": "GOOD", + "campaign.id": 111, + "campaign.name": "PMax A", + "metrics.impressions": 5000, + "metrics.clicks": 100, + "metrics.cost_micros": 40_000_000, + "metrics.conversions": 4, + "metrics.conversions_value": 400.0, + } + ] + + result = get_asset_groups(config, customer_id="1234567890") + + assert result["total_asset_groups"] == 1 + row = result["asset_groups"][0] + assert row["asset_group.ad_strength"] == "GOOD" + assert row["metrics.cost"] == 40.0 + assert row["metrics.roas"] == 10.0 + assert row["metrics.cpa"] == 10.0 + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_asset_groups( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_filters_to_performance_max(self, mock_query, config): + mock_query.return_value = [] + + get_asset_groups(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "campaign.advertising_channel_type = 'PERFORMANCE_MAX'" in call_query + + +# --------------------------------------------------------------------------- +# get_asset_group_assets +# --------------------------------------------------------------------------- + + +class TestGetAssetGroupAssets: + @patch("adloop.ads.gaql.execute_query") + def test_builds_youtube_url(self, mock_query, config): + mock_query.return_value = [ + { + "asset_group.id": 555, + "asset_group.name": "Group 1", + "asset_group_asset.field_type": "YOUTUBE_VIDEO", + "asset_group_asset.status": "ENABLED", + "asset_group_asset.policy_summary.review_status": "REVIEWED", + "asset.id": 999, + "asset.type": "YOUTUBE_VIDEO", + "asset.text_asset.text": None, + "asset.image_asset.full_size.url": None, + "asset.youtube_video_asset.youtube_video_id": "dQw4w9WgXcQ", + "asset.youtube_video_asset.youtube_video_title": "Sample", + "campaign.id": 111, + "campaign.name": "PMax A", + } + ] + + result = get_asset_group_assets(config, customer_id="1234567890") + + row = result["assets"][0] + assert ( + row["asset.youtube_video_asset.youtube_url"] + == "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + ) + + @patch("adloop.ads.gaql.execute_query") + def test_does_not_select_dropped_v24_fields(self, mock_query, config): + """Verify the query no longer references fields removed in API v24.""" + mock_query.return_value = [] + + get_asset_group_assets(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "performance_label" not in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_asset_group_id_filter(self, mock_query, config): + mock_query.return_value = [] + + get_asset_group_assets( + config, customer_id="1234567890", asset_group_id="777" + ) + + call_query = mock_query.call_args[0][2] + assert "asset_group.id = 777" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_id_filter(self, mock_query, config): + mock_query.return_value = [] + + get_asset_group_assets( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + def test_invalid_asset_group_id_raises(self, config): + with pytest.raises(ValueError, match="must be numeric"): + get_asset_group_assets( + config, customer_id="1234567890", asset_group_id="abc" + ) + + +# --------------------------------------------------------------------------- +# get_asset_group_signals +# --------------------------------------------------------------------------- + + +class TestGetAssetGroupSignals: + @patch("adloop.ads.gaql.execute_query") + def test_classifies_search_theme_vs_audience(self, mock_query, config): + mock_query.return_value = [ + { + "asset_group.id": 555, + "asset_group.name": "Group 1", + "asset_group_signal.resource_name": "x/1", + "asset_group_signal.audience.audience": None, + "asset_group_signal.search_theme.text": "buy running shoes", + "campaign.id": 111, + "campaign.name": "PMax A", + }, + { + "asset_group.id": 555, + "asset_group.name": "Group 1", + "asset_group_signal.resource_name": "x/2", + "asset_group_signal.audience.audience": "customers/1/audiences/abc", + "asset_group_signal.search_theme.text": None, + "campaign.id": 111, + "campaign.name": "PMax A", + }, + ] + + result = get_asset_group_signals(config, customer_id="1234567890") + + signals = result["signals"] + assert signals[0]["signal_type"] == "SEARCH_THEME" + assert signals[1]["signal_type"] == "AUDIENCE" + + @patch("adloop.ads.gaql.execute_query") + def test_empty_signals(self, mock_query, config): + mock_query.return_value = [] + + result = get_asset_group_signals(config, customer_id="1234567890") + + assert result["total_signals"] == 0 + + +# --------------------------------------------------------------------------- +# get_asset_group_top_combinations +# --------------------------------------------------------------------------- + + +class TestGetAssetGroupTopCombinations: + @patch("adloop.ads.gaql.execute_query") + def test_basic_query(self, mock_query, config): + mock_query.return_value = [ + { + "asset_group.id": 555, + "asset_group.name": "Group 1", + "asset_group_top_combination_view.asset_group_top_combinations": "...", + "campaign.id": 111, + "campaign.name": "PMax A", + } + ] + + result = get_asset_group_top_combinations( + config, customer_id="1234567890", asset_group_id="555" + ) + + assert result["total_rows"] == 1 + + @patch("adloop.ads.gaql.execute_query") + def test_query_excludes_metrics(self, mock_query, config): + """asset_group_top_combination_view does not expose metrics.* in v24.""" + mock_query.return_value = [] + + get_asset_group_top_combinations(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "metrics." not in call_query + # And cannot ORDER BY a metric we don't select. + assert "ORDER BY metrics" not in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_includes_limit(self, mock_query, config): + mock_query.return_value = [] + + get_asset_group_top_combinations(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "LIMIT 50" in call_query + + +# --------------------------------------------------------------------------- +# get_pmax_search_terms +# --------------------------------------------------------------------------- + + +class TestGetPmaxSearchTerms: + def test_requires_campaign_id(self, config): + result = get_pmax_search_terms(config, customer_id="1234567890") + + assert "error" in result + assert "campaign_id" in result["error"] + + @patch("adloop.ads.gaql.execute_query") + def test_returns_categories(self, mock_query, config): + mock_query.return_value = [ + { + "campaign_search_term_insight.id": "abc/123", + "campaign_search_term_insight.category_label": "buy running shoes", + "metrics.impressions": 500, + "metrics.clicks": 30, + } + ] + + result = get_pmax_search_terms( + config, customer_id="1234567890", campaign_id="111" + ) + + assert result["total_rows"] == 1 + row = result["search_term_categories"][0] + assert row["metrics.impressions"] == 500 + assert row["metrics.clicks"] == 30 + # Per Google Ads API v24, cost/conversion metrics are not selectable + # on campaign_search_term_insight. The tool surfaces a `note` field + # so callers know cost is not available. + assert "note" in result + + @patch("adloop.ads.gaql.execute_query") + def test_query_excludes_prohibited_metrics(self, mock_query, config): + """The API rejects cost_micros/conversions on campaign_search_term_insight.""" + mock_query.return_value = [] + + get_pmax_search_terms( + config, customer_id="1234567890", campaign_id="111" + ) + + call_query = mock_query.call_args[0][2] + assert "cost_micros" not in call_query + assert "conversions" not in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_handles_unsupported_api_version(self, mock_query, config): + mock_query.side_effect = Exception( + "UNRECOGNIZED_FIELD: campaign_search_term_insight" + ) + + result = get_pmax_search_terms( + config, customer_id="1234567890", campaign_id="111" + ) + + assert "error" in result + assert "v23.2" in result["hint"] + + def test_invalid_campaign_id_raises(self, config): + with pytest.raises(ValueError, match="must be numeric"): + get_pmax_search_terms( + config, customer_id="1234567890", campaign_id="DROP TABLE" + ) + + +# --------------------------------------------------------------------------- +# analyze_pmax_performance (cross-ref) +# --------------------------------------------------------------------------- + + +class TestAnalyzePmaxPerformance: + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_aggregates_full_diagnostic( + self, mock_camps, mock_groups, mock_assets, mock_channels, mock_ga4, config + ): + mock_camps.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "campaign.brand_guidelines_enabled": True, + "campaign_budget.amount": 25.0, + "metrics.clicks": 200, + "metrics.cost": 80.0, + "metrics.conversions": 8, + "metrics.conversions_value": 800.0, + "metrics.cpa": 10.0, + "metrics.roas": 10.0, + } + ] + } + mock_groups.return_value = { + "asset_groups": [ + { + "asset_group.id": 555, + "asset_group.name": "Group 1", + "asset_group.ad_strength": "POOR", + "campaign.id": 111, + "metrics.cost": 50.0, + "metrics.clicks": 100, + "metrics.conversions": 3, + } + ] + } + mock_assets.return_value = { + "assets": [ + { + "asset.id": 999, + "asset_group.id": 555, + "asset_group_asset.field_type": "HEADLINE", + "asset.text_asset.text": "Headline 1", + "asset.image_asset.full_size.url": None, + }, + { + "asset.id": 1000, + "asset_group.id": 555, + "asset_group_asset.field_type": "HEADLINE", + "asset.text_asset.text": "Headline 2", + "asset.image_asset.full_size.url": None, + }, + ] + } + mock_channels.return_value = { + "channel_breakdown": [ + { + "campaign.id": 111, + "segments.ad_network_type": "YOUTUBE_WATCH", + "metrics.cost": 75.0, + "metrics.clicks": 150, + "metrics.conversions": 6, + }, + { + "campaign.id": 111, + "segments.ad_network_type": "SEARCH", + "metrics.cost": 5.0, + "metrics.clicks": 50, + "metrics.conversions": 2, + }, + ], + "insights": [], + } + mock_ga4.return_value = { + "rows": [ + { + "sessionCampaignName": "PMax A", + "sessionSource": "google", + "sessionMedium": "cpc", + "sessions": "60", + "conversions": "5", + "engagedSessions": "45", + } + ] + } + + result = analyze_pmax_performance( + config, + customer_id="1234567890", + property_id="properties/123456", + ) + + assert result["total_campaigns"] == 1 + camp = result["campaigns"][0] + assert camp["campaign_id"] == "111" + assert camp["weak_asset_groups"] == 1 + assert len(camp["asset_groups"]) == 1 + ag = camp["asset_groups"][0] + assert ag["ad_strength"] == "POOR" + assert ag["asset_counts_by_type"]["HEADLINE"] == 2 + # Asset group has only HEADLINEs — every other required type is missing. + # missing_asset_minimums lists each one with current vs needed counts. + assert len(ag["missing_asset_minimums"]) >= 5 + # Channel skew check: YouTube is ~94% of spend (75/80) + skew_insights = [i for i in result["insights"] if "skewed" in i] + assert len(skew_insights) == 1 + # POOR ad strength insight + ad_strength_insights = [i for i in result["insights"] if "ad strength is POOR" in i] + assert len(ad_strength_insights) == 1 + # Missing-asset-minimums insight (replaces the old LOW-performing-asset check) + minimums_insights = [ + i for i in result["insights"] if "below minimums" in i + ] + assert len(minimums_insights) == 1 + # GA4 paid attached + assert camp["ga4_paid"]["sessions"] == 60 + assert camp["ga4_paid"]["conversions"] == 5 + + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_works_without_ga4_property( + self, mock_camps, mock_groups, mock_assets, mock_channels, config + ): + mock_camps.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "metrics.clicks": 100, + "metrics.cost": 40.0, + "metrics.conversions": 4, + "metrics.conversions_value": 400.0, + } + ] + } + mock_groups.return_value = {"asset_groups": []} + mock_assets.return_value = {"assets": []} + mock_channels.return_value = {"channel_breakdown": [], "insights": []} + + result = analyze_pmax_performance( + config, customer_id="1234567890", property_id="" + ) + + assert result["campaigns"][0]["ga4_paid"] is None + + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_zero_conversion_warning( + self, mock_camps, mock_groups, mock_assets, mock_channels, config + ): + mock_camps.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "metrics.clicks": 50, + "metrics.cost": 100.0, + "metrics.conversions": 0, + "metrics.conversions_value": 0, + } + ] + } + mock_groups.return_value = {"asset_groups": []} + mock_assets.return_value = {"assets": []} + mock_channels.return_value = {"channel_breakdown": [], "insights": []} + + result = analyze_pmax_performance(config, customer_id="1234567890") + + zero_conv = [i for i in result["insights"] if "0 conversions" in i] + assert len(zero_conv) == 1 + + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_campaign_id_filter_returns_error_when_missing( + self, mock_camps, mock_groups, mock_assets, mock_channels, config + ): + mock_camps.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + } + ] + } + mock_groups.return_value = {"asset_groups": []} + mock_assets.return_value = {"assets": []} + mock_channels.return_value = {"channel_breakdown": [], "insights": []} + + result = analyze_pmax_performance( + config, customer_id="1234567890", campaign_id="999" + ) + + assert "error" in result + assert "999" in result["error"] + + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_ga4_paid_is_none_when_ga4_fails( + self, mock_camps, mock_groups, mock_assets, mock_channels, mock_ga4, config + ): + """When GA4 returns an error, ga4_paid must be None — not zeros that + look like real data. Otherwise consumers can't distinguish 'GA4 + unavailable' from 'campaign actually has zero paid sessions'.""" + mock_camps.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "metrics.clicks": 100, + "metrics.cost": 40.0, + "metrics.conversions": 4, + "metrics.conversions_value": 400.0, + } + ] + } + mock_groups.return_value = {"asset_groups": []} + mock_assets.return_value = {"assets": []} + mock_channels.return_value = {"channel_breakdown": [], "insights": []} + mock_ga4.return_value = {"error": "GA4 property not configured"} + + result = analyze_pmax_performance( + config, + customer_id="1234567890", + property_id="properties/123456", + ) + + # ga4_paid must be None, NOT a zero-filled dict + assert result["campaigns"][0]["ga4_paid"] is None + # The warning should be in insights so the user knows why + ga4_warnings = [i for i in result["insights"] if "GA4" in i] + assert len(ga4_warnings) >= 1 + + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_ga4_paid_is_none_when_ga4_raises( + self, mock_camps, mock_groups, mock_assets, mock_channels, mock_ga4, config + ): + """Same guarantee when GA4 raises rather than returning an error dict.""" + mock_camps.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "metrics.clicks": 100, + "metrics.cost": 40.0, + "metrics.conversions": 4, + "metrics.conversions_value": 400.0, + } + ] + } + mock_groups.return_value = {"asset_groups": []} + mock_assets.return_value = {"assets": []} + mock_channels.return_value = {"channel_breakdown": [], "insights": []} + mock_ga4.side_effect = RuntimeError("network down") + + result = analyze_pmax_performance( + config, + customer_id="1234567890", + property_id="properties/123456", + ) + + assert result["campaigns"][0]["ga4_paid"] is None + + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_no_pmax_campaigns_in_account( + self, mock_camps, mock_groups, mock_assets, mock_channels, config + ): + mock_camps.return_value = {"campaigns": []} + mock_groups.return_value = {"asset_groups": []} + mock_assets.return_value = {"assets": []} + mock_channels.return_value = {"channel_breakdown": [], "insights": []} + + result = analyze_pmax_performance(config, customer_id="1234567890") + + assert result["total_campaigns"] == 0 + assert any( + "No Performance Max campaigns found" in i for i in result["insights"] + ) diff --git a/tests/test_pmax_write.py b/tests/test_pmax_write.py new file mode 100644 index 0000000..8f2238e --- /dev/null +++ b/tests/test_pmax_write.py @@ -0,0 +1,666 @@ +"""Tests for Performance Max draft tools — input validation behavior. + +These tests cover validation paths (no Google Ads API calls). The actual +mutate roundtrip is exercised at runtime via confirm_and_apply with +validate_only=True. +""" + +import pytest + +from adloop.ads.pmax_write import ( + PMAX_OPERATIONS, + draft_asset_group, + draft_asset_group_assets, + draft_asset_group_signal, + draft_image_asset, + draft_pmax_campaign, +) +from adloop.ads.write import draft_campaign +from adloop.config import AdLoopConfig, AdsConfig, GA4Config, SafetyConfig + +# Minimal valid PNG (1x1, transparent). Used in upload validation tests. +_TINY_PNG_BYTES = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfa\xcf" + b"\x00\x00\x00\x02\x00\x01\xe5'\xde\xfc\x00\x00\x00\x00IEND\xaeB`\x82" +) + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + ga4=GA4Config(property_id="properties/123456"), + safety=SafetyConfig(max_daily_budget=100.0), + ) + + +def _valid_asset_group(): + return { + "name": "Group A", + "final_urls": ["https://example.com/"], + "path1": "products", + "path2": "shoes", + "headlines": [ + "Buy running shoes", + "Free shipping today", + "Top brands on sale", + ], + "long_headlines": ["Find the perfect running shoes for your stride"], + "descriptions": [ + "Browse 100+ models from top brands.", + "Free returns within 30 days.", + ], + "business_name": "Acme Sports", + "marketing_image_assets": ["customers/1234567890/assets/1001"], + "square_marketing_image_assets": ["customers/1234567890/assets/1002"], + "logo_assets": ["customers/1234567890/assets/1003"], + } + + +# --------------------------------------------------------------------------- +# draft_campaign should reject channel_type=PERFORMANCE_MAX +# --------------------------------------------------------------------------- + + +class TestDraftCampaignRejectsPMax: + def test_rejects_performance_max_channel(self, config): + result = draft_campaign( + config, + customer_id="1234567890", + campaign_name="Test", + daily_budget=10.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + channel_type="PERFORMANCE_MAX", + geo_target_ids=["2840"], + language_ids=["1000"], + ) + + assert "error" in result + assert "draft_pmax_campaign" in result["error"] + + +# --------------------------------------------------------------------------- +# draft_pmax_campaign +# --------------------------------------------------------------------------- + + +class TestDraftPmaxCampaign: + def test_accepts_valid_campaign(self, config): + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=_valid_asset_group(), + ) + + assert "error" not in result + assert result["status"] == "PENDING_CONFIRMATION" + assert result["operation"] == "create_pmax_campaign" + assert result["plan_id"] + + def test_rejects_manual_cpc(self, config): + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MANUAL_CPC", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=_valid_asset_group(), + ) + + assert "error" in result + details = " ".join(result["details"]) + # The validator names the actual rejected value and points at the + # allowed Smart Bidding strategies. + assert "MANUAL_CPC" in details + assert "MAXIMIZE_CONVERSIONS" in details + + def test_rejects_target_spend(self, config): + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="TARGET_SPEND", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=_valid_asset_group(), + ) + + assert "error" in result + + def test_requires_asset_group(self, config): + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=None, + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "asset_group is required" in details + + def test_requires_geo_targets(self, config): + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=[], + language_ids=["1000"], + asset_group=_valid_asset_group(), + ) + + assert "error" in result + assert any("geo_target_ids" in d for d in result["details"]) + + def test_rejects_budget_above_cap(self, config): + # Cap is 100 in fixture; 200 exceeds it + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=200.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=_valid_asset_group(), + ) + + assert "error" in result + + def test_validates_text_char_limits(self, config): + bad = _valid_asset_group() + bad["headlines"] = [ + "This headline is way more than thirty characters long for sure", + "Short", + "Also short", + ] + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=bad, + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "30 chars" in details or "exceeds" in details + + def test_enforces_min_headlines(self, config): + bad = _valid_asset_group() + bad["headlines"] = ["only one"] + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=bad, + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "HEADLINE" in details + + def test_requires_image_resource_names_not_urls(self, config): + bad = _valid_asset_group() + bad["marketing_image_assets"] = ["https://example.com/img.png"] + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=bad, + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "resource_names" in details or "customers/" in details + + def test_requires_business_name_when_omitted(self, config): + # Regression: omitting business_name silently skipped the minimum + # check, so the asset group passed draft validation and only failed + # at apply-time API validation. Now caught at draft. + bad = _valid_asset_group() + bad["business_name"] = "" + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=bad, + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "BUSINESS_NAME" in details + + def test_brand_guidelines_defaults_to_true(self, config): + # New PMax campaigns default to brand_guidelines_enabled=True on + # Google's side. The tool defaults to True too so the apply doesn't + # hit REQUIRED_BUSINESS_NAME_ASSET_NOT_LINKED on fresh accounts. + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=_valid_asset_group(), + ) + assert "error" not in result + assert result["changes"]["brand_guidelines_enabled"] is True + + def test_brand_guidelines_opt_out(self, config): + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=_valid_asset_group(), + brand_guidelines_enabled=False, + ) + assert "error" not in result + assert result["changes"]["brand_guidelines_enabled"] is False + + def test_rejects_too_many_marketing_images(self, config): + # Regression: image lists were checked against minimums but not + # maximums. ASSET_MAXIMUMS["MARKETING_IMAGE"] = 20. + bad = _valid_asset_group() + bad["marketing_image_assets"] = [ + f"customers/1234567890/assets/{i}" for i in range(1, 22) + ] + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=bad, + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "at most 20" in details + assert "MARKETING_IMAGE" in details + + +# --------------------------------------------------------------------------- +# draft_asset_group +# --------------------------------------------------------------------------- + + +class TestDraftAssetGroup: + def test_accepts_valid(self, config): + result = draft_asset_group( + config, + customer_id="1234567890", + campaign_id="22488112473", + asset_group=_valid_asset_group(), + ) + + assert "error" not in result + assert result["operation"] == "create_asset_group" + + def test_requires_campaign_id(self, config): + result = draft_asset_group( + config, + customer_id="1234567890", + campaign_id="", + asset_group=_valid_asset_group(), + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "campaign_id" in details + + def test_requires_asset_group(self, config): + result = draft_asset_group( + config, + customer_id="1234567890", + campaign_id="22488112473", + asset_group=None, + ) + + assert "error" in result + + +# --------------------------------------------------------------------------- +# draft_asset_group_assets +# --------------------------------------------------------------------------- + + +class TestDraftAssetGroupAssets: + def test_accepts_text_only(self, config): + result = draft_asset_group_assets( + config, + customer_id="1234567890", + asset_group_id="6572147947", + headlines=["New headline 1", "New headline 2"], + ) + + assert "error" not in result + assert result["operation"] == "create_asset_group_assets" + + def test_requires_at_least_one_asset(self, config): + result = draft_asset_group_assets( + config, + customer_id="1234567890", + asset_group_id="6572147947", + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "At least one asset" in details + + def test_validates_headline_length(self, config): + result = draft_asset_group_assets( + config, + customer_id="1234567890", + asset_group_id="6572147947", + headlines=["a" * 31], + ) + + assert "error" in result + + def test_image_resource_names_must_be_resource_format(self, config): + result = draft_asset_group_assets( + config, + customer_id="1234567890", + asset_group_id="6572147947", + marketing_image_assets=["not-a-resource-name"], + ) + + assert "error" in result + + +# --------------------------------------------------------------------------- +# draft_asset_group_signal +# --------------------------------------------------------------------------- + + +class TestDraftAssetGroupSignal: + def test_accepts_search_theme(self, config): + result = draft_asset_group_signal( + config, + customer_id="1234567890", + asset_group_id="6572147947", + search_theme="buy women's running shoes", + ) + + assert "error" not in result + assert result["operation"] == "create_asset_group_signal" + + def test_accepts_audience(self, config): + result = draft_asset_group_signal( + config, + customer_id="1234567890", + asset_group_id="6572147947", + audience_resource_name="customers/1234567890/audiences/abc", + ) + + assert "error" not in result + + def test_rejects_both_signal_types_at_once(self, config): + result = draft_asset_group_signal( + config, + customer_id="1234567890", + asset_group_id="6572147947", + search_theme="buy shoes", + audience_resource_name="customers/1234567890/audiences/abc", + ) + + assert "error" in result + + def test_rejects_missing_signal_content(self, config): + result = draft_asset_group_signal( + config, + customer_id="1234567890", + asset_group_id="6572147947", + ) + + assert "error" in result + + def test_rejects_bad_audience_format(self, config): + result = draft_asset_group_signal( + config, + customer_id="1234567890", + asset_group_id="6572147947", + audience_resource_name="not-a-resource-name", + ) + + assert "error" in result + + +# --------------------------------------------------------------------------- +# draft_image_asset +# --------------------------------------------------------------------------- + + +class TestDraftImageAsset: + def test_accepts_valid_png(self, config, tmp_path): + png_path = tmp_path / "logo.png" + png_path.write_bytes(_TINY_PNG_BYTES) + + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": str(png_path), "name": "Acme Logo"}], + ) + + assert "error" not in result + assert result["operation"] == "upload_image_asset" + assert result["plan_id"] + # The validated metadata is what _apply_upload_image_asset reads. + images = result["changes"]["images"] + assert len(images) == 1 + assert images[0]["name"] == "Acme Logo" + assert images[0]["mime_type"] == "IMAGE_PNG" + assert images[0]["file_size"] == len(_TINY_PNG_BYTES) + assert len(images[0]["sha256"]) == 64 # SHA-256 hex digest + + def test_apply_rejects_same_size_content_swap(self, config, tmp_path): + # Codex review: validate against same-size content substitution + # between draft and confirm_and_apply. + from adloop.ads.pmax_write import _apply_upload_image_asset + + png_path = tmp_path / "logo.png" + png_path.write_bytes(_TINY_PNG_BYTES) + plan = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": str(png_path), "name": "Acme Logo"}], + ) + assert "error" not in plan + + # Replace bytes in-place with a same-size GIF payload — bypasses the + # file_size check, but the hash differs. + same_size_swap = b"GIF89a" + b"X" * (len(_TINY_PNG_BYTES) - 6) + assert len(same_size_swap) == len(_TINY_PNG_BYTES) + png_path.write_bytes(same_size_swap) + + with pytest.raises(ValueError, match="sha256 mismatch"): + _apply_upload_image_asset( + client=object(), + cid="1234567890", + changes=plan["changes"], + ) + + def test_accepts_batch(self, config, tmp_path): + a = tmp_path / "a.png" + b = tmp_path / "b.png" + a.write_bytes(_TINY_PNG_BYTES) + b.write_bytes(_TINY_PNG_BYTES) + + result = draft_image_asset( + config, + customer_id="1234567890", + images=[ + {"file_path": str(a), "name": "Image A"}, + {"file_path": str(b), "name": "Image B"}, + ], + ) + + assert "error" not in result + assert len(result["changes"]["images"]) == 2 + + def test_requires_images_list(self, config): + result = draft_image_asset(config, customer_id="1234567890", images=[]) + assert "error" in result + assert any("images" in d for d in result["details"]) + + def test_rejects_missing_file_path(self, config): + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"name": "no path"}], + ) + assert "error" in result + assert any("file_path" in d for d in result["details"]) + + def test_rejects_missing_name(self, config, tmp_path): + png_path = tmp_path / "logo.png" + png_path.write_bytes(_TINY_PNG_BYTES) + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": str(png_path)}], + ) + assert "error" in result + assert any("name" in d for d in result["details"]) + + def test_rejects_relative_path(self, config): + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": "rel/path.png", "name": "x"}], + ) + assert "error" in result + assert any("absolute" in d for d in result["details"]) + + def test_rejects_nonexistent_file(self, config, tmp_path): + result = draft_image_asset( + config, + customer_id="1234567890", + images=[ + {"file_path": str(tmp_path / "nope.png"), "name": "x"}, + ], + ) + assert "error" in result + assert any("does not exist" in d for d in result["details"]) + + def test_rejects_unsupported_extension(self, config, tmp_path): + webp = tmp_path / "bad.webp" + webp.write_bytes(b"\x00" * 100) + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": str(webp), "name": "x"}], + ) + assert "error" in result + assert any("unsupported extension" in d for d in result["details"]) + + def test_rejects_extension_content_mismatch(self, config, tmp_path): + # File claims .png but bytes are not a PNG. + fake = tmp_path / "fake.png" + fake.write_bytes(b"this is plain text, not an image") + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": str(fake), "name": "x"}], + ) + assert "error" in result + assert any("magic bytes" in d for d in result["details"]) + + def test_rejects_oversized_file(self, config, tmp_path, monkeypatch): + # Patch the cap so we don't have to materialise a 5 MB file. + from adloop.ads import pmax_write + + monkeypatch.setattr(pmax_write, "_IMAGE_MAX_BYTES", 64) + png_path = tmp_path / "logo.png" + png_path.write_bytes(_TINY_PNG_BYTES) # 67 bytes > 64-byte test cap + + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": str(png_path), "name": "Too big"}], + ) + assert "error" in result + assert any("5 MB" in d for d in result["details"]) + + def test_dispatch_table_registers_upload(self): + # confirm_and_apply finds the handler via PMAX_OPERATIONS. + assert "upload_image_asset" in PMAX_OPERATIONS + + +# --------------------------------------------------------------------------- +# remove_entity — asset_group_signal support +# --------------------------------------------------------------------------- + + +class TestRemoveAssetGroupSignal: + def test_accepts_composite_id(self, config): + from adloop.ads.write import remove_entity + + result = remove_entity( + config, + customer_id="1234567890", + entity_type="asset_group_signal", + entity_id="6590423305~2480811934780", + ) + + assert "error" not in result + assert result["operation"] == "remove_entity" + assert result["entity_type"] == "asset_group_signal" + + def test_rejects_bare_id_without_tilde(self, config): + # The dispatch path checks for the composite shape; the draft accepts + # the bare id but the API would reject it. We catch the format at + # apply time, but the draft itself surfaces a useful error only when + # apply runs. The validation here is the allowlist gate: the + # entity_type "asset_group_signal" must be accepted by remove_entity. + from adloop.ads.write import remove_entity + + result = remove_entity( + config, + customer_id="1234567890", + entity_type="asset_group_signal", + entity_id="bare-id", + ) + + # Allowlist passes; the format check is at apply time. The draft + # still returns a plan — the composite-format failure surfaces + # during confirm_and_apply. + assert result.get("operation") == "remove_entity" diff --git a/tests/test_read_tools.py b/tests/test_read_tools.py new file mode 100644 index 0000000..ae2f671 --- /dev/null +++ b/tests/test_read_tools.py @@ -0,0 +1,785 @@ +"""Tests for Google Ads read and insights tools.""" + +from unittest.mock import patch + +import pytest + +from adloop.ads.read import ( + get_ad_schedule_performance, + get_auction_insights, + get_bid_strategy_status, + get_budget_pacing, + get_change_history, + get_device_performance, + get_impression_share, + get_keyword_performance, + get_location_performance, + get_quality_score_details, + get_search_terms, +) +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + safety=SafetyConfig(max_daily_budget=50.0, require_dry_run=True), + ) + + +# --------------------------------------------------------------------------- +# get_impression_share +# --------------------------------------------------------------------------- + + +class TestGetImpressionShare: + @patch("adloop.ads.gaql.execute_query") + def test_campaign_level(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.id": 111, + "campaign.name": "Test Campaign", + "campaign.status": "ENABLED", + "metrics.impressions": 1000, + "metrics.clicks": 100, + "metrics.cost_micros": 50_000_000, + "metrics.search_impression_share": 0.45, + "metrics.search_budget_lost_impression_share": 0.20, + "metrics.search_rank_lost_impression_share": 0.35, + "metrics.search_exact_match_impression_share": 0.60, + "metrics.search_top_impression_share": 0.30, + "metrics.search_absolute_top_impression_share": 0.10, + } + ] + + result = get_impression_share(config, customer_id="1234567890") + + assert "impression_share" in result + assert result["total_rows"] == 1 + assert result["level"] == "campaign" + row = result["impression_share"][0] + assert row["metrics.cost"] == 50.0 + assert row["metrics.search_impression_share_pct"] == "45.0%" + assert row["metrics.search_budget_lost_impression_share_pct"] == "20.0%" + assert row["metrics.search_rank_lost_impression_share_pct"] == "35.0%" + + @patch("adloop.ads.gaql.execute_query") + def test_ad_group_level(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.name": "Camp", + "ad_group.id": 222, + "ad_group.name": "AG1", + "metrics.impressions": 500, + "metrics.clicks": 50, + "metrics.cost_micros": 10_000_000, + "metrics.search_impression_share": 0.80, + "metrics.search_budget_lost_impression_share": 0.05, + "metrics.search_rank_lost_impression_share": 0.15, + "metrics.search_exact_match_impression_share": 0.90, + "metrics.search_top_impression_share": 0.50, + "metrics.search_absolute_top_impression_share": 0.25, + } + ] + + result = get_impression_share( + config, customer_id="1234567890", level="ad_group" + ) + + assert result["level"] == "ad_group" + assert result["total_rows"] == 1 + # Verify ad_group query was built + call_query = mock_query.call_args[0][2] + assert "FROM ad_group" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_keyword_level(self, mock_query, config): + mock_query.return_value = [] + + result = get_impression_share( + config, customer_id="1234567890", level="keyword" + ) + + assert result["level"] == "keyword" + assert result["total_rows"] == 0 + call_query = mock_query.call_args[0][2] + assert "FROM keyword_view" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_impression_share(config, customer_id="1234567890") + + assert result["impression_share"] == [] + assert result["total_rows"] == 0 + + +# --------------------------------------------------------------------------- +# get_change_history +# --------------------------------------------------------------------------- + + +class TestGetChangeHistory: + @patch("adloop.ads.gaql.execute_query") + def test_default_query(self, mock_query, config): + mock_query.return_value = [ + { + "change_event.change_date_time": "2026-03-25 10:00:00", + "change_event.user_email": "user@example.com", + "change_event.change_resource_type": "CAMPAIGN", + "change_event.resource_change_operation": "UPDATE", + "change_event.changed_fields": "budget", + "change_event.old_resource": None, + "change_event.new_resource": None, + "change_event.resource_name": "customers/123/campaigns/456", + } + ] + + result = get_change_history(config, customer_id="1234567890") + + assert "changes" in result + assert result["total_changes"] == 1 + assert result["changes"][0]["change_event.user_email"] == "user@example.com" + + @patch("adloop.ads.gaql.execute_query") + def test_resource_type_filter(self, mock_query, config): + mock_query.return_value = [] + + get_change_history( + config, customer_id="1234567890", resource_type="CAMPAIGN" + ) + + call_query = mock_query.call_args[0][2] + assert "change_resource_type = 'CAMPAIGN'" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_operation_type_filter(self, mock_query, config): + mock_query.return_value = [] + + get_change_history( + config, customer_id="1234567890", operation_type="UPDATE" + ) + + call_query = mock_query.call_args[0][2] + assert "resource_change_operation = 'UPDATE'" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_date_range_appends_end_of_day(self, mock_query, config): + mock_query.return_value = [] + + get_change_history( + config, + customer_id="1234567890", + date_range_start="2026-03-01", + date_range_end="2026-03-27", + ) + + call_query = mock_query.call_args[0][2] + # End date should have 23:59:59 appended for timestamp comparison + assert "2026-03-27 23:59:59" in call_query + assert ">= '2026-03-01'" in call_query + assert "DURING LAST_14_DAYS" not in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_date_range_preserves_explicit_time(self, mock_query, config): + mock_query.return_value = [] + + get_change_history( + config, + customer_id="1234567890", + date_range_start="2026-03-01", + date_range_end="2026-03-27T15:00:00", + ) + + call_query = mock_query.call_args[0][2] + # Should NOT append 23:59:59 when caller already provided a time + assert "2026-03-27T15:00:00" in call_query + assert "23:59:59" not in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_limit_clamped_to_api_max(self, mock_query, config): + mock_query.return_value = [] + + get_change_history( + config, customer_id="1234567890", limit=50_000 + ) + + call_query = mock_query.call_args[0][2] + assert "LIMIT 10000" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_limit_clamped_to_minimum(self, mock_query, config): + mock_query.return_value = [] + + get_change_history( + config, customer_id="1234567890", limit=-5 + ) + + call_query = mock_query.call_args[0][2] + assert "LIMIT 1" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_change_history(config, customer_id="1234567890") + + assert result["changes"] == [] + assert result["total_changes"] == 0 + + +# --------------------------------------------------------------------------- +# get_device_performance +# --------------------------------------------------------------------------- + + +class TestGetDevicePerformance: + @patch("adloop.ads.gaql.execute_query") + def test_campaign_level(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.id": 111, + "campaign.name": "Test", + "segments.device": "MOBILE", + "metrics.impressions": 800, + "metrics.clicks": 80, + "metrics.ctr": 0.10, + "metrics.cost_micros": 20_000_000, + "metrics.average_cpc": 250_000, + "metrics.conversions": 4, + "metrics.conversions_value": 200.0, + }, + { + "campaign.id": 111, + "campaign.name": "Test", + "segments.device": "DESKTOP", + "metrics.impressions": 500, + "metrics.clicks": 50, + "metrics.ctr": 0.10, + "metrics.cost_micros": 15_000_000, + "metrics.average_cpc": 300_000, + "metrics.conversions": 3, + "metrics.conversions_value": 150.0, + }, + ] + + result = get_device_performance(config, customer_id="1234567890") + + assert result["total_rows"] == 2 + assert result["level"] == "campaign" + mobile = result["device_performance"][0] + assert mobile["metrics.cost"] == 20.0 + assert mobile["metrics.conversion_rate"] == 5.0 # 4/80 * 100 + + @patch("adloop.ads.gaql.execute_query") + def test_ad_group_level(self, mock_query, config): + mock_query.return_value = [] + + result = get_device_performance( + config, customer_id="1234567890", level="ad_group" + ) + + assert result["level"] == "ad_group" + call_query = mock_query.call_args[0][2] + assert "ad_group.id" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_device_performance(config, customer_id="1234567890") + + assert result["device_performance"] == [] + assert result["total_rows"] == 0 + + +# --------------------------------------------------------------------------- +# get_location_performance +# --------------------------------------------------------------------------- + + +class TestGetLocationPerformance: + @patch("adloop.ads.gaql.execute_query") + def test_default_query(self, mock_query, config): + mock_query.return_value = [ + { + "geographic_view.country_criterion_id": 2276, + "geographic_view.location_type": "LOCATION_OF_PRESENCE", + "campaign.name": "Germany Campaign", + "metrics.impressions": 500, + "metrics.clicks": 50, + "metrics.ctr": 0.10, + "metrics.cost_micros": 25_000_000, + "metrics.conversions": 5, + "metrics.conversions_value": 250.0, + } + ] + + result = get_location_performance(config, customer_id="1234567890") + + assert "locations" in result + assert result["total_locations"] == 1 + row = result["locations"][0] + assert row["metrics.cost"] == 25.0 + assert row["metrics.conversion_rate"] == 10.0 # 5/50 * 100 + + @patch("adloop.ads.gaql.execute_query") + def test_with_date_range(self, mock_query, config): + mock_query.return_value = [] + + get_location_performance( + config, + customer_id="1234567890", + date_range_start="2026-03-01", + date_range_end="2026-03-27", + ) + + call_query = mock_query.call_args[0][2] + assert "BETWEEN '2026-03-01' AND '2026-03-27'" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_location_performance(config, customer_id="1234567890") + + assert result["locations"] == [] + assert result["total_locations"] == 0 + + +# --------------------------------------------------------------------------- +# get_quality_score_details +# --------------------------------------------------------------------------- + + +class TestGetQualityScoreDetails: + @patch("adloop.ads.gaql.execute_query") + def test_default_query(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.name": "Test", + "campaign.id": 111, + "ad_group.name": "AG1", + "ad_group_criterion.keyword.text": "test keyword", + "ad_group_criterion.keyword.match_type": "EXACT", + "ad_group_criterion.quality_info.quality_score": 7, + "ad_group_criterion.quality_info.creative_quality_score": "ABOVE_AVERAGE", + "ad_group_criterion.quality_info.post_click_quality_score": "AVERAGE", + "ad_group_criterion.quality_info.search_predicted_ctr": "ABOVE_AVERAGE", + "metrics.impressions": 200, + "metrics.clicks": 20, + "metrics.cost_micros": 10_000_000, + "metrics.conversions": 2, + } + ] + + result = get_quality_score_details(config, customer_id="1234567890") + + assert "quality_scores" in result + assert result["total_keywords"] == 1 + row = result["quality_scores"][0] + assert row["ad_group_criterion.quality_info.quality_score"] == 7 + assert row["metrics.cost"] == 10.0 + assert row["metrics.cpa"] == 5.0 + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_quality_score_details( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_quality_score_details(config, customer_id="1234567890") + + assert result["quality_scores"] == [] + assert result["total_keywords"] == 0 + + +# --------------------------------------------------------------------------- +# get_bid_strategy_status +# --------------------------------------------------------------------------- + + +class TestGetBidStrategyStatus: + @patch("adloop.ads.gaql.execute_query") + def test_default_query(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.id": 111, + "campaign.name": "Test Campaign", + "campaign.status": "ENABLED", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "campaign.bidding_strategy_system_status": "LEARNING", + "campaign_budget.amount_micros": 30_000_000, + "metrics.conversions": 10, + "metrics.cost_micros": 100_000_000, + } + ] + + result = get_bid_strategy_status(config, customer_id="1234567890") + + assert "strategies" in result + assert result["total_campaigns"] == 1 + row = result["strategies"][0] + assert row["campaign.bidding_strategy_system_status"] == "LEARNING" + assert row["campaign_budget.amount"] == 30.0 + assert row["metrics.cost"] == 100.0 + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_bid_strategy_status( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_bid_strategy_status(config, customer_id="1234567890") + + assert result["strategies"] == [] + assert result["total_campaigns"] == 0 + + +# --------------------------------------------------------------------------- +# get_budget_pacing +# --------------------------------------------------------------------------- + + +class TestGetBudgetPacing: + def _mock_execute(self, budget_rows, spend_rows): + """Return a side_effect that returns different results per query.""" + def side_effect(config, customer_id, query): + if "THIS_MONTH" in query: + return spend_rows + return budget_rows + return side_effect + + @patch("adloop.ads.gaql.execute_query") + def test_basic_pacing(self, mock_query, config): + budget_rows = [ + { + "campaign.id": 111, + "campaign.name": "Test Campaign", + "campaign.status": "ENABLED", + "campaign_budget.amount_micros": 10_000_000, # 10 EUR/day + } + ] + spend_rows = [ + {"campaign.id": 111, "metrics.cost_micros": 5_000_000}, + {"campaign.id": 111, "metrics.cost_micros": 8_000_000}, + {"campaign.id": 111, "metrics.cost_micros": 7_000_000}, + ] + mock_query.side_effect = self._mock_execute(budget_rows, spend_rows) + + result = get_budget_pacing(config, customer_id="1234567890") + + assert "pacing" in result + assert result["total_campaigns"] == 1 + row = result["pacing"][0] + assert row["campaign.id"] == 111 + assert row["daily_budget"] == 10.0 + assert row["month_spend"] == 20.0 # 5 + 8 + 7 = 20 EUR + assert "days_elapsed" in row + assert "days_remaining" in row + assert "projected_month_spend" in row + assert "pace_pct" in row + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_budget_pacing(config, customer_id="1234567890", campaign_id="999") + + # Both queries should have the campaign filter + for call in mock_query.call_args_list: + assert "campaign.id = 999" in call[0][2] + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_budget_pacing(config, customer_id="1234567890") + + assert result["pacing"] == [] + assert result["total_campaigns"] == 0 + + +# --------------------------------------------------------------------------- +# get_ad_schedule_performance +# --------------------------------------------------------------------------- + + +class TestGetAdSchedulePerformance: + @patch("adloop.ads.gaql.execute_query") + def test_default_query(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.name": "Test", + "campaign.id": 111, + "segments.day_of_week": "MONDAY", + "segments.hour": 9, + "metrics.impressions": 100, + "metrics.clicks": 10, + "metrics.ctr": 0.10, + "metrics.cost_micros": 5_000_000, + "metrics.conversions": 1, + } + ] + + result = get_ad_schedule_performance(config, customer_id="1234567890") + + assert "schedule_performance" in result + assert result["total_rows"] == 1 + row = result["schedule_performance"][0] + assert row["segments.day_of_week"] == "MONDAY" + assert row["segments.hour"] == 9 + assert row["metrics.cost"] == 5.0 + assert row["metrics.conversion_rate"] == 10.0 # 1/10 * 100 + assert row["metrics.cpa"] == 5.0 # 5.0 / 1 + + @patch("adloop.ads.gaql.execute_query") + def test_zero_clicks_conversion_rate(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.name": "Test", + "campaign.id": 111, + "segments.day_of_week": "SUNDAY", + "segments.hour": 3, + "metrics.impressions": 50, + "metrics.clicks": 0, + "metrics.ctr": 0.0, + "metrics.cost_micros": 0, + "metrics.conversions": 0, + } + ] + + result = get_ad_schedule_performance(config, customer_id="1234567890") + + row = result["schedule_performance"][0] + assert row["metrics.conversion_rate"] == 0.0 + assert "metrics.cpa" not in row # no conversions = no CPA + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_ad_schedule_performance( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_ad_schedule_performance(config, customer_id="1234567890") + + assert result["schedule_performance"] == [] + assert result["total_rows"] == 0 + + +# --------------------------------------------------------------------------- +# get_auction_insights +# --------------------------------------------------------------------------- + + +class TestGetAuctionInsights: + @patch("adloop.ads.gaql.execute_query") + def test_successful_query(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.name": "Test", + "campaign.id": 111, + "segments.auction_insight_domain": "competitor.com", + "metrics.auction_insight_search_impression_share": 0.35, + "metrics.auction_insight_search_overlap_rate": 0.50, + "metrics.auction_insight_search_outranking_share": 0.40, + "metrics.auction_insight_search_position_above_rate": 0.20, + "metrics.auction_insight_search_top_impression_percentage": 0.30, + "metrics.auction_insight_search_absolute_top_impression_percentage": 0.10, + } + ] + + result = get_auction_insights(config, customer_id="1234567890") + + assert "auction_insights" in result + assert result["total_rows"] == 1 + + @patch("adloop.ads.gaql.execute_query") + def test_not_allowlisted(self, mock_query, config): + mock_query.side_effect = Exception( + "QUERY_NOT_ALLOWED: This query type is not supported" + ) + + result = get_auction_insights(config, customer_id="1234567890") + + assert "error" in result + assert "not available" in result["error"] + assert "hint" in result + + @patch("adloop.ads.gaql.execute_query") + def test_other_error_reraises(self, mock_query, config): + mock_query.side_effect = Exception("NETWORK_ERROR: connection failed") + + with pytest.raises(Exception, match="NETWORK_ERROR"): + get_auction_insights(config, customer_id="1234567890") + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_auction_insights( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_auction_insights(config, customer_id="1234567890") + + assert result["auction_insights"] == [] + assert result["total_rows"] == 0 + + +# --------------------------------------------------------------------------- +# get_keyword_performance +# --------------------------------------------------------------------------- + + +class TestGetKeywordPerformance: + @patch("adloop.ads.gaql.execute_query") + def test_returns_ids(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.name": "Campaign A", + "ad_group.name": "Ad Group 1", + "ad_group.id": 555, + "ad_group_criterion.criterion_id": 777, + "ad_group_criterion.keyword.text": "test keyword", + "ad_group_criterion.keyword.match_type": "EXACT", + "ad_group_criterion.quality_info.quality_score": 7, + "metrics.impressions": 500, + "metrics.clicks": 50, + "metrics.ctr": 0.1, + "metrics.average_cpc": 1_000_000, + "metrics.cost_micros": 50_000_000, + "metrics.conversions": 2, + } + ] + + result = get_keyword_performance(config, customer_id="1234567890") + + assert result["total_keywords"] == 1 + row = result["keywords"][0] + assert row["ad_group.id"] == 555 + assert row["ad_group_criterion.criterion_id"] == 777 + assert row["metrics.cost"] == 50.0 + assert row["metrics.cpa"] == 25.0 + + @patch("adloop.ads.gaql.execute_query") + def test_query_includes_id_fields(self, mock_query, config): + mock_query.return_value = [] + + get_keyword_performance(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "ad_group.id" in call_query + assert "ad_group_criterion.criterion_id" in call_query + + +# --------------------------------------------------------------------------- +# get_search_terms +# --------------------------------------------------------------------------- + + +class TestGetSearchTerms: + @patch("adloop.ads.gaql.execute_query") + def test_default_query(self, mock_query, config): + mock_query.return_value = [ + { + "search_term_view.search_term": "test query", + "campaign.name": "Campaign A", + "ad_group.name": "Ad Group 1", + "metrics.impressions": 100, + "metrics.clicks": 10, + "metrics.cost_micros": 5_000_000, + "metrics.conversions": 1, + } + ] + + result = get_search_terms(config, customer_id="1234567890") + + assert result["total_search_terms"] == 1 + row = result["search_terms"][0] + assert row["metrics.cost"] == 5.0 + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_search_terms( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_no_campaign_filter_by_default(self, mock_query, config): + mock_query.return_value = [] + + get_search_terms(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "campaign.id =" not in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_query_includes_campaign_id_field(self, mock_query, config): + mock_query.return_value = [ + { + "search_term_view.search_term": "test", + "campaign.id": 12345, + "campaign.name": "Campaign A", + "ad_group.name": "Ad Group 1", + "metrics.impressions": 100, + "metrics.clicks": 10, + "metrics.cost_micros": 5_000_000, + "metrics.conversions": 1, + } + ] + + result = get_search_terms(config, customer_id="1234567890") + + row = result["search_terms"][0] + assert row["campaign.id"] == 12345 + # Verify campaign.id is in the SELECT clause + call_query = mock_query.call_args[0][2] + assert "campaign.id" in call_query + + def test_invalid_campaign_id_raises(self, config): + with pytest.raises(ValueError, match="must be numeric"): + get_search_terms( + config, customer_id="1234567890", campaign_id="DROP TABLE" + ) diff --git a/tests/test_tracking.py b/tests/test_tracking.py new file mode 100644 index 0000000..dee31e4 --- /dev/null +++ b/tests/test_tracking.py @@ -0,0 +1,101 @@ +"""Tests for tracking validation tools.""" + +from unittest.mock import patch + +import pytest + +from adloop.config import AdLoopConfig, AdsConfig, GA4Config, SafetyConfig +from adloop.tracking import validate_tracking + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + ga4=GA4Config(property_id="properties/123456"), + safety=SafetyConfig(max_daily_budget=50.0, require_dry_run=True), + ) + + +class TestValidateTracking: + @patch("adloop.ga4.tracking.get_tracking_events") + def test_without_customer_id(self, mock_ga4, config): + mock_ga4.return_value = { + "rows": [ + {"eventName": "purchase", "eventCount": "50"}, + {"eventName": "page_view", "eventCount": "1000"}, + ], + "date_range": {"start": "28daysAgo", "end": "today"}, + } + + result = validate_tracking( + config, + expected_events=["purchase", "sign_up"], + ) + + assert len(result["matched"]) == 1 + assert result["matched"][0]["event_name"] == "purchase" + assert "sign_up" in result["missing_from_ga4"] + assert "ads_conversion_actions" not in result + + @patch("adloop.ads.gaql.execute_query") + @patch("adloop.ga4.tracking.get_tracking_events") + def test_with_customer_id(self, mock_ga4, mock_gaql, config): + mock_ga4.return_value = { + "rows": [ + {"eventName": "purchase", "eventCount": "50"}, + ], + "date_range": {"start": "28daysAgo", "end": "today"}, + } + mock_gaql.return_value = [ + { + "conversion_action.name": "purchase", + "conversion_action.type": "WEBPAGE", + "conversion_action.status": "ENABLED", + }, + { + "conversion_action.name": "phone_call", + "conversion_action.type": "PHONE_CALL", + "conversion_action.status": "ENABLED", + }, + ] + + result = validate_tracking( + config, + expected_events=["purchase", "sign_up"], + customer_id="1234567890", + ) + + assert "ads_conversion_actions" in result + assert len(result["ads_conversion_actions"]) == 2 + # sign_up should be flagged as missing from Ads conversion actions + ads_missing_insight = [ + i for i in result["insights"] + if "no matching Google Ads conversion action" in i + ] + assert len(ads_missing_insight) == 1 + assert "sign_up" in ads_missing_insight[0] + + @patch("adloop.ads.gaql.execute_query") + @patch("adloop.ga4.tracking.get_tracking_events") + def test_customer_id_api_error(self, mock_ga4, mock_gaql, config): + mock_ga4.return_value = { + "rows": [ + {"eventName": "purchase", "eventCount": "50"}, + ], + "date_range": {"start": "28daysAgo", "end": "today"}, + } + mock_gaql.side_effect = Exception("API error") + + result = validate_tracking( + config, + expected_events=["purchase"], + customer_id="1234567890", + ) + + error_insight = [ + i for i in result["insights"] + if "Could not retrieve Google Ads conversion actions" in i + ] + assert len(error_insight) == 1 + assert "ads_conversion_actions" not in result