Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/rules/adloop.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
| `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list | `shared_set_id` (required) |
| `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to | `shared_set_id` (optional) |
| `get_recommendations` | Google's auto-generated recommendations with estimated impact — budget, keyword, bid strategy, ad copy suggestions | `recommendation_types` (optional filter), `campaign_id` (optional) |
| `get_recommendation_subscriptions` | Which recommendation types Google auto-applies without a preview: type, status (ENABLED/PAUSED), create/modify dates. `insights[]` names every ENABLED type and flags one modified in the last 30 days. A type not listed is not auto-applied | none |
| `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength | `date_range_start`, `date_range_end` |
| `get_pmax_assets` | Per-asset details for PMax — field type, serving status, content. Use with `get_detailed_asset_performance` for quality signals | `campaign_id` (optional) |
| `get_asset_group_signals` | A PMax asset group's search themes and audience signal (with `signal_id` and approval status), plus the account's audiences that can become its signal | `asset_group_id` |
Expand Down Expand Up @@ -318,6 +319,7 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g
| `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_recommendation_subscriptions` | Pause auto-apply for recommendation types. Can only set PAUSED; re-enabling is not supported and stays in the Google Ads UI. Each type needs an ENABLED subscription. Single confirmation; the dry run uses validate_only | `recommendation_types` |
| `draft_dismiss_recommendations` | Dismiss Google recommendations the user has decided not to take. Nothing in the account changes and Google can show them again later; unknown or already dismissed IDs are refused. No validate-only mode: the dry run re-reads the account and checks each one is still active | `recommendation_ids` (`recommendation.id` from `get_recommendations`) |
| `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) |
| `draft_audience_attachment` | Attach audiences to a campaign or ad group as OBSERVATION (default: bids and reporting, reach unchanged), TARGETING (ads only reach the audiences; double confirmation) or exclusion. Refuses incompatible pairings, Search lists too small to target, audiences at both campaign and ad group level, and Performance Max | exactly one of `campaign_id` or `ad_group_id`; any of `user_list_ids`, `segment_ids`, `custom_audience_ids`, `combined_audience_ids`; `mode` observation/targeting; `exclude` |
Expand Down Expand Up @@ -660,6 +662,7 @@ Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) →
5. For keyword recommendations, call `get_keyword_performance` to check existing keyword overlap before acting
6. Present recommendations grouped by type with your assessment of each — the user should understand which ones are genuinely helpful vs which ones just increase Google's revenue
7. For the ones the user decides not to take, offer `draft_dismiss_recommendations` with their IDs, so they stop reappearing. Dismiss only what the user turned down: a dismissal is not a verdict you make on their behalf
8. Call `get_recommendation_subscriptions` too: an ENABLED subscription means Google applies that type on its own, without any preview. Tell the user, and offer `draft_recommendation_subscriptions` to pause it. Never suggest re-enabling auto-apply; if the user wants it, it is done in the Google Ads UI

### When user asks about audience performance or targeting

Expand Down
3 changes: 3 additions & 0 deletions .cursor/rules/adloop.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
| `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list | `shared_set_id` (required) |
| `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to | `shared_set_id` (optional) |
| `get_recommendations` | Google's auto-generated recommendations with estimated impact — budget, keyword, bid strategy, ad copy suggestions | `recommendation_types` (optional filter), `campaign_id` (optional) |
| `get_recommendation_subscriptions` | Which recommendation types Google auto-applies without a preview: type, status (ENABLED/PAUSED), create/modify dates. `insights[]` names every ENABLED type and flags one modified in the last 30 days. A type not listed is not auto-applied | none |
| `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength | `date_range_start`, `date_range_end` |
| `get_pmax_assets` | Per-asset details for PMax — field type, serving status, content. Use with `get_detailed_asset_performance` for quality signals | `campaign_id` (optional) |
| `get_asset_group_signals` | A PMax asset group's search themes and audience signal (with `signal_id` and approval status), plus the account's audiences that can become its signal | `asset_group_id` |
Expand Down Expand Up @@ -320,6 +321,7 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g
| `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_recommendation_subscriptions` | Pause auto-apply for recommendation types. Can only set PAUSED; re-enabling is not supported and stays in the Google Ads UI. Each type needs an ENABLED subscription. Single confirmation; the dry run uses validate_only | `recommendation_types` |
| `draft_dismiss_recommendations` | Dismiss Google recommendations the user has decided not to take. Nothing in the account changes and Google can show them again later; unknown or already dismissed IDs are refused. No validate-only mode: the dry run re-reads the account and checks each one is still active | `recommendation_ids` (`recommendation.id` from `get_recommendations`) |
| `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) |
| `draft_audience_attachment` | Attach audiences to a campaign or ad group as OBSERVATION (default: bids and reporting, reach unchanged), TARGETING (ads only reach the audiences; double confirmation) or exclusion. Refuses incompatible pairings, Search lists too small to target, audiences at both campaign and ad group level, and Performance Max | exactly one of `campaign_id` or `ad_group_id`; any of `user_list_ids`, `segment_ids`, `custom_audience_ids`, `combined_audience_ids`; `mode` observation/targeting; `exclude` |
Expand Down Expand Up @@ -662,6 +664,7 @@ Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) →
5. For keyword recommendations, call `get_keyword_performance` to check existing keyword overlap before acting
6. Present recommendations grouped by type with your assessment of each — the user should understand which ones are genuinely helpful vs which ones just increase Google's revenue
7. For the ones the user decides not to take, offer `draft_dismiss_recommendations` with their IDs, so they stop reappearing. Dismiss only what the user turned down: a dismissal is not a verdict you make on their behalf
8. Call `get_recommendation_subscriptions` too: an ENABLED subscription means Google applies that type on its own, without any preview. Tell the user, and offer `draft_recommendation_subscriptions` to pause it. Never suggest re-enabling auto-apply; if the user wants it, it is done in the Google Ads UI

