adloop 0.16.1__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {adloop-0.16.1 → adloop-0.18.0}/PKG-INFO +27 -1
  2. {adloop-0.16.1 → adloop-0.18.0}/README.md +26 -0
  3. {adloop-0.16.1 → adloop-0.18.0}/pyproject.toml +1 -1
  4. {adloop-0.16.1 → adloop-0.18.0}/pyproject.toml.orig +1 -1
  5. adloop-0.18.0/src/adloop/ads/ai_max.py +555 -0
  6. adloop-0.18.0/src/adloop/ads/brands.py +354 -0
  7. adloop-0.18.0/src/adloop/ads/custom_conversion_goals.py +720 -0
  8. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/read.py +14 -2
  9. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/write.py +1080 -17
  10. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ga4/reports.py +21 -0
  11. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/adloop.md +21 -1
  12. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/server.py +665 -7
  13. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/__init__.py +0 -0
  14. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/__main__.py +0 -0
  15. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/_mcp_patches.py +0 -0
  16. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/__init__.py +0 -0
  17. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/client.py +0 -0
  18. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/conversion_actions.py +0 -0
  19. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/currency.py +0 -0
  20. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/enums.py +0 -0
  21. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/forecast.py +0 -0
  22. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/gaql.py +0 -0
  23. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/pmax.py +0 -0
  24. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/validate_only.py +0 -0
  25. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/auth.py +0 -0
  26. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/cli.py +0 -0
  27. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/config.py +0 -0
  28. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/crossref.py +0 -0
  29. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/diagnostics.py +0 -0
  30. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ga4/__init__.py +0 -0
  31. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ga4/client.py +0 -0
  32. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ga4/tracking.py +0 -0
  33. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ga4/write.py +0 -0
  34. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/gsc/__init__.py +0 -0
  35. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/gsc/client.py +0 -0
  36. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/gsc/reports.py +0 -0
  37. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/gtm/__init__.py +0 -0
  38. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/gtm/client.py +0 -0
  39. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/gtm/read.py +0 -0
  40. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/merchant/__init__.py +0 -0
  41. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/merchant/client.py +0 -0
  42. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/merchant/read.py +0 -0
  43. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/pagespeed.py +0 -0
  44. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/reddit/__init__.py +0 -0
  45. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/reddit/auth.py +0 -0
  46. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/reddit/client.py +0 -0
  47. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/reddit/read.py +0 -0
  48. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/reddit/schedule.py +0 -0
  49. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/reddit/write.py +0 -0
  50. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/__init__.py +0 -0
  51. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  52. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  53. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/commands/create-ad.md +0 -0
  54. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  55. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  56. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  57. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules_install.py +0 -0
  58. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/runtime.py +0 -0
  59. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/safety/__init__.py +0 -0
  60. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/safety/audit.py +0 -0
  61. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/safety/guards.py +0 -0
  62. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/safety/preview.py +0 -0
  63. {adloop-0.16.1 → adloop-0.18.0}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.16.1
3
+ Version: 0.18.0
4
4
  Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -132,8 +132,23 @@ The best features come from real workflows. If you're using AdLoop and find your
132
132
  | `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
133
133
  | `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
