diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 2306458..488ff41 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -278,13 +278,13 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g | 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_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, optional `location_option` / `negative_location_option` | | `draft_shopping_campaign` | Create a Standard Shopping campaign for a Merchant Center feed: budget + campaign (PAUSED) + product ad group + Shopping ad + one "All products" product group, atomically | `campaign_name`, `daily_budget`, `merchant_id` (must be linked to the Ads account; checked), `product_bid` (CPC of the product group, required by Google), `geo_target_ids` (REQUIRED), `bidding_strategy` MANUAL_CPC (default) / TARGET_SPEND (Maximize clicks, optional `max_cpc`) / TARGET_ROAS (+ `target_roas`), `campaign_priority` LOW/MEDIUM/HIGH, optional `feed_label`. No keywords, no languages | | `draft_asset_group_assets` | Add and/or remove assets of an existing PMax asset group in one atomic change: text, images, YouTube videos | `asset_group_id`, `headlines` (≤30), `long_headlines` (≤90), `descriptions` (≤90), `business_name` (≤25), `images` (`{"url"}` or `{"upload_id"}`, optional `role` marketing/logo/landscape_logo; marketing images sorted into landscape 1.91:1, square 1:1, portrait 4:5 by ratio), `youtube_video_ids`, `remove_asset_ids` | | `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, 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` | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, location options, Search partners, display expansion | `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` | | `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 | @@ -366,6 +366,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform: - `update_ad_group` is the right tool for later MANUAL_CPC bid changes. Use `update_campaign` for TARGET_SPEND (Maximize Clicks) `max_cpc` changes. - Ad-group pause/enable is already handled by `pause_entity` / `enable_entity` with `entity_type="ad_group"`; do not invent a separate pause tool. - `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. Anything but `PRESENCE` widens reach and the preview warns; `update_campaign` shows the current value as `current_location_option` / `current_negative_location_option`. Read back with `get_campaign_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 99c2215..9f1bcbc 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -280,13 +280,13 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g | 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_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, optional `location_option` / `negative_location_option` | | `draft_shopping_campaign` | Create a Standard Shopping campaign for a Merchant Center feed: budget + campaign (PAUSED) + product ad group + Shopping ad + one "All products" product group, atomically | `campaign_name`, `daily_budget`, `merchant_id` (must be linked to the Ads account; checked), `product_bid` (CPC of the product group, required by Google), `geo_target_ids` (REQUIRED), `bidding_strategy` MANUAL_CPC (default) / TARGET_SPEND (Maximize clicks, optional `max_cpc`) / TARGET_ROAS (+ `target_roas`), `campaign_priority` LOW/MEDIUM/HIGH, optional `feed_label`. No keywords, no languages | | `draft_asset_group_assets` | Add and/or remove assets of an existing PMax asset group in one atomic change: text, images, YouTube videos | `asset_group_id`, `headlines` (≤30), `long_headlines` (≤90), `descriptions` (≤90), `business_name` (≤25), `images` (`{"url"}` or `{"upload_id"}`, optional `role` marketing/logo/landscape_logo; marketing images sorted into landscape 1.91:1, square 1:1, portrait 4:5 by ratio), `youtube_video_ids`, `remove_asset_ids` | | `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, 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` | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, location options, Search partners, display expansion | `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` | | `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 | @@ -368,6 +368,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform: - `update_ad_group` is the right tool for later MANUAL_CPC bid changes. Use `update_campaign` for TARGET_SPEND (Maximize Clicks) `max_cpc` changes. - Ad-group pause/enable is already handled by `pause_entity` / `enable_entity` with `entity_type="ad_group"`; do not invent a separate pause tool. - `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. Anything but `PRESENCE` widens reach and the preview warns; `update_campaign` shows the current value as `current_location_option` / `current_negative_location_option`. Read back with `get_campaign_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 90acf56..8cbda5d 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -2080,6 +2080,8 @@ def draft_campaign( display_network_enabled: bool | None = None, display_expansion_enabled: bool | None = None, max_cpc: float = 0, + location_option: str = "", + negative_location_option: str = "", ) -> dict: """Draft a full campaign structure — returns preview, does NOT execute. @@ -2091,6 +2093,8 @@ def draft_campaign( ["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. + location_option / negative_location_option: campaign.geo_target_type_setting. + Empty leaves Google's default (PRESENCE_OR_INTEREST for both). """ from adloop.safety.guards import ( SafetyViolation, @@ -2113,6 +2117,12 @@ def draft_campaign( if normalized_display_network_enabled is None: normalized_display_network_enabled = False + location_option, negative_location_option, location_errors = ( + _normalize_location_options(location_option, negative_location_option) + ) + if location_errors: + return {"error": "Validation failed", "details": location_errors} + errors, warnings = _validate_campaign( config, campaign_name=campaign_name, @@ -2137,6 +2147,13 @@ def draft_campaign( except SafetyViolation as e: return {"error": str(e)} + warnings.extend(_location_option_warnings(location_option, negative_location_option)) + location_changes = {} + if location_option: + location_changes["location_option"] = location_option + if negative_location_option: + location_changes["negative_location_option"] = negative_location_option + plan = ChangePlan( operation="create_campaign", entity_type="campaign", @@ -2155,6 +2172,7 @@ def draft_campaign( "search_partners_enabled": search_partners_enabled, "display_network_enabled": normalized_display_network_enabled, "max_cpc": max_cpc if max_cpc else None, + **location_changes, }, ) store_plan(plan) @@ -2239,11 +2257,14 @@ def update_campaign( display_network_enabled: bool | None = None, display_expansion_enabled: bool | None = None, max_cpc: float = 0, + location_option: str = "", + negative_location_option: str = "", ) -> dict: """Draft an update to an existing campaign — returns preview, does NOT execute. All parameters except campaign_id are optional — only include what you want to change. Geo/language targets are REPLACED entirely (not appended). + A location option change shows the current value next to the new one. """ from adloop.safety.guards import ( SafetyViolation, @@ -2267,6 +2288,11 @@ def update_campaign( ) errors.extend(alias_errors) + location_option, negative_location_option, location_errors = ( + _normalize_location_options(location_option, negative_location_option) + ) + errors.extend(location_errors) + campaign_id = str(campaign_id or "").strip() if not campaign_id: errors.append("campaign_id is required") @@ -2322,6 +2348,8 @@ def update_campaign( search_partners_enabled is not None, normalized_display_network_enabled is not None, max_cpc, + location_option, + negative_location_option, ]) if not has_any_change: errors.append("No changes specified — provide at least one parameter to update") @@ -2364,6 +2392,20 @@ def update_campaign( 'entity_type="campaign_criterion".' ) + current_locations: dict = {} + if location_option or negative_location_option: + current_locations = _current_location_options(config, customer_id, campaign_id) + if current_locations is None: + return {"error": f"Campaign {campaign_id} was not found in this account"} + # Warn about a change, not about a value the campaign already has. + warnings.extend(_location_option_warnings( + location_option + if location_option != current_locations["location_option"] else "", + negative_location_option + if negative_location_option != current_locations["negative_location_option"] + else "", + )) + # 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 @@ -2422,6 +2464,14 @@ def update_campaign( changes["display_network_enabled"] = normalized_display_network_enabled if max_cpc: changes["max_cpc"] = max_cpc + if location_option: + changes["current_location_option"] = current_locations["location_option"] + changes["location_option"] = location_option + if negative_location_option: + changes["current_negative_location_option"] = ( + current_locations["negative_location_option"] + ) + changes["negative_location_option"] = negative_location_option plan = ChangePlan( operation="update_campaign", @@ -3547,6 +3597,68 @@ def _validate_rsa( _VALID_CHANNEL_TYPES = {"SEARCH", "DISPLAY", "SHOPPING", "VIDEO", "PERFORMANCE_MAX"} +# campaign.geo_target_type_setting. Google's default for a new campaign is +# PRESENCE_OR_INTEREST, which also reaches people elsewhere who show interest +# in the targeted locations. +_LOCATION_OPTIONS = {"PRESENCE", "PRESENCE_OR_INTEREST", "SEARCH_INTEREST"} +_NEGATIVE_LOCATION_OPTIONS = {"PRESENCE", "PRESENCE_OR_INTEREST"} + + +def _normalize_location_options( + location_option: str, negative_location_option: str +) -> tuple[str, str, list[str]]: + """Upper-case both options; empty means "leave as is". Returns (pos, neg, errors).""" + pos = (location_option or "").strip().upper() + neg = (negative_location_option or "").strip().upper() + errors = [] + if pos and pos not in _LOCATION_OPTIONS: + errors.append( + f"location_option must be one of {sorted(_LOCATION_OPTIONS)}, got '{location_option}'" + ) + if neg and neg not in _NEGATIVE_LOCATION_OPTIONS: + errors.append( + "negative_location_option must be one of " + f"{sorted(_NEGATIVE_LOCATION_OPTIONS)}, got '{negative_location_option}'" + ) + return pos, neg, errors + + +def _location_option_warnings(pos: str, neg: str) -> list[str]: + """Say when a location option lets the campaign reach beyond its locations.""" + warnings = [] + if pos and pos != "PRESENCE": + warnings.append( + f"Location option {pos} widens reach: ads can also show to people outside " + "the targeted locations who search for or show interest in them. PRESENCE " + "limits ads to people in or regularly in the targeted locations." + ) + if neg == "PRESENCE": + warnings.append( + "Negative location option PRESENCE excludes only people in the excluded " + "locations; people elsewhere who show interest in them can still see the ads." + ) + return warnings + + +def _current_location_options( + config: AdLoopConfig, customer_id: str, campaign_id: str +) -> dict | None: + """The campaign's location options as they are now; None when it does not exist.""" + from adloop.ads.gaql import execute_query + + rows = execute_query(config, customer_id, ( + "SELECT campaign.geo_target_type_setting.positive_geo_target_type, " + "campaign.geo_target_type_setting.negative_geo_target_type " + f"FROM campaign WHERE campaign.id = {campaign_id}")) + if not rows: + return None + return { + "location_option": rows[0].get( + "campaign.geo_target_type_setting.positive_geo_target_type") or "", + "negative_location_option": rows[0].get( + "campaign.geo_target_type_setting.negative_geo_target_type") or "", + } + def _validate_campaign( config: AdLoopConfig, @@ -4273,6 +4385,14 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: campaign.network_settings.target_content_network = changes.get( "display_network_enabled", False ) + if changes.get("location_option"): + campaign.geo_target_type_setting.positive_geo_target_type = getattr( + client.enums.PositiveGeoTargetTypeEnum, changes["location_option"] + ) + if changes.get("negative_location_option"): + campaign.geo_target_type_setting.negative_geo_target_type = getattr( + client.enums.NegativeGeoTargetTypeEnum, changes["negative_location_option"] + ) # EU political advertising declaration — required for campaigns that may # serve in EU countries. This is an ENUM, not a bool. Value 3 means @@ -4415,11 +4535,15 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: bs = changes.get("bidding_strategy") search_partners_enabled = changes.get("search_partners_enabled") display_network_enabled = changes.get("display_network_enabled") + location_option = changes.get("location_option") + negative_location_option = changes.get("negative_location_option") if ( bs or search_partners_enabled is not None or display_network_enabled is not None or changes.get("max_cpc") + or location_option + or negative_location_option ): campaign_op = client.get_type("MutateOperation") campaign = campaign_op.campaign_operation.update @@ -4466,6 +4590,16 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: if display_network_enabled is not None: campaign.network_settings.target_content_network = display_network_enabled field_paths.append("network_settings.target_content_network") + if location_option: + campaign.geo_target_type_setting.positive_geo_target_type = getattr( + client.enums.PositiveGeoTargetTypeEnum, location_option + ) + field_paths.append("geo_target_type_setting.positive_geo_target_type") + if negative_location_option: + campaign.geo_target_type_setting.negative_geo_target_type = getattr( + client.enums.NegativeGeoTargetTypeEnum, negative_location_option + ) + field_paths.append("geo_target_type_setting.negative_geo_target_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 2306458..488ff41 100644 --- a/src/adloop/rules/adloop.md +++ b/src/adloop/rules/adloop.md @@ -278,13 +278,13 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g | 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_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, optional `location_option` / `negative_location_option` | | `draft_shopping_campaign` | Create a Standard Shopping campaign for a Merchant Center feed: budget + campaign (PAUSED) + product ad group + Shopping ad + one "All products" product group, atomically | `campaign_name`, `daily_budget`, `merchant_id` (must be linked to the Ads account; checked), `product_bid` (CPC of the product group, required by Google), `geo_target_ids` (REQUIRED), `bidding_strategy` MANUAL_CPC (default) / TARGET_SPEND (Maximize clicks, optional `max_cpc`) / TARGET_ROAS (+ `target_roas`), `campaign_priority` LOW/MEDIUM/HIGH, optional `feed_label`. No keywords, no languages | | `draft_asset_group_assets` | Add and/or remove assets of an existing PMax asset group in one atomic change: text, images, YouTube videos | `asset_group_id`, `headlines` (≤30), `long_headlines` (≤90), `descriptions` (≤90), `business_name` (≤25), `images` (`{"url"}` or `{"upload_id"}`, optional `role` marketing/logo/landscape_logo; marketing images sorted into landscape 1.91:1, square 1:1, portrait 4:5 by ratio), `youtube_video_ids`, `remove_asset_ids` | | `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, 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` | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, location options, Search partners, display expansion | `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` | | `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 | @@ -366,6 +366,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform: - `update_ad_group` is the right tool for later MANUAL_CPC bid changes. Use `update_campaign` for TARGET_SPEND (Maximize Clicks) `max_cpc` changes. - Ad-group pause/enable is already handled by `pause_entity` / `enable_entity` with `entity_type="ad_group"`; do not invent a separate pause tool. - `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. Anything but `PRESENCE` widens reach and the preview warns; `update_campaign` shows the current value as `current_location_option` / `current_negative_location_option`. Read back with `get_campaign_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 d6668c5..2a64d0a 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -3237,6 +3237,8 @@ def draft_campaign( display_network_enabled: bool | None = None, display_expansion_enabled: bool | None = None, max_cpc: float = 0, + location_option: str = "", + negative_location_option: str = "", ) -> dict: """Draft a full campaign structure — returns a PREVIEW, does NOT create anything. @@ -3290,6 +3292,14 @@ def draft_campaign( ad group when bidding_strategy is MANUAL_CPC, or the Maximize Clicks CPC cap when bidding_strategy is TARGET_SPEND. Not allowed with other strategies; 0 means none. + location_option: Who the geo targets reach. PRESENCE (people in or + regularly in the locations), PRESENCE_OR_INTEREST (also people + elsewhere who show interest in them) or SEARCH_INTEREST (people + searching for them). Empty leaves Google's default, + PRESENCE_OR_INTEREST. Anything but PRESENCE adds a warning. + negative_location_option: Who the location exclusions exclude. + PRESENCE or PRESENCE_OR_INTEREST; empty leaves Google's default, + PRESENCE_OR_INTEREST. """ from adloop.ads.write import draft_campaign as _impl @@ -3310,6 +3320,8 @@ def draft_campaign( display_network_enabled=display_network_enabled, display_expansion_enabled=display_expansion_enabled, max_cpc=max_cpc, + location_option=location_option, + negative_location_option=negative_location_option, ) @@ -3372,6 +3384,8 @@ def update_campaign( display_network_enabled: bool | None = None, display_expansion_enabled: bool | None = None, max_cpc: float = 0, + location_option: str = "", + negative_location_option: str = "", ) -> dict: """Draft an update to an existing campaign — returns a PREVIEW, does NOT apply. @@ -3416,6 +3430,13 @@ def update_campaign( bidding_strategy is TARGET_SPEND, or when the existing campaign already uses TARGET_SPEND. Increases are limited by the config's max_bid_increase_pct. 0 leaves it unchanged. + location_option: PRESENCE | PRESENCE_OR_INTEREST | SEARCH_INTEREST. + Who the geo targets reach; the preview shows the current value + (current_location_option) and warns when the new one is anything + but PRESENCE. Empty leaves it unchanged. + negative_location_option: PRESENCE | PRESENCE_OR_INTEREST. Who the + location exclusions exclude; the preview shows the current value. + Empty leaves it unchanged. """ from adloop.ads.write import update_campaign as _impl @@ -3433,6 +3454,8 @@ def update_campaign( display_network_enabled=display_network_enabled, display_expansion_enabled=display_expansion_enabled, max_cpc=max_cpc, + location_option=location_option, + negative_location_option=negative_location_option, ) diff --git a/tests/test_location_option.py b/tests/test_location_option.py new file mode 100644 index 0000000..9ab5f72 --- /dev/null +++ b/tests/test_location_option.py @@ -0,0 +1,260 @@ +"""Location options on draft_campaign and update_campaign (#104). + +campaign.geo_target_type_setting decides whether the geo targets reach only +people in the locations (PRESENCE) or also people elsewhere who show interest +in them (PRESENCE_OR_INTEREST, Google's default). Empty leaves it as it is. +""" + +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, *, positive="PRESENCE_OR_INTEREST", negative="PRESENCE_OR_INTEREST", + found=True): + queries: list[str] = [] + + def execute_query(_config, _cid, query): + queries.append(query) + if not found: + return [] + return [{ + "campaign.geo_target_type_setting.positive_geo_target_type": positive, + "campaign.geo_target_type_setting.negative_geo_target_type": negative, + }] + + monkeypatch.setattr("adloop.ads.gaql.execute_query", execute_query) + return queries + + +def _draft(config, **kwargs): + return write.draft_campaign( + config, + customer_id="1234567890", + campaign_name="Search", + daily_budget=20, + bidding_strategy="TARGET_SPEND", + geo_target_ids=["2840"], + language_ids=["1000"], + **kwargs, + ) + + +class TestDraftCampaign: + def test_omitted_leaves_googles_default_and_the_preview_unchanged(self, config): + result = _draft(config) + + assert "location_option" not in result["changes"] + assert "negative_location_option" not in result["changes"] + assert not any("Location option" in w for w in result.get("warnings", [])) + + def test_presence_is_planned_without_a_warning(self, config): + result = _draft(config, location_option="presence", negative_location_option="PRESENCE_OR_INTEREST") + + assert result["changes"]["location_option"] == "PRESENCE" + assert result["changes"]["negative_location_option"] == "PRESENCE_OR_INTEREST" + assert not any("widens reach" in w for w in result.get("warnings", [])) + assert result["requires_double_confirm"] is False + + @pytest.mark.parametrize("option", ["PRESENCE_OR_INTEREST", "SEARCH_INTEREST"]) + def test_a_wider_option_warns(self, config, option): + result = _draft(config, location_option=option) + + assert any(f"Location option {option} widens reach" in w for w in result["warnings"]) + assert result["requires_double_confirm"] is False + + def test_invalid_values_are_refused(self, config): + result = _draft(config, location_option="EVERYWHERE", negative_location_option="SEARCH_INTEREST") + + assert result["error"] == "Validation failed" + assert any("location_option must be one of" in d for d in result["details"]) + assert any("negative_location_option must be one of" in d for d in result["details"]) + + +class TestUpdateCampaign: + def test_preview_shows_old_and_new(self, config, monkeypatch): + _current(monkeypatch) + + result = write.update_campaign( + config, customer_id="1234567890", campaign_id=CAMPAIGN, + location_option="PRESENCE", negative_location_option="PRESENCE_OR_INTEREST", + ) + + assert result["changes"] == { + "campaign_id": CAMPAIGN, + "current_location_option": "PRESENCE_OR_INTEREST", + "location_option": "PRESENCE", + "current_negative_location_option": "PRESENCE_OR_INTEREST", + "negative_location_option": "PRESENCE_OR_INTEREST", + } + assert result["requires_double_confirm"] is False + assert "warnings" not in result + + def test_widening_warns_with_a_single_confirmation(self, config, monkeypatch): + _current(monkeypatch, positive="PRESENCE") + + result = write.update_campaign( + config, customer_id="1234567890", campaign_id=CAMPAIGN, + location_option="PRESENCE_OR_INTEREST", + ) + + assert result["changes"]["current_location_option"] == "PRESENCE" + assert any("widens reach" in w for w in result["warnings"]) + assert result["requires_double_confirm"] is False + + def test_negative_presence_says_it_excludes_fewer_people(self, config, monkeypatch): + _current(monkeypatch) + + result = write.update_campaign( + config, customer_id="1234567890", campaign_id=CAMPAIGN, + negative_location_option="PRESENCE", + ) + + assert "location_option" not in result["changes"] + assert any("excludes only people in the excluded" in w for w in result["warnings"]) + + def test_no_warning_for_a_value_the_campaign_already_has(self, config, monkeypatch): + _current(monkeypatch, positive="SEARCH_INTEREST", negative="PRESENCE") + + result = write.update_campaign( + config, customer_id="1234567890", campaign_id=CAMPAIGN, + location_option="SEARCH_INTEREST", negative_location_option="PRESENCE", + ) + + assert "warnings" not in result + + def test_a_location_option_alone_is_a_change(self, config, monkeypatch): + queries = _current(monkeypatch) + + result = write.update_campaign( + config, customer_id="1234567890", campaign_id=CAMPAIGN, location_option="PRESENCE", + ) + + assert result["status"] == "PENDING_CONFIRMATION" + assert f"campaign.id = {CAMPAIGN}" in queries[0] + + def test_unknown_campaign(self, config, monkeypatch): + _current(monkeypatch, found=False) + + result = write.update_campaign( + config, customer_id="1234567890", campaign_id=CAMPAIGN, location_option="PRESENCE", + ) + + assert result == {"error": f"Campaign {CAMPAIGN} was not found in this account"} + + def test_invalid_value_never_reads_the_account(self, config): + # The suite-wide no_live_gaql fixture raises if anything is read. + result = write.update_campaign( + config, customer_id="1234567890", campaign_id=CAMPAIGN, location_option="NEARBY", + ) + + assert result["error"] == "Validation failed" + + +def _client(service): + return _FakeClient({ + "GoogleAdsService": service, + "CampaignService": _FakePathService("campaigns"), + "CampaignBudgetService": _FakePathService("campaignBudgets"), + "AdGroupService": _FakePathService("adGroups"), + }) + + +class TestApply: + def test_create_sets_both_options(self): + service = _FakeGoogleAdsService([ + _FakeMutateOperationResponse("campaign_budget_result", "customers/1/campaignBudgets/1"), + _FakeMutateOperationResponse("campaign_result", "customers/1/campaigns/2"), + _FakeMutateOperationResponse("ad_group_result", "customers/1/adGroups/3"), + ]) + client = _client(service) + + write._apply_create_campaign(client, "1234567890", { + "campaign_name": "Search", + "daily_budget": 20, + "bidding_strategy": "TARGET_SPEND", + "channel_type": "SEARCH", + "geo_target_ids": [], + "language_ids": [], + "location_option": "PRESENCE", + "negative_location_option": "PRESENCE", + }) + + setting = service.operations[1].campaign_operation.create.geo_target_type_setting + assert setting.positive_geo_target_type == client.enums.PositiveGeoTargetTypeEnum.PRESENCE + assert setting.negative_geo_target_type == client.enums.NegativeGeoTargetTypeEnum.PRESENCE + + def test_create_without_options_leaves_them_unset(self): + service = _FakeGoogleAdsService() + client = _client(service) + + write._apply_create_campaign(client, "1234567890", { + "campaign_name": "Search", + "daily_budget": 20, + "bidding_strategy": "TARGET_SPEND", + "channel_type": "SEARCH", + "geo_target_ids": [], + "language_ids": [], + }) + + campaign = service.operations[1].campaign_operation.create + assert "geo_target_type_setting" not in campaign # Google applies its default + + def test_update_masks_only_the_option_given(self): + service = _FakeGoogleAdsService( + [_FakeMutateOperationResponse("campaign_result", "customers/1/campaigns/1")] + ) + client = _client(service) + + write._apply_update_campaign(client, "1234567890", { + "campaign_id": CAMPAIGN, + "current_location_option": "PRESENCE_OR_INTEREST", + "location_option": "PRESENCE", + }) + + operation = service.operations[0].campaign_operation + assert list(operation.update_mask.paths) == [ + "geo_target_type_setting.positive_geo_target_type" + ] + assert ( + operation.update.geo_target_type_setting.positive_geo_target_type + == client.enums.PositiveGeoTargetTypeEnum.PRESENCE + ) + + +@pytest.mark.asyncio +async def test_both_tools_document_the_parameters(): + from adloop.server import mcp + + tools = {t.name: t for t in await mcp.list_tools()} + for name in ("draft_campaign", "update_campaign"): + properties = tools[name].parameters["properties"] + for param in ("location_option", "negative_location_option"): + assert properties[param].get("description"), (name, param)