### When user asks about audience performance or targeting

Expand Down
202 changes: 202 additions & 0 deletions src/adloop/ads/recommendation_subscriptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""Read and pause Google Ads auto-apply recommendation subscriptions.

A subscription makes Google apply every new recommendation of one type
without asking, so a change reaches the account with no preview. The v25 API
(verified against the SDK):

recommendation_subscription resource_name, type, status (ENABLED | PAUSED),
create_date_time, modify_date_time
RecommendationSubscriptionService.MutateRecommendationSubscription
MutateRecommendationSubscriptionRequest{customer_id, operations[],
partial_failure, validate_only, response_content_type}

A type with no subscription row is not auto-applied. The write side can only
set a subscription to PAUSED: re-enabling auto-apply is a decision for the
Google Ads UI, because it lets Google change the account without a preview,
which is what AdLoop's flow exists to prevent. Pausing reduces automation, so
a single confirmation is enough. The request has ``validate_only``, so the dry
run goes through the validate-only client like other Google Ads writes.
"""

from __future__ import annotations

import re
from datetime import date, datetime, timezone
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from adloop.config import AdLoopConfig

OPERATION = "pause_recommendation_subscriptions"

# A subscription modified this recently is reported as a change, not only as a
# standing setting: someone (or something) turned it on or touched it lately.
RECENT_DAYS = 30

_TYPE_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")

_QUERY = """
SELECT recommendation_subscription.resource_name,
recommendation_subscription.type,
recommendation_subscription.status,
recommendation_subscription.create_date_time,
recommendation_subscription.modify_date_time
FROM recommendation_subscription
"""


def _read(config: AdLoopConfig, customer_id: str) -> list[dict]:
from adloop.ads.gaql import execute_query

return [
{
"type": row.get("recommendation_subscription.type", ""),
"status": row.get("recommendation_subscription.status", ""),
"resource_name": row.get("recommendation_subscription.resource_name", ""),
"create_date_time": row.get("recommendation_subscription.create_date_time", ""),
"modify_date_time": row.get("recommendation_subscription.modify_date_time", ""),
}
for row in execute_query(config, customer_id, _QUERY)
]


def _day(value: str) -> date | None:
"""``2026-10-27 09:14:03`` (account time zone) -> the date, or None."""
try:
return date.fromisoformat(str(value or "")[:10])
except ValueError:
return None


def get_recommendation_subscriptions(
config: AdLoopConfig, *, customer_id: str = "", today: date | None = None
) -> dict:
"""Read-only: every auto-apply subscription with its status and dates."""
subscriptions = sorted(_read(config, customer_id), key=lambda s: s["type"])
enabled = [s for s in subscriptions if s["status"] == "ENABLED"]
today = today or datetime.now(timezone.utc).date()

insights: list[str] = []
if enabled:
insights.append(
f"{len(enabled)} recommendation type(s) auto-apply without a preview: "
+ ", ".join(s["type"] for s in enabled)
+ ". Google changes the account for these on its own. "
"draft_recommendation_subscriptions pauses them."
)
for sub in enabled:
modified = _day(sub["modify_date_time"])
if modified and (today - modified).days <= RECENT_DAYS:
insights.append(
f"{sub['type']} auto-apply was turned on or changed on "
f"{sub['modify_date_time']}, within the last {RECENT_DAYS} days. "
"Confirm that was intended."
)
if not enabled:
insights.append("No recommendation type is set to auto-apply.")

return {
"subscriptions": subscriptions,
"total_subscriptions": len(subscriptions),
"enabled_count": len(enabled),
"insights": insights,
}


def draft_recommendation_subscriptions(
config: AdLoopConfig,
*,
customer_id: str = "",
recommendation_types: list[str] | None = None,
) -> dict:
"""Draft pausing auto-apply for recommendation types — returns PREVIEW.