134
134
  | `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
135
+ | `suggest_brands` | Resolve a brand name to the brands Google recognizes — brand ID, name, state, URLs |
136
+ | `check_brand_names` | Check a shortlist of brand names against Google's brand knowledge graph (max 25 per call) |
137
+ | `get_brand_lists` | List brand lists (SharedSets of type BRANDS) — ID, name, status, member count |
138
+ | `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
139
+ | `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
140
+ | `get_ai_max_settings` | AI Max knobs per campaign and ad group — `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list and each ad group's `disable_search_term_matching` |
141
+ | `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
135
142
  | `run_gaql` | Arbitrary GAQL queries for anything else |
136
143
 
144
+ > **Brand lists** — a list is a `SharedSet` of type `BRANDS`; attaching it to a campaign is a `CampaignCriterion.brand_list` (`negative=true` excludes, `false` restricts), **not** a `CampaignSharedSet` like negative keyword lists. `remove_from_brand_list` and `detach_brand_list_from_campaigns` remove for real — `SharedCriterion` has no status field.
145
+
146
+ > **Brand targeting** — brand criteria (brand lists, brand exclusions) target a brand's **Commercial Knowledge Graph ID**, not its display name. Use `suggest_brands` for a single name or `check_brand_names` for a shortlist to get the ID; `exact_match` marks a candidate whose name matches apart from case and punctuation, everything else is a Google suggestion.
147
+
148
+ > **AI Max is the container for Search brand exclusions** — Google rejects a brand list on a plain Search campaign with *"For search advertising channel, brand lists can only be applied to exclusive targeting, broad match campaigns for inclusive targeting or PMax generated campaigns."* `draft_prepare_brand_exclusions` sets the state that makes the exclusion usable **without** handing Google the automations: AI Max on, search term matching off per ad group, text and final-URL automation opted out. `draft_ai_max_settings` is the general form when only parts of that state should change — it refuses a plan that would leave an automation on unnoticed, and asks for a second confirmation when that automation was requested explicitly.
149
+
150
+ > **Custom conversion goals** — a named set of conversion actions that a campaign can be pointed at. `get_custom_conversion_goals` shows the goals with their actions and a campaign's current goal config; `draft_custom_conversion_goal` creates a set, `draft_update_custom_conversion_goal` renames or replaces its actions, `draft_assign_custom_conversion_goal` points a campaign at it and `draft_clear_custom_conversion_goal` puts the campaign back on the account-level goals. Only the goal and the campaign's goal config are touched — conversion actions, bidding and budgets stay as they are.
151
+
137
152
  > **Compact mode** — `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`: account totals, breakdowns, top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword candidates, thin RSAs) instead of every row. ~90% smaller responses — built for account audits so raw tables don't flood your AI's context.
138
153
 
139
154
  ### Cross-Reference Tools (GA4 + Ads Combined)
@@ -246,6 +261,17 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
246
261
  | `draft_key_event` | Mark a GA4 event as a key event (conversion) — the fix for "fires but isn't tracked as a conversion" |
247
262
  | `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income) — exclusions by default |
248
263
  | `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
264
+ | `propose_brand_list` | Draft a brand list (SharedSet of type BRANDS) from Commercial KG MIDs and optionally attach it to campaigns — `negative=true` (default) excludes the brands, `false` restricts targeting to them |
265
+ | `add_to_brand_list` | Draft adding brands to an existing brand list |
266
+ | `remove_from_brand_list` | Draft removing brands from a list (SharedCriteria have no status — removal is the only way; asks for a second confirmation) |
267
+ | `attach_brand_list_to_campaigns` | Draft attaching an existing brand list to campaigns as `CampaignCriterion.brand_list` |
268
+ | `detach_brand_list_from_campaigns` | Draft detaching a brand list from campaigns (removes only the criterion, the list stays) |
269
+ | `draft_ai_max_settings` | Draft AI Max controls for a Search campaign: `enable_ai_max`, per-ad-group `disable_search_term_matching`, plus text and final-URL asset automation (each `OPTED_IN` / `OPTED_OUT` / `UNCHANGED`) |
270
+ | `draft_prepare_brand_exclusions` | Draft the safe standard state in one step: AI Max on, search term matching off for every non-removed ad group, text and final-URL automation opted out. Touches nothing else |
271
+ | `draft_custom_conversion_goal` | Create a custom conversion goal (a named set of conversion actions) |
272
+ | `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
273
+ | `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
274
+ | `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
249
275
  | `pause_entity` | Pause a campaign, ad group, ad, or keyword |
250
276
  | `enable_entity` | Re-enable a paused entity |
251
277
  | `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
@@ -105,8 +105,23 @@ The best features come from real workflows. If you're using AdLoop and find your
105
105
  | `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
106
106
  | `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
107
107
  | `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
108
+ | `suggest_brands` | Resolve a brand name to the brands Google recognizes — brand ID, name, state, URLs |
109
+ | `check_brand_names` | Check a shortlist of brand names against Google's brand knowledge graph (max 25 per call) |
110
+ | `get_brand_lists` | List brand lists (SharedSets of type BRANDS) — ID, name, status, member count |
111
+ | `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
112
+ | `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
113
+ | `get_ai_max_settings` | AI Max knobs per campaign and ad group — `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list and each ad group's `disable_search_term_matching` |
114
+ | `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
108
115
  | `run_gaql` | Arbitrary GAQL queries for anything else |
109
116
 
