From e0926c53d0cedc87a7422e6a090f27e399bdb3bd Mon Sep 17 00:00:00 2001 From: xgp <244253+xgp@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:42:00 +0000 Subject: [PATCH] update_campaign: campaign-level broad match Optional keyword_match_type on update_campaign: BROAD turns campaign-level broad match on, UNSPECIFIED turns it off. Turning it on is refused unless the campaign, or the bidding_strategy in the same plan, is in _SMART_BIDDING_STRATEGIES (the set the BROAD keyword checks use), and needs a double confirmation. The preview says what Google does: every existing phrase and exact keyword is converted to broad (the originals show as Removed and are not restored by turning it off), and since September 2026 such campaigns are upgraded to AI Max. Turning it off takes a single confirmation and says converted keywords stay broad. Moving a campaign with broad match on to a non-Smart bidding_strategy is refused unless the same call passes keyword_match_type=UNSPECIFIED. _apply_update_campaign re-reads bidding_strategy_type and keyword_match_type and refuses both unsafe combinations, so plans applied out of order or a UI change in between cannot leave broad match without Smart Bidding; the dry run runs the same check. draft_campaign is unchanged. Fixes #106. --- .claude/rules/adloop.md | 3 +- .cursor/rules/adloop.mdc | 3 +- src/adloop/ads/write.py | 144 ++++++++++++++++ src/adloop/rules/adloop.md | 3 +- src/adloop/server.py | 14 ++ tests/test_campaign_broad_match.py | 259 +++++++++++++++++++++++++++++ 6 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 tests/test_campaign_broad_match.py diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 273364c..4bda3bc 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -285,7 +285,7 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g | `draft_asset_group_signals` | Add search themes or set the audience signal of a PMax asset group, or remove signals | `asset_group_id`, `add_search_themes` (≤80 chars, up to 50 per group), `audience_id` (one per group), `remove_signal_ids` | | `draft_pmax_product_exclusions` | Set the full list of products a retail PMax asset group excludes, on one dimension; everything else stays included | `asset_group_id`, `exclude_values` (the COMPLETE list; `[]` includes everything again), `dimension` item_id (default) / brand / custom_label_0–4 | | `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, location options, Search partners, display expansion, URL options | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `location_option`, `negative_location_option`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc`, `tracking_url_template`, `final_url_suffix`, `url_custom_parameters` | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, location options, Search partners, display expansion, URL options, campaign-level broad match | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `location_option`, `negative_location_option`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc`, `tracking_url_template`, `final_url_suffix`, `url_custom_parameters`, `keyword_match_type` | | `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`. Several assets may pin to one slot (they rotate there), but every slot must stay fillable: by an asset pinned to it or by a distinct unpinned asset. | | `draft_callouts` | Create callout assets for an ad group, a campaign or the account (does NOT publish) | `campaign_id` or `ad_group_id` (the scope follows the id), or `scope="account"`; `callouts` list with 1-25 chars each | @@ -370,6 +370,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform: - `update_campaign` budget changes show the current amount (`current_daily_budget`). A shared budget (`budget_shared_with` lists the other campaign IDs) or a portfolio bid strategy makes the plan require double confirmation, and apply refuses if the budget, its sharing or the portfolio changed after the preview. - Location options: `location_option` is who the geo targets reach — `PRESENCE` (people in or regularly in the locations), `PRESENCE_OR_INTEREST` (Google's default; also people elsewhere who show interest in them) or `SEARCH_INTEREST`. `negative_location_option` (`PRESENCE` or `PRESENCE_OR_INTEREST`) is who the exclusions exclude. Empty leaves the value as it is. The preview warns when a change widens reach: for `location_option` that is anything but `PRESENCE`, for `negative_location_option` it is `PRESENCE`, because then the exclusions only keep out people physically there, not people interested in the excluded places; `update_campaign` shows the current value as `current_location_option` / `current_negative_location_option`. Read back with `get_campaign_settings`. - Campaign URL options (`tracking_url_template`, `final_url_suffix`, `url_custom_parameters`): omitted leaves a value unchanged, `""` or `[]` removes it, and `url_custom_parameters` replaces the whole list. The template must contain `{lpurl}` or a variant (`{lpurl+2}`, `{unescapedlpurl}`, `{escapedlpurl+3}`, ...). Custom parameter keys are alphanumeric, at most 8 characters. The `update_campaign` preview shows each current value (`current_tracking_url_template` etc.), warns on a removal, and gives `example_click`: one of the campaign's final URLs with the campaign's options applied. It is approximate: a template on the ad group, ad or keyword overrides the campaign's, and ValueTrack parameters are filled at click time. Check it for typos before approving. Read back with `get_campaign_settings`. +- Campaign-level broad match: `update_campaign` `keyword_match_type="BROAD"` CONVERTS every existing phrase and exact keyword in the campaign into broad match. Google shows the originals as Removed, turning the setting off does not restore them, and since September 2026 Google also upgrades such campaigns to AI Max. It is refused unless the campaign (or the `bidding_strategy` in the same call) is Smart Bidding — the same rule as BROAD keywords — checked again at apply, and needs a double confirmation. `keyword_match_type="UNSPECIFIED"` turns it off (single confirmation): no further conversions, but converted keywords stay broad and must be re-added as phrase/exact by hand. Moving a broad-match campaign to a non-Smart `bidding_strategy` is refused unless the same call passes `UNSPECIFIED`. Never turn it on to "get more traffic" on an exact/phrase campaign; read it back with `get_campaign_settings`, which lists it in `widening_settings`. - `update_campaign` replaces POSITIVE geo/language targets entirely (not append). Pass the full desired list. NEGATIVE geo exclusions (criteria with `negative=TRUE`) are **preserved** across a positive-geo replacement — they survive the swap. The preview surfaces preserved negative geo IDs in `preserved_negative_geo_target_ids` so the change is auditable. To ADD negative geo exclusions (e.g. exclude a city inside a targeted state), use `add_negative_locations` — do not try to express exclusions through `geo_target_ids`. To remove a negative geo exclusion explicitly, use `remove_entity` with `entity_type="campaign_criterion"`. - **Asset scope:** callouts and snippets link where the id points (`ad_group_id` → ad group, `campaign_id` → campaign). Account-wide (`scope="account"`) reaches every campaign without assets of its own; use it only when the user asks for account level. **Asset edits** (`update_callout`, `update_sitelink`, `update_structured_snippet`) change the asset everywhere it is linked; check with GAQL (`campaign_asset`, `ad_group_asset`, `customer_asset`) where else it is used before editing a shared asset. If the apply reports the asset changed in Google Ads since the preview, draft again. - **Call assets, schedules, promotions and prices.** Never guess `country_code` or `language_code`: take them from the number and the landing page, and ask when unclear. Money amounts are in the account currency (the preview names it); pass `currency_code` only for a different one. Ad schedule hours are in the **account time zone** (the previews show it), not the searcher's: convert the user's local hours first. A campaign without a schedule serves at all hours, so its first `add_ad_schedule` windows switch off every other hour and day: say so, the preview needs a double confirmation. To change a window, remove the old one with `remove_entity` (`entity_type="campaign_criterion"`, the `remove_id` from the preview), then add the new one. Prices must match the landing page or Google disapproves them. diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index 2263af9..c2be6d1 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -287,7 +287,7 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g | `draft_asset_group_signals` | Add search themes or set the audience signal of a PMax asset group, or remove signals | `asset_group_id`, `add_search_themes` (≤80 chars, up to 50 per group), `audience_id` (one per group), `remove_signal_ids` | | `draft_pmax_product_exclusions` | Set the full list of products a retail PMax asset group excludes, on one dimension; everything else stays included | `asset_group_id`, `exclude_values` (the COMPLETE list; `[]` includes everything again), `dimension` item_id (default) / brand / custom_label_0–4 | | `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, location options, Search partners, display expansion, URL options | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `location_option`, `negative_location_option`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc`, `tracking_url_template`, `final_url_suffix`, `url_custom_parameters` | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, location options, Search partners, display expansion, URL options, campaign-level broad match | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `location_option`, `negative_location_option`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc`, `tracking_url_template`, `final_url_suffix`, `url_custom_parameters`, `keyword_match_type` | | `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`. Several assets may pin to one slot (they rotate there), but every slot must stay fillable: by an asset pinned to it or by a distinct unpinned asset. | | `draft_callouts` | Create callout assets for an ad group, a campaign or the account (does NOT publish) | `campaign_id` or `ad_group_id` (the scope follows the id), or `scope="account"`; `callouts` list with 1-25 chars each | @@ -372,6 +372,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform: - `update_campaign` budget changes show the current amount (`current_daily_budget`). A shared budget (`budget_shared_with` lists the other campaign IDs) or a portfolio bid strategy makes the plan require double confirmation, and apply refuses if the budget, its sharing or the portfolio changed after the preview. - Location options: `location_option` is who the geo targets reach — `PRESENCE` (people in or regularly in the locations), `PRESENCE_OR_INTEREST` (Google's default; also people elsewhere who show interest in them) or `SEARCH_INTEREST`. `negative_location_option` (`PRESENCE` or `PRESENCE_OR_INTEREST`) is who the exclusions exclude. Empty leaves the value as it is. The preview warns when a change widens reach: for `location_option` that is anything but `PRESENCE`, for `negative_location_option` it is `PRESENCE`, because then the exclusions only keep out people physically there, not people interested in the excluded places; `update_campaign` shows the current value as `current_location_option` / `current_negative_location_option`. Read back with `get_campaign_settings`. - Campaign URL options (`tracking_url_template`, `final_url_suffix`, `url_custom_parameters`): omitted leaves a value unchanged, `""` or `[]` removes it, and `url_custom_parameters` replaces the whole list. The template must contain `{lpurl}` or a variant (`{lpurl+2}`, `{unescapedlpurl}`, `{escapedlpurl+3}`, ...). Custom parameter keys are alphanumeric, at most 8 characters. The `update_campaign` preview shows each current value (`current_tracking_url_template` etc.), warns on a removal, and gives `example_click`: one of the campaign's final URLs with the campaign's options applied. It is approximate: a template on the ad group, ad or keyword overrides the campaign's, and ValueTrack parameters are filled at click time. Check it for typos before approving. Read back with `get_campaign_settings`. +- Campaign-level broad match: `update_campaign` `keyword_match_type="BROAD"` CONVERTS every existing phrase and exact keyword in the campaign into broad match. Google shows the originals as Removed, turning the setting off does not restore them, and since September 2026 Google also upgrades such campaigns to AI Max. It is refused unless the campaign (or the `bidding_strategy` in the same call) is Smart Bidding — the same rule as BROAD keywords — checked again at apply, and needs a double confirmation. `keyword_match_type="UNSPECIFIED"` turns it off (single confirmation): no further conversions, but converted keywords stay broad and must be re-added as phrase/exact by hand. Moving a broad-match campaign to a non-Smart `bidding_strategy` is refused unless the same call passes `UNSPECIFIED`. Never turn it on to "get more traffic" on an exact/phrase campaign; read it back with `get_campaign_settings`, which lists it in `widening_settings`. - `update_campaign` replaces POSITIVE geo/language targets entirely (not append). Pass the full desired list. NEGATIVE geo exclusions (criteria with `negative=TRUE`) are **preserved** across a positive-geo replacement — they survive the swap. The preview surfaces preserved negative geo IDs in `preserved_negative_geo_target_ids` so the change is auditable. To ADD negative geo exclusions (e.g. exclude a city inside a targeted state), use `add_negative_locations` — do not try to express exclusions through `geo_target_ids`. To remove a negative geo exclusion explicitly, use `remove_entity` with `entity_type="campaign_criterion"`. - **Asset scope:** callouts and snippets link where the id points (`ad_group_id` → ad group, `campaign_id` → campaign). Account-wide (`scope="account"`) reaches every campaign without assets of its own; use it only when the user asks for account level. **Asset edits** (`update_callout`, `update_sitelink`, `update_structured_snippet`) change the asset everywhere it is linked; check with GAQL (`campaign_asset`, `ad_group_asset`, `customer_asset`) where else it is used before editing a shared asset. If the apply reports the asset changed in Google Ads since the preview, draft again. - **Call assets, schedules, promotions and prices.** Never guess `country_code` or `language_code`: take them from the number and the landing page, and ask when unclear. Money amounts are in the account currency (the preview names it); pass `currency_code` only for a different one. Ad schedule hours are in the **account time zone** (the previews show it), not the searcher's: convert the user's local hours first. A campaign without a schedule serves at all hours, so its first `add_ad_schedule` windows switch off every other hour and day: say so, the preview needs a double confirmation. To change a window, remove the old one with `remove_entity` (`entity_type="campaign_criterion"`, the `remove_id` from the preview), then add the new one. Prices must match the landing page or Google disapproves them. diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index 54c61ba..a3de18e 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -2275,6 +2275,7 @@ def update_campaign( tracking_url_template: str | None = None, final_url_suffix: str | None = None, url_custom_parameters: list[dict] | None = None, + keyword_match_type: str = "", ) -> dict: """Draft an update to an existing campaign — returns preview, does NOT execute. @@ -2283,6 +2284,8 @@ def update_campaign( A location option change shows the current value next to the new one. URL options: None leaves one unchanged, an empty string or list clears it; the preview shows the current values and an example click URL. + keyword_match_type BROAD (campaign-level broad match on) needs Smart + Bidding and a double confirmation; UNSPECIFIED turns it off. """ from adloop.safety.guards import ( SafetyViolation, @@ -2315,6 +2318,13 @@ def update_campaign( ) errors.extend(url_errors) + keyword_match_type = (keyword_match_type or "").strip().upper() + if keyword_match_type and keyword_match_type not in _CAMPAIGN_KEYWORD_MATCH_TYPES: + errors.append( + "keyword_match_type must be BROAD (campaign-level broad match on) or " + f"UNSPECIFIED (off), got '{keyword_match_type}'" + ) + campaign_id = str(campaign_id or "").strip() if not campaign_id: errors.append("campaign_id is required") @@ -2373,6 +2383,7 @@ def update_campaign( location_option, negative_location_option, url_options, + keyword_match_type, ]) if not has_any_change: errors.append("No changes specified — provide at least one parameter to update") @@ -2446,6 +2457,33 @@ def update_campaign( } url_example = _example_click_urls(current_urls["final_url"], effective) + current_match: dict = {} + broad_on = False + leaves_smart = bool(bs) and bs not in _SMART_BIDDING_STRATEGIES + if keyword_match_type or leaves_smart: + current_match = _current_keyword_match_type(config, customer_id, campaign_id) + if current_match is None: + return {"error": f"Campaign {campaign_id} was not found in this account"} + is_broad = current_match["keyword_match_type"] == "BROAD" + broad_on = keyword_match_type == "BROAD" and not is_broad + # Same rule as BROAD keywords: no broad match without Smart Bidding. A + # bidding change in the same plan counts, since both apply in one mutate. + broad_error = _broad_match_bidding_error( + keyword_match_type, bs, current_match["bidding_strategy"], is_broad + ) + if broad_error: + return {"error": "Validation failed", "details": [broad_error]} + if broad_on: + warnings.append(_BROAD_ON_WARNING) + if keyword_match_type == "UNSPECIFIED" and is_broad: + warnings.append(_BROAD_OFF_WARNING) + if leaves_smart: + warnings.append( + f"Under {bs} the keywords already converted to broad match " + "keep matching broadly without Smart Bidding. Replace them with " + "phrase or exact match keywords." + ) + # A budget can be shared by several campaigns, and a portfolio bid # strategy too: say who else a budget change reaches, and that a bidding # change takes the campaign out of its portfolio. Both need a double @@ -2517,6 +2555,12 @@ def update_campaign( changes[f"current_{field}"] = current_urls[field] changes[field] = url_options[field] + if keyword_match_type: + changes["current_keyword_match_type"] = current_match["keyword_match_type"] + changes["keyword_match_type"] = keyword_match_type + if broad_on: + requires_double_confirm = True + plan = ChangePlan( operation="update_campaign", entity_type="campaign", @@ -3811,6 +3855,97 @@ def _example_click_urls(final_url: str, options: dict) -> dict: return example +# campaign.keyword_match_type: BROAD makes every keyword in the campaign match +# as broad; UNSPECIFIED (the default) leaves each keyword's own match type. +_CAMPAIGN_KEYWORD_MATCH_TYPES = {"BROAD", "UNSPECIFIED"} + +_BROAD_ON_WARNING = ( + "Turning campaign-level broad match on CONVERTS every existing phrase and exact " + "match keyword in this campaign into broad match; Google shows the originals as " + "Removed, and turning the setting off later does not restore them. Since " + "September 2026 Google also upgrades campaigns with this setting to AI Max. " + "Ads can show on searches the keyword list never named: check search terms " + "daily after this goes live." +) +_BROAD_OFF_WARNING = ( + "Turning campaign-level broad match off stops further keywords being converted, " + "but keywords already converted stay broad match and the original phrase and " + "exact keywords are not restored; re-add them by hand. If Google upgraded the " + "campaign to AI Max, check get_campaign_settings: this does not turn it off." +) + + +def _current_keyword_match_type( + config: AdLoopConfig, customer_id: str, campaign_id: str +) -> dict | None: + """The campaign's keyword match type and bidding strategy; None when it does not exist.""" + from adloop.ads.gaql import execute_query + + rows = execute_query(config, customer_id, ( + "SELECT campaign.keyword_match_type, campaign.bidding_strategy_type " + f"FROM campaign WHERE campaign.id = {campaign_id}")) + if not rows: + return None + return { + "keyword_match_type": rows[0].get("campaign.keyword_match_type") or "UNSPECIFIED", + "bidding_strategy": rows[0].get("campaign.bidding_strategy_type") or "", + } + + +def _broad_match_bidding_error( + keyword_match_type: str, new_strategy: str, current_strategy: str, is_broad: bool +) -> str: + """Why this combination would leave broad match without Smart Bidding, or "".""" + strategy = new_strategy or current_strategy + if keyword_match_type == "BROAD" and strategy not in _SMART_BIDDING_STRATEGIES: + return ( + "Campaign-level broad match requires Smart Bidding " + f"(tCPA/tROAS/Maximize Conversions); this campaign uses {strategy}. " + "Broad match without Smart Bidding leads to irrelevant matches and " + "wasted budget. Switch the campaign to Smart Bidding first." + ) + if ( + new_strategy and new_strategy not in _SMART_BIDDING_STRATEGIES + and is_broad and keyword_match_type != "UNSPECIFIED" + ): + return ( + "This campaign has campaign-level broad match on, which needs Smart " + f"Bidding. To move it to {new_strategy}, turn broad match off in the same " + 'call (keyword_match_type="UNSPECIFIED") so both change in one mutate.' + ) + return "" + + +def _check_broad_match_bidding(service, cid: str, changes: dict) -> None: + """Re-check the broad match / Smart Bidding rule against the account at apply. + + The draft checked it, but two plans applied in reverse order, or a bidding + change in the UI, could otherwise leave broad match on a non-Smart strategy. + Runs in the dry run too: the validate-only client passes reads through. + """ + match_type = changes.get("keyword_match_type") or "" + new_strategy = changes.get("bidding_strategy") or "" + leaves_smart = bool(new_strategy) and new_strategy not in _SMART_BIDDING_STRATEGIES + if match_type != "BROAD" and not (leaves_smart and match_type != "UNSPECIFIED"): + return + campaign_id = changes["campaign_id"] + rows = list(service.search(customer_id=cid, query=( + "SELECT campaign.bidding_strategy_type, campaign.keyword_match_type " + f"FROM campaign WHERE campaign.id = {campaign_id}"))) + if not rows: + raise ValueError(f"Campaign {campaign_id} not found") + campaign = rows[0].campaign + current_strategy = getattr(campaign.bidding_strategy_type, "name", campaign.bidding_strategy_type) + current_match = getattr(campaign.keyword_match_type, "name", campaign.keyword_match_type) + error = _broad_match_bidding_error( + match_type, new_strategy, str(current_strategy), str(current_match) == "BROAD" + ) + if error: + raise RuntimeError( + f"Campaign {campaign_id} changed after this preview; nothing was sent. {error}" + ) + + def _validate_campaign( config: AdLoopConfig, *, @@ -4698,6 +4833,7 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: campaign_id = changes["campaign_id"] resource_name = campaign_service.campaign_path(cid, campaign_id) _check_budget_and_bidding_unchanged(service, cid, changes) + _check_broad_match_bidding(service, cid, changes) # Bid strategy and campaign-level setting changes bs = changes.get("bidding_strategy") @@ -4706,6 +4842,7 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: location_option = changes.get("location_option") negative_location_option = changes.get("negative_location_option") url_fields = [field for field in _URL_OPTION_FIELDS if field in changes] + keyword_match_type = changes.get("keyword_match_type") if ( bs or search_partners_enabled is not None @@ -4714,6 +4851,7 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: or location_option or negative_location_option or url_fields + or keyword_match_type ): campaign_op = client.get_type("MutateOperation") campaign = campaign_op.campaign_operation.update @@ -4778,6 +4916,12 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: for param in changes.get("url_custom_parameters") or []: campaign.url_custom_parameters.append(_custom_parameter(client, param)) field_paths.extend(url_fields) + if keyword_match_type: + # UNSPECIFIED under the mask clears the setting. + campaign.keyword_match_type = getattr( + client.enums.CampaignKeywordMatchTypeEnum, keyword_match_type + ) + field_paths.append("keyword_match_type") if field_paths: campaign_op.campaign_operation.update_mask.CopyFrom( diff --git a/src/adloop/rules/adloop.md b/src/adloop/rules/adloop.md index 273364c..4bda3bc 100644 --- a/src/adloop/rules/adloop.md +++ b/src/adloop/rules/adloop.md @@ -285,7 +285,7 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g | `draft_asset_group_signals` | Add search themes or set the audience signal of a PMax asset group, or remove signals | `asset_group_id`, `add_search_themes` (≤80 chars, up to 50 per group), `audience_id` (one per group), `remove_signal_ids` | | `draft_pmax_product_exclusions` | Set the full list of products a retail PMax asset group excludes, on one dimension; everything else stays included | `asset_group_id`, `exclude_values` (the COMPLETE list; `[]` includes everything again), `dimension` item_id (default) / brand / custom_label_0–4 | | `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, location options, Search partners, display expansion, URL options | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `location_option`, `negative_location_option`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc`, `tracking_url_template`, `final_url_suffix`, `url_custom_parameters` | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, location options, Search partners, display expansion, URL options, campaign-level broad match | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `location_option`, `negative_location_option`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc`, `tracking_url_template`, `final_url_suffix`, `url_custom_parameters`, `keyword_match_type` | | `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`. Several assets may pin to one slot (they rotate there), but every slot must stay fillable: by an asset pinned to it or by a distinct unpinned asset. | | `draft_callouts` | Create callout assets for an ad group, a campaign or the account (does NOT publish) | `campaign_id` or `ad_group_id` (the scope follows the id), or `scope="account"`; `callouts` list with 1-25 chars each | @@ -370,6 +370,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform: - `update_campaign` budget changes show the current amount (`current_daily_budget`). A shared budget (`budget_shared_with` lists the other campaign IDs) or a portfolio bid strategy makes the plan require double confirmation, and apply refuses if the budget, its sharing or the portfolio changed after the preview. - Location options: `location_option` is who the geo targets reach — `PRESENCE` (people in or regularly in the locations), `PRESENCE_OR_INTEREST` (Google's default; also people elsewhere who show interest in them) or `SEARCH_INTEREST`. `negative_location_option` (`PRESENCE` or `PRESENCE_OR_INTEREST`) is who the exclusions exclude. Empty leaves the value as it is. The preview warns when a change widens reach: for `location_option` that is anything but `PRESENCE`, for `negative_location_option` it is `PRESENCE`, because then the exclusions only keep out people physically there, not people interested in the excluded places; `update_campaign` shows the current value as `current_location_option` / `current_negative_location_option`. Read back with `get_campaign_settings`. - Campaign URL options (`tracking_url_template`, `final_url_suffix`, `url_custom_parameters`): omitted leaves a value unchanged, `""` or `[]` removes it, and `url_custom_parameters` replaces the whole list. The template must contain `{lpurl}` or a variant (`{lpurl+2}`, `{unescapedlpurl}`, `{escapedlpurl+3}`, ...). Custom parameter keys are alphanumeric, at most 8 characters. The `update_campaign` preview shows each current value (`current_tracking_url_template` etc.), warns on a removal, and gives `example_click`: one of the campaign's final URLs with the campaign's options applied. It is approximate: a template on the ad group, ad or keyword overrides the campaign's, and ValueTrack parameters are filled at click time. Check it for typos before approving. Read back with `get_campaign_settings`. +- Campaign-level broad match: `update_campaign` `keyword_match_type="BROAD"` CONVERTS every existing phrase and exact keyword in the campaign into broad match. Google shows the originals as Removed, turning the setting off does not restore them, and since September 2026 Google also upgrades such campaigns to AI Max. It is refused unless the campaign (or the `bidding_strategy` in the same call) is Smart Bidding — the same rule as BROAD keywords — checked again at apply, and needs a double confirmation. `keyword_match_type="UNSPECIFIED"` turns it off (single confirmation): no further conversions, but converted keywords stay broad and must be re-added as phrase/exact by hand. Moving a broad-match campaign to a non-Smart `bidding_strategy` is refused unless the same call passes `UNSPECIFIED`. Never turn it on to "get more traffic" on an exact/phrase campaign; read it back with `get_campaign_settings`, which lists it in `widening_settings`. - `update_campaign` replaces POSITIVE geo/language targets entirely (not append). Pass the full desired list. NEGATIVE geo exclusions (criteria with `negative=TRUE`) are **preserved** across a positive-geo replacement — they survive the swap. The preview surfaces preserved negative geo IDs in `preserved_negative_geo_target_ids` so the change is auditable. To ADD negative geo exclusions (e.g. exclude a city inside a targeted state), use `add_negative_locations` — do not try to express exclusions through `geo_target_ids`. To remove a negative geo exclusion explicitly, use `remove_entity` with `entity_type="campaign_criterion"`. - **Asset scope:** callouts and snippets link where the id points (`ad_group_id` → ad group, `campaign_id` → campaign). Account-wide (`scope="account"`) reaches every campaign without assets of its own; use it only when the user asks for account level. **Asset edits** (`update_callout`, `update_sitelink`, `update_structured_snippet`) change the asset everywhere it is linked; check with GAQL (`campaign_asset`, `ad_group_asset`, `customer_asset`) where else it is used before editing a shared asset. If the apply reports the asset changed in Google Ads since the preview, draft again. - **Call assets, schedules, promotions and prices.** Never guess `country_code` or `language_code`: take them from the number and the landing page, and ask when unclear. Money amounts are in the account currency (the preview names it); pass `currency_code` only for a different one. Ad schedule hours are in the **account time zone** (the previews show it), not the searcher's: convert the user's local hours first. A campaign without a schedule serves at all hours, so its first `add_ad_schedule` windows switch off every other hour and day: say so, the preview needs a double confirmation. To change a window, remove the old one with `remove_entity` (`entity_type="campaign_criterion"`, the `remove_id` from the preview), then add the new one. Prices must match the landing page or Google disapproves them. diff --git a/src/adloop/server.py b/src/adloop/server.py index 9d759aa..023dd54 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -3468,6 +3468,7 @@ def update_campaign( tracking_url_template: str | None = None, final_url_suffix: str | None = None, url_custom_parameters: _DictListOpt = None, + keyword_match_type: str = "", ) -> dict: """Draft an update to an existing campaign — returns a PREVIEW, does NOT apply. @@ -3531,6 +3532,18 @@ def update_campaign( removal, and gives example_click: one of the campaign's final URLs with the campaign's options applied. It is approximate: a template on the ad group, ad or keyword overrides the campaign's. + keyword_match_type: Campaign-level broad match. BROAD turns it on and + CONVERTS every existing phrase and exact keyword in the campaign to + broad match (the originals show as Removed; turning it off does not + restore them), and since September 2026 Google upgrades such + campaigns to AI Max. Refused unless the campaign uses Smart Bidding + (MAXIMIZE_CONVERSIONS, MAXIMIZE_CONVERSION_VALUE, TARGET_CPA, + TARGET_ROAS), checked again at apply; needs a double confirmation. + UNSPECIFIED turns it off: no further conversions, but converted + keywords stay broad. Moving a campaign with broad match on to a + non-Smart bidding_strategy is refused unless the same call passes + UNSPECIFIED. The preview shows the current value. Empty leaves it + unchanged. """ from adloop.ads.write import update_campaign as _impl @@ -3553,6 +3566,7 @@ def update_campaign( tracking_url_template=tracking_url_template, final_url_suffix=final_url_suffix, url_custom_parameters=url_custom_parameters, + keyword_match_type=keyword_match_type, ) diff --git a/tests/test_campaign_broad_match.py b/tests/test_campaign_broad_match.py new file mode 100644 index 0000000..46e9e13 --- /dev/null +++ b/tests/test_campaign_broad_match.py @@ -0,0 +1,259 @@ +"""Campaign-level broad match on update_campaign (#106). + +BROAD makes every keyword in the campaign match as broad, so turning it on +follows the BROAD-keyword rule (Smart Bidding only) and needs a double +confirmation. UNSPECIFIED turns it off, the safe direction. +""" + +from __future__ import annotations + +import pytest + +from adloop.ads import write +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig +from adloop.safety import preview as preview_store +from tests.test_ads_write import ( + _FakeClient, + _FakeGoogleAdsService, + _FakeMutateOperationResponse, + _FakePathService, +) + +CAMPAIGN = "1001" + + +@pytest.fixture(autouse=True) +def _plans(): + preview_store.set_plan_store(preview_store.InMemoryPlanStore()) + yield + preview_store.set_plan_store(preview_store.InMemoryPlanStore()) + + +@pytest.fixture +def config(tmp_path) -> AdLoopConfig: + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890"), + safety=SafetyConfig(log_file=str(tmp_path / "audit.log"), max_daily_budget=500), + ) + + +def _current(monkeypatch, *, match_type="UNSPECIFIED", bidding="MAXIMIZE_CONVERSIONS", found=True): + def execute_query(_config, _cid, query): + if "keyword_match_type" not in query: + # update_campaign's shared budget / portfolio read for a bidding change. + return [{"campaign.id": CAMPAIGN, "campaign.campaign_budget": "", + "campaign.bidding_strategy": ""}] + if not found: + return [] + return [{"campaign.keyword_match_type": match_type, + "campaign.bidding_strategy_type": bidding}] + + monkeypatch.setattr("adloop.ads.gaql.execute_query", execute_query) + + +def _update(config, **kwargs): + return write.update_campaign(config, customer_id="1234567890", campaign_id=CAMPAIGN, **kwargs) + + +class TestTurningItOn: + @pytest.mark.parametrize("bidding", sorted(write._SMART_BIDDING_STRATEGIES)) + def test_smart_bidding_allows_it_with_a_double_confirmation(self, config, monkeypatch, bidding): + _current(monkeypatch, bidding=bidding) + + result = _update(config, keyword_match_type="broad") + + assert result["changes"]["current_keyword_match_type"] == "UNSPECIFIED" + assert result["changes"]["keyword_match_type"] == "BROAD" + assert result["requires_double_confirm"] is True + warning = next(w for w in result["warnings"] if "CONVERTS" in w) + assert "shows the originals as Removed" in warning + assert "does not restore them" in warning + assert "AI Max" in warning + + @pytest.mark.parametrize("bidding", ["MANUAL_CPC", "TARGET_SPEND"]) + def test_refused_without_smart_bidding(self, config, monkeypatch, bidding): + _current(monkeypatch, bidding=bidding) + + result = _update(config, keyword_match_type="BROAD") + + assert result["error"] == "Validation failed" + assert f"this campaign uses {bidding}" in result["details"][0] + + def test_a_smart_bidding_switch_in_the_same_plan_counts(self, config, monkeypatch): + _current(monkeypatch, bidding="TARGET_SPEND") + + result = _update(config, keyword_match_type="BROAD", bidding_strategy="MAXIMIZE_CONVERSIONS") + + assert result["changes"]["keyword_match_type"] == "BROAD" + assert result["changes"]["bidding_strategy"] == "MAXIMIZE_CONVERSIONS" + assert result["requires_double_confirm"] is True + + def test_a_non_smart_switch_in_the_same_plan_is_refused(self, config, monkeypatch): + _current(monkeypatch, bidding="MAXIMIZE_CONVERSIONS") + + result = _update(config, keyword_match_type="BROAD", bidding_strategy="MANUAL_CPC") + + assert result["error"] == "Validation failed" + + def test_already_broad_is_not_a_widening_change(self, config, monkeypatch): + _current(monkeypatch, match_type="BROAD") + + result = _update(config, keyword_match_type="BROAD") + + assert result["requires_double_confirm"] is False + assert "warnings" not in result + + +class TestTurningItOff: + def test_single_confirmation_and_says_keywords_stay_broad(self, config, monkeypatch): + _current(monkeypatch, match_type="BROAD", bidding="MAXIMIZE_CONVERSIONS") + + result = _update(config, keyword_match_type="UNSPECIFIED") + + assert result["changes"]["current_keyword_match_type"] == "BROAD" + assert result["changes"]["keyword_match_type"] == "UNSPECIFIED" + assert result["requires_double_confirm"] is False + (warning,) = result["warnings"] + assert "keywords already converted stay broad match" in warning + + def test_already_off_says_nothing(self, config, monkeypatch): + _current(monkeypatch) + + assert "warnings" not in _update(config, keyword_match_type="UNSPECIFIED") + + +class TestLeavingSmartBidding: + @pytest.mark.parametrize("bidding", ["MANUAL_CPC", "TARGET_SPEND"]) + def test_a_broad_campaign_cannot_leave_smart_bidding_alone(self, config, monkeypatch, bidding): + _current(monkeypatch, match_type="BROAD") + + result = _update(config, bidding_strategy=bidding) + + assert result["error"] == "Validation failed" + assert 'keyword_match_type="UNSPECIFIED"' in result["details"][0] + + def test_allowed_when_the_same_call_turns_broad_off(self, config, monkeypatch): + _current(monkeypatch, match_type="BROAD") + + result = _update(config, bidding_strategy="MANUAL_CPC", keyword_match_type="UNSPECIFIED") + + assert result["changes"]["bidding_strategy"] == "MANUAL_CPC" + assert result["changes"]["keyword_match_type"] == "UNSPECIFIED" + assert any("Under MANUAL_CPC" in w for w in result["warnings"]) + + def test_a_campaign_without_broad_moves_freely(self, config, monkeypatch): + _current(monkeypatch) + + result = _update(config, bidding_strategy="MANUAL_CPC") + + assert result["changes"]["bidding_strategy"] == "MANUAL_CPC" + + +class TestValidation: + def test_other_values_are_refused_before_any_read(self, config): + # The suite-wide no_live_gaql fixture raises if anything is read. + result = _update(config, keyword_match_type="EXACT") + + assert result["error"] == "Validation failed" + assert any("BROAD" in d and "UNSPECIFIED" in d for d in result["details"]) + + def test_unknown_campaign(self, config, monkeypatch): + _current(monkeypatch, found=False) + + assert _update(config, keyword_match_type="UNSPECIFIED") == { + "error": f"Campaign {CAMPAIGN} was not found in this account" + } + + +def _row(bidding, match_type): + from types import SimpleNamespace + + return SimpleNamespace(campaign=SimpleNamespace( + bidding_strategy_type=SimpleNamespace(name=bidding), + keyword_match_type=SimpleNamespace(name=match_type), + )) + + +class _ReadingService(_FakeGoogleAdsService): + def __init__(self, row): + super().__init__([_FakeMutateOperationResponse("campaign_result", "customers/1/campaigns/1")]) + self.row = row + self.queries = [] + + def search(self, customer_id, query): + self.queries.append(query) + return [self.row] + + +def _client(service): + return _FakeClient({"GoogleAdsService": service, "CampaignService": _FakePathService("campaigns")}) + + +class TestApplyRecheck: + def test_broad_on_is_refused_if_bidding_moved_off_smart_since_the_preview(self): + service = _ReadingService(_row("MANUAL_CPC", "UNSPECIFIED")) + + with pytest.raises(RuntimeError, match="nothing was sent"): + write._apply_update_campaign(_client(service), "1234567890", { + "campaign_id": CAMPAIGN, "keyword_match_type": "BROAD", + }) + + assert service.operations is None + + def test_leaving_smart_bidding_is_refused_if_broad_came_on_since_the_preview(self): + service = _ReadingService(_row("MAXIMIZE_CONVERSIONS", "BROAD")) + + with pytest.raises(RuntimeError, match="UNSPECIFIED"): + write._apply_update_campaign(_client(service), "1234567890", { + "campaign_id": CAMPAIGN, "bidding_strategy": "MANUAL_CPC", + }) + + assert service.operations is None + + def test_still_smart_goes_through(self): + service = _ReadingService(_row("TARGET_CPA", "UNSPECIFIED")) + + write._apply_update_campaign(_client(service), "1234567890", { + "campaign_id": CAMPAIGN, "keyword_match_type": "BROAD", + }) + + assert "campaign.bidding_strategy_type" in service.queries[0] + assert list(service.operations[0].campaign_operation.update_mask.paths) == ["keyword_match_type"] + + def test_turning_it_off_needs_no_read(self): + # _FakeGoogleAdsService.search raises: nothing to re-check here. + service = _FakeGoogleAdsService( + [_FakeMutateOperationResponse("campaign_result", "customers/1/campaigns/1")] + ) + + write._apply_update_campaign(_client(service), "1234567890", { + "campaign_id": CAMPAIGN, "keyword_match_type": "UNSPECIFIED", + }) + + +class TestApply: + @pytest.mark.parametrize("value", ["BROAD", "UNSPECIFIED"]) + def test_update_sets_the_field_under_its_mask(self, value): + service = _ReadingService(_row("MAXIMIZE_CONVERSIONS", "UNSPECIFIED")) + client = _client(service) + + write._apply_update_campaign(client, "1234567890", { + "campaign_id": CAMPAIGN, + "current_keyword_match_type": "BROAD" if value == "UNSPECIFIED" else "UNSPECIFIED", + "keyword_match_type": value, + }) + + operation = service.operations[0].campaign_operation + assert list(operation.update_mask.paths) == ["keyword_match_type"] + assert operation.update.keyword_match_type == getattr( + client.enums.CampaignKeywordMatchTypeEnum, value + ) + + +@pytest.mark.asyncio +async def test_update_campaign_documents_the_parameter(): + from adloop.server import mcp + + tools = {t.name: t for t in await mcp.list_tools()} + assert tools["update_campaign"].parameters["properties"]["keyword_match_type"].get("description") + assert "keyword_match_type" not in tools["draft_campaign"].parameters["properties"]