diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 270f6c1..76de655 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -43,6 +43,7 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | `get_asset_performance` | Per-asset details for PMax — field type, serving status, content. Use with `get_detailed_asset_performance` for quality signals | `campaign_id` (optional) | | `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most | `campaign_id` (optional) | | `get_audience_performance` | Audience segment metrics — remarketing, in-market, affinity, demographics | `date_range_start`, `date_range_end`, `campaign_id` (optional) | +| `get_demographic_targeting` | List current demographic criteria (age/gender/parental status/income) on an ad group or campaign — returns each criterion's `remove_id` for use with `remove_entity` | exactly one of `ad_group_id` or `campaign_id` | | `run_gaql` | Custom queries not covered by other tools | `query`, `format` (table/json/csv) | **Return format notes:** @@ -53,6 +54,7 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics - 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_audience_performance` works for campaigns with explicit audience targeting. PMax audience targeting is automatic and may not appear in this report. +- `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`. ### Cross-Reference Tools (GA4 + Ads combined) @@ -103,9 +105,10 @@ These tools call both APIs internally and return unified results with computed ` | `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", "campaign_asset", "asset", "customer_asset"), `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) | **Write tool workflow:** @@ -123,7 +126,7 @@ These tools call both APIs internally and return unified results with computed ` - 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` 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 remove a negative geo exclusion explicitly, use `remove_entity` with `entity_type="campaign_criterion"`. - `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", "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 "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. +- `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. - All operations (including dry runs) are logged to `~/.adloop/audit.log`. @@ -376,6 +379,28 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 4. For PMax campaigns, note that audience targeting is automatic — audience performance data may not appear in the `ad_group_audience_view` report. Audience signals in PMax are configured at the asset group level and serve as hints, not hard targeting. 5. For Display/Search campaigns with explicit audience overlays, use the data to recommend bid adjustments or audience exclusions +### When user asks to exclude or include demographics ("exclude ages 25-34", "exclude women", "only show to parents") + +1. Identify the target scope: usually an ad group, sometimes a campaign. If the user says "in campaign X", target the campaign; if they say "in ad group Y" or "on this ad group", target the ad group. If unclear, ask — demographic exclusions at ad-group level are the more common pattern. +2. Get the entity ID: + - For an ad group: `run_gaql` with `SELECT ad_group.id, ad_group.name FROM ad_group WHERE campaign.id = {campaign_id}` (or filter by name) + - For a campaign: `get_campaign_performance` +3. Call `get_demographic_targeting` first to see what's already excluded — avoid creating duplicate exclusions. +4. **Map the user's request to Google's fixed buckets:** + - **Age**: 18-24, 25-34, 35-44, 45-54, 55-64, 65+. Google does NOT support arbitrary ranges. If the user says "23-35", explain that the closest buckets are 25-34 (covers 25-34 exactly) plus partial overlap with 18-24 (23-24) and 35-44 (35). Ask which buckets to use. + - **Gender**: female, male, undetermined + - **Parental status**: parent, not_a_parent, undetermined + - **Income range**: PERCENTILES (not currency). top-10, 11-20, 21-30, 31-40, 41-50, lower-50. Only works in select countries (US, AU, JP, etc.). Do NOT translate dollar amounts — income range targeting is based on a user's household income percentile in their country. +5. Call `draft_demographic_targeting` with the appropriate dimension(s). Default is `negative=True` (exclusion). Use `negative=False` only when the user explicitly wants to NARROW targeting to specific demographics — warn them that this excludes everyone else (plus UNDETERMINED). +6. Present the preview to the user — show clearly which segments will no longer see ads (for exclusions) or will be the only ones who do (for positive targeting). +7. Wait for explicit user approval before calling `confirm_and_apply`. +8. **To remove an existing demographic exclusion**, call `get_demographic_targeting` to find the criterion's `remove_id`, then use `remove_entity` with `entity_type="ad_group_criterion"` or `entity_type="campaign_criterion"`. + +**Common pitfalls:** +- "Audiences" and "demographics" mean different things in Google Ads. Audiences are user lists (remarketing, in-market, affinity) — use `get_audience_performance` and audience-targeting tools. Demographics are age/gender/parental/income — use `get_demographic_targeting` and `draft_demographic_targeting`. If the user says "exclude audiences" but describes ages, they mean demographics. +- Excluding many segments within a single dimension drastically reduces reach. The tool will warn when you exclude nearly all values in one dimension. +- Demographic exclusions are ad-serving filters, not bid modifiers. To raise or lower bids for a demographic, use bid adjustments (not currently exposed as a dedicated tool — use `run_gaql` and the Google Ads UI). + ## Default Parameters When the user doesn't specify: diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index af3cae4..1d991e9 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -45,6 +45,7 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | `get_asset_performance` | Per-asset details for PMax — field type, serving status, content. Use with `get_detailed_asset_performance` for quality signals | `campaign_id` (optional) | | `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most | `campaign_id` (optional) | | `get_audience_performance` | Audience segment metrics — remarketing, in-market, affinity, demographics | `date_range_start`, `date_range_end`, `campaign_id` (optional) | +| `get_demographic_targeting` | List current demographic criteria (age/gender/parental status/income) on an ad group or campaign — returns each criterion's `remove_id` for use with `remove_entity` | exactly one of `ad_group_id` or `campaign_id` | | `run_gaql` | Custom queries not covered by other tools | `query`, `format` (table/json/csv) | **Return format notes:** @@ -55,6 +56,7 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics - 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_audience_performance` works for campaigns with explicit audience targeting. PMax audience targeting is automatic and may not appear in this report. +- `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`. ### Cross-Reference Tools (GA4 + Ads combined) @@ -105,9 +107,10 @@ These tools call both APIs internally and return unified results with computed ` | `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", "campaign_asset", "asset", "customer_asset"), `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) | **Write tool workflow:** @@ -125,7 +128,7 @@ These tools call both APIs internally and return unified results with computed ` - 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` 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 remove a negative geo exclusion explicitly, use `remove_entity` with `entity_type="campaign_criterion"`. - `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", "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 "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. +- `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. - All operations (including dry runs) are logged to `~/.adloop/audit.log`. @@ -378,6 +381,28 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 4. For PMax campaigns, note that audience targeting is automatic — audience performance data may not appear in the `ad_group_audience_view` report. Audience signals in PMax are configured at the asset group level and serve as hints, not hard targeting. 5. For Display/Search campaigns with explicit audience overlays, use the data to recommend bid adjustments or audience exclusions +### When user asks to exclude or include demographics ("exclude ages 25-34", "exclude women", "only show to parents") + +1. Identify the target scope: usually an ad group, sometimes a campaign. If the user says "in campaign X", target the campaign; if they say "in ad group Y" or "on this ad group", target the ad group. If unclear, ask — demographic exclusions at ad-group level are the more common pattern. +2. Get the entity ID: + - For an ad group: `run_gaql` with `SELECT ad_group.id, ad_group.name FROM ad_group WHERE campaign.id = {campaign_id}` (or filter by name) + - For a campaign: `get_campaign_performance` +3. Call `get_demographic_targeting` first to see what's already excluded — avoid creating duplicate exclusions. +4. **Map the user's request to Google's fixed buckets:** + - **Age**: 18-24, 25-34, 35-44, 45-54, 55-64, 65+. Google does NOT support arbitrary ranges. If the user says "23-35", explain that the closest buckets are 25-34 (covers 25-34 exactly) plus partial overlap with 18-24 (23-24) and 35-44 (35). Ask which buckets to use. + - **Gender**: female, male, undetermined + - **Parental status**: parent, not_a_parent, undetermined + - **Income range**: PERCENTILES (not currency). top-10, 11-20, 21-30, 31-40, 41-50, lower-50. Only works in select countries (US, AU, JP, etc.). Do NOT translate dollar amounts — income range targeting is based on a user's household income percentile in their country. +5. Call `draft_demographic_targeting` with the appropriate dimension(s). Default is `negative=True` (exclusion). Use `negative=False` only when the user explicitly wants to NARROW targeting to specific demographics — warn them that this excludes everyone else (plus UNDETERMINED). +6. Present the preview to the user — show clearly which segments will no longer see ads (for exclusions) or will be the only ones who do (for positive targeting). +7. Wait for explicit user approval before calling `confirm_and_apply`. +8. **To remove an existing demographic exclusion**, call `get_demographic_targeting` to find the criterion's `remove_id`, then use `remove_entity` with `entity_type="ad_group_criterion"` or `entity_type="campaign_criterion"`. + +**Common pitfalls:** +- "Audiences" and "demographics" mean different things in Google Ads. Audiences are user lists (remarketing, in-market, affinity) — use `get_audience_performance` and audience-targeting tools. Demographics are age/gender/parental/income — use `get_demographic_targeting` and `draft_demographic_targeting`. If the user says "exclude audiences" but describes ages, they mean demographics. +- Excluding many segments within a single dimension drastically reduces reach. The tool will warn when you exclude nearly all values in one dimension. +- Demographic exclusions are ad-serving filters, not bid modifiers. To raise or lower bids for a demographic, use bid adjustments (not currently exposed as a dedicated tool — use `run_gaql` and the Google Ads UI). + ## Default Parameters When the user doesn't specify: diff --git a/src/adloop/ads/read.py b/src/adloop/ads/read.py index 4070814..e4081ee 100644 --- a/src/adloop/ads/read.py +++ b/src/adloop/ads/read.py @@ -534,6 +534,115 @@ def get_audience_performance( return {"audiences": rows, "total_audiences": len(rows), "insights": insights} +def get_demographic_targeting( + config: AdLoopConfig, + *, + customer_id: str = "", + ad_group_id: str = "", + campaign_id: str = "", +) -> dict: + """List current demographic targeting (AGE_RANGE, GENDER, PARENTAL_STATUS, INCOME_RANGE). + + Provide either `ad_group_id` or `campaign_id`. Returns criteria with their + `criterion_id` (needed to remove a criterion), the demographic value, and + whether the criterion is negative (excluded) or positive (narrowed targeting). + + By default, Google Ads serves ads to all demographic segments — a criterion + only appears here when the user has explicitly added an exclusion or + positive targeting refinement. + """ + from adloop.ads.gaql import execute_query + + if not ad_group_id and not campaign_id: + return { + "error": "Provide either ad_group_id or campaign_id", + } + if ad_group_id and campaign_id: + return { + "error": "Provide only one of ad_group_id or campaign_id, not both", + } + + demographic_types = ( + "'AGE_RANGE', 'GENDER', 'PARENTAL_STATUS', 'INCOME_RANGE'" + ) + + if ad_group_id: + query = f""" + SELECT ad_group_criterion.criterion_id, + ad_group_criterion.type, + ad_group_criterion.negative, + ad_group_criterion.status, + ad_group_criterion.age_range.type, + ad_group_criterion.gender.type, + ad_group_criterion.parental_status.type, + ad_group_criterion.income_range.type, + ad_group.id, ad_group.name, + campaign.id, campaign.name + FROM ad_group_criterion + WHERE ad_group.id = {ad_group_id} + AND ad_group_criterion.type IN ({demographic_types}) + ORDER BY ad_group_criterion.type + """ + level = "ad_group" + else: + query = f""" + SELECT campaign_criterion.criterion_id, + campaign_criterion.type, + campaign_criterion.negative, + campaign_criterion.status, + campaign_criterion.age_range.type, + campaign_criterion.gender.type, + campaign_criterion.parental_status.type, + campaign_criterion.income_range.type, + campaign.id, campaign.name + FROM campaign_criterion + WHERE campaign.id = {campaign_id} + AND campaign_criterion.type IN ({demographic_types}) + ORDER BY campaign_criterion.type + """ + level = "campaign" + + rows = execute_query(config, customer_id, query) + + # Surface the composite resource ID for each criterion so the AI can pass + # it straight to remove_entity (which expects 'parentId~criterionId'). + for row in rows: + criterion_id = row.get(f"{level}_criterion.criterion_id") + if criterion_id is None: + continue + if level == "ad_group": + parent_id = row.get("ad_group.id") + else: + parent_id = row.get("campaign.id") + if parent_id is not None: + row["remove_id"] = f"{parent_id}~{criterion_id}" + + insights: list[str] = [] + if not rows: + insights.append( + f"No demographic criteria found on this {level}. By default, Google " + f"Ads serves ads to all age/gender/parental/income segments — " + f"criteria only appear here once you actively exclude or narrow them." + ) + else: + negatives = sum( + 1 for r in rows + if r.get(f"{level}_criterion.negative") is True + ) + if negatives: + insights.append( + f"{negatives} demographic exclusion(s) active on this {level}. " + f"Excluded segments will not see ads." + ) + + return { + "level": level, + "criteria": rows, + "total_criteria": len(rows), + "insights": insights, + } + + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index b06ab23..9b236cc 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -66,6 +66,132 @@ def _normalize_rsa_assets(items: list) -> list[dict]: return out +# Demographic targeting — Google Ads exposes four demographic dimensions. +# By default, ads serve to all segments. Criteria added below either exclude +# (negative=True, the common case) or narrow targeting (negative=False). +_AGE_RANGE_TYPES = { + "AGE_RANGE_18_24", + "AGE_RANGE_25_34", + "AGE_RANGE_35_44", + "AGE_RANGE_45_54", + "AGE_RANGE_55_64", + "AGE_RANGE_65_UP", + "AGE_RANGE_UNDETERMINED", +} + +_GENDER_TYPES = {"FEMALE", "MALE", "UNDETERMINED"} + +_PARENTAL_STATUS_TYPES = {"PARENT", "NOT_A_PARENT", "UNDETERMINED"} + +# Income ranges are demographic PERCENTILES (top X% by income in supported +# countries), not currency buckets. Available primarily in US/AU/JP. +_INCOME_RANGE_TYPES = { + "INCOME_RANGE_0_50", # Lower 50% + "INCOME_RANGE_50_60", # 41-50% + "INCOME_RANGE_60_70", # 31-40% + "INCOME_RANGE_70_80", # 21-30% + "INCOME_RANGE_80_90", # 11-20% + "INCOME_RANGE_90_UP", # Top 10% + "INCOME_RANGE_UNDETERMINED", +} + +# Human-readable aliases → API enum. Lowercased keys; lookup uses .lower(). +_AGE_RANGE_ALIASES = { + "18-24": "AGE_RANGE_18_24", + "25-34": "AGE_RANGE_25_34", + "35-44": "AGE_RANGE_35_44", + "45-54": "AGE_RANGE_45_54", + "55-64": "AGE_RANGE_55_64", + "65+": "AGE_RANGE_65_UP", + "65-up": "AGE_RANGE_65_UP", + "undetermined": "AGE_RANGE_UNDETERMINED", + "unknown": "AGE_RANGE_UNDETERMINED", +} + +_GENDER_ALIASES = { + "female": "FEMALE", + "f": "FEMALE", + "male": "MALE", + "m": "MALE", + "undetermined": "UNDETERMINED", + "unknown": "UNDETERMINED", +} + +_PARENTAL_ALIASES = { + "parent": "PARENT", + "parents": "PARENT", + "not_a_parent": "NOT_A_PARENT", + "not-a-parent": "NOT_A_PARENT", + "not a parent": "NOT_A_PARENT", + "non-parent": "NOT_A_PARENT", + "undetermined": "UNDETERMINED", + "unknown": "UNDETERMINED", +} + +_INCOME_ALIASES = { + "lower-50": "INCOME_RANGE_0_50", + "0-50": "INCOME_RANGE_0_50", + "41-50": "INCOME_RANGE_50_60", + "50-60": "INCOME_RANGE_50_60", + "31-40": "INCOME_RANGE_60_70", + "60-70": "INCOME_RANGE_60_70", + "21-30": "INCOME_RANGE_70_80", + "70-80": "INCOME_RANGE_70_80", + "11-20": "INCOME_RANGE_80_90", + "80-90": "INCOME_RANGE_80_90", + "top-10": "INCOME_RANGE_90_UP", + "top 10%": "INCOME_RANGE_90_UP", + "90-up": "INCOME_RANGE_90_UP", + "90+": "INCOME_RANGE_90_UP", + "undetermined": "INCOME_RANGE_UNDETERMINED", + "unknown": "INCOME_RANGE_UNDETERMINED", +} + + +def _normalize_demographic_values( + values: list[str] | None, + enum_set: set[str], + alias_map: dict[str, str], + dimension: str, +) -> tuple[list[str], list[str]]: + """Map a user-supplied list to Google Ads enum strings. + + Returns (normalized_values, errors). Accepts either the canonical enum + (case-insensitive) or a human-readable alias like '25-34' or 'female'. + """ + if not values: + return [], [] + + normalized: list[str] = [] + errors: list[str] = [] + seen: set[str] = set() + for raw in values: + if not isinstance(raw, str): + errors.append(f"{dimension}: values must be strings, got {type(raw).__name__}") + continue + candidate = raw.strip() + if not candidate: + continue + upper = candidate.upper().replace(" ", "_").replace("-", "_") + lower = candidate.lower() + if upper in enum_set: + api_value = upper + elif lower in alias_map: + api_value = alias_map[lower] + else: + errors.append( + f"{dimension}: '{raw}' is not a valid value. " + f"Use one of {sorted(enum_set)} or a human-readable alias." + ) + continue + if api_value in seen: + continue + seen.add(api_value) + normalized.append(api_value) + + return normalized, errors + + # --------------------------------------------------------------------------- # URL validation — verify URLs exist before creating ads/sitelinks # --------------------------------------------------------------------------- @@ -649,6 +775,116 @@ def detach_shared_set_from_campaigns( return plan.to_preview() +def draft_demographic_targeting( + config: AdLoopConfig, + *, + customer_id: str = "", + ad_group_id: str = "", + campaign_id: str = "", + age_ranges: list[str] | None = None, + genders: list[str] | None = None, + parental_statuses: list[str] | None = None, + income_ranges: list[str] | None = None, + negative: bool = True, +) -> dict: + """Draft demographic targeting criteria (age, gender, parental status, income) — returns PREVIEW. + + By default, Google Ads serves to all demographic segments. This tool adds + criteria that either EXCLUDE a segment (negative=True, default) or NARROW + targeting to it (negative=False — uncommon). + + Provide either `ad_group_id` or `campaign_id`. At least one of the + demographic lists (age_ranges/genders/parental_statuses/income_ranges) + must be non-empty. + + Accepted values per dimension: + - age_ranges: '18-24', '25-34', '35-44', '45-54', '55-64', '65+' (or the + canonical AGE_RANGE_18_24 etc.). Note: Google's buckets are fixed — + 'Exclude 23-35' has no exact mapping; you must pick the closest buckets. + - genders: 'female', 'male', 'undetermined' + - parental_statuses: 'parent', 'not_a_parent', 'undetermined' + - income_ranges: PERCENTILE buckets, not currency. 'top-10' (top 10%), + '11-20', '21-30', '31-40', '41-50', 'lower-50', 'undetermined'. + Only available in select countries (US, AU, JP, etc.). + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("add_demographic_criteria", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors: list[str] = [] + if bool(ad_group_id) == bool(campaign_id): + errors.append( + "Provide exactly one of ad_group_id or campaign_id" + ) + + age_values, age_errors = _normalize_demographic_values( + age_ranges, _AGE_RANGE_TYPES, _AGE_RANGE_ALIASES, "age_ranges" + ) + gender_values, gender_errors = _normalize_demographic_values( + genders, _GENDER_TYPES, _GENDER_ALIASES, "genders" + ) + parental_values, parental_errors = _normalize_demographic_values( + parental_statuses, + _PARENTAL_STATUS_TYPES, + _PARENTAL_ALIASES, + "parental_statuses", + ) + income_values, income_errors = _normalize_demographic_values( + income_ranges, _INCOME_RANGE_TYPES, _INCOME_ALIASES, "income_ranges" + ) + errors.extend(age_errors + gender_errors + parental_errors + income_errors) + + if not (age_values or gender_values or parental_values or income_values): + errors.append( + "At least one of age_ranges, genders, parental_statuses, or " + "income_ranges must contain a value" + ) + + if errors: + return {"error": "Validation failed", "details": errors} + + warnings: list[str] = [] + if not negative: + warnings.append( + "negative=False adds POSITIVE demographic criteria, which NARROWS " + "targeting. Users not matching the criteria (plus UNDETERMINED) " + "will no longer see ads. This is uncommon — exclusions are the " + "typical pattern." + ) + excludes_all_age = len(age_values) >= len(_AGE_RANGE_TYPES) - 1 + excludes_all_gender = len(gender_values) >= len(_GENDER_TYPES) - 1 + if negative and (excludes_all_age or excludes_all_gender): + warnings.append( + "Excluding nearly every value in a single demographic dimension " + "will reduce ad delivery sharply. Verify this is intentional." + ) + + plan = ChangePlan( + operation="add_demographic_criteria", + entity_type="ad_group_criterion" if ad_group_id else "campaign_criterion", + entity_id=ad_group_id or campaign_id, + customer_id=customer_id, + changes={ + "ad_group_id": ad_group_id, + "campaign_id": campaign_id, + "age_ranges": age_values, + "genders": gender_values, + "parental_statuses": parental_values, + "income_ranges": income_values, + "negative": negative, + }, + ) + store_plan(plan) + preview = plan.to_preview() + if warnings: + preview["warnings"] = warnings + return preview + + def update_ad_group( config: AdLoopConfig, *, @@ -1507,8 +1743,13 @@ def confirm_and_apply( _VALID_MATCH_TYPES = {"EXACT", "PHRASE", "BROAD"} _VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"} _REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | { - "negative_keyword", "campaign_asset", "asset", "customer_asset", + "negative_keyword", "shared_criterion", + "ad_group_criterion", + "campaign_criterion", + "campaign_asset", + "asset", + "customer_asset", } _SMART_BIDDING_STRATEGIES = { @@ -2148,6 +2389,7 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict: "add_to_negative_keyword_list": _apply_add_to_negative_keyword_list, "attach_shared_set_to_campaigns": _apply_attach_shared_set_to_campaigns, "detach_shared_set_from_campaigns": _apply_detach_shared_set_from_campaigns, + "add_demographic_criteria": _apply_add_demographic_criteria, "pause_entity": _apply_status_change, "enable_entity": _apply_status_change, "remove_entity": _apply_remove, @@ -2647,6 +2889,101 @@ def _apply_add_negative_keywords(client: object, cid: str, changes: dict) -> dic return {"resource_names": [r.resource_name for r in response.results]} +def _apply_add_demographic_criteria( + client: object, cid: str, changes: dict +) -> dict: + """Create AGE_RANGE/GENDER/PARENTAL_STATUS/INCOME_RANGE criteria. + + Creates ad_group_criterion entries when `ad_group_id` is set, otherwise + campaign_criterion entries when `campaign_id` is set. + """ + ad_group_id = changes.get("ad_group_id") or "" + campaign_id = changes.get("campaign_id") or "" + negative = bool(changes.get("negative", True)) + + if ad_group_id: + service = client.get_service("AdGroupCriterionService") + ad_group_path = client.get_service("AdGroupService").ad_group_path( + cid, ad_group_id + ) + + def make_criterion(): + op = client.get_type("AdGroupCriterionOperation") + criterion = op.create + criterion.ad_group = ad_group_path + criterion.negative = negative + return op, criterion + + operations = _build_demographic_criteria(client, changes, make_criterion) + response = service.mutate_ad_group_criteria( + customer_id=cid, operations=operations + ) + return {"resource_names": [r.resource_name for r in response.results]} + + if campaign_id: + service = client.get_service("CampaignCriterionService") + campaign_path = client.get_service("CampaignService").campaign_path( + cid, campaign_id + ) + + def make_criterion(): + op = client.get_type("CampaignCriterionOperation") + criterion = op.create + criterion.campaign = campaign_path + criterion.negative = negative + return op, criterion + + operations = _build_demographic_criteria(client, changes, make_criterion) + response = service.mutate_campaign_criteria( + customer_id=cid, operations=operations + ) + return {"resource_names": [r.resource_name for r in response.results]} + + raise ValueError("Either ad_group_id or campaign_id must be set") + + +def _build_demographic_criteria( + client: object, + changes: dict, + make_criterion: object, +) -> list: + """Build the list of criterion operations from the four demographic dimensions. + + `make_criterion` is a zero-arg callable that returns a fresh + (operation, criterion) pair pre-populated with the parent (ad_group or + campaign) and negative flag. + """ + operations = [] + + for value in changes.get("age_ranges") or []: + op, criterion = make_criterion() + criterion.age_range.type_ = getattr( + client.enums.AgeRangeTypeEnum, value + ) + operations.append(op) + + for value in changes.get("genders") or []: + op, criterion = make_criterion() + criterion.gender.type_ = getattr(client.enums.GenderTypeEnum, value) + operations.append(op) + + for value in changes.get("parental_statuses") or []: + op, criterion = make_criterion() + criterion.parental_status.type_ = getattr( + client.enums.ParentalStatusTypeEnum, value + ) + operations.append(op) + + for value in changes.get("income_ranges") or []: + op, criterion = make_criterion() + criterion.income_range.type_ = getattr( + client.enums.IncomeRangeTypeEnum, value + ) + operations.append(op) + + return operations + + def _resolve_ad_entity_id(client: object, cid: str, entity_id: str) -> str: """Ensure ad entity_id is in 'adGroupId~adId' composite format. @@ -2705,7 +3042,7 @@ def _apply_remove( customer_id=cid, operations=[operation] ) - elif entity_type == "keyword": + elif entity_type in ("keyword", "ad_group_criterion"): service = client.get_service("AdGroupCriterionService") operation = client.get_type("AdGroupCriterionOperation") operation.remove = f"customers/{cid}/adGroupCriteria/{entity_id}" @@ -2713,7 +3050,7 @@ def _apply_remove( customer_id=cid, operations=[operation] ) - elif entity_type == "negative_keyword": + elif entity_type in ("negative_keyword", "campaign_criterion"): service = client.get_service("CampaignCriterionService") operation = client.get_type("CampaignCriterionOperation") operation.remove = f"customers/{cid}/campaignCriteria/{entity_id}" diff --git a/src/adloop/server.py b/src/adloop/server.py index 3d8bf03..aa045a7 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -57,6 +57,9 @@ def _coerce_json_string_to_list(value): _DictListOpt = Annotated[ list[dict] | None, BeforeValidator(_coerce_json_string_to_list) ] +_StrOrDictList = Annotated[ + list[str | dict], BeforeValidator(_coerce_json_string_to_list) +] def _build_orchestration_instructions() -> str: """Compact orchestration hint sent via MCP ``InitializeResult.instructions``. @@ -719,6 +722,34 @@ def get_audience_performance( ) +@mcp.tool(annotations=_READONLY) +@_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( + _config, + customer_id=customer_id or _config.ads.customer_id, + ad_group_id=ad_group_id, + campaign_id=campaign_id, + ) + + # --------------------------------------------------------------------------- # Cross-Reference Tools (GA4 + Ads Combined) # --------------------------------------------------------------------------- @@ -1001,8 +1032,8 @@ def update_campaign( @_safe def draft_responsive_search_ad( ad_group_id: str, - headlines: list[str | dict], - descriptions: list[str | dict], + headlines: _StrOrDictList, + descriptions: _StrOrDictList, final_url: str, customer_id: str = "", path1: str = "", @@ -1210,6 +1241,58 @@ def detach_shared_set_from_campaigns( ) +@mcp.tool(annotations=_WRITE) +@_safe +def draft_demographic_targeting( + customer_id: str = "", + ad_group_id: str = "", + campaign_id: str = "", + # noqa: B006 — mutable default required for MCP JSON schema. Using + # `_StrList = []` produces a flat `{"type": "array", "default": []}` + # schema that naive MCP clients handle; `_StrListOpt = None` would + # produce an `anyOf: [array, null]` form that some clients ignore. + age_ranges: _StrList = [], # noqa: B006 + genders: _StrList = [], # noqa: B006 + parental_statuses: _StrList = [], # noqa: B006 + income_ranges: _StrList = [], # noqa: B006 + negative: bool = True, +) -> dict: + """Draft demographic targeting (age/gender/parental status/income) — returns a PREVIEW. + + By default, Google Ads serves to all demographic segments. This tool adds + criteria that EXCLUDE a segment (negative=True, default) or NARROW + targeting to it (negative=False — uncommon). + + Provide exactly one of `ad_group_id` or `campaign_id`. At least one of + the four demographic lists must contain a value. + + Accepted values: + - age_ranges: '18-24', '25-34', '35-44', '45-54', '55-64', '65+'. + Google's buckets are FIXED — 'Exclude 23-35' has no exact mapping; + ask the user which buckets to use. + - genders: 'female', 'male', 'undetermined' + - parental_statuses: 'parent', 'not_a_parent', 'undetermined' + - income_ranges: PERCENTILES (not currency). 'top-10', '11-20', '21-30', + '31-40', '41-50', 'lower-50', 'undetermined'. Available in select + countries only (US, AU, JP, etc.). + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import draft_demographic_targeting as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + ad_group_id=ad_group_id, + campaign_id=campaign_id, + age_ranges=age_ranges, + genders=genders, + parental_statuses=parental_statuses, + income_ranges=income_ranges, + negative=negative, + ) + + @mcp.tool(annotations=_WRITE) @_safe def update_ad_group( diff --git a/tests/test_ads_write.py b/tests/test_ads_write.py index 20833a1..73c5e77 100644 --- a/tests/test_ads_write.py +++ b/tests/test_ads_write.py @@ -1278,6 +1278,351 @@ def test_non_numeric_shared_set_id_rejected_for_campaigns(self, config): assert "numeric" in result["error"] +class TestDraftDemographicTargeting: + def test_returns_preview_for_age_exclusion_on_ad_group(self, config): + result = write.draft_demographic_targeting( + config, + customer_id="123-456-7890", + ad_group_id="2002", + age_ranges=["25-34", "35-44"], + ) + assert result["operation"] == "add_demographic_criteria" + assert result["entity_type"] == "ad_group_criterion" + assert result["entity_id"] == "2002" + assert result["changes"]["age_ranges"] == [ + "AGE_RANGE_25_34", + "AGE_RANGE_35_44", + ] + assert result["changes"]["negative"] is True + assert result["status"] == "PENDING_CONFIRMATION" + + def test_accepts_canonical_enum_values(self, config): + result = write.draft_demographic_targeting( + config, + ad_group_id="2002", + genders=["MALE"], + parental_statuses=["NOT_A_PARENT"], + ) + assert result["changes"]["genders"] == ["MALE"] + assert result["changes"]["parental_statuses"] == ["NOT_A_PARENT"] + + def test_accepts_human_readable_aliases(self, config): + result = write.draft_demographic_targeting( + config, + ad_group_id="2002", + genders=["female"], + parental_statuses=["parent"], + income_ranges=["top-10"], + ) + assert result["changes"]["genders"] == ["FEMALE"] + assert result["changes"]["parental_statuses"] == ["PARENT"] + assert result["changes"]["income_ranges"] == ["INCOME_RANGE_90_UP"] + + def test_supports_campaign_level(self, config): + result = write.draft_demographic_targeting( + config, + campaign_id="1001", + age_ranges=["18-24"], + ) + assert result["entity_type"] == "campaign_criterion" + assert result["entity_id"] == "1001" + assert result["changes"]["campaign_id"] == "1001" + + def test_requires_exactly_one_parent(self, config): + both = write.draft_demographic_targeting( + config, + ad_group_id="2002", + campaign_id="1001", + age_ranges=["25-34"], + ) + assert both["error"] == "Validation failed" + assert any( + "exactly one of ad_group_id or campaign_id" in d + for d in both["details"] + ) + + neither = write.draft_demographic_targeting( + config, + age_ranges=["25-34"], + ) + assert neither["error"] == "Validation failed" + + def test_requires_at_least_one_dimension_value(self, config): + result = write.draft_demographic_targeting( + config, + ad_group_id="2002", + ) + assert result["error"] == "Validation failed" + assert any("at least one" in d.lower() for d in result["details"]) + + def test_rejects_invalid_value(self, config): + result = write.draft_demographic_targeting( + config, + ad_group_id="2002", + age_ranges=["23-35"], + ) + assert result["error"] == "Validation failed" + assert any("23-35" in d for d in result["details"]) + + def test_deduplicates_values(self, config): + result = write.draft_demographic_targeting( + config, + ad_group_id="2002", + age_ranges=["25-34", "AGE_RANGE_25_34", "25-34"], + ) + assert result["changes"]["age_ranges"] == ["AGE_RANGE_25_34"] + + def test_positive_targeting_emits_warning(self, config): + result = write.draft_demographic_targeting( + config, + ad_group_id="2002", + genders=["female"], + negative=False, + ) + assert result["changes"]["negative"] is False + assert "warnings" in result + assert any("narrows" in w.lower() for w in result["warnings"]) + + +def test_draft_responsive_search_ad_accepts_json_string_list(config, monkeypatch): + """Regression: headlines/descriptions accept JSON-encoded list strings. + + Same coercion contract as every other list-accepting MCP tool — covered + here because draft_responsive_search_ad uses the mixed-element + _StrOrDictList alias rather than _StrList. + """ + import asyncio + import json + from adloop.server import mcp + + # Bypass URL reachability check so the test doesn't make a network call. + monkeypatch.setattr( + "adloop.ads.write._validate_urls", lambda urls, timeout=10: {u: None for u in urls} + ) + + headlines = [ + "Quality Service Today", + "Trusted by Thousands", + "Free Quote in 60 Sec", + ] + descriptions = [ + "Get a personalized quote in under a minute. No commitment required.", + "Top-rated service with a satisfaction guarantee. Try us today.", + ] + + async def run(): + tool = await mcp.get_tool("draft_responsive_search_ad") + return await tool.run({ + "customer_id": "123-456-7890", + "ad_group_id": "2002", + "headlines": json.dumps(headlines), + "descriptions": json.dumps(descriptions), + "final_url": "https://example.com/landing", + }) + + result = asyncio.run(run()) + payload = result.structured_content + # If the coercion failed, we'd hit a Pydantic list_type error and never + # reach this point — so a clean preview is the regression check. + assert payload["operation"] == "create_responsive_search_ad" + stored_headlines = payload["changes"]["headlines"] + stored_descriptions = payload["changes"]["descriptions"] + assert len(stored_headlines) == len(headlines) + assert len(stored_descriptions) == len(descriptions) + # Each entry should round-trip the text — internal normalization may wrap + # strings as {"text": ..., "pinned_field": None}, which is fine. + for original, stored in zip(headlines, stored_headlines): + text = stored["text"] if isinstance(stored, dict) else stored + assert text == original + for original, stored in zip(descriptions, stored_descriptions): + text = stored["text"] if isinstance(stored, dict) else stored + assert text == original + + +def test_draft_demographic_targeting_accepts_json_string_list(config): + """The MCP server's _StrListOpt validator must coerce JSON-encoded list strings. + + Some MCP clients/harnesses pre-serialize list params as JSON strings. + The BeforeValidator decodes them back to a list before Pydantic checks + the type, so the tool keeps working without client-side workarounds. + """ + import asyncio + import json + from adloop.server import mcp + + async def run(): + tool = await mcp.get_tool("draft_demographic_targeting") + return await tool.run({ + "customer_id": "123-456-7890", + "ad_group_id": "2002", + # JSON-encoded list — what a misbehaving client might send. + "age_ranges": json.dumps(["25-34", "35-44"]), + }) + + result = asyncio.run(run()) + # ToolResult exposes structured_content for tools that return a dict. + payload = result.structured_content + assert payload["operation"] == "add_demographic_criteria" + assert payload["changes"]["age_ranges"] == ["AGE_RANGE_25_34", "AGE_RANGE_35_44"] + + +class _FakeAdGroupCriterionService: + def __init__(self): + self.operations = None + + def mutate_ad_group_criteria(self, customer_id, operations): + self.operations = operations + return SimpleNamespace( + results=[ + SimpleNamespace( + resource_name=f"customers/{customer_id}/adGroupCriteria/2002~{i}" + ) + for i, _ in enumerate(operations) + ] + ) + + +class _FakeCampaignCriterionService: + def __init__(self): + self.operations = None + + def mutate_campaign_criteria(self, customer_id, operations): + self.operations = operations + return SimpleNamespace( + results=[ + SimpleNamespace( + resource_name=f"customers/{customer_id}/campaignCriteria/1001~{i}" + ) + for i, _ in enumerate(operations) + ] + ) + + +def test_apply_add_demographic_criteria_at_ad_group_level(): + crit_service = _FakeAdGroupCriterionService() + client = _FakeClient({ + "AdGroupCriterionService": crit_service, + "AdGroupService": _FakePathService("adGroups"), + }) + + write._apply_add_demographic_criteria( + client, + "1234567890", + { + "ad_group_id": "2002", + "campaign_id": "", + "age_ranges": ["AGE_RANGE_25_34"], + "genders": ["FEMALE"], + "parental_statuses": [], + "income_ranges": [], + "negative": True, + }, + ) + + assert len(crit_service.operations) == 2 + age_op = crit_service.operations[0].create + gender_op = crit_service.operations[1].create + assert age_op.negative is True + assert age_op.ad_group == "customers/1234567890/adGroups/2002" + # AgeRangeTypeEnum.AGE_RANGE_25_34 → 503000 + assert age_op.age_range.type_ == client.enums.AgeRangeTypeEnum.AGE_RANGE_25_34 + assert gender_op.gender.type_ == client.enums.GenderTypeEnum.FEMALE + + +def test_apply_add_demographic_criteria_at_campaign_level(): + crit_service = _FakeCampaignCriterionService() + client = _FakeClient({ + "CampaignCriterionService": crit_service, + "CampaignService": _FakePathService("campaigns"), + }) + + write._apply_add_demographic_criteria( + client, + "1234567890", + { + "ad_group_id": "", + "campaign_id": "1001", + "age_ranges": [], + "genders": [], + "parental_statuses": [], + "income_ranges": ["INCOME_RANGE_90_UP"], + "negative": True, + }, + ) + + assert len(crit_service.operations) == 1 + op = crit_service.operations[0].create + assert op.campaign == "customers/1234567890/campaigns/1001" + assert op.negative is True + assert op.income_range.type_ == client.enums.IncomeRangeTypeEnum.INCOME_RANGE_90_UP + + +def test_remove_entity_accepts_ad_group_criterion_alias(config): + """Demographic criteria should be removable via the semantic alias.""" + result = write.remove_entity( + config, + customer_id="123-456-7890", + entity_type="ad_group_criterion", + entity_id="2002~99", + ) + assert result["operation"] == "remove_entity" + assert result["entity_type"] == "ad_group_criterion" + assert result["entity_id"] == "2002~99" + + +def test_remove_entity_accepts_campaign_criterion_alias(config): + result = write.remove_entity( + config, + customer_id="123-456-7890", + entity_type="campaign_criterion", + entity_id="1001~99", + ) + assert result["entity_type"] == "campaign_criterion" + + +class TestGetDemographicTargeting: + def test_requires_one_parent(self, config): + no_parent = read.get_demographic_targeting(config) + assert "error" in no_parent + both = read.get_demographic_targeting( + config, ad_group_id="2002", campaign_id="1001" + ) + assert "error" in both + + def test_enriches_rows_with_remove_id(self, config, monkeypatch): + fake_rows = [ + { + "ad_group_criterion.criterion_id": "99", + "ad_group_criterion.type": "AGE_RANGE", + "ad_group_criterion.negative": True, + "ad_group_criterion.status": "ENABLED", + "ad_group_criterion.age_range.type": "AGE_RANGE_25_34", + "ad_group.id": "2002", + "ad_group.name": "Brand Terms", + "campaign.id": "1001", + "campaign.name": "Search Launch", + } + ] + monkeypatch.setattr( + "adloop.ads.gaql.execute_query", lambda *_a, **_kw: fake_rows + ) + result = read.get_demographic_targeting( + config, customer_id="123-456-7890", ad_group_id="2002" + ) + assert result["level"] == "ad_group" + assert result["total_criteria"] == 1 + assert result["criteria"][0]["remove_id"] == "2002~99" + assert any("exclusion" in i.lower() for i in result["insights"]) + + def test_empty_emits_default_insight(self, config, monkeypatch): + monkeypatch.setattr("adloop.ads.gaql.execute_query", lambda *_a, **_kw: []) + result = read.get_demographic_targeting( + config, ad_group_id="2002" + ) + assert result["total_criteria"] == 0 + assert any("no demographic criteria" in i.lower() for i in result["insights"]) + + def test_extract_error_message_handles_plain_exceptions(): assert write._extract_error_message(ValueError("something broke")) == "something broke"