117
+ > **Brand lists** — a list is a `SharedSet` of type `BRANDS`; attaching it to a campaign is a `CampaignCriterion.brand_list` (`negative=true` excludes, `false` restricts), **not** a `CampaignSharedSet` like negative keyword lists. `remove_from_brand_list` and `detach_brand_list_from_campaigns` remove for real — `SharedCriterion` has no status field.
118
+
119
+ > **Brand targeting** — brand criteria (brand lists, brand exclusions) target a brand's **Commercial Knowledge Graph ID**, not its display name. Use `suggest_brands` for a single name or `check_brand_names` for a shortlist to get the ID; `exact_match` marks a candidate whose name matches apart from case and punctuation, everything else is a Google suggestion.
120
+
121
+ > **AI Max is the container for Search brand exclusions** — Google rejects a brand list on a plain Search campaign with *"For search advertising channel, brand lists can only be applied to exclusive targeting, broad match campaigns for inclusive targeting or PMax generated campaigns."* `draft_prepare_brand_exclusions` sets the state that makes the exclusion usable **without** handing Google the automations: AI Max on, search term matching off per ad group, text and final-URL automation opted out. `draft_ai_max_settings` is the general form when only parts of that state should change — it refuses a plan that would leave an automation on unnoticed, and asks for a second confirmation when that automation was requested explicitly.
122
+
123
+ > **Custom conversion goals** — a named set of conversion actions that a campaign can be pointed at. `get_custom_conversion_goals` shows the goals with their actions and a campaign's current goal config; `draft_custom_conversion_goal` creates a set, `draft_update_custom_conversion_goal` renames or replaces its actions, `draft_assign_custom_conversion_goal` points a campaign at it and `draft_clear_custom_conversion_goal` puts the campaign back on the account-level goals. Only the goal and the campaign's goal config are touched — conversion actions, bidding and budgets stay as they are.
124
+
110
125
  > **Compact mode** — `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`: account totals, breakdowns, top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword candidates, thin RSAs) instead of every row. ~90% smaller responses — built for account audits so raw tables don't flood your AI's context.
111
126
 
112
127
  ### Cross-Reference Tools (GA4 + Ads Combined)
@@ -219,6 +234,17 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
219
234
  | `draft_key_event` | Mark a GA4 event as a key event (conversion) — the fix for "fires but isn't tracked as a conversion" |
220
235
  | `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income) — exclusions by default |
221
236
  | `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
237
+ | `propose_brand_list` | Draft a brand list (SharedSet of type BRANDS) from Commercial KG MIDs and optionally attach it to campaigns — `negative=true` (default) excludes the brands, `false` restricts targeting to them |
238
+ | `add_to_brand_list` | Draft adding brands to an existing brand list |
239
+ | `remove_from_brand_list` | Draft removing brands from a list (SharedCriteria have no status — removal is the only way; asks for a second confirmation) |
240
+ | `attach_brand_list_to_campaigns` | Draft attaching an existing brand list to campaigns as `CampaignCriterion.brand_list` |
241
+ | `detach_brand_list_from_campaigns` | Draft detaching a brand list from campaigns (removes only the criterion, the list stays) |
242
+ | `draft_ai_max_settings` | Draft AI Max controls for a Search campaign: `enable_ai_max`, per-ad-group `disable_search_term_matching`, plus text and final-URL asset automation (each `OPTED_IN` / `OPTED_OUT` / `UNCHANGED`) |
243
+ | `draft_prepare_brand_exclusions` | Draft the safe standard state in one step: AI Max on, search term matching off for every non-removed ad group, text and final-URL automation opted out. Touches nothing else |
244
+ | `draft_custom_conversion_goal` | Create a custom conversion goal (a named set of conversion actions) |
245
+ | `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
246
+ | `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
247
+ | `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
222
248
  | `pause_entity` | Pause a campaign, ad group, ad, or keyword |
223
249
  | `enable_entity` | Re-enable a paused entity |
224
250
  | `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.16.1"