Pausing is the only change this can make. Each type must have an ENABLED
subscription; a type with none, or one already paused, is refused by name
so the preview only lists what the apply will change.
"""
from adloop.safety.guards import SafetyViolation, check_blocked_operation
from adloop.safety.preview import ChangePlan, store_plan

try:
check_blocked_operation(OPERATION, config.safety)
except SafetyViolation as e:
return {"error": str(e)}

types: list[str] = []
errors: list[str] = []
for raw in recommendation_types or []:
value = str(raw or "").strip().upper()
if not value:
continue
if not _TYPE_RE.match(value):
errors.append(f"'{raw}' is not a RecommendationType name")
elif value not in types:
types.append(value)
if not types and not errors:
errors.append("At least one recommendation type is required")
if errors:
return {"error": "Validation failed", "details": errors}

current = {s["type"]: s for s in _read(config, customer_id)}
not_enabled = [t for t in types if current.get(t, {}).get("status") != "ENABLED"]
if not_enabled:
return {
"error": "Validation failed",
"details": [
"Not auto-applied in this account (no subscription, or already "
"paused): " + ", ".join(not_enabled)
+ ". get_recommendation_subscriptions lists the subscriptions."
],
}

subscriptions = [
{
"type": t,
"resource_name": current[t]["resource_name"],
"current_status": "ENABLED",
"status": "PAUSED",
"modify_date_time": current[t]["modify_date_time"],
}
for t in types
]
plan = ChangePlan(
operation=OPERATION,
entity_type="recommendation_subscription",
entity_id=",".join(types),
customer_id=customer_id,
changes={
"subscriptions": subscriptions,
"count": len(subscriptions),
"note": (
"Pausing stops Google applying these recommendation types on its "
"own. Recommendations still appear in get_recommendations. "
"Re-enabling auto-apply is done in the Google Ads UI."
),
},
)
store_plan(plan)
return plan.to_preview()


def _apply_pause_recommendation_subscriptions(
client: object, cid: str, changes: dict
) -> dict:
"""Set each planned subscription to PAUSED in one request."""
from google.protobuf import field_mask_pb2

service = client.get_service("RecommendationSubscriptionService")
request = client.get_type("MutateRecommendationSubscriptionRequest")
request.customer_id = cid
paused_status = client.enums.RecommendationSubscriptionStatusEnum.PAUSED
for sub in changes["subscriptions"]:
# Hard-coded: whatever a stored plan says, this path only pauses.
operation = client.get_type("RecommendationSubscriptionOperation")
operation.update.resource_name = sub["resource_name"]
operation.update.status = paused_status
operation.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=["status"]))
request.operations.append(operation)

response = service.mutate_recommendation_subscription(request=request)
paused = [result.resource_name for result in response.results]
return {"paused": paused, "paused_count": len(paused)}
4 changes: 4 additions & 0 deletions src/adloop/ads/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -4101,6 +4101,9 @@ def _dispatch_ads_plan(client: object, cid: str, plan: object) -> dict:
from adloop.ads.pmax import apply_asset_group_signals as _apply_asset_group_signals
from adloop.ads.pmax import apply_pmax_product_filter as _apply_pmax_product_filter
from adloop.ads.recommendations import _apply_dismiss_recommendations
from adloop.ads.recommendation_subscriptions import (
_apply_pause_recommendation_subscriptions,
)
# Custom conversion goals live in their own module for the same reason.
from adloop.ads.custom_conversion_goals import (
_apply_assign_custom_conversion_goal,
Expand Down Expand Up @@ -4136,6 +4139,7 @@ def _dispatch_ads_plan(client: object, cid: str, plan: object) -> dict:
"detach_brand_list_from_campaigns": _apply_detach_brand_list_from_campaigns,
"update_ai_max_settings": _apply_ai_max_settings,
"dismiss_recommendations": _apply_dismiss_recommendations,
"pause_recommendation_subscriptions": _apply_pause_recommendation_subscriptions,
"create_custom_conversion_goal": _apply_create_custom_conversion_goal,
"update_custom_conversion_goal": _apply_update_custom_conversion_goal,
"assign_custom_conversion_goal": _apply_assign_custom_conversion_goal,
Expand Down
Loading
Loading