3
+ version = "0.18.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.16.1"
3
+ version = "0.18.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,555 @@
1
+ """AI Max controls — the container that makes brand exclusions usable in Search.
2
+
3
+ Google Ads API v25 fields this module touches (verified against the SDK, not
4
+ guessed):
5
+
6
+ campaign.ai_max_setting.enable_ai_max bool, writable
7
+ campaign.ai_max_setting.bundling_required output only
8
+ campaign.asset_automation_settings[] repeated
9
+ .asset_automation_type AssetAutomationTypeEnum
10
+ .asset_automation_status AssetAutomationStatusEnum (OPTED_IN / OPTED_OUT)
11
+ ad_group.ai_max_ad_group_setting.disable_search_term_matching bool, writable
12
+
13
+ Why this exists: attaching a brand list to a plain Search campaign is rejected
14
+ with
15
+
16
+ For search advertising channel, brand lists can only be applied to
17
+ exclusive targeting, broad match campaigns for inclusive targeting or
18
+ PMax generated campaigns.
19
+
20
+ Search campaigns therefore need AI Max as the container for brand exclusions —
21
+ while everything AI Max would otherwise automate stays switched off: search
22
+ term matching per ad group, and the text/URL asset automations at campaign
23
+ level. ``enable_ai_max`` alone is not a safe state, which is why the apply
24
+ order below matters.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ if TYPE_CHECKING:
32
+ from adloop.config import AdLoopConfig
33
+
34
+ # AssetAutomationTypeEnum members we expose by name in the tool interface.
35
+ # v25 has no plain "FINAL_URL_EXPANSION": the URL knob is modelled as
36
+ # FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION.
37
+ TEXT_ASSET_AUTOMATION = "TEXT_ASSET_AUTOMATION"
38
+ FINAL_URL_EXPANSION = "FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION"
39
+
40
+ AUTOMATION_CHOICES = ("OPTED_IN", "OPTED_OUT", "UNCHANGED")
41
+
42
+ _CAMPAIGN_SELECT = """
43
+ SELECT campaign.id, campaign.name, campaign.status,
44
+ campaign.advertising_channel_type,
45
+ campaign.ai_max_setting.enable_ai_max,
46
+ campaign.ai_max_setting.bundling_required,
47
+ campaign.asset_automation_settings
48
+ FROM campaign
49
+ WHERE {where}
50
+ ORDER BY campaign.name
51
+ """
52
+
53
+ _CAMPAIGN_WHERE_ALL = (
54
+ "campaign.status != 'REMOVED' AND campaign.advertising_channel_type = 'SEARCH'"
55
+ )
56
+
57
+ _AD_GROUP_SELECT = """
58
+ SELECT campaign.id, ad_group.id, ad_group.name, ad_group.status,
59
+ ad_group.ai_max_ad_group_setting.disable_search_term_matching
60
+ FROM ad_group
61
+ WHERE {where}
62
+ ORDER BY campaign.id, ad_group.name
63
+ """
64
+
65
+ _AD_GROUP_WHERE_ALL = (
66
+ "ad_group.status != 'REMOVED' AND campaign.status != 'REMOVED' "
67
+ "AND campaign.advertising_channel_type = 'SEARCH'"
68
+ )
69
+
70
+
71
+ def get_ai_max_settings(
72
+ config: AdLoopConfig,
73
+ *,
74
+ customer_id: str = "",
75
+ campaign_id: str = "",
76
+ ) -> dict:
77
+ """Read-only view of the AI Max knobs, per campaign and per ad group.
78
+
79
+ Without ``campaign_id`` every non-removed Search campaign is returned.
80
+ """
81
+ from adloop.ads.client import get_ads_client, normalize_customer_id
82
+
83
+ campaign_id = str(campaign_id or "").strip()
84
+ if campaign_id and not campaign_id.isdigit():
85
+ return {"error": "campaign_id must be a numeric ID"}
86
+
87
+ cid = normalize_customer_id(customer_id or config.ads.customer_id)
88
+ client = get_ads_client(config)
89
+ return read_ai_max_state(client, cid, campaign_id=campaign_id)
90
+
91
+
92
+ def read_ai_max_state(client: object, cid: str, *, campaign_id: str = "") -> dict:
93
+ """Query the campaign settings and ad group switches; return a nested view."""
94
+ service = client.get_service("GoogleAdsService")
95
+
96
+ # The WHERE clause is assembled explicitly — no string surgery on an
97
+ # indented query, which could silently widen the result set.
98
+ if campaign_id:
99
+ if not str(campaign_id).isdigit():
100
+ raise ValueError("campaign_id must be a numeric ID")
101
+ campaign_where = f"campaign.id = {campaign_id}"
102
+ ad_group_where = (
103
+ f"ad_group.status != 'REMOVED' AND campaign.id = {campaign_id}"
104
+ )
105
+ else:
106
+ campaign_where = _CAMPAIGN_WHERE_ALL
107
+ ad_group_where = _AD_GROUP_WHERE_ALL
108
+ campaign_query = _CAMPAIGN_SELECT.format(where=campaign_where)
109
+ ad_group_query = _AD_GROUP_SELECT.format(where=ad_group_where)
110
+
111
+ campaign_rows = _search(service, cid, campaign_query)
112
+ ad_group_rows = _search(service, cid, ad_group_query)
113
+
114
+ campaigns = _normalize_campaign_rows(campaign_rows)
115
+ if campaign_id:
116
+ # Never let a row for another campaign through: the draft plans on
117
+ # campaigns[0], so a wider result set would silently plan the wrong one.
118
+ campaigns = [
119
+ campaign
120
+ for campaign in campaigns
121
+ if campaign["campaign_id"] == str(campaign_id)
122
+ ]
123
+ by_id = {campaign["campaign_id"]: campaign for campaign in campaigns}
124
+ for row in ad_group_rows:
125
+ campaign_key = str(row.get("campaign.id", ""))
126
+ campaign = by_id.get(campaign_key)
127
+ if campaign is None:
128
+ continue
129
+ campaign["ad_groups"].append(
130
+ {
131
+ "ad_group_id": str(row.get("ad_group.id", "")),
132
+ "ad_group_name": row.get("ad_group.name"),
133
+ "status": row.get("ad_group.status"),
134
+ "disable_search_term_matching": row.get(
135
+ "ad_group.ai_max_ad_group_setting.disable_search_term_matching"
136
+ ),
137
+ }
138
+ )
139
+
140
+ return {
141
+ "campaigns": campaigns,
142
+ "total_campaigns": len(campaigns),
143
+ "total_ad_groups": sum(len(c["ad_groups"]) for c in campaigns),
144
+ }
145
+
146
+
147
+ def _search(service: object, cid: str, query: str) -> list[dict]:
148
+ """Run a GAQL query and flatten each row into {field_path: value}."""
149
+ from adloop.ads.gaql import _extract_field, _parse_select_fields
150
+
151
+ fields = _parse_select_fields(query)
152
+ return [
153
+ {field: _extract_field(row, field) for field in fields}
154
+ for row in service.search(customer_id=cid, query=query)
155
+ ]
156
+
157
+
158
+ def _normalize_campaign_rows(rows: list[dict]) -> list[dict]:
159
+ """Collapse GAQL rows into one entry per campaign.
160
+
161
+ GAQL may return one row per repeated ``asset_automation_settings`` element
162
+ or a single row with parallel lists, depending on how the field is
163
+ expanded. Both shapes are handled instead of assuming one.
164
+ """
165
+ campaigns: dict[str, dict] = {}
166
+ for row in rows:
167
+ key = str(row.get("campaign.id", ""))
168
+ if not key:
169
+ continue
170
+ entry = campaigns.setdefault(
171
+ key,
172
+ {
173
+ "campaign_id": key,
174
+ "campaign_name": row.get("campaign.name"),
175
+ "status": row.get("campaign.status"),
176
+ "advertising_channel_type": row.get("campaign.advertising_channel_type"),
177
+ "enable_ai_max": row.get("campaign.ai_max_setting.enable_ai_max"),
178
+ "bundling_required": row.get(
179
+ "campaign.ai_max_setting.bundling_required"
180
+ ),
181
+ "asset_automation_settings": [],
182
+ "ad_groups": [],
183
+ },
184
+ )
185
+ for pair in _asset_automation_pairs(row):
186
+ existing = {
187
+ item["asset_automation_type"]: item
188
+ for item in entry["asset_automation_settings"]
189
+ }
190
+ existing[pair["asset_automation_type"]] = pair
191
+ entry["asset_automation_settings"] = list(existing.values())
192
+ return list(campaigns.values())
193
+
194
+
195
+ def _asset_automation_pairs(row: dict) -> list[dict]:
196
+ """Normalize the asset automation settings of one GAQL row.
197
+
198
+ The whole message field is selected (``campaign.asset_automation_settings``,
199
+ not its sub-fields — GAQL answers those with PROHIBITED_FIELD_IN_SELECT_CLAUSE),
200
+ so the common shape is a list of dicts. The parallel-list shape is still
201
+ handled because it is what a sub-field select would have produced.
202
+ """
203
+ value = row.get("campaign.asset_automation_settings")
204
+ pairs: list[dict] = []
205
+
206
+ if isinstance(value, dict):
207
+ entries: list[object] = [value]
208
+ elif isinstance(value, list):
209
+ entries = value
210
+ else:
211
+ entries = []
212
+
213
+ for entry in entries:
214
+ if isinstance(entry, dict):
215
+ asset_type = entry.get("asset_automation_type")
216
+ if not asset_type:
217
+ continue
218
+ normalized = {"asset_automation_type": asset_type}
219
+ if entry.get("asset_automation_status"):
220
+ normalized["asset_automation_status"] = entry["asset_automation_status"]
221
+ # AUTOMATED_VIDEO_CRAWL carries its configuration in a nested
222
+ # setting instead of a status; keep it so a later write can send it
223
+ # back unchanged.
224
+ if entry.get("automated_video_crawl_setting"):
225
+ normalized["automated_video_crawl_setting"] = entry[
226
+ "automated_video_crawl_setting"
227
+ ]
228
+ pairs.append(normalized)
229
+
230
+ if pairs:
231
+ return pairs
232
+
233
+ # Fallback: sub-field selection shape (parallel lists in one row).
234
+ types = row.get("campaign.asset_automation_settings.asset_automation_type")
235
+ statuses = row.get("campaign.asset_automation_settings.asset_automation_status")
236
+ if isinstance(types, list):
237
+ for index, asset_type in enumerate(types):
238
+ status = (
239
+ statuses[index]
240
+ if isinstance(statuses, list) and index < len(statuses)
241
+ else None
242
+ )
243
+ if asset_type:
244
+ pairs.append(
245
+ {"asset_automation_type": asset_type, "asset_automation_status": status}
246
+ )
247
+ elif types:
248
+ pairs.append(
249
+ {"asset_automation_type": types, "asset_automation_status": statuses}
250
+ )
251
+ return pairs
252
+
253
+
254
+ def merge_asset_automation(
255
+ current: list[dict], updates: dict[str, str]
256
+ ) -> list[dict]:
257
+ """Return the complete settings list with ``updates`` applied per type.
258
+
259
+ Google documents the field only as "the opt-in/out status of each
260
+ AssetAutomationType" — not whether an update replaces or merges the list.
261
+ Sending the complete merged list is correct either way, and preserving the
262
+ untouched types is what keeps this from silently switching other
263
+ automations on or off.
264
+ """
265
+ merged: dict[str, dict] = {}
266
+ for item in current:
267
+ asset_type = item.get("asset_automation_type")
268
+ if asset_type:
269
+ merged[asset_type] = dict(item)
270
+ for asset_type, status in updates.items():
271
+ if status == "UNCHANGED":
272
+ continue
273
+ entry = merged.setdefault(
274
+ asset_type,
275
+ {"asset_automation_type": asset_type, "asset_automation_status": None},
276
+ )
277
+ entry["asset_automation_status"] = status
278
+ return list(merged.values())
279
+
280
+
281
+ def plan_targets(
282
+ state: dict,
283
+ *,
284
+ disable_search_term_matching: bool,
285
+ ad_group_ids: list[str] | None,
286
+ include_paused_ad_groups: bool,
287
+ ) -> tuple[list[dict], list[str]]:
288
+ """Resolve which ad groups the plan touches. Returns (targets, warnings)."""
289
+ groups = state.get("ad_groups", [])
290
+ warnings: list[str] = []
291
+
292
+ if ad_group_ids:
293
+ wanted = [str(g) for g in ad_group_ids]
294
+ known = {group["ad_group_id"]: group for group in groups}
295
+ unknown = [g for g in wanted if g not in known]
296
+ if unknown:
297
+ warnings.append(
298
+ "ad_group_ids not found in this campaign (or removed): "
299
+ + ", ".join(unknown)
300
+ )
301
+ targets = [known[g] for g in wanted if g in known]
302
+ else:
303
+ targets = list(groups)
304
+ if not include_paused_ad_groups:
305
+ skipped = [g for g in targets if g.get("status") != "ENABLED"]
306
+ targets = [g for g in targets if g.get("status") == "ENABLED"]
307
+ if skipped:
308
+ warnings.append(
309
+ f"{len(skipped)} paused ad group(s) skipped "
310
+ "(include_paused_ad_groups=false)"
311
+ )
312
+
313
+ for group in targets:
314
+ if group.get("status") == "REMOVED":
315
+ warnings.append(
316
+ f"ad group {group['ad_group_id']} is REMOVED and must never be mutated"
317
+ )
318
+ targets = [g for g in targets if g.get("status") != "REMOVED"]
319
+ return targets, warnings
320
+
321
+
322
+ def plan_risks(
323
+ campaign: dict,
324
+ *,
325
+ enable_ai_max: bool | None,
326
+ disable_search_term_matching: bool | None,
327
+ automation_updates: dict[str, str],
328
+ targeted_ad_group_ids: set[str],
329
+ ) -> dict[str, list[str]]:
330
+ """Classify the automations still running in the planned AI Max state.
331
+
332
+ This module's rule is that AI Max only ever runs as a container with its
333
+ automations off. A plan can nevertheless end in the automat*on* state in two
334
+ ways, and they must not be treated the same:
335
+
336
+ ``implicit``
337
+ The automation keeps running because the caller never mentioned it — an
338
+ unchanged campaign setting or an untouched ad group switch. That is the
339
+ trap this module exists to prevent, and it should be refused.
340
+ ``explicit``
341
+ The caller named the automation in the arguments
342
+ (``disable_search_term_matching=false``, ``text_asset_automation=
343
+ OPTED_IN``), or deliberately narrowed the ad group selection. Switching
344
+ to AI Max with its automations is a legitimate Google Ads configuration,
345
+ so it must stay expressible — but it deserves a second confirmation and
346
+ a warning that names what stays automated.
347
+
348
+ Returns ``{"implicit": [...], "explicit": [...]}``, both empty when the
349
+ resulting state has AI Max off or nothing left to automate.
350
+ """
351
+ resulting_ai_max = (
352
+ bool(campaign.get("enable_ai_max"))
353
+ if enable_ai_max is None
354
+ else bool(enable_ai_max)
355
+ )
356
+ if not resulting_ai_max:
357
+ return {"implicit": [], "explicit": []}
358
+
359
+ inherited: list[str] = []
360
+ chosen: list[str] = []
361
+
362
+ never_mentioned: list[str] = []
363
+ outside_selection: list[str] = []
364
+ switched_on: list[str] = []
365
+ for group in campaign.get("ad_groups", []):
366
+ if group.get("status") == "REMOVED":
367
+ continue
368
+ targeted = group.get("ad_group_id") in targeted_ad_group_ids
369
+ if disable_search_term_matching is None or not targeted:
370
+ after = group.get("disable_search_term_matching") is True
371
+ else:
372
+ after = bool(disable_search_term_matching)
373
+ if after:
374
+ continue
375
+ name = group.get("ad_group_name") or group["ad_group_id"]
376
+ if disable_search_term_matching is None:
377
+ never_mentioned.append(name)
378
+ elif targeted:
379
+ switched_on.append(name)
380
+ else:
381
+ outside_selection.append(name)
382
+
383
+ def _list(names: list[str]) -> str:
384
+ shown = ", ".join(names[:5])
385
+ more = "" if len(names) <= 5 else f" (+{len(names) - 5} more)"
386
+ return f"{shown}{more}"
387
+
388
+ if never_mentioned:
389
+ inherited.append(
390
+ f"search term matching would stay on for {len(never_mentioned)} ad "
391
+ f"group(s): {_list(never_mentioned)} — pass "
392
+ "disable_search_term_matching=true to switch it off"
393
+ )
394
+ if outside_selection:
395
+ # The caller chose the selection (include_paused_ad_groups=false or
396
+ # ad_group_ids), so this is a decision, not an accident — but it still
397
+ # deserves the second confirmation and a warning that names the groups.
398
+ chosen.append(
399
+ f"search term matching stays on for {len(outside_selection)} ad "
400
+ f"group(s) outside the selection: {_list(outside_selection)} — pass "
401
+ "include_paused_ad_groups=true or list them in ad_group_ids to "
402
+ "switch it off as well"
403
+ )
404
+ if switched_on:
405
+ chosen.append(
406
+ f"search term matching stays on for {len(switched_on)} ad group(s) "
407
+ f"— requested with disable_search_term_matching=false: "
408
+ f"{_list(switched_on)}"
409
+ )
410
+
411
+ for asset_type, status in automation_updates.items():
412
+ if status == "OPTED_IN":
413
+ chosen.append(f"{asset_type} is requested as OPTED_IN")
414
+
415
+ if automation_updates.get(TEXT_ASSET_AUTOMATION, "UNCHANGED") == "UNCHANGED":
416
+ if _current_status(campaign, TEXT_ASSET_AUTOMATION) == "OPTED_IN":
417
+ inherited.append(
418
+ f"{TEXT_ASSET_AUTOMATION} is OPTED_IN in the campaign and left "
419
+ "unchanged — pass text_asset_automation to switch it off"
420
+ )
421
+ if automation_updates.get(FINAL_URL_EXPANSION, "UNCHANGED") == "UNCHANGED":
422
+ if _current_status(campaign, FINAL_URL_EXPANSION) == "OPTED_IN":
423
+ inherited.append(
424
+ f"{FINAL_URL_EXPANSION} is OPTED_IN in the campaign and left "
425
+ "unchanged — pass final_url_expansion to switch it off"
426
+ )
427
+
428
+ return {"implicit": inherited, "explicit": chosen}
429
+
430
+
431
+ def _current_status(campaign: dict, asset_type: str) -> str | None:
432
+ for item in campaign.get("asset_automation_settings") or []:
433
+ if item.get("asset_automation_type") == asset_type:
434
+ return item.get("asset_automation_status")
435
+ return None
436
+
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # Mutation primitives (all called from the write applier)
440
+ # ---------------------------------------------------------------------------
441
+
442
+
443
+ def mutate_ad_group_search_term_matching(
444
+ client: object, cid: str, ad_groups: list[dict], disable: bool
445
+ ) -> dict:
446
+ """Set ``disable_search_term_matching`` on the given ad groups in one request."""
447
+ from google.protobuf import field_mask_pb2
448
+
449
+ service = client.get_service("AdGroupService")
450
+ operations = []
451
+ for group in ad_groups:
452
+ operation = client.get_type("AdGroupOperation")
453
+ update = operation.update
454
+ update.resource_name = service.ad_group_path(cid, group["ad_group_id"])
455
+ update.ai_max_ad_group_setting.disable_search_term_matching = bool(disable)
456
+ operation.update_mask = field_mask_pb2.FieldMask(
457
+ paths=["ai_max_ad_group_setting.disable_search_term_matching"]
458
+ )
459
+ operations.append(operation)
460
+
461
+ request = client.get_type("MutateAdGroupsRequest")
462
+ request.customer_id = cid
463
+ request.operations.extend(operations)
464
+ request.partial_failure = True
465
+ response = service.mutate_ad_groups(request=request)
466
+ return _split_partial_failure(
467
+ client, response, ad_groups, key="ad_group_id", partial_key="failed_ad_groups"
468
+ )
469
+
470
+
471
+ def mutate_campaign_ai_max(client: object, cid: str, changes: dict) -> dict:
472
+ """Update campaign-level AI Max settings in a single operation."""
473
+ from google.protobuf import field_mask_pb2
474
+
475
+ service = client.get_service("CampaignService")
476
+ operation = client.get_type("CampaignOperation")
477
+ campaign = operation.update
478
+ campaign.resource_name = service.campaign_path(cid, changes["campaign_id"])
479
+
480
+ paths: list[str] = []
481
+ if changes.get("enable_ai_max") is not None:
482
+ campaign.ai_max_setting.enable_ai_max = bool(changes["enable_ai_max"])
483
+ paths.append("ai_max_setting.enable_ai_max")
484
+
485
+ settings = changes.get("asset_automation_settings") or []
486
+ if settings:
487
+ # proto-plus repeated message fields have no ``add()`` and the nested
488
+ # message classes are not reachable by attribute — but ``append`` takes
489
+ # a plain dict and maps enum names and nested messages itself. Settings
490
+ # read back from the account travel through unchanged, so an update that
491
+ # replaces the list cannot silently drop a configuration we never
492
+ # touched (AUTOMATED_VIDEO_CRAWL carries its own nested setting).
493
+ for item in settings:
494
+ entry = {"asset_automation_type": item["asset_automation_type"]}
495
+ if item.get("asset_automation_status"):
496
+ entry["asset_automation_status"] = item["asset_automation_status"]
497
+ if item.get("automated_video_crawl_setting"):
498
+ entry["automated_video_crawl_setting"] = item[
499
+ "automated_video_crawl_setting"
500
+ ]
501
+ campaign.asset_automation_settings.append(entry)
502
+ paths.append("asset_automation_settings")
503
+
504
+ if not paths:
505
+ return {"attempted": False, "reason": "no campaign-level change requested"}
506
+
507
+ operation.update_mask = field_mask_pb2.FieldMask(paths=paths)
508
+ response = service.mutate_campaigns(customer_id=cid, operations=[operation])
509
+ return {
510
+ "attempted": True,
511
+ "update_mask": paths,
512
+ "resource_name": response.results[0].resource_name
513
+ if response.results
514
+ else None,
515
+ }
516
+
517
+
518
+ def _split_partial_failure(
519
+ client: object, response: object, targets: list[dict], *, key: str, partial_key: str
520
+ ) -> dict:
521
+ """Split a partial-failure mutate response into succeeded/failed entries."""
522
+ from adloop.ads.write import _parse_partial_failure_per_op
523
+
524
+ pf_error = getattr(response, "partial_failure_error", None)
525
+ per_op_errors = _parse_partial_failure_per_op(client, pf_error)
526
+
527
+ succeeded: list[str] = []
528
+ failed: list[dict] = []
529
+ for index, result in enumerate(response.results):
530
+ target = targets[index] if index < len(targets) else {}
531
+ if getattr(result, "resource_name", ""):
532
+ succeeded.append(str(target.get(key, "")))
533
+ else:
534
+ failed.append(
535
+ {
536
+ key: str(target.get(key, "")),
537
+ "operation_index": index,
538
+ "error": per_op_errors.get(
539
+ index, "Unknown error (see partial_failure_message)"
540
+ ),
541
+ }
542
+ )
543
+
544
+ out: dict[str, Any] = {
545
+ "succeeded": succeeded,
546
+ "failed": failed,
547
+ "count": len(succeeded),
548
+ }
549
+ if failed:
550
+ out["partial_failure"] = True
551
+ out[partial_key] = failed
552
+ message = getattr(pf_error, "message", "") if pf_error is not None else ""
553
+ if message:
554
+ out["partial_failure_message"] = message
555
+ return out